v1 docs config go 1.26

[ClaudeConfig]

scut claude config manages Claude Code's settings.json on behalf of the user. install writes (or merges) every scut hook handler and the status line into the chosen scope. uninstall reverses the operation. status reports what is currently wired. The command never destroys foreign configuration — keys it does not own are round-tripped verbatim.

3subcommands
25hook events wired
2scopes (project, user)
1file touched per run

// 01Overview

Before this command existed, wiring scut into Claude Code required hand-editing roughly 150 lines of JSON into .claude/settings.json (project scope) or ~/.claude/settings.json (user scope). The setup was error-prone, hard to keep in sync with newly added hook events, and impossible to undo without another hand-edit.

scut claude config replaces those hand-edits with three explicit operations. The command tree itself only generates the JSON wiring that invokes the hook subcommand tree — it does not modify or replace any hook handler. See claude-hook-commands for the handlers themselves.

Three properties define the behavior:

  • Idempotent. Running install twice with the same inputs produces byte-identical files.
  • Non-destructive. Top-level keys other than hooks and statusLine are round-tripped verbatim through an inlined fallback map.
  • Refuses unsafe overwrites. If settings.json already has a statusLine entry whose command does not start with scut , install errors out rather than silently replacing the user's existing status line.

// 02Command Tree

command-tree.txt ASCII
scut claude config
   ├── install     Write/merge scut entries into settings.json
   ├── uninstall   Remove scut entries from settings.json
   └── status      Show currently-installed scut entries

The group is a sibling of hook and status-line under claude.Cmd. It follows the pattern documented in kong-base-setup §04: a parent Cmd struct with cmd:"" tagged children, each child a leaf with a Run() method.

internal/cmd/claude/claude.go GO
type Cmd struct {
    Log      bool   `help:"Enable logging to ~/.scut/logging/ at info level."`
    LogLevel string `help:"Set log level: debug, info, warn, error (implies --log)." placeholder:"LEVEL"`

    Hook       hook.Cmd      `cmd:"hook" help:"Hook event handlers..."`
    StatusLine statusLineCmd `cmd:"status-line" help:"Render the Claude Code status bar..."`
    Config     config.Cmd    `cmd:"config" help:"Configure Claude Code settings.json — install or remove scut hooks and status line."`
}

Leaf Run() methods accept the existing dependency-injected bindings — io.Writer, afero.Fs, *slog.Logger. No new kong.BindTo calls are required.

// 03install

scut claude config install writes scut's hook and status-line entries into settings.json, merging with any existing content.

Flags

FlagTypeDefaultEffect
--scope enum {project, user} project Target file: .claude/settings.json (project) or ~/.claude/settings.json (user)
--only []string (comma-sep) empty = all Whitelist of items to install. Tokens: any hook event slug (post-tool-use, session-start, …) and/or status-line
--log bool false Bake --log into generated command strings (enables JSONL logging at info level)
--log-level string empty Bake --log-level=LEVEL (implies --log). One of debug, info, warn, error
--dry-run bool false Write the resulting JSON to stdout and do not touch the file

Flow

  1. Resolve the path for --scope (see §09).
  2. Read the existing file. If it does not exist, start from an empty Settings.
  3. Unmarshal into Settings using encoding/json/v2. Foreign top-level keys land in the inlined Foreign field as jsontext.Value.
  4. Compute the install set: --only if specified, otherwise {all 25 hook events, status-line}. Validate every token; unknown tokens return ErrUnknownOnlyToken wrapped with the valid set.
  5. For each token in the install set, build the entry and merge it (see §07).
  6. Marshal the resulting Settings with json.Deterministic(true) and jsontext.WithIndent("  "), then append a trailing \n.
  7. If --dry-run, write to stdout and return. Otherwise create the parent directory at 0755 if needed and write the file at 0644.
  8. Log a summary at info level: scope, file path, count of entries written, whether logging was baked in.

Examples

examples.sh SHELL
# Wire everything into the current project's .claude/settings.json
scut claude config install

# Only the formatter + status line, project scope
scut claude config install --only=post-tool-use,status-line

# User scope with debug logging baked in
scut claude config install --scope=user --log-level=debug

