[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.
// 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
installtwice with the same inputs produces byte-identical files. - Non-destructive. Top-level keys other than
hooksandstatusLineare round-tripped verbatim through an inlined fallback map. - Refuses unsafe overwrites. If
settings.jsonalready has astatusLineentry whosecommanddoes not start withscut,installerrors out rather than silently replacing the user's existing status line.
// 02Command Tree
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.
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
| Flag | Type | Default | Effect |
|---|---|---|---|
| --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
- Resolve the path for
--scope(see §09). - Read the existing file. If it does not exist, start from an empty
Settings. - Unmarshal into
Settingsusingencoding/json/v2. Foreign top-level keys land in the inlinedForeignfield asjsontext.Value. - Compute the install set:
--onlyif specified, otherwise{all 25 hook events, status-line}. Validate every token; unknown tokens returnErrUnknownOnlyTokenwrapped with the valid set. - For each token in the install set, build the entry and merge it (see §07).
- Marshal the resulting
Settingswithjson.Deterministic(true)andjsontext.WithIndent(" "), then append a trailing\n. - If
--dry-run, write tostdoutand return. Otherwise create the parent directory at0755if needed and write the file at0644. - Log a summary at info level: scope, file path, count of entries written, whether logging was baked in.
Examples
# 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
| Flag | Type | Default | Effect |
|---|---|---|---|
| --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
- Resolve the path for
--scope. If the file does not exist, printno settings file at <path>; nothing to removeto stderr and exit 0. - Read and unmarshal as in
install. - Compute the remove set with the same defaulting + validation rules as
install. - For each
hooks[Event]array, drop entries whose every innerhooks[].commandstarts withscut(token boundary aware — see §07). If a hook array becomes empty after removal, delete thehooks[Event]key entirely. - If
statusLine.commandstarts withscutandstatus-lineis in the remove set, delete thestatusLinekey. - If the resulting
hooksmap is empty, delete it too. - If
--dry-run, write to stdout. Otherwise write the file at0644.
uninstall 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
| Flag | Type | Default | Effect |
|---|---|---|---|
| --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)
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)
{
"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.
//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"` }
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:
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.json | scut 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.
// 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 ofForeignin sorted order. hooksentry 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.Hooksorder: as written in the registry — one entry per event.
// 08Generated Entries
Status line
{
"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": {
"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:
// 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: "*"}, }
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:
Hook: scut claude [--log | --log-level=LEVEL] hook <slug> Status line: scut claude [--log | --log-level=LEVEL] status-line
--log-levelimplies--log: emit--log-level=LEVELalone (matchesclaude.go'sresolveLevel;--logwould be redundant).- Neither flag set: omit both — bare
scut claude hook <slug>. --logalone: emit--log.
// 09Scope Resolution
| Scope | Path | Resolution |
|---|---|---|
| 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):
// 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.
// 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:
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.
| Condition | Behavior | Sentinel | Exit 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
| Role | Path |
|---|---|
| Group + wiring | internal/cmd/claude/config/config.go |
| Settings model + marshalling | internal/cmd/claude/config/settings.go |
| Hook registry | internal/cmd/claude/config/registry.go |
| Ownership predicate | internal/cmd/claude/config/ownership.go |
| Sentinel errors | internal/cmd/claude/config/errors.go |
| Scope path resolution | internal/cmd/claude/config/scope.go |
install command | internal/cmd/claude/config/install.go |
uninstall command | internal/cmd/claude/config/uninstall.go |
status command | internal/cmd/claude/config/status.go |
| Wiring into claude group | internal/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
commandstring is always barescut ..., never/usr/local/bin/scut .... Users who need an absolute path can hand-edit; absolute-path detection inowns()is reserved for a future iteration. - Migrating existing user settings. If a user has a hand-written settings.json with non-scut
statusLine,installrefuses rather than rewriting. - Per-event matcher customization via CLI. The matcher for each event is hard-coded in
hookSpecs(e.g.Write|Editfor PostToolUse). Custom matchers require a code change to the registry, not a CLI flag. - Watching settings.json for drift. No file watcher;
statusis a point-in-time read. - Restoring from backup.
installdoes not create asettings.json.bak. Users versioning.claude/settings.jsonin git get the same benefit for free. - Validating that
scutis on PATH. The generated command assumesscutis discoverable when Claude Code spawns the subprocess.