diff --git a/ROADMAP.md b/ROADMAP.md index 26dd4a6c..25fef2c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6363,7 +6363,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 428. **DONE — default permission mode is workspace-write with auditable permission provenance** — fixed 2026-06-03 in `fix: default to workspace-write permissions`. Fresh invocations now resolve the fallback permission mode to `workspace-write` instead of `danger-full-access`; `danger-full-access` requires an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in. `status --output-format json` includes `permission_mode_source` and `permission_mode_env_var`, and `doctor --output-format json` includes a `permissions` check with `mode`, `source`, `source_explicit`, `message`, and tool allow/gate lists. Invalid CLI permission modes now emit typed `invalid_permission_mode` JSON errors, and docs describe the three modes plus the safe default. Regression coverage: `default_permission_mode_is_workspace_write_and_audited_428`, `explicit_danger_permission_mode_is_audited_and_alias_supported_428`, `invalid_permission_mode_json_is_typed_428`, parser default tests, classifier coverage, and `given_workspace_write_enforcer_when_web_tools_then_denied`. -429. **No global `--cwd`/`-C`/`--directory` flag — `claw` cannot be invoked against an arbitrary working directory without first `cd`-ing into it; `--cwd` only exists as a subcommand option for `system-prompt`, and the `cli_parse` "Did you mean --acp?" suggestion is misleading (the `--acp` flag is unrelated to directory selection)** — dogfooded 2026-05-11 by Jobdori on `ec882f4c` in response to Clawhip pinpoint nudge at `1503267943285264394`. Reproduction: `claw --cwd /tmp/claw-dog-cwd status --output-format json` → `{"error":"unknown option: --cwd","hint":"Did you mean --acp?\nRun `claw --help` for usage.","kind":"cli_parse"}`. Same error for `--cwd `, `--cwd `, `--cwd `, `--cwd ""`. Inspecting `claw --help`: `--cwd PATH` appears ONLY in the usage line `claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — it is not a global flag and is not accepted by `status`, `doctor`, `mcp list`, `init`, or any other subcommand. Users programmatically running claw against multiple workspaces must `cd` into each one before invoking, breaking the `subprocess.run(['claw', 'status', '--cwd', ws], cwd=other_dir)` pattern that every other major CLI (cargo `-C`, git `-C`, npm `--prefix`, gh `--repo` semantically, kubectl `--kubeconfig`+`--context`) supports. **Sibling misleading-suggestion bug:** the `cli_parse` error's `hint` field suggests `Did you mean --acp?` for `--cwd`. `--acp` is the alias for ACP/Zed editor integration (entirely unrelated to working directory). The Levenshtein-distance auto-complete is matching on first-character similarity without considering semantic relatedness. Users following the hint get a totally orthogonal feature. **Required fix shape:** (a) add a global `--cwd PATH` / `-C PATH` flag accepted before any subcommand, parsed in the global flag pre-pass; (b) validate the path exists and is a directory; emit `kind:"invalid_cwd"` with `path:` and `reason:` (`"not_found"`/`"not_a_directory"`/`"empty"`) when validation fails; (c) document the precedence: `--cwd` flag > `$PWD` > `env::current_dir()`; (d) fix the "Did you mean" hint algorithm to filter suggestions by semantic category (don't suggest `--acp` for `--cwd`; suggest `claw system-prompt --cwd PATH` if the user clearly wants `cwd` override but used the wrong scope); (e) regression test: `claw --cwd /tmp status --output-format json` from any `$PWD` returns `workspace.cwd:"/private/tmp"` (or `cwd:"/tmp"` after #421 fix). **Why this matters:** every claw automation orchestrator runs claw against multiple workspaces from a single parent process. Forcing `cd` before each invocation breaks parallelism (can't use shared cwd across concurrent invocations), breaks subprocess wrappers that want to pass cwd explicitly, and breaks `xargs`/`parallel`-style pipelines. Cross-references #421 (cwd canonicalization leak — fix should canonicalize but report user-input via `--cwd`). Source: Jobdori live dogfood, `ec882f4c`, 2026-05-11. +429. **DONE — global workspace directory override is accepted and validated before dispatch** — fixed 2026-06-03 in `fix: add global cwd override`. `claw --cwd PATH ...`, `claw -C PATH ...`, and `claw --directory PATH ...` now run as if launched from the selected workspace before config, status, doctor, MCP, skills, and other command dispatch. The override takes precedence over process `$PWD`; invalid values emit typed `invalid_cwd` JSON errors with `path` and `reason` (`not_found`, `not_a_directory`, or `empty`) instead of the old misleading `Did you mean --acp?` CLI parse path. Help/usage docs list the global flags and the precedence/validation contract. Regression coverage: `global_cwd_flag_routes_status_workspace_and_short_alias_429`, `global_cwd_flag_reports_typed_invalid_paths_429`, and classifier coverage for `invalid_cwd`. 430. **`dump-manifests` is documented as "emit every skill/agent/tool manifest the resolver would load for the current cwd" but actually requires the upstream Claude Code TypeScript source files (`src/commands.ts`, `src/tools.ts`, `src/entrypoints/cli.tsx`) — the command is unusable for any user who installed claw without cloning the original Claude Code repo** — dogfooded 2026-05-11 by Jobdori on `075c2144` in response to Clawhip pinpoint nudge at `1503275502046023690`. Reproduction: `claw dump-manifests --output-format json` returns `{"error":"Manifest source files are missing.","hint":"repo root: /private/tmp/claw-dog-0530\n missing: src/commands.ts, src/tools.ts, src/entrypoints/cli.tsx\n Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass \`claw dump-manifests --manifests-dir /path/to/upstream\`.","kind":"missing_manifests"}`. The fresh-main worktree at `/private/tmp/claw-dog-0530` does not contain these TypeScript files because the Rust port doesn't include the upstream TS source. The `--help` text says the command works against "the current cwd" but in practice it requires `CLAUDE_CODE_UPSTREAM=` pointing at an unshipped TS source tree. **Three sibling problems compounded:** (a) **derivative-work disclosure leak**: the error message exposes that `claw-code` is a port of Claude Code (`CLAUDE_CODE_UPSTREAM` env var name) — even if true, surfacing this in a casual diagnostic message couples user-facing behavior to upstream provenance details. (b) **kind drift**: `claw dump-manifests --manifests-dir /tmp/nonexistent --output-format json` returns `kind:"unknown"`, while `claw dump-manifests` (no override) returns `kind:"missing_manifests"`. Same root cause (no usable upstream), two different `kind` discriminators — automation cannot switch on a single error type. (c) **export-positional-arg silently dropped**: probed in the same run — `claw export ` ignores the path and returns `kind:"no_managed_sessions"` regardless of what positional arg was passed. The `--help` advertises `[PATH]` as the output-file destination but the path is discarded before validation, indistinguishable from invocation with no args. **Required fix shape:** (a) make `dump-manifests` emit the manifests claw-code itself ships with (Rust-resolver-discovered skills/agents/tools), independent of any upstream TS source — that matches the `--help` description; (b) if upstream-comparison is genuinely needed for parity work, move it to a separate command like `parity dump-upstream-manifests` and remove the upstream dependency from `dump-manifests`; (c) standardize on one error `kind` for the manifest-missing failure mode (`missing_manifests` is more descriptive than `unknown`); (d) `claw export ` must validate the path positional arg before the session-discovery check, so users see `kind:"invalid_output_path"` (or similar) when the path is malformed instead of always seeing `kind:"no_managed_sessions"`. **Why this matters:** `dump-manifests` is the inventory surface a downstream automation lane would call to learn what claw can do in the current workspace. If it's broken without upstream TS source, downstream lanes can't introspect — they have to fall back to `agents list`/`skills list`/`mcp list` separately and re-aggregate. Cross-references #422 (kind:unknown for unknown_subcommand), #423 (kind:unknown for missing_argument), #428 (kind:unknown for invalid_permission_mode) — `kind:"unknown"` keeps appearing as the catch-all for surfaces that should have typed kinds. Source: Jobdori live dogfood, `075c2144`, 2026-05-11. diff --git a/USAGE.md b/USAGE.md index e2aff86c..c58c913e 100644 --- a/USAGE.md +++ b/USAGE.md @@ -193,8 +193,11 @@ cd rust ./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml" ./target/debug/claw --permission-mode workspace-write prompt "update README.md" ./target/debug/claw --allowedTools read,glob "inspect the runtime crate" +./target/debug/claw --cwd ../other-workspace status --output-format json ``` +Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH` are accepted before any subcommand. They are validated before command dispatch and take precedence over the process `$PWD`; invalid paths return typed `invalid_cwd` JSON errors in JSON mode. + Supported permission modes (default: `workspace-write`): - `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution. diff --git a/rust/README.md b/rust/README.md index ccf4f56a..19fc18b0 100644 --- a/rust/README.md +++ b/rust/README.md @@ -124,6 +124,7 @@ Flags: --model MODEL --output-format text|json --permission-mode MODE + --cwd PATH, -C PATH, --directory PATH --dangerously-skip-permissions, --skip-permissions --allowedTools TOOLS --resume [SESSION.jsonl|session-id|latest] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d815bdf2..7d3507e2 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -285,6 +285,9 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "--model", "--output-format", "--permission-mode", + "--cwd", + "--directory", + "-C", "--skip-permissions", "--dangerously-skip-permissions", "--allowedTools", @@ -323,23 +326,32 @@ fn main() { let (short_reason, inline_hint) = split_error_hint(&message); // #781: fall back to a kind-derived hint when the message has no \n-delimited hint let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); + let mut error_json = serde_json::json!({ + "type": "error", + "kind": kind, + "status": "error", + "error_kind": kind, + "error": short_reason, + "message": short_reason, + "action": "abort", + "hint": hint, + "exit_code": 1, + }); + if kind == "invalid_cwd" { + if let Some(error) = error.downcast_ref::() { + if let Some(object) = error_json.as_object_mut() { + object.insert("path".to_string(), serde_json::json!(&error.path)); + object.insert( + "reason".to_string(), + serde_json::json!(error.reason.as_str()), + ); + } + } + } // #819/#820/#823: JSON mode error envelopes must go to stdout so machine // consumers can parse failures from stdout byte 0 (parity with all // non-interactive command guards that already use println! / to_stdout). - println!( - "{}", - serde_json::json!({ - "type": "error", - "kind": kind, - "status": "error", - "error_kind": kind, - "error": short_reason, - "message": short_reason, - "action": "abort", - "hint": hint, - "exit_code": 1, - }) - ); + println!("{}", error_json); } else { // #156: Add machine-readable error kind to text output so stderr observers // don't need to regex-scrape the prose. @@ -399,6 +411,8 @@ fn classify_error_kind(message: &str) -> &'static str { "missing_argument" } else if message.contains("unsupported skills action") { "unsupported_skills_action" + } else if message.starts_with("invalid_cwd:") { + "invalid_cwd" } else if message.contains("unrecognized argument") || message.contains("unknown option") { "cli_parse" } else if message.starts_with("missing_flag_value:") { @@ -548,6 +562,178 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InvalidCwdReason { + Empty, + NotFound, + NotADirectory, +} + +impl InvalidCwdReason { + fn as_str(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::NotFound => "not_found", + Self::NotADirectory => "not_a_directory", + } + } +} + +#[derive(Debug)] +struct InvalidCwdError { + path: String, + reason: InvalidCwdReason, +} + +impl InvalidCwdError { + fn new(path: impl Into, reason: InvalidCwdReason) -> Self { + Self { + path: path.into(), + reason, + } + } +} + +impl std::fmt::Display for InvalidCwdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "invalid_cwd: {}: `{}`\nUsage: --cwd , -C , or --directory ", + self.reason.as_str(), + self.path + ) + } +} + +impl std::error::Error for InvalidCwdError {} + +fn split_global_cwd_args( + args: &[String], +) -> Result<(Vec, Option), Box> { + let mut filtered = Vec::with_capacity(args.len()); + let mut cwd = None; + let mut index = 0; + + while index < args.len() { + let arg = &args[index]; + match arg.as_str() { + "--cwd" | "-C" | "--directory" => { + let value = args.get(index + 1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "missing_flag_value: missing value for --cwd.\nUsage: --cwd , -C , or --directory ", + ) + })?; + cwd = Some(validate_global_cwd(value)?); + index += 2; + } + flag if flag.starts_with("--cwd=") => { + let value = &flag[6..]; + cwd = Some(validate_global_cwd(value)?); + index += 1; + } + flag if flag.starts_with("--directory=") => { + let value = &flag[12..]; + cwd = Some(validate_global_cwd(value)?); + index += 1; + } + flag if global_flag_takes_value(flag) => { + filtered.push(arg.clone()); + if let Some(value) = args.get(index + 1) { + filtered.push(value.clone()); + index += 2; + } else { + index += 1; + } + } + flag if global_flag_is_value_inline(flag) => { + filtered.push(arg.clone()); + index += 1; + } + flag if global_flag_without_value(flag) => { + filtered.push(arg.clone()); + index += 1; + } + "--" => { + filtered.extend(args[index..].iter().cloned()); + break; + } + other if other.starts_with('-') => { + filtered.push(arg.clone()); + index += 1; + } + _ => { + filtered.extend(args[index..].iter().cloned()); + break; + } + } + } + + Ok((filtered, cwd)) +} + +fn global_flag_takes_value(flag: &str) -> bool { + matches!( + flag, + "--model" + | "--output-format" + | "--permission-mode" + | "--base-commit" + | "--reasoning-effort" + | "--allowedTools" + | "--allowed-tools" + ) +} + +fn global_flag_is_value_inline(flag: &str) -> bool { + flag.starts_with("--model=") + || flag.starts_with("--output-format=") + || flag.starts_with("--permission-mode=") + || flag.starts_with("--base-commit=") + || flag.starts_with("--reasoning-effort=") + || flag.starts_with("--allowedTools=") + || flag.starts_with("--allowed-tools=") +} + +fn global_flag_without_value(flag: &str) -> bool { + matches!( + flag, + "--help" + | "-h" + | "--version" + | "-V" + | "--dangerously-skip-permissions" + | "--skip-permissions" + | "--compact" + | "--allow-broad-cwd" + | "--print" + | "--acp" + | "-acp" + ) +} + +fn validate_global_cwd(value: &str) -> Result { + if value.trim().is_empty() { + return Err(InvalidCwdError::new(value, InvalidCwdReason::Empty)); + } + let path = PathBuf::from(value); + match fs::metadata(&path) { + Ok(metadata) if metadata.is_dir() => Ok(path), + Ok(_) => Err(InvalidCwdError::new(value, InvalidCwdReason::NotADirectory)), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + Err(InvalidCwdError::new(value, InvalidCwdReason::NotFound)) + } + Err(_) => Err(InvalidCwdError::new(value, InvalidCwdReason::NotFound)), + } +} + +fn apply_global_cwd(cwd: Option) -> Result<(), Box> { + if let Some(cwd) = cwd { + env::set_current_dir(cwd)?; + } + Ok(()) +} + /// Read piped stdin content when stdin is not a terminal. /// /// Returns `None` when stdin is attached to a terminal (interactive REPL use), @@ -654,6 +840,8 @@ fn run() -> Result<(), Box> { if json_mode { runtime::suppress_config_warnings_for_json_mode(); } + let (args, cwd) = split_global_cwd_args(&args)?; + apply_global_cwd(cwd)?; match parse_args(&args)? { CliAction::DumpManifests { output_format, @@ -12073,6 +12261,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " --output-format FORMAT Non-interactive output format: text or json" )?; + writeln!( + out, + " --cwd PATH, -C PATH, --directory PATH Run as if launched from PATH" + )?; writeln!( out, " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" @@ -14086,6 +14278,10 @@ mod tests { classify_error_kind("invalid_permission_mode: unsupported permission mode 'bogus'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"), "invalid_permission_mode" ); + assert_eq!( + classify_error_kind("invalid_cwd: not_found: `/tmp/missing`\nUsage: --cwd "), + "invalid_cwd" + ); assert_eq!( classify_error_kind("is not yet implemented"), "unsupported_command" diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index dabb32dc..e86fe73b 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -24,6 +24,13 @@ fn help_emits_json_when_requested() { .as_str() .expect("help text") .contains("Usage:")); + assert!( + parsed["message"] + .as_str() + .expect("help text") + .contains("--cwd PATH, -C PATH, --directory PATH"), + "help JSON should document global cwd override (#429): {parsed}" + ); } #[test] @@ -534,6 +541,99 @@ fn invalid_permission_mode_json_is_typed_428() { ); } +#[test] +fn global_cwd_flag_routes_status_workspace_and_short_alias_429() { + let parent = unique_temp_dir("global-cwd-parent-429"); + let workspace = parent.join("workspace"); + let launcher = parent.join("launcher"); + fs::create_dir_all(&workspace).expect("workspace dir should exist"); + fs::create_dir_all(&launcher).expect("launcher dir should exist"); + + let workspace_str = workspace.to_str().expect("utf8 workspace"); + let expected_cwd = fs::canonicalize(&workspace) + .expect("workspace should canonicalize") + .display() + .to_string(); + let status = assert_json_command( + &launcher, + &["--cwd", workspace_str, "--output-format", "json", "status"], + ); + assert_eq!(status["kind"], "status"); + assert_eq!(status["workspace"]["cwd"], expected_cwd); + + let short_status = assert_json_command( + &launcher, + &["-C", workspace_str, "status", "--output-format", "json"], + ); + assert_eq!(short_status["workspace"]["cwd"], expected_cwd); + + let directory_status = assert_json_command( + &launcher, + &[ + "--directory", + workspace_str, + "--output-format=json", + "status", + ], + ); + assert_eq!(directory_status["workspace"]["cwd"], expected_cwd); +} + +#[test] +fn global_cwd_flag_reports_typed_invalid_paths_429() { + let root = unique_temp_dir("global-cwd-invalid-429"); + let file = root.join("not-a-directory"); + fs::create_dir_all(&root).expect("root dir should exist"); + fs::write(&file, "not a dir").expect("file fixture should write"); + + let missing = root.join("missing"); + let output = run_claw( + &root, + &[ + "--cwd", + missing.to_str().expect("utf8 missing path"), + "status", + "--output-format", + "json", + ], + &[], + ); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|_| panic!("invalid cwd should emit JSON, got: {stdout:?}")); + assert_eq!(parsed["kind"], "invalid_cwd"); + assert_eq!(parsed["error_kind"], "invalid_cwd"); + assert_eq!(parsed["reason"], "not_found"); + assert_eq!(parsed["path"], missing.to_str().expect("utf8 missing path")); + assert!(output.stderr.is_empty()); + + let file_output = run_claw( + &root, + &[ + "--cwd", + file.to_str().expect("utf8 file path"), + "status", + "--output-format=json", + ], + &[], + ); + assert_eq!(file_output.status.code(), Some(1)); + let file_stdout = String::from_utf8_lossy(&file_output.stdout); + let file_json: Value = serde_json::from_str(file_stdout.trim()) + .unwrap_or_else(|_| panic!("file cwd should emit JSON, got: {file_stdout:?}")); + assert_eq!(file_json["kind"], "invalid_cwd"); + assert_eq!(file_json["reason"], "not_a_directory"); + + let empty_output = run_claw(&root, &["--cwd", "", "status", "--output-format=json"], &[]); + assert_eq!(empty_output.status.code(), Some(1)); + let empty_stdout = String::from_utf8_lossy(&empty_output.stdout); + let empty_json: Value = serde_json::from_str(empty_stdout.trim()) + .unwrap_or_else(|_| panic!("empty cwd should emit JSON, got: {empty_stdout:?}")); + assert_eq!(empty_json["kind"], "invalid_cwd"); + assert_eq!(empty_json["reason"], "empty"); +} + #[test] fn status_json_accepts_namespaced_model_env_and_surfaces_alias_426() { let root = unique_temp_dir("status-model-env-426");