# See what would be written without touching the file
scut claude config install --dry-run

// 04uninstall

scut claude config uninstall removes scut entries from settings.json, preserving any non-scut content.

Flags

FlagTypeDefaultEffect
--scope enum {project, user} project Target file (same as install)
--only []string (comma-sep) empty = all scut entries Whitelist of items to remove. Same token set as install --only.
--dry-run bool false Write the resulting JSON to stdout without touching the file

Flow

  1. Resolve the path for --scope. If the file does not exist, print no settings file at <path>; nothing to remove to stderr and exit 0.
  2. Read and unmarshal as in install.
  3. Compute the remove set with the same defaulting + validation rules as install.
  4. For each hooks[Event] array, drop entries whose every inner hooks[].command starts with scut  (token boundary aware — see §07). If a hook array becomes empty after removal, delete the hooks[Event] key entirely.
  5. If statusLine.command starts with scut  and status-line is in the remove set, delete the statusLine key.
  6. If the resulting hooks map is empty, delete it too.
  7. If --dry-run, write to stdout. Otherwise write the file at 0644.
noteuninstall only removes entries whose command begins with scut  (followed by space or tab) or is exactly scut. Foreign tools that happen to embed the string scut elsewhere in their command are untouched.

// 05status

scut claude config status prints what scut currently has wired across the configured scopes.

Flags

FlagTypeDefaultEffect
--scope enum {project, user, both} both Which scope(s) to inspect
--json bool false Emit a structured JSON object instead of the human-readable table

Human-readable output (default)

status-output.txt ASCII
PROJECT  .claude/settings.json
  status-line    scut claude status-line
  PostToolUse    scut claude --log hook post-tool-use   (matcher: Write|Edit)
  PreToolUse     scut claude hook pre-tool-use          (matcher: *)
  SessionStart   scut claude hook session-start         (matcher: *)
  …

USER     /Users/aj/.claude/settings.json
  (no scut entries)

JSON output (--json)

status-output.json JSON
{
  "scopes": [
    {
      "scope": "project",
      "path": ".claude/settings.json",
      "exists": true,
      "entries": [
        { "kind": "statusLine", "command": "scut claude status-line" },
        { "kind": "hook", "event": "PostToolUse", "matcher": "Write|Edit", "command": "scut claude --log hook post-tool-use" }
      ]
    },
    { "scope": "user", "path": "/Users/aj/.claude/settings.json", "exists": false, "entries": [] }
  ]
}

// 06Settings Model

The Go model for settings.json needs three properties: type safety on the keys scut owns, lossless round-tripping of keys scut does not own, and deterministic output for diff-friendly file writes.

encoding/json/v2 with the inline tag option gives us exactly this. The inlined field acts as a fallback for every top-level key not directly handled by the parent struct.

internal/cmd/claude/config/settings.go GO
//go:build goexperiment.jsonv2

package config

import (
    "encoding/json/jsontext"
    json "encoding/json/v2"
)

// Settings is the subset of Claude Code's settings.json that scut manipulates.
// Foreign top-level keys round-trip through the Foreign field.
type Settings struct {
    StatusLine *StatusLine             `json:"statusLine,omitzero"`
    Hooks      map[string][]HookGroup `json:"hooks,omitzero"`
    Foreign    map[string]jsontext.Value `json:",inline"`
}

type StatusLine struct {
    Type    string `json:"type"`
    Command string `json:"command"`
}

// HookGroup is one entry in a settings.json hook array (e.g. settings.json#/hooks/PostToolUse[0]).
type HookGroup struct {
    Matcher string     `json:"matcher,omitzero"`
    Hooks   []HookEntry `json:"hooks"`
}

type HookEntry struct {
    Type          string `json:"type"`
    Command       string `json:"command"`
    StatusMessage string `json:"statusMessage,omitzero"`
}
noteThe Hooks field is keyed by Claude Code's event name (e.g. "PostToolUse", "SessionStart") — the same casing Claude Code reads. The map value is an array because Claude Code allows multiple matcher groups per event. Botctrl owns at most one matcher group per event (its own).

Build tag

