Make dump-manifests recoverable outside the inferred build tree

The backlog sweep found that the user-cited #21-#23 items were already
closed, and the next real pain point was `claw dump-manifests` failing
without a direct way to point at the upstream manifest source. This adds
an explicit `--manifests-dir` path, upgrades the failure messages to say
whether the source root or required files are missing, and updates the
ROADMAP closeout to reflect that #45 is now fixed.

Constraint: Preserve existing dump-manifests behavior when no explicit override is supplied
Rejected: Require CLAUDE_CODE_UPSTREAM for every invocation | breaks existing build-tree workflows and is unnecessarily rigid
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep manifest-source override guidance centralized so future error-path edits do not drift
Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace; architect review APPROVE
Not-tested: Manual invocation against every legacy env-based manifest lookup layout
This commit is contained in:
Yeachan-Heo
2026-04-12 02:56:48 +00:00
parent 1d83e67802
commit 4f83a81cf6
3 changed files with 182 additions and 52 deletions

View File

@@ -177,7 +177,10 @@ fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? {
CliAction::DumpManifests { output_format } => dump_manifests(output_format)?,
CliAction::DumpManifests {
output_format,
manifests_dir,
} => dump_manifests(manifests_dir.as_deref(), output_format)?,
CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?,
CliAction::Agents {
args,
@@ -274,6 +277,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
enum CliAction {
DumpManifests {
output_format: CliOutputFormat,
manifests_dir: Option<PathBuf>,
},
BootstrapPlan {
output_format: CliOutputFormat,
@@ -623,7 +627,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests { output_format }),
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
"agents" => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]),
@@ -1213,6 +1217,39 @@ fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<
})
}
fn parse_dump_manifests_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut manifests_dir: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if arg == "--manifests-dir" {
let value = args
.get(index + 1)
.ok_or_else(|| String::from("--manifests-dir requires a path"))?;
manifests_dir = Some(PathBuf::from(value));
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--manifests-dir=") {
if value.is_empty() {
return Err(String::from("--manifests-dir requires a path"));
}
manifests_dir = Some(PathBuf::from(value));
index += 1;
continue;
}
return Err(format!("unknown dump-manifests option: {arg}"));
}
Ok(CliAction::DumpManifests {
output_format,
manifests_dir,
})
}
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
@@ -1915,32 +1952,58 @@ fn looks_like_slash_command_token(token: &str) -> bool {
.any(|spec| spec.name == name || spec.aliases.contains(&name))
}
fn dump_manifests(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
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("../..");
dump_manifests_at_path(&workspace_dir, output_format)
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`.";
// Internal function for testing that accepts a workspace directory path.
fn dump_manifests_at_path(
workspace_dir: &std::path::Path,
manifests_dir: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
// Surface the resolved path in the error so users can diagnose missing
// manifest files without guessing what path the binary expected.
// ROADMAP #45: this path is only correct when running from the build tree;
// a proper fix would ship manifests alongside the binary.
let resolved = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.to_path_buf());
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 paths = UpstreamPaths::from_workspace_dir(&resolved);
// Pre-check: verify manifest directory exists
let manifest_dir = paths.repo_root();
if !manifest_dir.exists() {
let source_root = paths.repo_root();
if !source_root.exists() {
return Err(format!(
"Manifest files (commands.ts, tools.ts) define CLI commands and tools.\n Expected at: {}\n Run `claw init` to create them or specify --manifests-dir.",
manifest_dir.display()
"Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
source_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());
}
@@ -1966,7 +2029,7 @@ fn dump_manifests_at_path(
Ok(())
}
Err(error) => Err(format!(
"failed to extract manifests: {error}\n looked in: {path}",
"failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
path = paths.repo_root().display()
)
.into()),
@@ -8048,7 +8111,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
out,
" Diagnose local auth, config, workspace, and sandbox health"
)?;
writeln!(out, " claw dump-manifests")?;
writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?;
writeln!(out, " claw bootstrap-plan")?;
writeln!(out, " claw agents")?;
writeln!(out, " claw mcp")?;
@@ -9063,6 +9126,33 @@ mod tests {
);
}
#[test]
fn dump_manifests_subcommand_accepts_explicit_manifest_dir() {
assert_eq!(
parse_args(&[
"dump-manifests".to_string(),
"--manifests-dir".to_string(),
"/tmp/upstream".to_string(),
])
.expect("dump-manifests should parse"),
CliAction::DumpManifests {
output_format: CliOutputFormat::Text,
manifests_dir: Some(PathBuf::from("/tmp/upstream")),
}
);
assert_eq!(
parse_args(&[
"dump-manifests".to_string(),
"--manifests-dir=/tmp/upstream".to_string()
])
.expect("inline dump-manifests flag should parse"),
CliAction::DumpManifests {
output_format: CliOutputFormat::Text,
manifests_dir: Some(PathBuf::from("/tmp/upstream")),
}
);
}
#[test]
fn local_command_help_flags_stay_on_the_local_parser_path() {
assert_eq!(
@@ -11554,43 +11644,78 @@ mod sandbox_report_tests {
#[cfg(test)]
mod dump_manifests_tests {
use super::{dump_manifests_at_path, CliOutputFormat};
use std::fs;
#[test]
fn dump_manifests_shows_helpful_error_when_manifests_missing() {
// Create a temp directory without manifest files
let temp_dir = std::env::temp_dir().join(format!(
let root = std::env::temp_dir().join(format!(
"claw_test_missing_manifests_{}",
std::process::id()
));
std::fs::create_dir_all(&temp_dir).expect("failed to create temp dir");
let workspace = root.join("workspace");
std::fs::create_dir_all(&workspace).expect("failed to create temp workspace");
// Clean up at the end of the test
let _cleanup = std::panic::catch_unwind(|| {
// Call dump_manifests_at_path with the temp directory
let result = dump_manifests_at_path(&temp_dir, CliOutputFormat::Text);
let result = dump_manifests_at_path(&workspace, None, CliOutputFormat::Text);
assert!(
result.is_err(),
"expected an error when manifests are missing"
);
// Assert that the call fails
assert!(
result.is_err(),
"expected an error when manifests are missing"
);
let error_msg = result.unwrap_err().to_string();
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}"
);
// Assert the error message contains "Manifest files (commands.ts, tools.ts)"
assert!(
error_msg.contains("Manifest files (commands.ts, tools.ts)"),
"error message should mention manifest files: {error_msg}"
);
let _ = std::fs::remove_dir_all(&root);
}
// Assert the error message contains the expected path
assert!(
error_msg.contains(&temp_dir.display().to_string()),
"error message should contain the expected path: {error_msg}"
);
});
#[test]
fn dump_manifests_uses_explicit_manifest_dir() {
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");
// Clean up temp directory
let _ = std::fs::remove_dir_all(&temp_dir);
let result = dump_manifests_at_path(&workspace, Some(&upstream), CliOutputFormat::Text);
assert!(
result.is_ok(),
"explicit manifest dir should succeed: {result:?}"
);
let _ = fs::remove_dir_all(&root);
}
}

View File

@@ -174,13 +174,15 @@ fn dump_manifests_and_init_emit_json_when_requested() {
fs::create_dir_all(&root).expect("temp dir should exist");
let upstream = write_upstream_fixture(&root);
let manifests = assert_json_command_with_env(
let manifests = assert_json_command(
&root,
&["--output-format", "json", "dump-manifests"],
&[(
"CLAUDE_CODE_UPSTREAM",
&[
"--output-format",
"json",
"dump-manifests",
"--manifests-dir",
upstream.to_str().expect("utf8 upstream"),
)],
],
);
assert_eq!(manifests["kind"], "dump-manifests");
assert_eq!(manifests["commands"], 1);