feat: #146 — wire claw config and claw diff as standalone subcommands

## 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.
This commit is contained in:
YeonGyu-Kim 2026-04-21 20:07:28 +09:00
parent 7d63699f9f
commit f877acacbf
2 changed files with 165 additions and 0 deletions

View File

@ -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.

View File

@ -253,6 +253,37 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
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<String>,
output_format: CliOutputFormat,
},
Diff {
output_format: CliOutputFormat,
},
Export {
session_reference: String,
output_path: Option<PathBuf>,
@ -685,6 +725,38 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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]