fix: make dump-manifests self-contained

This commit is contained in:
bellman
2026-06-04 02:46:44 +09:00
parent cd58c054ca
commit 4522490bd5
6 changed files with 328 additions and 169 deletions

View File

@@ -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: <path>` 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 <session> /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/<id>/` 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 <bogus-positional>` 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 <PATH>` 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 <name>` 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 <bogus-id>` requires creds). **Three sibling findings in same probe:** (a) `claw skills install <bogus-name>` 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/<name>.md` files with no scaffolding command, while `claw init` only creates the top-level `.claw/` skeleton. **Required fix shape:** (a) `skills uninstall <name>` 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:<name>` for present skills; (b) `skills install <source>` 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 <name>` (or `claw init agent <name>`) that scaffolds `.claw/agents/<name>.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.

1
rust/Cargo.lock generated
View File

@@ -2244,7 +2244,6 @@ version = "0.1.3"
dependencies = [
"api",
"commands",
"compat-harness",
"crossterm",
"log",
"mock-anthropic-service",

View File

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

View File

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

View File

@@ -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::<InvalidOutputPathError>() {
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<String>, 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<String>, Option<PathBuf>), Box<dyn std::error::Error>> {
@@ -3844,12 +3904,12 @@ fn dump_manifests(
manifests_dir: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
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 <path>] [--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<dyn std::error::Error>> {
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::<Vec<_>>();
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<Value, Box<dyn std::error::Error>> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<dyn std::error::Error>> {
@@ -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<dyn std::error::Error>> {
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)]

View File

@@ -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<String> {
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()