Every .go file in internal/cmd/claude/config/ carries //go:build goexperiment.jsonv2 at the top. This avoids cross-file type-visibility issues that would arise from tagging only the files that import the experimental package directly — Kong leaf structs declared in one file must be referenced by the group struct in another, and a non-tagged file cannot see types defined in a tagged file when the experiment is off. mage always sets GOEXPERIMENT=jsonv2, so the tag is invisible at runtime.

Deterministic output

Marshalling is wrapped in marshalSettings(s Settings) ([]byte, error), which is the single point that every write path goes through:

marshal-settings.go GO
data, err := json.Marshal(s,
    json.Deterministic(true),
    jsontext.WithIndent("  "),
)
if err != nil {
    return nil, fmt.Errorf("marshaling settings: %w", err)
}
return append(data, '\n'), nil

json.Deterministic(true) sorts map keys (including the inlined Foreign fallback) at marshal time, so running install twice produces byte-identical bytes. Without it, Go's randomised map iteration would defeat idempotence. jsontext.WithIndent("  ") emits two-space indented multiline output. A trailing \n is appended per POSIX text-file convention.

// 07Merge Semantics

The defining property: install is idempotent and non-destructive. Running install twice produces byte-identical files. Running install on a file with foreign content preserves that content byte-equivalent in foreign positions.

Ownership rules

Region of settings.jsonscut owns?Behavior on install / uninstall
Top-level keys other than hooks and statusLine no Round-tripped verbatim via Foreign map[string]jsontext.Value
statusLine when its command starts with scut  yes install: replaces with scut's entry. uninstall: deletes the statusLine key.
statusLine when its command does not start with scut  no install: refuses with ErrForeignStatusLine. The user must explicitly remove it or pass --only excluding status-line. uninstall: leaves alone.
hooks[Event][i] where every inner hooks[].command starts with scut  yes install: replaces the group. uninstall: removes the group.
hooks[Event][i] where any inner hooks[].command is foreign no Round-tripped verbatim. install inserts scut's own group as a separate element rather than touching the foreign one. uninstall ignores it.

The scut  prefix test

A command string is "scut-owned" if and only if, after trimming leading whitespace, it equals scut or starts with scut followed by a space or tab. This rejects commands like not-scut ... and scutsomething while accepting scut claude hook post-tool-use and scut claude --log hook post-tool-use.

internal/cmd/claude/config/ownership.go GO
// owns reports whether command is a scut invocation we should manage.
func owns(command string) bool {
    c := strings.TrimLeft(command, " \t")
    if c == "scut" {
        return true
    }
    return strings.HasPrefix(c, "scut ") || strings.HasPrefix(c, "scut\t")
}

Output formatting

The determinism invariant — same inputs produce byte-identical bytes — is what makes idempotence testable. The rules:

  • Indent: two spaces.
  • Trailing newline: file ends with a single \n.
  • Top-level key order: determined by json.Deterministic(true) — typed fields first (struct order: statusLine, hooks), then every key of Foreign in sorted order.
  • hooks entry order: event-name keys are written in sorted order (deterministic mode sorts maps). Inside each event's array, scut's own group is always at index 0; foreign groups follow in their original relative order.
  • HookGroup.Hooks order: as written in the registry — one entry per event.

// 08Generated Entries

Status line

statusLine.json JSON
{
  "statusLine": {
    "type": "command",
    "command": "scut claude status-line"
  }
}

Hook event entry

Each hook event gets one HookGroup. The matcher is * (Claude Code's wildcard) for every event except PostToolUse, which uses Write|Edit and adds a statusMessage.

hooks-entry.json JSON
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "scut claude hook post-tool-use",
            "statusMessage": "Formatting..."
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "scut claude hook session-start" }
        ]
      }
    ]
  }
}

Registry

The single source-of-truth table mapping each --only token (CLI slug) to its Claude Code event name and per-event customizations:

internal/cmd/claude/config/registry.go GO
// hookSpec describes one row in the install registry.
// Slug is the --only token AND the leaf command name under "scut claude hook".
// Event is Claude Code's event-name key in settings.json.
type hookSpec struct {
    Slug          string
    Event         string
    Matcher       string
    StatusMessage string
}

