fix: keep JSON control surfaces local

This commit is contained in:
bellman
2026-06-03 19:12:20 +09:00
parent e752b05425
commit 55da189315
3 changed files with 472 additions and 64 deletions

View File

@@ -647,6 +647,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
);
}
},
CliAction::Models {
action,
output_format,
} => print_models(action.as_deref(), output_format)?,
CliAction::Diff { output_format } => match output_format {
CliOutputFormat::Text => {
println!("{}", render_diff_report()?);
@@ -770,6 +774,10 @@ enum CliAction {
section: Option<String>,
output_format: CliOutputFormat,
},
Models {
action: Option<String>,
output_format: CliOutputFormat,
},
Diff {
output_format: CliOutputFormat,
},
@@ -817,6 +825,8 @@ enum LocalHelpTopic {
Plugins,
Mcp,
Config,
Model,
Settings,
Diff,
}
@@ -1076,6 +1086,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
"mcp" => Some(LocalHelpTopic::Mcp),
"config" => Some(LocalHelpTopic::Config),
"model" | "models" => Some(LocalHelpTopic::Model),
"settings" => Some(LocalHelpTopic::Settings),
"diff" => Some(LocalHelpTopic::Diff),
_ => None,
};
@@ -1292,10 +1304,23 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"interactive_only: `claw ultraplan` is a slash command.\nStart `claw` and run `/ultraplan` inside the REPL."
.to_string(),
),
"model" if rest.len() > 1 => Err(
"interactive_only: `claw model` is a slash command.\nStart `claw` and run `/model [model-name]` inside the REPL."
.to_string(),
),
"model" | "models" => {
let tail = &rest[1..];
let action = tail.first().cloned();
if tail.len() > 1 {
return Err(format!(
"unexpected extra arguments after `claw {} {}`: {}\nUsage: claw {} [help] [--output-format json]",
rest[0],
tail[0],
tail[1..].join(" "),
rest[0]
));
}
Ok(CliAction::Models {
action,
output_format,
})
}
// #771: usage/stats/fork are slash-only verbs with no multi-arg match arms
"usage" => Err(
"interactive_only: `claw usage` is a slash command.\nUse `claw --resume SESSION.jsonl /usage` or start `claw` and run `/usage`."
@@ -1337,6 +1362,25 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}),
}
}
"settings" => {
let tail = &rest[1..];
if tail.is_empty() {
Ok(CliAction::Config {
section: Some("settings".to_string()),
output_format,
})
} else if tail.len() == 1 && matches!(tail[0].as_str(), "help" | "--help" | "-h") {
Ok(CliAction::HelpTopic {
topic: LocalHelpTopic::Settings,
output_format,
})
} else {
Err(format!(
"unexpected extra arguments after `claw settings`: {}\nUsage: claw settings [help] [--output-format json]",
tail.join(" ")
))
}
}
"system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format),
"acp" => parse_acp_args(&rest[1..], output_format),
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
@@ -1453,6 +1497,8 @@ fn parse_local_help_action(
"system-prompt" => LocalHelpTopic::SystemPrompt,
"dump-manifests" => LocalHelpTopic::DumpManifests,
"bootstrap-plan" => LocalHelpTopic::BootstrapPlan,
"model" | "models" => LocalHelpTopic::Model,
"settings" => LocalHelpTopic::Settings,
_ => return None,
};
let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a));
@@ -1518,6 +1564,8 @@ fn parse_single_word_command_alias(
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
"mcp" => Some(LocalHelpTopic::Mcp),
"config" => Some(LocalHelpTopic::Config),
"model" | "models" => Some(LocalHelpTopic::Model),
"settings" => Some(LocalHelpTopic::Settings),
"diff" => Some(LocalHelpTopic::Diff),
_ => None,
};
@@ -1567,6 +1615,8 @@ fn parse_single_word_command_alias(
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
"mcp" => Some(LocalHelpTopic::Mcp),
"config" => Some(LocalHelpTopic::Config),
"model" | "models" => Some(LocalHelpTopic::Model),
"settings" => Some(LocalHelpTopic::Settings),
"diff" => Some(LocalHelpTopic::Diff),
_ => None,
};
@@ -1710,6 +1760,17 @@ fn parse_direct_slash_cli_action(
let raw = rest.join(" ");
match SlashCommand::parse(&raw) {
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
Ok(Some(SlashCommand::Status)) => Ok(CliAction::Status {
model,
model_flag_raw: None,
permission_mode,
output_format,
allowed_tools,
}),
Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }),
Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }),
Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }),
Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor { output_format }),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents {
args,
output_format,
@@ -7920,6 +7981,21 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
Formats text (default), json
Related /config · claw doctor"
.to_string(),
LocalHelpTopic::Model => "Models
Usage claw models [help] [--output-format <format>]
Aliases claw model
Purpose show bounded local model command guidance without entering the REPL
Output supported model-selection surfaces and current config model value
Formats text (default), json
Related /model · claw config model · claw status"
.to_string(),
LocalHelpTopic::Settings => "Settings
Usage claw settings [help] [--output-format <format>]
Purpose show effective settings/config using the local config envelope
Output same as claw config settings; no provider request or session resume required
Formats text (default), json
Related claw config · claw doctor"
.to_string(),
LocalHelpTopic::Diff => "Diff
Usage claw diff [--output-format <format>]
Purpose show the diff of changes relative to the expected base commit
@@ -7947,10 +8023,77 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
LocalHelpTopic::Plugins => "plugins",
LocalHelpTopic::Mcp => "mcp",
LocalHelpTopic::Config => "config",
LocalHelpTopic::Model => "models",
LocalHelpTopic::Settings => "settings",
LocalHelpTopic::Diff => "diff",
}
}
fn print_models(
action: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let help_requested = action.is_some_and(|value| matches!(value, "help" | "--help" | "-h"));
if help_requested {
return print_help_topic(LocalHelpTopic::Model, output_format);
}
if let Some(action) = action {
return Err(format!(
"unsupported_models_action: unsupported models action: {action}.\nUsage: claw models [help] [--output-format json]"
)
.into());
}
let configured_model = config_model_for_current_dir();
let resolved_config_model = configured_model
.as_deref()
.map(resolve_model_alias_with_config);
match output_format {
CliOutputFormat::Text => {
println!("Models");
println!(" Default {DEFAULT_MODEL}");
println!(" Built-in aliases opus, sonnet, haiku");
if let Some(raw) = configured_model.as_deref() {
println!(
" Config model {raw}{}",
resolved_config_model
.as_deref()
.filter(|resolved| *resolved != raw)
.map(|resolved| format!(" -> {resolved}"))
.unwrap_or_default()
);
} else {
println!(" Config model <unset>");
}
println!(" Usage claw --model <provider/model> prompt <text>");
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "models",
"action": "list",
"status": "ok",
"default_model": DEFAULT_MODEL,
"aliases": [
{"name": "opus", "model": resolve_model_alias("opus")},
{"name": "sonnet", "model": resolve_model_alias("sonnet")},
{"name": "haiku", "model": resolve_model_alias("haiku")}
],
"configured_model": configured_model,
"resolved_configured_model": resolved_config_model,
"local_only": true,
"requires_credentials": false,
"requires_provider_request": false,
"message": "Use --model <provider/model> or configure a model in claw settings."
}))?
);
}
}
Ok(())
}
fn render_export_help_json() -> serde_json::Value {
json!({
"kind": "help",
@@ -8188,24 +8331,29 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => runtime_config.get("env"),
"hooks" => runtime_config.get("hooks"),
"model" => runtime_config.get("model"),
let rendered = match section {
"env" => runtime_config.get("env").map(|value| value.render()),
"hooks" => runtime_config.get("hooks").map(|value| value.render()),
"model" => runtime_config.get("model").map(|value| value.render()),
"plugins" => runtime_config
.get("plugins")
.or_else(|| runtime_config.get("enabledPlugins")),
.or_else(|| runtime_config.get("enabledPlugins"))
.map(|value| value.render()),
"mcp" | "mcp_servers" | "mcpServers" => runtime_config
.get("mcp")
.or_else(|| runtime_config.get("mcp_servers"))
.or_else(|| runtime_config.get("mcpServers")),
"sandbox" => runtime_config.get("sandbox"),
"permissions" => runtime_config.get("permissions"),
"skills" => runtime_config.get("skills"),
"agents" => runtime_config.get("agents"),
.or_else(|| runtime_config.get("mcpServers"))
.map(|value| value.render()),
"sandbox" => runtime_config.get("sandbox").map(|value| value.render()),
"permissions" => runtime_config
.get("permissions")
.map(|value| value.render()),
"skills" => runtime_config.get("skills").map(|value| value.render()),
"agents" => runtime_config.get("agents").map(|value| value.render()),
"settings" => Some(runtime_config.as_json().render()),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents."
" Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings."
));
return Ok(lines.join(
"
@@ -8215,10 +8363,7 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
rendered.unwrap_or_else(|| "<unset>".to_string())
));
return Ok(lines.join(
"
@@ -8308,16 +8453,17 @@ fn render_config_json(
"permissions" => runtime_config.get("permissions").map(|v| v.render()),
"skills" => runtime_config.get("skills").map(|v| v.render()),
"agents" => runtime_config.get("agents").map(|v| v.render()),
"settings" => Some(runtime_config.as_json().render()),
other => {
// #741: populate hint field for unsupported section errors so callers reading
// .hint get actionable guidance instead of null
let hint = if matches!(other, "list" | "show" | "help" | "info") {
format!(
"'claw config {other}' is not a subcommand. To list all config: `claw config`. To inspect a section: `claw config <section>` where section is one of: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents."
"'claw config {other}' is not a subcommand. To list all config: `claw config`. To inspect a section: `claw config <section>` where section is one of: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings."
)
} else {
format!(
"'{other}' is not a config section. Supported: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents."
"'{other}' is not a config section. Supported: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings."
)
};
return Ok(serde_json::json!({
@@ -8327,9 +8473,9 @@ fn render_config_json(
"error_kind": "unsupported_config_section",
"section": other,
"ok": false,
"error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents."),
"error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings."),
"hint": hint,
"supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents"],
"supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"],
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"files": files,

View File

@@ -161,6 +161,52 @@ fn status_and_sandbox_emit_json_when_requested() {
assert!(sandbox["filesystem_mode"].as_str().is_some());
}
// #831: direct resume-safe slash commands should use the same local CliAction
// JSON surfaces as their bare subcommands, not interactive_only guidance.
#[test]
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
let root = unique_temp_dir("direct-resume-safe-slash-831");
fs::create_dir_all(&root).expect("temp dir should exist");
Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.expect("git init should launch");
for (command, expected_kind, expected_status) in [
("/version", "version", "ok"),
("/sandbox", "sandbox", "warn"),
("/diff", "diff", "ok"),
("/status", "status", "ok"),
] {
let output = run_claw(&root, &["--output-format", "json", command], &[]);
assert!(
output.status.success(),
"{command} should route to a local CliAction, stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let parsed: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
assert_eq!(
parsed["status"], expected_status,
"{command} status: {parsed}"
);
assert_ne!(
parsed["error_kind"], "interactive_only",
"{command} must not emit interactive_only (#831): {parsed}"
);
assert!(
stderr.is_empty(),
"{command} JSON mode must keep stderr empty (#831): {stderr:?}"
);
}
}
#[test]
fn status_json_surfaces_permission_mode_override_for_security_audit() {
let root = unique_temp_dir("status-json-permission-mode");
@@ -1320,8 +1366,8 @@ fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815()
}
#[test]
fn local_json_surfaces_suppress_config_deprecation_stderr_816() {
let root = unique_temp_dir("global-json-warning-816");
fn global_json_surfaces_suppress_config_deprecation_stderr_810_821_824() {
let root = unique_temp_dir("global-json-warning-810-821-824");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home should exist");
@@ -1340,30 +1386,65 @@ fn local_json_surfaces_suppress_config_deprecation_stderr_816() {
("HOME", home.to_str().expect("utf8 home")),
];
let session_path = write_session_fixture(&root, "resume-config-warning-824", Some("config"));
let resume_config = format!("--resume={}", session_path.to_str().expect("utf8 session"));
for (args, expected_kind, expected_action) in [
(
&["--output-format", "json", "plugins", "list"][..],
vec!["--output-format", "json", "plugins", "list"],
"plugin",
"list",
),
(
&["--output-format", "json", "mcp", "list"][..],
vec!["--output-format", "json", "mcp", "list"],
"mcp",
"list",
),
(
&["--output-format", "json", "doctor"][..],
vec!["--output-format", "json", "doctor"],
"doctor",
"doctor",
),
(vec!["--output-format", "json", "status"], "status", "show"),
(
vec!["--output-format", "json", "sandbox"],
"sandbox",
"status",
),
(
vec!["--output-format", "json", "system-prompt"],
"system-prompt",
"show",
),
(
vec!["--output-format", "json", "skills", "list"],
"skills",
"list",
),
(
vec!["--output-format", "json", "agents", "list"],
"agents",
"list",
),
(
vec!["--output-format", "json", resume_config.as_str(), "/config"],
"config",
"list",
),
] {
let output = run_claw(&root, args, &envs);
let output = run_claw(&root, &args, &envs);
assert!(
output.status.success(),
"args={args:?}\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
output.stdout.first(),
Some(&b'{'),
"args={args:?} stdout JSON must start at byte 0, got: {}",
String::from_utf8_lossy(&output.stdout)
);
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
assert_eq!(parsed["kind"], expected_kind, "args={args:?}");
@@ -1372,10 +1453,10 @@ fn local_json_surfaces_suppress_config_deprecation_stderr_816() {
matches!(parsed["status"].as_str(), Some("ok" | "warn")),
"args={args:?} should report successful local status: {parsed}"
);
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
assert!(
!stderr.contains("field \"enabledPlugins\" is deprecated"),
"successful JSON surface must not leak config deprecation prose to stderr for args={args:?}:\n{stderr}"
output.stderr.is_empty(),
"successful JSON surface must keep stderr empty for args={args:?}, got:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
}
@@ -1680,9 +1761,9 @@ fn diff_json_changed_file_count_deduplication_733() {
#[test]
fn prompt_no_arg_json_error_kind_750() {
// #751/#750: `claw prompt --output-format json` with no prompt argument must emit
// error_kind:"missing_prompt" and a non-empty hint. Before #750 it returned
// error_kind:"unknown" + hint:null.
// #751/#750/#823: `claw prompt --output-format json` with no prompt argument must emit
// error_kind:"missing_prompt" with stdout JSON, empty stderr, and a non-empty hint.
// Before #823 the structured envelope could be routed to stderr, leaving stdout empty.
use std::process::Command;
let root = unique_temp_dir("prompt-no-arg");
fs::create_dir_all(&root).expect("temp dir");
@@ -1697,28 +1778,30 @@ fn prompt_no_arg_json_error_kind_750() {
!output.status.success(),
"claw prompt with no arg must exit non-zero"
);
assert_eq!(
output.status.code(),
Some(1),
"claw prompt with no arg must exit rc=1 (#823)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
stderr, "",
"claw prompt (no arg) --output-format json must keep stderr empty (#823); got: {stderr}"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
let raw = if stdout.trim().starts_with('{') {
stdout.trim().to_string()
} else {
stderr
};
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| {
panic!("claw prompt (no arg) --output-format json must emit valid JSON; got: {raw}")
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| {
panic!(
"claw prompt (no arg) --output-format json must emit valid stdout JSON; got: {stdout}"
)
});
assert_eq!(
parsed["error_kind"], "missing_prompt",
"claw prompt no-arg must have error_kind:missing_prompt (#750); got: {parsed}"
"claw prompt no-arg must have error_kind:missing_prompt (#750/#823); got: {parsed}"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw prompt no-arg hint must be non-empty (#750)"
"claw prompt no-arg hint must be non-empty (#750/#823)"
);
assert!(
hint.contains("claw prompt") || hint.contains("echo"),
@@ -1726,6 +1809,50 @@ fn prompt_no_arg_json_error_kind_750() {
);
}
#[test]
fn prompt_empty_arg_json_stdout_missing_prompt_823() {
// #823: `claw --output-format json prompt ""` must match the missing prompt
// channel contract: rc=1, stdout JSON, error_kind:"missing_prompt", empty stderr.
use std::process::Command;
let root = unique_temp_dir("prompt-empty-arg-823");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "prompt", ""])
.output()
.expect("claw prompt empty arg should run");
assert_eq!(
output.status.code(),
Some(1),
"claw prompt empty arg must exit rc=1 (#823)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
stderr, "",
"claw prompt empty arg --output-format json must keep stderr empty (#823); got: {stderr}"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| {
panic!(
"claw prompt empty arg --output-format json must emit valid stdout JSON; got: {stdout}"
)
});
assert_eq!(
parsed["error_kind"], "missing_prompt",
"claw prompt empty arg must have error_kind:missing_prompt (#823); got: {parsed}"
);
assert_eq!(
parsed["action"], "abort",
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
);
}
#[test]
fn flag_value_errors_have_error_kind_and_hint_756() {
// #756: missing/invalid flag-value errors must emit typed error_kind + non-null hint.
@@ -2316,10 +2443,10 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767(
#[test]
fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
// #770: `claw cost breakdown`, `claw clear --force`, `claw memory reset`,
// `claw ultraplan bogus`, `claw model opus extra` all fell through to
// CliAction::Prompt and reached the credential gate, returning
// error_kind:"missing_credentials". These are all slash-only commands;
// any multi-token invocation should return interactive_only guidance.
// and `claw ultraplan bogus` all fell through to CliAction::Prompt and
// reached the credential gate, returning error_kind:"missing_credentials".
// These remain slash-only commands; multi-token invocations should return
// interactive_only guidance. `model` is now a local bounded surface (#807).
let root = unique_temp_dir("slash-verbs-770");
fs::create_dir_all(&root).expect("temp dir should exist");
@@ -2328,7 +2455,6 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
&["clear", "--force"],
&["memory", "reset"],
&["ultraplan", "bogus"],
&["model", "opus", "extra"],
];
for args in cases {
@@ -2503,13 +2629,35 @@ fn interactive_only_guard_batch_769_to_771() {
&["clear", "--force"],
&["memory", "reset"],
&["ultraplan", "bogus"],
&["model", "opus", "extra"],
// #771: usage/stats/fork
&["usage", "extra"],
&["stats", "extra"],
&["fork", "newbranch"],
];
let model_output = run_claw(
&root,
&["--output-format", "json", "model", "opus", "extra"],
&[],
);
assert!(
!model_output.status.success(),
"claw model opus extra should exit non-zero"
);
let model_stdout = String::from_utf8_lossy(&model_output.stdout);
let model_json: serde_json::Value = serde_json::from_str(model_stdout.trim())
.unwrap_or_else(|_| panic!("claw model opus extra should emit JSON, got: {model_stdout}"));
assert_eq!(
model_json["error_kind"], "unexpected_extra_args",
"claw model opus extra should now stay local and typed (#807), not missing_credentials: {model_json}"
);
assert!(
model_json["hint"]
.as_str()
.is_some_and(|hint| !hint.is_empty()),
"claw model opus extra should include a usage hint: {model_json}"
);
for args in cases {
let full_args: Vec<&str> = std::iter::once("--output-format")
.chain(std::iter::once("json"))
@@ -3899,6 +4047,86 @@ fn diff_non_git_dir_has_error_kind_and_hint_801() {
);
}
fn assert_local_json_without_missing_credentials(
output: &std::process::Output,
expected_kind: &str,
) -> serde_json::Value {
assert_eq!(
output.status.code(),
Some(0),
"local JSON command should exit 0"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stdout.trim().is_empty(),
"local JSON command must emit stdout JSON"
);
assert!(
stderr.is_empty(),
"local JSON command must keep stderr empty, got: {stderr:?}"
);
assert!(
!stdout.contains("missing_credentials"),
"local JSON command must not hit provider credential startup: {stdout}"
);
let j: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|_| panic!("stdout must be parseable JSON, got: {stdout:?}"));
assert_eq!(j["status"], "ok", "local JSON status: {j}");
assert_eq!(j["kind"], expected_kind, "local JSON kind: {j}");
j
}
// #807: model/model(s) JSON/help surfaces must stay bounded and local.
#[test]
fn models_json_and_model_help_json_are_local_807() {
let root = unique_temp_dir("models-local-json-807");
std::fs::create_dir_all(&root).expect("create temp dir");
let models = run_claw(&root, &["models", "--output-format", "json"], &[]);
let models_json = assert_local_json_without_missing_credentials(&models, "models");
assert_eq!(
models_json["action"], "list",
"models action: {models_json}"
);
assert_eq!(
models_json["requires_provider_request"], false,
"models must be local: {models_json}"
);
let help = run_claw(&root, &["model", "help", "--output-format", "json"], &[]);
let help_json = assert_local_json_without_missing_credentials(&help, "help");
assert_eq!(
help_json["command"], "models",
"model help command: {help_json}"
);
}
// #808: settings JSON/help surfaces must stay bounded and local.
#[test]
fn settings_json_and_help_json_are_local_808() {
let root = unique_temp_dir("settings-local-json-808");
std::fs::create_dir_all(&root).expect("create temp dir");
let settings = run_claw(&root, &["settings", "--output-format", "json"], &[]);
let settings_json = assert_local_json_without_missing_credentials(&settings, "config");
assert_eq!(
settings_json["action"], "show",
"settings action: {settings_json}"
);
assert_eq!(
settings_json["section"], "settings",
"settings section: {settings_json}"
);
let help = run_claw(&root, &["settings", "help", "--output-format", "json"], &[]);
let help_json = assert_local_json_without_missing_credentials(&help, "help");
assert_eq!(
help_json["command"], "settings",
"settings help command: {help_json}"
);
}
// #825: unknown single-word subcommand must return command_not_found, not
// fall through to missing_credentials after provider startup.
#[test]
@@ -4111,13 +4339,13 @@ fn non_resume_safe_interactive_only_hint_omits_resume_suggestion() {
fn resume_safe_interactive_only_hint_includes_resume_suggestion() {
let root = unique_temp_dir("resume-hint-829");
std::fs::create_dir_all(&root).expect("create temp dir");
let output = run_claw(&root, &["--output-format", "json", "/diff"], &[]);
let output = run_claw(&root, &["--output-format", "json", "/compact"], &[]);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|_| panic!("/diff must emit JSON (#829), got: {stdout:?}"));
.unwrap_or_else(|_| panic!("/compact must emit JSON (#829), got: {stdout:?}"));
let hint = j["hint"].as_str().unwrap_or("");
assert!(
hint.contains("--resume"),
"/diff hint must suggest --resume (it is resume-safe) (#829): hint={hint:?}"
"/compact hint must suggest --resume (it is resume-safe and not a local direct action) (#829): hint={hint:?}"
);
}