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

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()