diff --git a/ROADMAP.md b/ROADMAP.md index 25fef2c5..ab652332 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1244,8 +1244,7 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes 45. **`claw dump-manifests` fails with opaque "No such file or directory"** — dogfooded 2026-04-09. `claw dump-manifests` emits `error: failed to extract manifests: No such file or directory (os error 2)` with no indication of which file or directory is missing. **Partial fix at `47aa1a5`+1**: error message now includes `looked in: ` so the build-tree path is visible, what manifests are, or how to fix it. Fix shape: (a) surface the missing path in the error message; (b) add a pre-check that explains what manifests are and where they should be (e.g. `.claw/manifests/` or the plugins directory); (c) if the command is only valid after `claw init` or after installing plugins, say so explicitly. Source: Jobdori dogfood 2026-04-09. -45. **`claw dump-manifests` fails with opaque `No such file or directory`** — **done (verified 2026-04-12):** current `main` now accepts `claw dump-manifests --manifests-dir PATH`, pre-checks for the required upstream manifest files (`src/commands.ts`, `src/tools.ts`, `src/entrypoints/cli.tsx`), and replaces the opaque os error with guidance that points users to `CLAUDE_CODE_UPSTREAM` or `--manifests-dir`. Fresh proof: parser coverage for both flag forms, unit coverage for missing-manifest and explicit-path flows, and `output_format_contract` JSON coverage via the new flag all pass. **Original filing below.** -45. **`claw dump-manifests` fails with opaque `No such file or directory`** — **done (verified 2026-04-12):** current `main` now accepts `claw dump-manifests --manifests-dir PATH`, pre-checks for the required upstream manifest files (`src/commands.ts`, `src/tools.ts`, `src/entrypoints/cli.tsx`), and replaces the opaque os error with guidance that points users to `CLAUDE_CODE_UPSTREAM` or `--manifests-dir`. Fresh proof: parser coverage for both flag forms, unit coverage for missing-manifest and explicit-path flows, and `output_format_contract` JSON coverage via the new flag all pass. **Original filing below.** +45. **`claw dump-manifests` fails with opaque `No such file or directory`** — **done (verified 2026-06-03):** current `main` now emits a self-contained Rust resolver inventory for `claw dump-manifests` without requiring upstream TypeScript files, build-machine paths, or `CLAUDE_CODE_UPSTREAM`. Explicit `--manifests-dir PATH` scopes resolver discovery to another directory and missing/not-directory values emit typed `missing_manifests` guidance. Fresh proof: parser coverage for both flag forms, unit coverage for self-contained default, explicit-directory, and missing-directory flows, plus `output_format_contract` JSON coverage all pass. **Original filing below.** 46. **`/tokens`, `/cache`, `/stats` were dead spec — parse arms missing** — dogfooded 2026-04-09. All three had spec entries with `resume_supported: true` but no parse arms, producing the circular error "Unknown slash command: /tokens — Did you mean /tokens". Also `SlashCommand::Stats` existed but was unimplemented in both REPL and resume dispatch. **Done at `60ec2ae` 2026-04-09**: `"tokens" | "cache"` now alias to `SlashCommand::Stats`; `Stats` is wired in both REPL and resume path with full JSON output. Source: Jobdori dogfood. 47. **`/diff` fails with cryptic "unknown option 'cached'" outside a git repo; resume /diff used wrong CWD** — dogfooded 2026-04-09. `claw --resume /diff` in a non-git directory produced `git diff --cached failed: error: unknown option 'cached'` because git falls back to `--no-index` mode outside a git tree. Also resume `/diff` used `session_path.parent()` (the `.claw/sessions//` dir) as CWD for the diff — never a git repo. **Done at `aef85f8` 2026-04-09**: `render_diff_report_for()` now checks `git rev-parse --is-inside-work-tree` first and returns a clear "no git repository" message; resume `/diff` uses `std::env::current_dir()`. Source: Jobdori dogfood. @@ -1547,7 +1546,7 @@ Original filing (2026-04-13): user requested a `-acp` parameter to support ACP p **Source.** Jobdori dogfood 2026-04-17 against `/tmp/cd3` on main HEAD `e58c194` in response to Clawhip pinpoint nudge at `1494653681222811751`. Distinct from #80/#81/#82 (status/error surfaces lie about *static* runtime state): this is a surface that lies about *time itself*, and the lie is smeared into every live-agent system prompt, not just a single error string or status field. -84. **`claw dump-manifests` default search path is the build machine's absolute filesystem path baked in at compile time — broken and information-leaking for any user running a distributed binary** — dogfooded 2026-04-17 on main HEAD `70a0f0c` from `/tmp/cd4` (fresh workspace). Running `claw dump-manifests` with no arguments emits: +84. **DONE — `claw dump-manifests` no longer bakes or leaks the build machine's absolute filesystem path** — fixed 2026-06-03 in `fix: make dump-manifests self-contained`. The runtime default now uses the current workspace and emits the Rust resolver inventory directly, so distributed binaries no longer depend on upstream TypeScript source files or compile-time `CARGO_MANIFEST_DIR` paths. Explicit `--manifests-dir` remains as a discovery-root override and invalid roots return typed `missing_manifests` diagnostics. Original filing below: running `claw dump-manifests` with no arguments emitted: ``` error: Manifest source files are missing. repo root: /Users/yeongyu/clawd/claw-code @@ -6366,7 +6365,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 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. +430. **DONE — `dump-manifests` emits the self-contained Rust resolver inventory instead of requiring upstream Claude Code TypeScript source files** — fixed 2026-06-03 in `fix: make dump-manifests self-contained`. `claw dump-manifests --output-format json` now succeeds from an installed workspace with `source:"rust-resolver"`, command/tool/agent/skill/bootstrap manifests, no `CLAUDE_CODE_UPSTREAM` hint, and no `src/commands.ts` dependency. Explicit `--manifests-dir` scopes resolver discovery to another directory and missing/not-directory roots emit typed `missing_manifests` JSON. Sibling export diagnostics now validate explicit positional/`--output` paths before session discovery and return typed `invalid_output_path` JSON with `path` and `reason`. Regression coverage: `dump_manifests_defaults_to_rust_resolver_inventory`, `dump_manifests_scopes_explicit_manifest_dir_without_upstream_ts`, `dump_manifests_missing_explicit_dir_has_typed_kind`, `dump_manifests_and_init_emit_json_when_requested`, `local_json_surfaces_have_non_empty_action_contract_714`, and `export_invalid_output_path_reports_typed_json_430`. 431. **`skills uninstall ` requires Anthropic credentials despite being a local filesystem operation — `claw skills uninstall nonexistent-skill-xyz --output-format json` returns `kind:"missing_credentials"` instead of resolving locally that the skill doesn't exist** — dogfooded 2026-05-11 by Jobdori on `328fd114` in response to Clawhip pinpoint nudge at `1503275502046023690` (sibling probe to #430). Reproduction (no creds, isolated `CLAW_CONFIG_HOME`): `claw skills uninstall nonexistent-skill-xyz --output-format json` returns `{"error":"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY...","kind":"missing_credentials"}`. Uninstalling a skill is a pure local filesystem operation: read the skills directory, find the named skill, remove its files. There is no semantic reason to require API credentials. Same class of bug as #357 (`session list` requires creds), #369 (`session help/fork` require creds), and #427 (`resume ` requires creds). **Three sibling findings in same probe:** (a) `claw skills install ` returns `{"error":"No such file or directory (os error 2)","kind":"unknown"}` — leaks raw OS error string with no hint about expected install source format (path vs name vs URL?), and the catch-all `kind:"unknown"` again instead of typed `kind:"skill_install_source_not_found"`. (b) `claw skills install` (no args) returns `action:"help"` with `unexpected:"install"` — but `install` IS a documented subcommand. The handler treats it as "unknown action" instead of "missing required argument". Should emit `kind:"missing_argument"` with `argument:"install_source"`. (c) `claw agents create my-agent` returns `action:"help"` with `unexpected:"create my-agent"` — there is no agent-creation surface at all. Users must hand-craft `.claw/agents/.md` files with no scaffolding command, while `claw init` only creates the top-level `.claw/` skeleton. **Required fix shape:** (a) `skills uninstall ` must be local-first: enumerate the local skills dir, return `kind:"skill_not_found"` (with `skills_dir:` and `available_names:[]` fields) for missing, or remove the files and return `kind:"skills"` with `action:"uninstall", removed:` for present skills; (b) `skills install ` must distinguish source forms (`path:`, `name:`, `url:`) and emit `kind:"invalid_install_source"` with the parsed-and-failed reason; (c) `skills install` (no args) emits `kind:"missing_argument"` with `argument:"install_source"`; (d) add `claw agents create ` (or `claw init agent `) that scaffolds `.claw/agents/.md` with a stub frontmatter; or document explicitly that agents are user-authored only. **Why this matters:** lifecycle commands (`uninstall`, `install`, `create`) are the primary surface for managing claw's extension surface area. If `uninstall` requires API creds, an offline user who fat-fingered an install can't undo it. If `install` returns a raw OS error, automation can't programmatically recover. If `agents create` doesn't exist, agent authoring is undocumented file-touching only. Cross-references #357, #369, #427 (auth-gate-on-local-ops cluster), and #422/#423/#428/#430 (`kind:"unknown"` catch-all cluster). Source: Jobdori live dogfood, `328fd114`, 2026-05-11. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d428d7af..8f1a171d 100755 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2244,7 +2244,6 @@ version = "0.1.3" dependencies = [ "api", "commands", - "compat-harness", "crossterm", "log", "mock-anthropic-service", diff --git a/rust/README.md b/rust/README.md index 19fc18b0..4150ad2a 100644 --- a/rust/README.md +++ b/rust/README.md @@ -147,6 +147,7 @@ Top-level commands: ``` `claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`. +`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory. The command surface is moving quickly. For the canonical live help text, run: @@ -185,7 +186,7 @@ rust/ └── crates/ ├── api/ # Provider clients + streaming + request preflight ├── commands/ # Shared slash-command registry + help rendering - ├── compat-harness/ # TS manifest extraction harness + ├── compat-harness/ # Compatibility/parity harness utilities ├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock ├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces ├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop @@ -198,7 +199,7 @@ rust/ - **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight - **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering -- **compat-harness** — extracts tool/prompt manifests from upstream TS source +- **compat-harness** — compatibility and parity helpers for comparing behavior with upstream fixtures - **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs - **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces - **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index 10f52eac..d0441760 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -12,7 +12,6 @@ path = "src/main.rs" [dependencies] api = { path = "../api" } commands = { path = "../commands" } -compat-harness = { path = "../compat-harness" } crossterm = "0.28" pulldown-cmark = "0.13" rustyline = "15" diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7d3507e2..cf6b9927 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -48,7 +48,6 @@ use commands::{ slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch, SlashCommand, }; -use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; @@ -347,6 +346,16 @@ fn main() { ); } } + } else if kind == "invalid_output_path" { + 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 @@ -387,7 +396,9 @@ fn classify_error_kind(message: &str) -> &'static str { "command_not_found" } else if message.contains("missing Anthropic credentials") { "missing_credentials" - } else if message.contains("Manifest source files are missing") { + } else if message.contains("Manifest source files are missing") + || message.starts_with("missing_manifests:") + { "missing_manifests" } else if message.contains("no worker state file found") { "missing_worker_state" @@ -413,6 +424,8 @@ fn classify_error_kind(message: &str) -> &'static str { "unsupported_skills_action" } else if message.starts_with("invalid_cwd:") { "invalid_cwd" + } else if message.starts_with("invalid_output_path:") { + "invalid_output_path" } else if message.contains("unrecognized argument") || message.contains("unknown option") { "cli_parse" } else if message.starts_with("missing_flag_value:") { @@ -607,6 +620,53 @@ impl std::fmt::Display for InvalidCwdError { impl std::error::Error for InvalidCwdError {} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InvalidOutputPathReason { + Empty, + ParentNotFound, + ParentNotADirectory, + PathIsDirectory, +} + +impl InvalidOutputPathReason { + fn as_str(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::ParentNotFound => "parent_not_found", + Self::ParentNotADirectory => "parent_not_a_directory", + Self::PathIsDirectory => "path_is_directory", + } + } +} + +#[derive(Debug)] +struct InvalidOutputPathError { + path: String, + reason: InvalidOutputPathReason, +} + +impl InvalidOutputPathError { + fn new(path: impl Into, reason: InvalidOutputPathReason) -> Self { + Self { + path: path.into(), + reason, + } + } +} + +impl std::fmt::Display for InvalidOutputPathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "invalid_output_path: {}: `{}`\nUsage: claw export [PATH] [--session SESSION] [--output PATH]", + self.reason.as_str(), + self.path + ) + } +} + +impl std::error::Error for InvalidOutputPathError {} + fn split_global_cwd_args( args: &[String], ) -> Result<(Vec, Option), Box> { @@ -3844,12 +3904,12 @@ fn dump_manifests( manifests_dir: Option<&Path>, output_format: CliOutputFormat, ) -> Result<(), Box> { - let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let workspace_dir = env::current_dir()?; dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) } -const DUMP_MANIFESTS_OVERRIDE_HINT: &str = - "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; +const DUMP_MANIFESTS_USAGE_HINT: &str = + "Usage: claw dump-manifests [--manifests-dir ] [--output-format json]"; // Internal function for testing that accepts a workspace directory path. fn dump_manifests_at_path( @@ -3857,72 +3917,105 @@ fn dump_manifests_at_path( manifests_dir: Option<&Path>, output_format: CliOutputFormat, ) -> Result<(), Box> { - let paths = if let Some(dir) = manifests_dir { - let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); - UpstreamPaths::from_repo_root(resolved) - } else { - // Surface the resolved path in the error so users can diagnose missing - // manifest files without guessing what path the binary expected. - let resolved = workspace_dir - .canonicalize() - .unwrap_or_else(|_| workspace_dir.to_path_buf()); - UpstreamPaths::from_workspace_dir(&resolved) - }; + let discovery_root = manifests_dir.unwrap_or(workspace_dir); + let resolved_root = discovery_root + .canonicalize() + .unwrap_or_else(|_| discovery_root.to_path_buf()); - let source_root = paths.repo_root(); - if !source_root.exists() { + if !resolved_root.exists() { return Err(format!( - "Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), + "missing_manifests: manifest discovery directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_USAGE_HINT}", + resolved_root.display(), + ) + .into()); + } + if !resolved_root.is_dir() { + return Err(format!( + "missing_manifests: manifest discovery path is not a directory.\n looked in: {}\n {DUMP_MANIFESTS_USAGE_HINT}", + resolved_root.display(), ) .into()); } - let required_paths = [ - ("src/commands.ts", paths.commands_path()), - ("src/tools.ts", paths.tools_path()), - ("src/entrypoints/cli.tsx", paths.cli_path()), - ]; - let missing = required_paths - .iter() - .filter_map(|(label, path)| (!path.is_file()).then_some(*label)) - .collect::>(); - if !missing.is_empty() { - return Err(format!( - "Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), - missing.join(", "), - ) - .into()); - } - - match extract_manifest(&paths) { - Ok(manifest) => { - match output_format { - CliOutputFormat::Text => { - println!("commands: {}", manifest.commands.entries().len()); - println!("tools: {}", manifest.tools.entries().len()); - println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "dump-manifests", - "action": "dump", - "commands": manifest.commands.entries().len(), - "tools": manifest.tools.entries().len(), - "bootstrap_phases": manifest.bootstrap.phases().len(), - }))? - ), - } - Ok(()) + let manifest = build_rust_resolver_manifest(&resolved_root)?; + match output_format { + CliOutputFormat::Text => { + println!("Manifest Dump"); + println!(" Source rust-resolver"); + println!(" Workspace {}", resolved_root.display()); + println!(" Commands {}", manifest["commands"]); + println!(" Tools {}", manifest["tools"]); + println!(" Agents {}", manifest["agents"]); + println!(" Skills {}", manifest["skills"]); + println!(" Bootstrap phases {}", manifest["bootstrap_phases"]); } - Err(error) => Err(format!( - "failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - path = paths.repo_root().display() - ) - .into()), + CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&manifest)?), } + Ok(()) +} + +fn build_rust_resolver_manifest(workspace_dir: &Path) -> Result> { + let command_entries = slash_command_specs() + .iter() + .map(|spec| { + json!({ + "name": spec.name, + "aliases": spec.aliases, + "summary": spec.summary, + "argument_hint": spec.argument_hint, + "resume_supported": spec.resume_supported, + "implemented": !STUB_COMMANDS.contains(&spec.name), + }) + }) + .collect::>(); + + let tool_entries = mvp_tool_specs() + .into_iter() + .map(|spec| { + json!({ + "name": spec.name, + "description": spec.description, + "required_permission": spec.required_permission.as_str(), + "input_schema": spec.input_schema, + }) + }) + .collect::>(); + + let agent_report = handle_agents_slash_command_json(None, workspace_dir)?; + let skill_report = handle_skills_slash_command_json(None, workspace_dir)?; + let agents = agent_report + .get("agents") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let skills = skill_report + .get("skills") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let bootstrap = runtime::BootstrapPlan::claude_code_default() + .phases() + .iter() + .map(|phase| format!("{phase:?}")) + .collect::>(); + + Ok(json!({ + "kind": "dump-manifests", + "action": "dump", + "status": "ok", + "source": "rust-resolver", + "workspace": workspace_dir.display().to_string(), + "commands": command_entries.len(), + "tools": tool_entries.len(), + "agents": agents.len(), + "skills": skills.len(), + "bootstrap_phases": bootstrap.len(), + "command_manifests": command_entries, + "tool_manifests": tool_entries, + "agent_manifests": agents, + "skill_manifests": skills, + "bootstrap_manifest": bootstrap, + })) } fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box> { @@ -9912,6 +10005,52 @@ fn resolve_export_path( Ok(cwd.join(final_name)) } +fn validate_export_output_path(path: Option<&Path>) -> Result<(), InvalidOutputPathError> { + let Some(path) = path else { + return Ok(()); + }; + let raw = path.to_string_lossy(); + if raw.trim().is_empty() { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::Empty, + )); + } + if matches!(fs::metadata(path), Ok(metadata) if metadata.is_dir()) { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::PathIsDirectory, + )); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + match fs::metadata(parent) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::ParentNotADirectory, + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::ParentNotFound, + )); + } + Err(_) => { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::ParentNotFound, + )); + } + } + } + Ok(()) +} + const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; fn summarize_tool_payload_for_markdown(payload: &str) -> String { @@ -9930,6 +10069,7 @@ fn run_export( output_path: Option<&Path>, output_format: CliOutputFormat, ) -> Result<(), Box> { + validate_export_output_path(output_path)?; let (handle, session) = load_session_reference(session_reference)?; let markdown = render_session_markdown(&session, &handle.id, &handle.path); @@ -17472,81 +17612,76 @@ mod sandbox_report_tests { #[cfg(test)] mod dump_manifests_tests { - use super::{dump_manifests_at_path, CliOutputFormat}; + use super::{build_rust_resolver_manifest, dump_manifests_at_path, CliOutputFormat}; use std::fs; #[test] - fn dump_manifests_shows_helpful_error_when_manifests_missing() { - let root = std::env::temp_dir().join(format!( - "claw_test_missing_manifests_{}", - std::process::id() - )); + fn dump_manifests_defaults_to_rust_resolver_inventory() { + let root = + std::env::temp_dir().join(format!("claw_test_rust_manifests_{}", std::process::id())); let workspace = root.join("workspace"); - std::fs::create_dir_all(&workspace).expect("failed to create temp workspace"); + fs::create_dir_all(&workspace).expect("workspace should exist"); - let result = dump_manifests_at_path(&workspace, None, CliOutputFormat::Text); - assert!( - result.is_err(), - "expected an error when manifests are missing" - ); + let manifest = build_rust_resolver_manifest(&workspace).expect("manifest should build"); + assert_eq!(manifest["kind"], "dump-manifests"); + assert_eq!(manifest["source"], "rust-resolver"); + assert!(manifest["commands"].as_u64().expect("commands count") > 0); + assert!(manifest["tools"].as_u64().expect("tools count") > 0); + assert!(manifest["command_manifests"] + .as_array() + .expect("command manifests") + .iter() + .any(|entry| entry["name"] == "status")); + assert!(manifest["tool_manifests"] + .as_array() + .expect("tool manifests") + .iter() + .any(|entry| entry["name"] == "read_file")); + assert!(dump_manifests_at_path(&workspace, None, CliOutputFormat::Text).is_ok()); - let error_msg = result.unwrap_err().to_string(); - - assert!( - error_msg.contains("Manifest source files are missing"), - "error message should mention missing manifest sources: {error_msg}" - ); - assert!( - error_msg.contains(&root.display().to_string()), - "error message should contain the resolved repo root path: {error_msg}" - ); - assert!( - error_msg.contains("src/commands.ts"), - "error message should mention missing commands.ts: {error_msg}" - ); - assert!( - error_msg.contains("CLAUDE_CODE_UPSTREAM"), - "error message should explain how to supply the upstream path: {error_msg}" - ); - - let _ = std::fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&root); } #[test] - fn dump_manifests_uses_explicit_manifest_dir() { + fn dump_manifests_scopes_explicit_manifest_dir_without_upstream_ts() { let root = std::env::temp_dir().join(format!( "claw_test_explicit_manifest_dir_{}", std::process::id() )); let workspace = root.join("workspace"); - let upstream = root.join("upstream"); - fs::create_dir_all(workspace.join("nested")).expect("workspace should exist"); - fs::create_dir_all(upstream.join("src/entrypoints")) - .expect("upstream fixture should exist"); - fs::write( - upstream.join("src/commands.ts"), - "import FooCommand from './commands/foo'\n", - ) - .expect("commands fixture should write"); - fs::write( - upstream.join("src/tools.ts"), - "import ReadTool from './tools/read'\n", - ) - .expect("tools fixture should write"); - fs::write( - upstream.join("src/entrypoints/cli.tsx"), - "startupProfiler()\n", - ) - .expect("cli fixture should write"); + let manifest_dir = root.join("manifest-source"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + fs::create_dir_all(&manifest_dir).expect("manifest dir should exist"); - let result = dump_manifests_at_path(&workspace, Some(&upstream), CliOutputFormat::Text); + let result = dump_manifests_at_path(&workspace, Some(&manifest_dir), CliOutputFormat::Text); assert!( result.is_ok(), - "explicit manifest dir should succeed: {result:?}" + "explicit manifest dir should not require upstream TS files: {result:?}" ); let _ = fs::remove_dir_all(&root); } + + #[test] + fn dump_manifests_missing_explicit_dir_has_typed_kind() { + let root = std::env::temp_dir().join(format!( + "claw_test_missing_manifest_dir_{}", + std::process::id() + )); + let workspace = root.join("workspace"); + let missing = root.join("missing"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + + let result = dump_manifests_at_path(&workspace, Some(&missing), CliOutputFormat::Text); + let error = result.expect_err("missing explicit manifest dir should fail"); + let error_msg = error.to_string(); + assert!(error_msg.starts_with("missing_manifests:")); + assert!(error_msg.contains(&missing.display().to_string())); + assert!(!error_msg.contains("CLAUDE_CODE_UPSTREAM")); + assert!(!error_msg.contains("src/commands.ts")); + + let _ = fs::remove_dir_all(&root); + } } #[cfg(test)] 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 e86fe73b..0854b175 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -634,6 +634,60 @@ fn global_cwd_flag_reports_typed_invalid_paths_429() { assert_eq!(empty_json["reason"], "empty"); } +#[test] +fn export_invalid_output_path_reports_typed_json_430() { + let root = unique_temp_dir("export-invalid-output-430"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let missing_relative = "missing/transcript.md"; + let missing_output = run_claw( + &root, + &["--output-format", "json", "export", missing_relative], + &[], + ); + assert_eq!(missing_output.status.code(), Some(1)); + assert!( + missing_output.stderr.is_empty(), + "invalid export path JSON should keep stderr empty, got:\n{}", + String::from_utf8_lossy(&missing_output.stderr) + ); + let missing_stdout = String::from_utf8_lossy(&missing_output.stdout); + let missing_json: Value = serde_json::from_str(missing_stdout.trim()).unwrap_or_else(|_| { + panic!("invalid export path should emit JSON, got: {missing_stdout:?}") + }); + assert_eq!(missing_json["kind"], "invalid_output_path"); + assert_eq!(missing_json["error_kind"], "invalid_output_path"); + assert_eq!(missing_json["reason"], "parent_not_found"); + assert_eq!(missing_json["path"], missing_relative); + + let directory = root.join("existing-directory"); + fs::create_dir_all(&directory).expect("directory fixture should exist"); + let directory_output = run_claw( + &root, + &[ + "--output-format=json", + "export", + "--output", + directory.to_str().expect("utf8 directory path"), + ], + &[], + ); + assert_eq!(directory_output.status.code(), Some(1)); + assert!(directory_output.stderr.is_empty()); + let directory_stdout = String::from_utf8_lossy(&directory_output.stdout); + let directory_json: Value = + serde_json::from_str(directory_stdout.trim()).unwrap_or_else(|_| { + panic!("directory export path should emit JSON, got: {directory_stdout:?}") + }); + assert_eq!(directory_json["kind"], "invalid_output_path"); + assert_eq!(directory_json["error_kind"], "invalid_output_path"); + assert_eq!(directory_json["reason"], "path_is_directory"); + assert_eq!( + directory_json["path"], + directory.to_str().expect("utf8 directory path") + ); +} + #[test] fn status_json_accepts_namespaced_model_env_and_surfaces_alias_426() { let root = unique_temp_dir("status-model-env-426"); @@ -1154,20 +1208,22 @@ fn dump_manifests_and_init_emit_json_when_requested() { let root = unique_temp_dir("manifest-init-json"); fs::create_dir_all(&root).expect("temp dir should exist"); - let upstream = write_upstream_fixture(&root); - let manifests = assert_json_command( - &root, - &[ - "--output-format", - "json", - "dump-manifests", - "--manifests-dir", - upstream.to_str().expect("utf8 upstream"), - ], - ); + let manifests = assert_json_command(&root, &["--output-format", "json", "dump-manifests"]); assert_eq!(manifests["kind"], "dump-manifests"); - assert_eq!(manifests["commands"], 1); - assert_eq!(manifests["tools"], 1); + assert_eq!(manifests["status"], "ok"); + assert_eq!(manifests["source"], "rust-resolver"); + assert!(manifests["commands"].as_u64().expect("commands count") > 0); + assert!(manifests["tools"].as_u64().expect("tools count") > 0); + assert!(manifests["command_manifests"] + .as_array() + .expect("command manifests") + .iter() + .any(|entry| entry["name"] == "status")); + assert!(manifests["tool_manifests"] + .as_array() + .expect("tool manifests") + .iter() + .any(|entry| entry["name"] == "read_file")); let workspace = root.join("workspace"); fs::create_dir_all(&workspace).expect("workspace should exist"); @@ -1663,7 +1719,6 @@ fn local_json_surfaces_have_non_empty_action_contract_714() { let session_path = write_session_fixture(&workspace, "action-sweep-export", Some("export me")); let export_output = root.join("export.md"); - let upstream = write_upstream_fixture(&root); let git_init = Command::new("git") .arg("init") .current_dir(&git_workspace) @@ -1701,13 +1756,7 @@ fn local_json_surfaces_have_non_empty_action_contract_714() { ), ( &workspace, - vec![ - "--output-format".into(), - "json".into(), - "dump-manifests".into(), - "--manifests-dir".into(), - upstream.to_str().expect("upstream utf8").into(), - ], + strings(&["--output-format", "json", "dump-manifests"]), ), ( &workspace, @@ -2301,29 +2350,6 @@ fn strings(items: &[&str]) -> Vec { items.iter().map(|item| (*item).to_string()).collect() } -fn write_upstream_fixture(root: &Path) -> PathBuf { - let upstream = root.join("claw-code"); - let src = upstream.join("src"); - let entrypoints = src.join("entrypoints"); - fs::create_dir_all(&entrypoints).expect("upstream entrypoints dir should exist"); - fs::write( - src.join("commands.ts"), - "import FooCommand from './commands/foo'\n", - ) - .expect("commands fixture should write"); - fs::write( - src.join("tools.ts"), - "import ReadTool from './tools/read'\n", - ) - .expect("tools fixture should write"); - fs::write( - entrypoints.join("cli.tsx"), - "if (args[0] === '--version') {}\nstartupProfiler()\n", - ) - .expect("cli fixture should write"); - upstream -} - fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf { let session_path = root.join("session.jsonl"); let mut session = Session::new()