mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 00:24:50 +08:00
Close the clawability backlog with deterministic CLI output and lane lineage
Finish the remaining roadmap work by making direct CLI JSON output deterministic across the non-interactive surface, restoring the degraded-startup MCP test as a real workspace test, and adding branch-lock plus commit-lineage primitives so downstream lane consumers can distinguish superseded worktree commits from canonical lineage. Constraint: Keep the user-facing config namespace centered on .claw while preserving legacy fallback discovery for compatibility Constraint: Verification needed to stay clean-room and reproducible from the checked-in workspace alone Rejected: Leave the output-format contract implied by ad-hoc smoke runs only | too easy for direct CLI regressions to slip back into prose-only output Rejected: Keep commit provenance as free-form detail text | downstream consumers need structured branch/worktree/supersession metadata Confidence: medium Scope-risk: moderate Directive: Extend the JSON contract through the same direct CLI entrypoints instead of adding one-off serializers on parallel code paths Tested: python .github/scripts/check_doc_source_of_truth.py Tested: cd rust && cargo fmt --all --check Tested: cd rust && cargo test --workspace Tested: cd rust && cargo clippy -p commands -p tools -p rusty-claude-cli --all-targets --no-deps -- -D warnings Not-tested: full cargo clippy --workspace --all-targets -- -D warnings still reports unrelated pre-existing runtime lint debt outside this change set
This commit is contained in:
@@ -31,3 +31,4 @@ workspace = true
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[
|
||||
];
|
||||
|
||||
type AllowedToolSet = BTreeSet<String>;
|
||||
type RuntimePluginStateBuildOutput = (
|
||||
Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
Vec<RuntimeToolDefinition>,
|
||||
);
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
@@ -109,9 +113,12 @@ Run `claw --help` for usage."
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
match parse_args(&args)? {
|
||||
CliAction::DumpManifests => dump_manifests(),
|
||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
|
||||
CliAction::DumpManifests { output_format } => dump_manifests(output_format)?,
|
||||
CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?,
|
||||
CliAction::Agents {
|
||||
args,
|
||||
output_format,
|
||||
} => LiveCli::print_agents(args.as_deref(), output_format)?,
|
||||
CliAction::Mcp {
|
||||
args,
|
||||
output_format,
|
||||
@@ -120,12 +127,17 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
args,
|
||||
output_format,
|
||||
} => LiveCli::print_skills(args.as_deref(), output_format)?,
|
||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||
CliAction::Version => print_version(),
|
||||
CliAction::PrintSystemPrompt {
|
||||
cwd,
|
||||
date,
|
||||
output_format,
|
||||
} => print_system_prompt(cwd, date, output_format)?,
|
||||
CliAction::Version { output_format } => print_version(output_format)?,
|
||||
CliAction::ResumeSession {
|
||||
session_path,
|
||||
commands,
|
||||
} => resume_session(&session_path, &commands),
|
||||
output_format,
|
||||
} => resume_session(&session_path, &commands, output_format),
|
||||
CliAction::Status {
|
||||
model,
|
||||
permission_mode,
|
||||
@@ -140,27 +152,32 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
permission_mode,
|
||||
} => LiveCli::new(model, true, allowed_tools, permission_mode)?
|
||||
.run_turn_with_output(&prompt, output_format)?,
|
||||
CliAction::Login => run_login()?,
|
||||
CliAction::Logout => run_logout()?,
|
||||
CliAction::Doctor => run_doctor()?,
|
||||
CliAction::Init => run_init()?,
|
||||
CliAction::Login { output_format } => run_login(output_format)?,
|
||||
CliAction::Logout { output_format } => run_logout(output_format)?,
|
||||
CliAction::Doctor { output_format } => run_doctor(output_format)?,
|
||||
CliAction::Init { output_format } => run_init(output_format)?,
|
||||
CliAction::Repl {
|
||||
model,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
} => run_repl(model, allowed_tools, permission_mode)?,
|
||||
CliAction::HelpTopic(topic) => print_help_topic(topic),
|
||||
CliAction::Help => print_help(),
|
||||
CliAction::Help { output_format } => print_help(output_format)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum CliAction {
|
||||
DumpManifests,
|
||||
BootstrapPlan,
|
||||
DumpManifests {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
BootstrapPlan {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Agents {
|
||||
args: Option<String>,
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Mcp {
|
||||
args: Option<String>,
|
||||
@@ -173,11 +190,15 @@ enum CliAction {
|
||||
PrintSystemPrompt {
|
||||
cwd: PathBuf,
|
||||
date: String,
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Version {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Version,
|
||||
ResumeSession {
|
||||
session_path: PathBuf,
|
||||
commands: Vec<String>,
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Status {
|
||||
model: String,
|
||||
@@ -194,10 +215,18 @@ enum CliAction {
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
},
|
||||
Login,
|
||||
Logout,
|
||||
Doctor,
|
||||
Init,
|
||||
Login {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Logout {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Doctor {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Init {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Repl {
|
||||
model: String,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
@@ -205,7 +234,9 @@ enum CliAction {
|
||||
},
|
||||
HelpTopic(LocalHelpTopic),
|
||||
// prompt-mode formatting is only supported for non-interactive runs
|
||||
Help,
|
||||
Help {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -346,11 +377,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
}
|
||||
|
||||
if wants_help {
|
||||
return Ok(CliAction::Help);
|
||||
return Ok(CliAction::Help { output_format });
|
||||
}
|
||||
|
||||
if wants_version {
|
||||
return Ok(CliAction::Version);
|
||||
return Ok(CliAction::Version { output_format });
|
||||
}
|
||||
|
||||
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
||||
@@ -364,22 +395,25 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
});
|
||||
}
|
||||
if rest.first().map(String::as_str) == Some("--resume") {
|
||||
return parse_resume_args(&rest[1..]);
|
||||
return parse_resume_args(&rest[1..], output_format);
|
||||
}
|
||||
if let Some(action) = parse_local_help_action(&rest) {
|
||||
return action;
|
||||
}
|
||||
if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format) {
|
||||
if let Some(action) =
|
||||
parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format)
|
||||
{
|
||||
return action;
|
||||
}
|
||||
|
||||
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
||||
|
||||
match rest[0].as_str() {
|
||||
"dump-manifests" => Ok(CliAction::DumpManifests),
|
||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
||||
"dump-manifests" => Ok(CliAction::DumpManifests { output_format }),
|
||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
|
||||
"agents" => Ok(CliAction::Agents {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
output_format,
|
||||
}),
|
||||
"mcp" => Ok(CliAction::Mcp {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
@@ -389,10 +423,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
output_format,
|
||||
}),
|
||||
"system-prompt" => parse_system_prompt_args(&rest[1..]),
|
||||
"login" => Ok(CliAction::Login),
|
||||
"logout" => Ok(CliAction::Logout),
|
||||
"init" => Ok(CliAction::Init),
|
||||
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
|
||||
"login" => Ok(CliAction::Login { output_format }),
|
||||
"logout" => Ok(CliAction::Logout { output_format }),
|
||||
"init" => Ok(CliAction::Init { output_format }),
|
||||
"prompt" => {
|
||||
let prompt = rest[1..].join(" ");
|
||||
if prompt.trim().is_empty() {
|
||||
@@ -446,15 +480,15 @@ fn parse_single_word_command_alias(
|
||||
}
|
||||
|
||||
match rest[0].as_str() {
|
||||
"help" => Some(Ok(CliAction::Help)),
|
||||
"version" => Some(Ok(CliAction::Version)),
|
||||
"help" => Some(Ok(CliAction::Help { output_format })),
|
||||
"version" => Some(Ok(CliAction::Version { output_format })),
|
||||
"status" => Some(Ok(CliAction::Status {
|
||||
model: model.to_string(),
|
||||
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
|
||||
output_format,
|
||||
})),
|
||||
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
||||
"doctor" => Some(Ok(CliAction::Doctor)),
|
||||
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
|
||||
other => bare_slash_command_guidance(other).map(Err),
|
||||
}
|
||||
}
|
||||
@@ -502,8 +536,11 @@ fn parse_direct_slash_cli_action(
|
||||
) -> Result<CliAction, String> {
|
||||
let raw = rest.join(" ");
|
||||
match SlashCommand::parse(&raw) {
|
||||
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
|
||||
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
|
||||
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
|
||||
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents {
|
||||
args,
|
||||
output_format,
|
||||
}),
|
||||
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
|
||||
args: match (action, target) {
|
||||
(None, None) => None,
|
||||
@@ -727,7 +764,10 @@ fn filter_tool_specs(
|
||||
tool_registry.definitions(allowed_tools)
|
||||
}
|
||||
|
||||
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
||||
fn parse_system_prompt_args(
|
||||
args: &[String],
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<CliAction, String> {
|
||||
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
|
||||
let mut date = DEFAULT_DATE.to_string();
|
||||
let mut index = 0;
|
||||
@@ -752,10 +792,14 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CliAction::PrintSystemPrompt { cwd, date })
|
||||
Ok(CliAction::PrintSystemPrompt {
|
||||
cwd,
|
||||
date,
|
||||
output_format,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
||||
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), &[]),
|
||||
Some(first) if looks_like_slash_command_token(first) => {
|
||||
@@ -795,6 +839,7 @@ fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
||||
Ok(CliAction::ResumeSession {
|
||||
session_path,
|
||||
commands,
|
||||
output_format,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -930,9 +975,21 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
})
|
||||
}
|
||||
|
||||
fn run_doctor() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let report = render_doctor_report()?;
|
||||
println!("{}", report.render());
|
||||
let message = report.render();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "doctor",
|
||||
"message": message,
|
||||
"report": message,
|
||||
"has_failures": report.has_failures(),
|
||||
}))?
|
||||
),
|
||||
}
|
||||
if report.has_failures() {
|
||||
return Err("doctor found failing checks".into());
|
||||
}
|
||||
@@ -1212,26 +1269,54 @@ fn looks_like_slash_command_token(token: &str) -> bool {
|
||||
.any(|spec| spec.name == name || spec.aliases.contains(&name))
|
||||
}
|
||||
|
||||
fn dump_manifests() {
|
||||
fn dump_manifests(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
|
||||
match extract_manifest(&paths) {
|
||||
Ok(manifest) => {
|
||||
println!("commands: {}", manifest.commands.entries().len());
|
||||
println!("tools: {}", manifest.tools.entries().len());
|
||||
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("failed to extract manifests: {error}");
|
||||
std::process::exit(1);
|
||||
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",
|
||||
"commands": manifest.commands.entries().len(),
|
||||
"tools": manifest.tools.entries().len(),
|
||||
"bootstrap_phases": manifest.bootstrap.phases().len(),
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(format!("failed to extract manifests: {error}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_bootstrap_plan() {
|
||||
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
|
||||
println!("- {phase:?}");
|
||||
fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let phases = runtime::BootstrapPlan::claude_code_default()
|
||||
.phases()
|
||||
.iter()
|
||||
.map(|phase| format!("{phase:?}"))
|
||||
.collect::<Vec<_>>();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
for phase in &phases {
|
||||
println!("- {phase}");
|
||||
}
|
||||
}
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "bootstrap-plan",
|
||||
"phases": phases,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_oauth_config() -> OAuthConfig {
|
||||
@@ -1249,7 +1334,7 @@ fn default_oauth_config() -> OAuthConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_login() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||
let default_oauth = default_oauth_config();
|
||||
@@ -1262,8 +1347,10 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
|
||||
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
|
||||
.build_url();
|
||||
|
||||
println!("Starting Claude OAuth login...");
|
||||
println!("Listening for callback on {redirect_uri}");
|
||||
if output_format == CliOutputFormat::Text {
|
||||
println!("Starting Claude OAuth login...");
|
||||
println!("Listening for callback on {redirect_uri}");
|
||||
}
|
||||
if let Err(error) = open_browser(&authorize_url) {
|
||||
eprintln!("warning: failed to open browser automatically: {error}");
|
||||
println!("Open this URL manually:\n{authorize_url}");
|
||||
@@ -1287,8 +1374,13 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
|
||||
let exchange_request =
|
||||
OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
|
||||
let exchange_request = OAuthTokenExchangeRequest::from_config(
|
||||
oauth,
|
||||
code,
|
||||
state,
|
||||
pkce.verifier,
|
||||
redirect_uri.clone(),
|
||||
);
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
|
||||
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||
@@ -1297,13 +1389,33 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
|
||||
expires_at: token_set.expires_at,
|
||||
scopes: token_set.scopes,
|
||||
})?;
|
||||
println!("Claude OAuth login complete.");
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("Claude OAuth login complete."),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "login",
|
||||
"callback_port": callback_port,
|
||||
"redirect_uri": redirect_uri,
|
||||
"message": "Claude OAuth login complete.",
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
clear_oauth_credentials()?;
|
||||
println!("Claude OAuth credentials cleared.");
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("Claude OAuth credentials cleared."),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "logout",
|
||||
"message": "Claude OAuth credentials cleared.",
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1361,21 +1473,50 @@ fn wait_for_oauth_callback(
|
||||
Ok(callback)
|
||||
}
|
||||
|
||||
fn print_system_prompt(cwd: PathBuf, date: String) {
|
||||
match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
|
||||
Ok(sections) => println!("{}", sections.join("\n\n")),
|
||||
Err(error) => {
|
||||
eprintln!("failed to build system prompt: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
fn print_system_prompt(
|
||||
cwd: PathBuf,
|
||||
date: String,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?;
|
||||
let message = sections.join(
|
||||
"
|
||||
|
||||
",
|
||||
);
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "system-prompt",
|
||||
"message": message,
|
||||
"sections": sections,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_version() {
|
||||
println!("{}", render_version_report());
|
||||
fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let report = render_version_report();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{report}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "version",
|
||||
"message": report,
|
||||
"version": VERSION,
|
||||
"git_sha": GIT_SHA,
|
||||
"target": BUILD_TARGET,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resume_session(session_path: &Path, commands: &[String]) {
|
||||
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
|
||||
let resolved_path = if session_path.exists() {
|
||||
session_path.to_path_buf()
|
||||
} else {
|
||||
@@ -1425,7 +1566,35 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
||||
}) => {
|
||||
session = next_session;
|
||||
if let Some(message) = message {
|
||||
println!("{message}");
|
||||
if output_format == CliOutputFormat::Json
|
||||
&& matches!(command, SlashCommand::Status)
|
||||
{
|
||||
let tracker = UsageTracker::from_session(&session);
|
||||
let usage = tracker.cumulative_usage();
|
||||
let context = status_context(Some(&resolved_path)).expect("status context");
|
||||
let value = json!({
|
||||
"kind": "status",
|
||||
"messages": session.messages.len(),
|
||||
"turns": tracker.turns(),
|
||||
"latest_total": tracker.current_turn_usage().total_tokens(),
|
||||
"cumulative_input": usage.input_tokens,
|
||||
"cumulative_output": usage.output_tokens,
|
||||
"cumulative_total": usage.total_tokens(),
|
||||
"workspace": {
|
||||
"cwd": context.cwd,
|
||||
"project_root": context.project_root,
|
||||
"git_branch": context.git_branch,
|
||||
"git_state": context.git_summary.headline(),
|
||||
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
|
||||
}
|
||||
});
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&value).expect("status json")
|
||||
);
|
||||
} else {
|
||||
println!("{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -2357,13 +2526,7 @@ impl RuntimeMcpState {
|
||||
|
||||
fn build_runtime_mcp_state(
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<
|
||||
(
|
||||
Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
Vec<RuntimeToolDefinition>,
|
||||
),
|
||||
Box<dyn std::error::Error>,
|
||||
> {
|
||||
) -> Result<RuntimePluginStateBuildOutput, Box<dyn std::error::Error>> {
|
||||
let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else {
|
||||
return Ok((None, Vec::new()));
|
||||
};
|
||||
@@ -2809,7 +2972,7 @@ impl LiveCli {
|
||||
false
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
run_init()?;
|
||||
run_init(CliOutputFormat::Text)?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
@@ -2817,7 +2980,7 @@ impl LiveCli {
|
||||
false
|
||||
}
|
||||
SlashCommand::Version => {
|
||||
Self::print_version();
|
||||
Self::print_version(CliOutputFormat::Text);
|
||||
false
|
||||
}
|
||||
SlashCommand::Export { path } => {
|
||||
@@ -2831,7 +2994,7 @@ impl LiveCli {
|
||||
self.handle_plugins_command(action.as_deref(), target.as_deref())?
|
||||
}
|
||||
SlashCommand::Agents { args } => {
|
||||
Self::print_agents(args.as_deref())?;
|
||||
Self::print_agents(args.as_deref(), CliOutputFormat::Text)?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Skills { args } => {
|
||||
@@ -3113,9 +3276,23 @@ impl LiveCli {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_agents(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn print_agents(
|
||||
args: Option<&str>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
println!("{}", handle_agents_slash_command(args, &cwd)?);
|
||||
let message = handle_agents_slash_command(args, &cwd)?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "agents",
|
||||
"message": message,
|
||||
"args": args,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3154,8 +3331,8 @@ impl LiveCli {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_version() {
|
||||
println!("{}", render_version_report());
|
||||
fn print_version(output_format: CliOutputFormat) {
|
||||
let _ = crate::print_version(output_format);
|
||||
}
|
||||
|
||||
fn export_session(
|
||||
@@ -4060,8 +4237,18 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
|
||||
Ok(initialize_repo(&cwd)?.render())
|
||||
}
|
||||
|
||||
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", init_claude_md()?);
|
||||
fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let message = init_claude_md()?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "init",
|
||||
"message": message,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5916,16 +6103,13 @@ impl CliToolExecutor {
|
||||
fn execute_search_tool(&self, value: serde_json::Value) -> Result<String, ToolError> {
|
||||
let input: ToolSearchRequest = serde_json::from_value(value)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||
let (pending_mcp_servers, mcp_degraded) = self
|
||||
.mcp_state
|
||||
.as_ref()
|
||||
.map(|state| {
|
||||
let (pending_mcp_servers, mcp_degraded) =
|
||||
self.mcp_state.as_ref().map_or((None, None), |state| {
|
||||
let state = state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
(state.pending_servers(), state.degraded_report())
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
});
|
||||
serde_json::to_string_pretty(&self.tool_registry.search(
|
||||
&input.query,
|
||||
input.max_results.unwrap_or(5),
|
||||
@@ -6203,8 +6387,21 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
let _ = print_help_to(&mut io::stdout());
|
||||
fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut buffer = Vec::new();
|
||||
print_help_to(&mut buffer)?;
|
||||
let message = String::from_utf8(buffer)?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => print!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "help",
|
||||
"message": message,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -6513,11 +6710,15 @@ mod tests {
|
||||
fn parses_version_flags_without_initializing_prompt_mode() {
|
||||
assert_eq!(
|
||||
parse_args(&["--version".to_string()]).expect("args should parse"),
|
||||
CliAction::Version
|
||||
CliAction::Version {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["-V".to_string()]).expect("args should parse"),
|
||||
CliAction::Version
|
||||
CliAction::Version {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6579,6 +6780,7 @@ mod tests {
|
||||
CliAction::PrintSystemPrompt {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
date: "2026-04-01".to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6587,23 +6789,34 @@ mod tests {
|
||||
fn parses_login_and_logout_subcommands() {
|
||||
assert_eq!(
|
||||
parse_args(&["login".to_string()]).expect("login should parse"),
|
||||
CliAction::Login
|
||||
CliAction::Login {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["logout".to_string()]).expect("logout should parse"),
|
||||
CliAction::Logout
|
||||
CliAction::Logout {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
|
||||
CliAction::Doctor
|
||||
CliAction::Doctor {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["init".to_string()]).expect("init should parse"),
|
||||
CliAction::Init
|
||||
CliAction::Init {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["agents".to_string()]).expect("agents should parse"),
|
||||
CliAction::Agents { args: None }
|
||||
CliAction::Agents {
|
||||
args: None,
|
||||
output_format: CliOutputFormat::Text
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["mcp".to_string()]).expect("mcp should parse"),
|
||||
@@ -6623,7 +6836,8 @@ mod tests {
|
||||
parse_args(&["agents".to_string(), "--help".to_string()])
|
||||
.expect("agents help should parse"),
|
||||
CliAction::Agents {
|
||||
args: Some("--help".to_string())
|
||||
args: Some("--help".to_string()),
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6653,11 +6867,15 @@ mod tests {
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
assert_eq!(
|
||||
parse_args(&["help".to_string()]).expect("help should parse"),
|
||||
CliAction::Help
|
||||
CliAction::Help {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["version".to_string()]).expect("version should parse"),
|
||||
CliAction::Version
|
||||
CliAction::Version {
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["status".to_string()]).expect("status should parse"),
|
||||
@@ -6727,7 +6945,10 @@ mod tests {
|
||||
fn parses_direct_agents_mcp_and_skills_slash_commands() {
|
||||
assert_eq!(
|
||||
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
|
||||
CliAction::Agents { args: None }
|
||||
CliAction::Agents {
|
||||
args: None,
|
||||
output_format: CliOutputFormat::Text
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()])
|
||||
@@ -6807,6 +7028,7 @@ mod tests {
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("session.jsonl"),
|
||||
commands: vec!["/compact".to_string()],
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6818,6 +7040,7 @@ mod tests {
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("latest"),
|
||||
commands: vec![],
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -6826,6 +7049,7 @@ mod tests {
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("latest"),
|
||||
commands: vec!["/status".to_string()],
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6848,6 +7072,7 @@ mod tests {
|
||||
"/compact".to_string(),
|
||||
"/cost".to_string(),
|
||||
],
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6878,6 +7103,7 @@ mod tests {
|
||||
"/export notes.txt".to_string(),
|
||||
"/clear --confirm".to_string(),
|
||||
],
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6896,6 +7122,7 @@ mod tests {
|
||||
CliAction::ResumeSession {
|
||||
session_path: PathBuf::from("session.jsonl"),
|
||||
commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()],
|
||||
output_format: CliOutputFormat::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8005,6 +8232,7 @@ UU conflicted.rs",
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
|
||||
@@ -729,8 +729,7 @@ fn assert_token_cost_reporting(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert!(
|
||||
run.response["estimated_cost"]
|
||||
.as_str()
|
||||
.map(|cost| cost.starts_with('$'))
|
||||
.unwrap_or(false),
|
||||
.is_some_and(|cost| cost.starts_with('$')),
|
||||
"estimated_cost should be a dollar-prefixed string"
|
||||
);
|
||||
}
|
||||
|
||||
193
rust/crates/rusty-claude-cli/tests/output_format_contract.rs
Normal file
193
rust/crates/rusty-claude-cli/tests/output_format_contract.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn help_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("help-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "help"]);
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert!(parsed["message"]
|
||||
.as_str()
|
||||
.expect("help text")
|
||||
.contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("version-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
||||
assert_eq!(parsed["kind"], "version");
|
||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_sandbox_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("status-sandbox-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||
assert_eq!(status["kind"], "status");
|
||||
assert!(status["workspace"]["cwd"].as_str().is_some());
|
||||
|
||||
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
|
||||
assert_eq!(sandbox["kind"], "sandbox");
|
||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_commands_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("inventory-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let agents = assert_json_command(&root, &["--output-format", "json", "agents"]);
|
||||
assert_eq!(agents["kind"], "agents");
|
||||
|
||||
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
assert_eq!(mcp["action"], "list");
|
||||
|
||||
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
||||
assert_eq!(skills["kind"], "skills");
|
||||
assert_eq!(skills["action"], "list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let plan = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"]);
|
||||
assert_eq!(plan["kind"], "bootstrap-plan");
|
||||
assert!(plan["phases"].as_array().expect("phases").len() > 1);
|
||||
|
||||
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
|
||||
assert_eq!(prompt["kind"], "system-prompt");
|
||||
assert!(prompt["message"]
|
||||
.as_str()
|
||||
.expect("prompt text")
|
||||
.contains("interactive agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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_with_env(
|
||||
&root,
|
||||
&["--output-format", "json", "dump-manifests"],
|
||||
&[(
|
||||
"CLAUDE_CODE_UPSTREAM",
|
||||
upstream.to_str().expect("utf8 upstream"),
|
||||
)],
|
||||
);
|
||||
assert_eq!(manifests["kind"], "dump-manifests");
|
||||
assert_eq!(manifests["commands"], 1);
|
||||
assert_eq!(manifests["tools"], 1);
|
||||
|
||||
let workspace = root.join("workspace");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
let init = assert_json_command(&workspace, &["--output-format", "json", "init"]);
|
||||
assert_eq!(init["kind"], "init");
|
||||
assert!(workspace.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("doctor-resume-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
|
||||
assert_eq!(doctor["kind"], "doctor");
|
||||
assert!(doctor["message"].is_string());
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n",
|
||||
)
|
||||
.expect("session should write");
|
||||
let resumed = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/status",
|
||||
],
|
||||
);
|
||||
assert_eq!(resumed["kind"], "status");
|
||||
assert_eq!(resumed["messages"], 1);
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Value {
|
||||
let output = run_claw(current_dir, args, envs);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be valid json")
|
||||
}
|
||||
|
||||
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command.current_dir(current_dir).args(args);
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
|
||||
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 unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-output-format-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user