// hookSpecs is the registry of installable hook events. Initialised once at
// package load and never mutated thereafter.
var hookSpecs = []hookSpec{
    {Slug: "session-start",        Event: "SessionStart",        Matcher: "*"},
    {Slug: "session-end",          Event: "SessionEnd",          Matcher: "*"},
    {Slug: "instructions-loaded",  Event: "InstructionsLoaded",  Matcher: "*"},
    {Slug: "user-prompt-submit",   Event: "UserPromptSubmit",   Matcher: "*"},
    {Slug: "pre-tool-use",         Event: "PreToolUse",         Matcher: "*"},
    {Slug: "post-tool-use",        Event: "PostToolUse",        Matcher: "Write|Edit", StatusMessage: "Formatting..."},
    {Slug: "post-tool-use-failure",Event: "PostToolUseFailure", Matcher: "*"},
    {Slug: "permission-request",   Event: "PermissionRequest",   Matcher: "*"},
    {Slug: "notification",         Event: "Notification",         Matcher: "*"},
    {Slug: "subagent-start",       Event: "SubagentStart",       Matcher: "*"},
    {Slug: "subagent-stop",        Event: "SubagentStop",        Matcher: "*"},
    {Slug: "stop",                 Event: "Stop",                 Matcher: "*"},
    {Slug: "stop-failure",         Event: "StopFailure",         Matcher: "*"},
    {Slug: "task-created",         Event: "TaskCreated",         Matcher: "*"},
    {Slug: "task-completed",       Event: "TaskCompleted",       Matcher: "*"},
    {Slug: "teammate-idle",        Event: "TeammateIdle",        Matcher: "*"},
    {Slug: "config-change",        Event: "ConfigChange",        Matcher: "*"},
    {Slug: "cwd-changed",          Event: "CwdChanged",          Matcher: "*"},
    {Slug: "file-changed",         Event: "FileChanged",         Matcher: "*"},
    {Slug: "worktree-create",      Event: "WorktreeCreate",      Matcher: "*"},
    {Slug: "worktree-remove",      Event: "WorktreeRemove",      Matcher: "*"},
    {Slug: "pre-compact",          Event: "PreCompact",          Matcher: "*"},
    {Slug: "post-compact",         Event: "PostCompact",         Matcher: "*"},
    {Slug: "elicitation",          Event: "Elicitation",          Matcher: "*"},
    {Slug: "elicitation-result",   Event: "ElicitationResult",   Matcher: "*"},
}
invariantThe set of slugs in hookSpecs must exactly match the leaf command names under internal/cmd/claude/hook/hook.go. A reflection-based unit test (registry_test.go) enforces this — it walks every cmd:"" tag on hook.Cmd fields and asserts each appears exactly once in hookSpecs, and vice versa. Adding a new hook event without updating the registry will fail the test.

Command-string construction

Composed at install time from the install flags:

