#![cfg(feature = "async")]
use std::collections::HashSet;
use std::process::Command;
use claude_wrapper::duplex::DuplexOptions;
use claude_wrapper::{
ClaudeCommand, Effort, McpAddCommand, McpGetCommand, McpListCommand, McpRemoveCommand,
OutputFormat, PermissionMode, PluginInstallCommand, PluginListCommand, QueryCommand, Scope,
Transport,
};
const KNOWN_HIDDEN: &[(&str, &str, &str)] = &[(
"--max-turns",
"",
"absent from `claude --help` as of 2.1.220 but still accepted \
(verified: `claude -p ... --max-turns 1` exits 0). Used by \
QueryCommand::max_turns and DuplexOptions::max_turns.",
)];
fn help_text(subcommand: &[&str]) -> String {
let binary = std::env::var("CLAUDE_CONTRACT_BIN").unwrap_or_else(|_| "claude".to_string());
let mut cmd = Command::new(&binary);
cmd.args(subcommand)
.arg("--help")
.env_remove("CLAUDECODE")
.env_remove("CLAUDE_CODE_ENTRYPOINT");
let out = cmd
.output()
.unwrap_or_else(|e| panic!("running `{binary} {} --help`: {e}", subcommand.join(" ")));
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn help_flags(subcommand: &[&str]) -> HashSet<String> {
parse_help_flags(&help_text(subcommand))
}
fn help_options(subcommand: &[&str]) -> Vec<Vec<String>> {
parse_help_options(&help_text(subcommand))
}
fn parse_help_flags(text: &str) -> HashSet<String> {
parse_help_options(text).into_iter().flatten().collect()
}
fn parse_help_options(text: &str) -> Vec<Vec<String>> {
let mut options = Vec::new();
let mut in_options = false;
for line in text.lines() {
if line.trim() == "Options:" {
in_options = true;
continue;
}
if !in_options {
continue;
}
if !line.is_empty() && !line.starts_with(' ') {
break;
}
let Some(rest) = line.strip_prefix(" ") else {
continue;
};
if !rest.starts_with('-') {
continue;
}
let head = rest
.split(" ")
.next()
.unwrap_or(rest)
.split(" <")
.next()
.unwrap_or(rest)
.split(" [")
.next()
.unwrap_or(rest);
let spellings: Vec<String> = head
.split(',')
.map(str::trim)
.filter(|token| token.starts_with('-'))
.map(str::to_string)
.collect();
if !spellings.is_empty() {
options.push(spellings);
}
}
options
}
fn flags_in_command_string(cmd: &str) -> Vec<String> {
let args: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
emitted_flags(&args)
}
fn emitted_flags(args: &[String]) -> Vec<String> {
let mut out = Vec::new();
for arg in args {
if arg == "--" {
break;
}
if arg.starts_with('-') && arg.len() > 1 && !out.contains(arg) {
out.push(arg.clone());
}
}
out
}
fn assert_flags_documented(label: &str, subcommand: &[&str], args: &[String]) {
assert_flags_listed(label, subcommand, emitted_flags(args));
}
fn assert_flags_listed(label: &str, subcommand: &[&str], flags: Vec<String>) {
let documented = help_flags(subcommand);
assert!(
!documented.is_empty(),
"{label}: parsed no flags from `claude {} --help`; the help format \
probably changed and this suite is now blind",
subcommand.join(" ")
);
let sub_path = subcommand.join(" ");
let mut missing = Vec::new();
for flag in flags {
if documented.contains(&flag) {
continue;
}
if KNOWN_HIDDEN
.iter()
.any(|(f, s, _)| *f == flag && *s == sub_path)
{
eprintln!("{label}: {flag} is hidden but known (see KNOWN_HIDDEN)");
continue;
}
missing.push(flag);
}
assert!(
missing.is_empty(),
"{label}: `claude {sub_path} --help` no longer lists {missing:?}. \
Either the CLI dropped them (fix the builder) or they went hidden \
(add to KNOWN_HIDDEN with the date and a verification note)."
);
}
const DECLINED: &[(&str, &str, &str)] = &[
(
"--help",
ANY_SUBCOMMAND,
"help output, not a builder option",
),
(
"--version",
ANY_SUBCOMMAND,
"read by Claude::cli_version, which spawns `claude --version` \
directly rather than through a command builder",
),
(
"--ide",
"",
"connects an interactive session to a running IDE; there is no \
session for a headless run to connect",
),
(
"--chrome",
"",
"Claude in Chrome integration, interactive-only",
),
(
"--no-chrome",
"",
"disables the Chrome integration; listed separately from --chrome \
rather than as an alias of it",
),
(
"--ax-screen-reader",
"",
"renders screen-reader friendly TUI output; the wrapper parses \
stream-json, not rendered text",
),
(
"--background",
"",
"starts the session as a background agent, which is the CLI owning a \
process lifecycle the wrapper owns itself (--bg is the same option)",
),
(
"--remote-control",
"",
"starts an interactive session with Remote Control enabled",
),
(
"--remote-control-session-name-prefix",
"",
"names auto-generated Remote Control sessions; only meaningful with \
--remote-control",
),
(
"--forward-subagent-text",
"",
"unwrapped gap, see issue #799: works only with --print and \
--output-format=stream-json, which is what the wrapper runs",
),
(
"--autocompact",
"",
"unwrapped gap, see issue #799: spawn-time context window setting, \
where slash.rs already builds the /compact operation",
),
(
"--allow-dangerously-skip-permissions",
"",
"unwrapped gap, see issue #799: the weaker form of the flag \
DangerousClient already emits, making bypass available rather than \
active",
),
(
"--cloud",
"",
"unwrapped gap, see issue #799: a session source alongside --resume \
and --from-pr",
),
(
"--environment",
"",
"unwrapped gap, see issue #799: selects the self-hosted environment \
for a --cloud session",
),
(
"--teleport",
"",
"unwrapped gap, see issue #799: resumes a teleport session",
),
(
"--permission-prompts",
"",
"unwrapped gap, see issue #799: chooses who answers headless permission prompts",
),
(
"--restricted",
"",
"unwrapped gap, see issue #799: limits tools, settings, and file access in headless runs",
),
(
"--system-prompt-snapshot",
"",
"unwrapped gap, see issue #799: controls prompt recording across requests and resumes",
),
(
"--config",
"plugin install",
"unwrapped gap, see issue #799: sets a plugin userConfig option \
non-interactively",
),
(
"--accept-command",
"plugin install",
"unwrapped gap, see issue #799: confirms one displayed plugin install command by hash",
),
(
"--json",
"plugin install",
"unwrapped gap, see issue #799: requests a machine-readable install result",
),
(
"--registry",
"plugin install",
"unwrapped gap, see issue #799: new registry selector needs a typed API audit",
),
(
"--yes",
"plugin install",
"unwrapped gap, see issue #799: PluginUninstallCommand and \
PluginPruneCommand have yes(), PluginInstallCommand does not, and \
the CLI documents it as required when stdout is not a TTY",
),
];
const ANY_SUBCOMMAND: &str = "*";
fn all_root_flags() -> HashSet<String> {
let mut flags: HashSet<String> = HashSet::new();
flags.extend(emitted_flags(&maximal_query().args()));
flags.extend(emitted_flags(
&QueryCommand::new("p")
.resume("11111111-1111-1111-1111-111111111111")
.fork_session()
.args(),
));
flags.extend(emitted_flags(
&QueryCommand::new("p").continue_session().args(),
));
flags.extend(emitted_flags(
&QueryCommand::new("p").worktree_named("wt").args(),
));
flags.extend(emitted_flags(&QueryCommand::new("p").hermetic().args()));
for (_, cmd) in exclusive_query_commands() {
flags.extend(emitted_flags(&cmd.args()));
}
flags.extend(flags_in_command_string(&maximal_duplex_command()));
flags
}
fn is_declined(flag: &str, sub_path: &str) -> bool {
DECLINED.iter().any(|(declined, scope, _)| {
*declined == flag && (*scope == sub_path || *scope == ANY_SUBCOMMAND)
})
}
fn assert_help_is_covered(label: &str, subcommand: &[&str], emitted: &HashSet<String>) {
let options = help_options(subcommand);
assert!(
!options.is_empty(),
"{label}: parsed no options from `claude {} --help`; the help format \
probably changed and this check is now blind",
subcommand.join(" ")
);
let sub_path = subcommand.join(" ");
let mut uncovered: Vec<String> = options
.iter()
.filter(|spellings| !spellings.iter().any(|flag| emitted.contains(flag)))
.filter(|spellings| !spellings.iter().any(|flag| is_declined(flag, &sub_path)))
.filter_map(|spellings| spellings.iter().max_by_key(|s| s.len()).cloned())
.collect();
uncovered.sort();
assert!(
uncovered.is_empty(),
"{label}: `claude {sub_path} --help` lists {uncovered:?}, which no \
builder emits. Either wrap them or add them to DECLINED with a \
reason."
);
}
fn maximal_query() -> QueryCommand {
QueryCommand::new("contract probe")
.model("haiku")
.fallback_model("sonnet")
.effort(Effort::Low)
.system_prompt("sys")
.append_system_prompt("more")
.agent("reviewer")
.agents_json(r#"{"a":{"description":"d","prompt":"p"}}"#)
.permission_mode(PermissionMode::Plan)
.allowed_tools(["Read", "Bash(git:*)"])
.disallowed_tools(["Write"])
.add_dir("/tmp")
.max_turns(3)
.max_budget_usd(0.5)
.json_schema(r#"{"type":"object"}"#)
.mcp_config("/tmp/mcp.json")
.strict_mcp_config()
.settings("/tmp/settings.json")
.setting_sources("user")
.session_id("11111111-1111-1111-1111-111111111111")
.fallback_model("sonnet")
.output_format(OutputFormat::StreamJson)
.include_partial_messages()
.verbose(true)
.tools(["Read"])
.file("/tmp/x.txt")
.betas("beta1")
.plugin_dir("/tmp/plugins")
.debug_filter("api")
.debug_file("/tmp/debug.log")
.no_session_persistence()
.bare()
.brief()
.tmux()
.disable_slash_commands()
.include_hook_events()
.name("contract")
.replay_user_messages(true)
.exclude_dynamic_system_prompt_sections()
.plugin_url("https://example.test/plugins")
.safe_mode()
.prompt_suggestions(true)
}
fn exclusive_query_commands() -> Vec<(&'static str, QueryCommand)> {
vec![
(
"QueryCommand::from_pr",
QueryCommand::new("p").from_pr("123"),
),
(
"QueryCommand::dangerously_skip_permissions",
QueryCommand::new("p").dangerously_skip_permissions(),
),
]
}
fn maximal_duplex_command() -> String {
let opts = DuplexOptions::default()
.model("haiku")
.effort(Effort::Low)
.permission_mode(PermissionMode::Plan)
.allowed_tools(["Read"])
.disallowed_tools(["Write"])
.add_dir("/tmp")
.max_turns(2)
.max_budget_usd(0.25)
.append_system_prompt("x")
.agent("reviewer")
.mcp_config("/tmp/mcp.json")
.strict_mcp_config()
.session_id("11111111-1111-1111-1111-111111111111");
let claude = claude_wrapper::Claude::builder()
.binary("claude")
.build()
.expect("building a client for the command preview");
opts.to_command_string(&claude)
}
#[test]
#[ignore = "requires a real claude binary"]
fn query_flags_are_still_documented() {
let cmd = maximal_query();
assert_flags_documented("QueryCommand", &[], &cmd.args());
for (label, cmd) in exclusive_query_commands() {
assert_flags_documented(label, &[], &cmd.args());
}
}
#[test]
#[ignore = "requires a real claude binary"]
fn query_session_flags_are_still_documented() {
let resume = QueryCommand::new("p")
.resume("11111111-1111-1111-1111-111111111111")
.fork_session();
assert_flags_documented("QueryCommand::resume", &[], &resume.args());
let cont = QueryCommand::new("p").continue_session();
assert_flags_documented("QueryCommand::continue", &[], &cont.args());
}
#[test]
#[ignore = "requires a real claude binary"]
fn query_isolation_flags_are_still_documented() {
let worktree = QueryCommand::new("p").worktree_named("wt");
assert_flags_documented("QueryCommand::worktree", &[], &worktree.args());
let hermetic = QueryCommand::new("p").hermetic();
assert_flags_documented("QueryCommand::hermetic", &[], &hermetic.args());
}
#[test]
#[ignore = "requires a real claude binary"]
fn duplex_flags_are_still_documented() {
assert_flags_listed(
"DuplexOptions",
&[],
flags_in_command_string(&maximal_duplex_command()),
);
}
#[test]
#[ignore = "requires a real claude binary"]
fn mcp_family_flags_are_still_documented() {
assert_flags_documented("mcp list", &["mcp", "list"], &McpListCommand::new().args());
assert_flags_documented("mcp get", &["mcp", "get"], &McpGetCommand::new("n").args());
let add = McpAddCommand::new("n", "https://example.test/mcp")
.transport(Transport::Http)
.scope(Scope::User)
.env("K", "V")
.header("Authorization: Bearer x")
.callback_port(8080)
.client_id("cid");
assert_flags_documented("mcp add", &["mcp", "add"], &add.args());
let remove = McpRemoveCommand::new("n").scope(Scope::User);
assert_flags_documented("mcp remove", &["mcp", "remove"], &remove.args());
}
#[test]
#[ignore = "requires a real claude binary"]
fn plugin_family_flags_are_still_documented() {
let list = PluginListCommand::new().json().available();
assert_flags_documented("plugin list", &["plugin", "list"], &list.args());
let install = PluginInstallCommand::new("p").scope(Scope::User);
assert_flags_documented("plugin install", &["plugin", "install"], &install.args());
}
#[test]
#[ignore = "requires a real claude binary"]
fn root_help_is_fully_covered() {
assert_help_is_covered("root", &[], &all_root_flags());
}
#[test]
#[ignore = "requires a real claude binary"]
fn mcp_family_help_is_fully_covered() {
let add: HashSet<String> = emitted_flags(
&McpAddCommand::new("n", "https://example.test/mcp")
.transport(Transport::Http)
.scope(Scope::User)
.env("K", "V")
.header("Authorization: Bearer x")
.callback_port(8080)
.client_id("cid")
.client_secret()
.args(),
)
.into_iter()
.collect();
assert_help_is_covered("mcp add", &["mcp", "add"], &add);
let list: HashSet<String> = emitted_flags(&McpListCommand::new().args())
.into_iter()
.collect();
assert_help_is_covered("mcp list", &["mcp", "list"], &list);
let get: HashSet<String> = emitted_flags(&McpGetCommand::new("n").args())
.into_iter()
.collect();
assert_help_is_covered("mcp get", &["mcp", "get"], &get);
let remove: HashSet<String> =
emitted_flags(&McpRemoveCommand::new("n").scope(Scope::User).args())
.into_iter()
.collect();
assert_help_is_covered("mcp remove", &["mcp", "remove"], &remove);
}
#[test]
#[ignore = "requires a real claude binary"]
fn plugin_family_help_is_fully_covered() {
let list: HashSet<String> = emitted_flags(&PluginListCommand::new().json().available().args())
.into_iter()
.collect();
assert_help_is_covered("plugin list", &["plugin", "list"], &list);
let install: HashSet<String> =
emitted_flags(&PluginInstallCommand::new("p").scope(Scope::User).args())
.into_iter()
.collect();
assert_help_is_covered("plugin install", &["plugin", "install"], &install);
}
#[test]
#[ignore = "requires a real claude binary"]
fn the_check_can_actually_fail() {
let documented = help_flags(&[]);
assert!(
documented.contains("--print"),
"sanity: --print must be documented; got {} flags",
documented.len()
);
assert!(
!documented.contains("--definitely-not-a-real-claude-flag"),
"the parser is returning flags the CLI does not have, so every other \
test in this file is vacuous"
);
}
#[cfg(test)]
mod parser_tests {
use super::*;
const SAMPLE: &str = "\
Usage: claude [options] [command] [prompt]
Examples:
claude mcp add --transport http name https://example.test/mcp
Options:
--add-dir <directories...> Additional directories
--allowedTools, --allowed-tools <tools...>
Comma or space-separated list
-p, --print Print response and exit
--verbose Override verbose mode
Commands:
mcp Configure servers
";
#[test]
fn parses_options_and_aliases() {
let flags = parse_help_flags(SAMPLE);
for expected in [
"--add-dir",
"--allowedTools",
"--allowed-tools",
"-p",
"--print",
"--verbose",
] {
assert!(flags.contains(expected), "missing {expected} in {flags:?}");
}
}
#[test]
fn ignores_examples_and_stops_at_the_next_section() {
let flags = parse_help_flags(SAMPLE);
assert!(!flags.contains("--transport"), "leaked from Examples");
assert!(!flags.contains("mcp"));
}
#[test]
fn emitted_flags_skips_positionals_after_the_separator() {
let args = vec![
"--print".to_string(),
"--model".to_string(),
"haiku".to_string(),
"--".to_string(),
"--not-a-flag".to_string(),
];
assert_eq!(emitted_flags(&args), vec!["--print", "--model"]);
}
#[test]
fn emitted_flags_deduplicates() {
let args = vec![
"--add-dir".to_string(),
"/a".to_string(),
"--add-dir".to_string(),
"/b".to_string(),
];
assert_eq!(emitted_flags(&args), vec!["--add-dir"]);
}
#[test]
fn parse_help_options_keeps_aliases_together() {
let options = parse_help_options(SAMPLE);
assert!(
options.contains(&vec![
"--allowedTools".to_string(),
"--allowed-tools".to_string()
]),
"alias spellings must stay in one group; got {options:?}"
);
assert!(
options.contains(&vec!["-p".to_string(), "--print".to_string()]),
"a short form and its long form are one option; got {options:?}"
);
}
#[test]
fn the_two_parses_agree() {
let flat = parse_help_flags(SAMPLE);
let grouped: HashSet<String> = parse_help_options(SAMPLE).into_iter().flatten().collect();
assert_eq!(flat, grouped);
}
#[test]
fn covering_one_spelling_covers_its_aliases() {
let emitted: HashSet<String> = ["--allowed-tools".to_string()].into_iter().collect();
let uncovered: Vec<Vec<String>> = parse_help_options(SAMPLE)
.into_iter()
.filter(|spellings| !spellings.iter().any(|f| emitted.contains(f)))
.collect();
assert!(
!uncovered
.iter()
.any(|group| group.iter().any(|f| f == "--allowedTools")),
"--allowedTools should be covered by --allowed-tools; got {uncovered:?}"
);
}
#[test]
fn declined_scopes_match_exactly_or_by_wildcard() {
assert!(
is_declined("--help", ""),
"wildcard scope must match the root"
);
assert!(
is_declined("--help", "mcp add"),
"wildcard scope must match a subcommand"
);
assert!(
is_declined("--config", "plugin install"),
"an exact scope must match itself"
);
assert!(
!is_declined("--config", ""),
"an exact scope must not leak to other subcommands"
);
}
#[test]
fn declined_entries_carry_a_reason() {
for (flag, _, reason) in DECLINED {
assert!(
reason.len() > 20,
"{flag}: DECLINED entries must say why, not just that"
);
}
}
#[test]
fn declined_root_flags_are_not_also_emitted() {
let emitted = all_root_flags();
for (flag, scope, _) in DECLINED {
if !scope.is_empty() && *scope != ANY_SUBCOMMAND {
continue;
}
assert!(
!emitted.contains(*flag),
"{flag} is in DECLINED but a builder emits it"
);
}
}
#[test]
fn known_hidden_entries_carry_a_note() {
for (flag, _, note) in KNOWN_HIDDEN {
assert!(
note.len() > 40,
"{flag}: KNOWN_HIDDEN entries must say when it was observed \
and how it was verified"
);
}
}
}