mirror of
https://github.com/instructkr/claude-code.git
synced 2026-06-03 19:16:44 +00:00
fix: keep JSON control surfaces local
This commit is contained in:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user