command-template.txt ASCII
Hook:        scut claude [--log | --log-level=LEVEL] hook <slug>
Status line: scut claude [--log | --log-level=LEVEL] status-line
  • --log-level implies --log: emit --log-level=LEVEL alone (matches claude.go's resolveLevel; --log would be redundant).
  • Neither flag set: omit both — bare scut claude hook <slug>.
  • --log alone: emit --log.

// 09Scope Resolution

ScopePathResolution
project .claude/settings.json Relative to os.Getwd() at command time. No .git walk-up — Claude Code itself uses .claude/settings.json relative to where it is launched.
user ~/.claude/settings.json os.UserHomeDir() + .claude/settings.json.

Parent directory creation: if the parent (.claude/ or ~/.claude/) does not exist, install creates it with mode 0755 before writing. uninstall and status never create directories.

Resolution is implemented as two single-purpose helpers rather than one function with three same-typed string parameters (which would be indistinguishable at call sites):

internal/cmd/claude/config/scope.go GO
// projectSettingsPath returns the project-scope settings path relative to cwd.
func projectSettingsPath(cwd string) string {
    return filepath.Join(cwd, ".claude", "settings.json")
}

// userSettingsPath returns the user-scope settings path. It returns an error
// only when os.UserHomeDir fails.
func userSettingsPath() (string, error) {
    home, err := os.UserHomeDir()
    if err != nil {
        return "", fmt.Errorf("resolving user home: %w", err)
    }
    return filepath.Join(home, ".claude", "settings.json"), nil
}

Both helpers are pure functions of their inputs and are trivially unit-testable without mocking.

// 10Error Handling

Sentinel errors

Programmatically-detectable error conditions are exported as sentinels so callers can use errors.Is instead of string matching on err.Error() — error strings are not API.

internal/cmd/claude/config/errors.go GO
// ErrForeignStatusLine is returned by install when settings.json already
// has a statusLine entry whose command does not start with "scut ".
var ErrForeignStatusLine = errors.New("settings.json has a non-scut statusLine")

// ErrUnknownOnlyToken is returned when --only contains a token that is
// neither a registered hook slug nor the literal "status-line".
var ErrUnknownOnlyToken = errors.New("unknown --only token")

Sentinels are wrapped with %w at the use site so the outer message carries path or token context while errors.Is still walks the chain:

install-wrap.go GO
return fmt.Errorf("%w at %q: pass --only without status-line to skip, or remove it manually first",
    ErrForeignStatusLine, path)

Condition matrix

All path interpolations use %q so empty paths and paths containing whitespace are visible in the output.

ConditionBehaviorSentinelExit code
Settings file does not exist (install) Treat as empty Settings{}; create directories and write. 0
Settings file does not exist (uninstall) Write no settings file at %q; nothing to remove to stderr. 0
Settings file does not exist (status) Report the scope as "exists": false with empty entries. 0
Settings file unparseable JSON Return fmt.Errorf("parsing %q: %w", path, err); Kong's FatalIfErrorf prints to stderr. — (wraps the jsonv2 error) 1
Unknown token in --only Return an error wrapping ErrUnknownOnlyToken with the offending token (%q) and a sorted list of valid tokens. ErrUnknownOnlyToken 1
Foreign statusLine exists, install would replace it Return an error wrapping ErrForeignStatusLine with the path (%q). Exit before writing anything. ErrForeignStatusLine 1
--log-level value not in {debug, info, warn, error} Kong's enum validation rejects it at parse time. — (Kong) 1
os.UserHomeDir() fails (user scope) Return the wrapped error from userSettingsPath (%w). — (wraps OS error) 1

// 11Code

RolePath
Group + wiringinternal/cmd/claude/config/config.go
Settings model + marshallinginternal/cmd/claude/config/settings.go
Hook registryinternal/cmd/claude/config/registry.go
Ownership predicateinternal/cmd/claude/config/ownership.go
Sentinel errorsinternal/cmd/claude/config/errors.go
Scope path resolutioninternal/cmd/claude/config/scope.go
install commandinternal/cmd/claude/config/install.go
uninstall commandinternal/cmd/claude/config/uninstall.go
status commandinternal/cmd/claude/config/status.go
Wiring into claude groupinternal/cmd/claude/claude.go

Tests split between white-box (package config) for unexported helpers — owns, marshalSettings, scope helpers, the registry invariant — and black-box (package config_test) for the public command surface using afero.MemMapFs.

// 12Limitations

Deliberately out of scope for this iteration:

  • Absolute paths in generated commands. The generated command string is always bare scut ..., never /usr/local/bin/scut .... Users who need an absolute path can hand-edit; absolute-path detection in owns() is reserved for a future iteration.
  • Migrating existing user settings. If a user has a hand-written settings.json with non-scut statusLine, install refuses rather than rewriting.
  • Per-event matcher customization via CLI. The matcher for each event is hard-coded in hookSpecs (e.g. Write|Edit for PostToolUse). Custom matchers require a code change to the registry, not a CLI flag.
  • Watching settings.json for drift. No file watcher; status is a point-in-time read.
  • Restoring from backup. install does not create a settings.json.bak. Users versioning .claude/settings.json in git get the same benefit for free.
  • Validating that scut is on PATH. The generated command assumes scut is discoverable when Claude Code spawns the subprocess.