From f877acacbf8d086b96ec3c21bd3323479e4ff3c4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 21 Apr 2026 20:07:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#146=20=E2=80=94=20wire=20`claw=20confi?= =?UTF-8?q?g`=20and=20`claw=20diff`=20as=20standalone=20subcommands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `claw config` and `claw diff` are pure-local read-only introspection commands (config merges .claw.json + .claw/settings.json from disk; diff shells out to `git diff --cached` + `git diff`). Neither needs a session context, yet both rejected direct CLI invocation: $ claw config error: `claw config` is a slash command. Use `claw --resume SESSION.jsonl /config` ... $ claw diff error: `claw diff` is a slash command. ... This forced clawing operators to spin up a full session just to inspect static disk state, and broke natural pipelines like `claw config --output-format json | jq`. ## Root cause Sibling of #145: `SlashCommand::Config { section }` and `SlashCommand::Diff` had working renderers (`render_config_report`, `render_config_json`, `render_diff_report`, `render_diff_json_for`) exposed for resume sessions, but the top-level CLI parser in `parse_subcommand()` had no arms for them. Zero-arg `config`/`diff` hit `parse_single_word_command_alias`'s fallback to `bare_slash_command_guidance`, producing the misleading guidance. ## Changes ### rust/crates/rusty-claude-cli/src/main.rs - Added `CliAction::Config { section, output_format }` and `CliAction::Diff { output_format }` variants. - Added `"config"` / `"diff"` arms to the top-level parser in `parse_subcommand()`. `config` accepts an optional section name (env|hooks|model|plugins) matching SlashCommand::Config semantics. `diff` takes no positional args. Both reject extra trailing args with a clear error. - Added `"config" | "diff" => None` to `parse_single_word_command_alias` so bare invocations fall through to the new parser arms instead of the slash-guidance error. - Added dispatch in run() that calls existing renderers: text mode uses `render_config_report` / `render_diff_report`; JSON mode uses `render_config_json` / `render_diff_json_for` with `serde_json::to_string_pretty`. - Added 5 regression assertions in parse_args test covering: parse_args(["config"]), parse_args(["config", "env"]), parse_args(["config", "--output-format", "json"]), parse_args(["diff"]), parse_args(["diff", "--output-format", "json"]). ### ROADMAP.md Added Pinpoint #146 documenting the gap, verification, root cause, fix shape, and acceptance. Explicitly notes which other slash commands (`hooks`, `usage`, `context`, etc.) are NOT candidates because they are session-state-modifying. ## Live verification $ claw config # no config files Config Working directory /private/tmp/cd-146-verify Loaded files 0 Merged keys 0 Discovered files user missing ... project missing ... local missing ... Exit 0. $ claw config --output-format json { "cwd": "...", "files": [...], ... } $ claw diff # no git Diff Result no git repository Detail ... Exit 0. $ claw diff --output-format json # inside claw-code { "kind": "diff", "result": "changes", "staged": "", "unstaged": "diff --git ..." } Exit 0. ## Tests - rusty-claude-cli bin: 177 tests pass (5 new assertions in parse_args) - Full workspace green except pre-existing resume_latest flake (unrelated) ## Not changed `hooks`, `usage`, `context`, `tasks`, `theme`, `voice`, `rename`, `copy`, `color`, `effort`, `branch`, `rewind`, `ide`, `tag`, `output-style`, `add-dir` — all session-mutating or interactive-only; correctly remain slash-only. Closes ROADMAP #146. --- ROADMAP.md | 41 ++++++++ rust/crates/rusty-claude-cli/src/main.rs | 124 +++++++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 4f1e379..e7fc715 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5578,3 +5578,44 @@ MCP **Blocker.** None. `CliAction::Plugins` already exists with a working dispatcher. **Source.** Jobdori dogfood 2026-04-21 19:30 KST on main HEAD `faeaa1d` in response to Clawhip nudge. Joins **prompt misdelivery** cluster. Session tally: ROADMAP #145. + +## Pinpoint #146. `claw config` and `claw diff` are pure-local introspection commands but require `--resume SESSION.jsonl` wrapping + +**Gap.** Running `claw config` or `claw diff` directly exits with an error pointing to `claw --resume SESSION.jsonl /config` as the only path. Both commands are pure, read-only introspection: `config` reads files from disk and merges them; `diff` shells out to `git diff --cached` + `git diff`. Neither needs a session context to produce correct output. + +**Verified on main HEAD `7d63699` (2026-04-21 20:03 KST):** + +``` +$ claw config +error: `claw config` is a slash command. Use `claw --resume SESSION.jsonl /config` or start `claw` and run `/config`. + +$ claw config --output-format json +{"error":"`claw config` is a slash command. ...","type":"error"} + +$ claw diff +error: `claw diff` is a slash command. Use `claw --resume SESSION.jsonl /diff` or start `claw` and run `/diff`. +``` + +Meanwhile `agents`, `mcp`, `skills`, `status`, `doctor`, `sandbox`, `plugins` (after #145) all work standalone. + +**Why this is a clawability gap.** +1. **Synthetic friction**: requires a session file to inspect static disk state. A claw probing configuration has to spin up a session it doesn't need. +2. **Surface asymmetry**: all other read-only diagnostics are standalone. `config` and `diff` are the remaining holdouts. +3. **Pipeline-unfriendly**: `claw config --output-format json | jq` and `claw diff | less` are natural operator workflows; both are currently broken. +4. **Both already have working JSON renderers** (`render_config_json`, `render_diff_json_for`) — infrastructure for top-level wiring exists. + +**Fix shape (~30 lines).** Add `"config"` and `"diff"` arms to the top-level parser in `main.rs` (mirroring #145's `plugins` wiring). Each dispatches to a new `CliAction` variant or to existing resume-supported renderers directly. Text mode uses `render_config_report` / `render_diff_report`; JSON mode uses `render_config_json` / `render_diff_json_for`. Remove `config` from `bare_slash_command_guidance`'s fallback allowlist only if explicitly gating (parser arm already short-circuits). + +**Acceptance.** +- `claw config` exits 0 with discovered-file listing + merged-keys count. +- `claw config --output-format json` emits typed envelope with discovered files and merged JSON. +- `claw config env` / `claw config plugins` surface specific sections (matches `SlashCommand::Config { section }` semantics). +- `claw diff` exits 0 with clean-tree message or staged/unstaged summary. +- `claw diff --output-format json` emits typed envelope. +- Regression tests: `parse_args(["config"])` → `CliAction::Config`; `parse_args(["diff"])` → `CliAction::Diff`. + +**Blocker.** None. Renderers exist and are resume-supported (proving they're pure-local). + +**Not applying to.** `hooks` (session-state-modifying, explicitly flagged "unsupported resumed slash command" in main.rs), `usage`, `context`, `tasks`, `theme`, `voice`, `rename`, `copy`, `color`, `effort`, `branch`, `rewind`, `ide`, `tag`, `output-style`, `add-dir` — all session-mutating or interactive-only. + +**Source.** Jobdori dogfood 2026-04-21 20:03 KST on main HEAD `7d63699` in response to Clawhip nudge. Joins **surface asymmetry** cluster (#145 sibling). Session tally: ROADMAP #146. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 90f9c58..0eb08a7 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -253,6 +253,37 @@ fn run() -> Result<(), Box> { CliAction::Acp { output_format } => print_acp_status(output_format)?, CliAction::State { output_format } => run_worker_state(output_format)?, CliAction::Init { output_format } => run_init(output_format)?, + // #146: dispatch pure-local introspection. Text mode uses existing + // render_config_report/render_diff_report; JSON mode uses the + // corresponding _json helpers already exposed for resume sessions. + CliAction::Config { + section, + output_format, + } => { + match output_format { + CliOutputFormat::Text => { + println!("{}", render_config_report(section.as_deref())?); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&render_config_json(section.as_deref())?)? + ); + } + } + } + CliAction::Diff { output_format } => match output_format { + CliOutputFormat::Text => { + println!("{}", render_diff_report()?); + } + CliOutputFormat::Json => { + let cwd = env::current_dir()?; + println!( + "{}", + serde_json::to_string_pretty(&render_diff_json_for(&cwd)?)? + ); + } + }, CliAction::Export { session_reference, output_path, @@ -349,6 +380,15 @@ enum CliAction { Init { output_format: CliOutputFormat, }, + // #146: `claw config` and `claw diff` are pure-local read-only + // introspection commands; wire them as standalone CLI subcommands. + Config { + section: Option, + output_format: CliOutputFormat, + }, + Diff { + output_format: CliOutputFormat, + }, Export { session_reference: String, output_path: Option, @@ -685,6 +725,38 @@ fn parse_args(args: &[String]) -> Result { output_format, }) } + // #146: `config` is pure-local read-only introspection (merges + // `.claw.json` + `.claw/settings.json` from disk, no network, no + // state mutation). Previously callers had to spin up a session with + // `claw --resume SESSION.jsonl /config` to see their own config, + // which is synthetic friction. Accepts an optional section name + // (env|hooks|model|plugins) matching the slash command shape. + "config" => { + let tail = &rest[1..]; + let section = tail.first().cloned(); + if tail.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw config {}`: {}", + tail[0], + tail[1..].join(" ") + )); + } + Ok(CliAction::Config { + section, + output_format, + }) + } + // #146: `diff` is pure-local (shells out to `git diff --cached` + + // `git diff`). No session needed to inspect the working tree. + "diff" => { + if rest.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw diff`: {}", + rest[1..].join(" ") + )); + } + Ok(CliAction::Diff { output_format }) + } "skills" => { let args = join_optional_args(&rest[1..]); match classify_skills_slash_command(args.as_deref()) { @@ -843,6 +915,11 @@ fn parse_single_word_command_alias( "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), "doctor" => Some(Ok(CliAction::Doctor { output_format })), "state" => Some(Ok(CliAction::State { output_format })), + // #146: let `config` and `diff` fall through to parse_subcommand + // where they are wired as pure-local introspection, instead of + // producing the "is a slash command" guidance. Zero-arg cases + // reach parse_subcommand too via this None. + "config" | "diff" => None, other => bare_slash_command_guidance(other).map(Err), } } @@ -9619,6 +9696,53 @@ mod tests { output_format: CliOutputFormat::Json, } ); + // #146: `config` and `diff` must parse as standalone CLI actions, + // not fall through to the "is a slash command" error. Both are + // pure-local read-only introspection. + assert_eq!( + parse_args(&["config".to_string()]).expect("config should parse"), + CliAction::Config { + section: None, + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["config".to_string(), "env".to_string()]) + .expect("config env should parse"), + CliAction::Config { + section: Some("env".to_string()), + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&[ + "config".to_string(), + "--output-format".to_string(), + "json".to_string(), + ]) + .expect("config --output-format json should parse"), + CliAction::Config { + section: None, + output_format: CliOutputFormat::Json, + } + ); + assert_eq!( + parse_args(&["diff".to_string()]).expect("diff should parse"), + CliAction::Diff { + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&[ + "diff".to_string(), + "--output-format".to_string(), + "json".to_string(), + ]) + .expect("diff --output-format json should parse"), + CliAction::Diff { + output_format: CliOutputFormat::Json, + } + ); } #[test]