1228 lines
44 KiB
Rust
1228 lines
44 KiB
Rust
use std::env;
|
|
use std::io::IsTerminal;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use api::ToolDefinition;
|
|
use commands::{
|
|
classify_skills_slash_command, resolve_skill_invocation, slash_command_specs,
|
|
SkillSlashDispatch, SlashCommand,
|
|
};
|
|
use runtime::{ConfigLoader, PermissionMode, ResolvedPermissionMode};
|
|
use serde_json::{json, Value};
|
|
use tools::GlobalToolRegistry;
|
|
|
|
use crate::build_runtime_plugin_state_with_loader;
|
|
use crate::constants::*;
|
|
use crate::looks_like_slash_command_token;
|
|
use crate::model::*;
|
|
use crate::normalize_permission_mode;
|
|
use crate::resume_command_can_absorb_token;
|
|
|
|
pub(crate) enum CliAction {
|
|
DumpManifests {
|
|
output_format: CliOutputFormat,
|
|
manifests_dir: Option<PathBuf>,
|
|
},
|
|
BootstrapPlan {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Agents {
|
|
args: Option<String>,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Mcp {
|
|
args: Option<String>,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Skills {
|
|
args: Option<String>,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Plugins {
|
|
action: Option<String>,
|
|
target: Option<String>,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
PrintSystemPrompt {
|
|
cwd: PathBuf,
|
|
date: String,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Version {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
ResumeSession {
|
|
session_path: PathBuf,
|
|
commands: Vec<String>,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Status {
|
|
model: String,
|
|
// #148: raw `--model` flag input (pre-alias-resolution), if any.
|
|
// None means no flag was supplied; env/config/default fallback is
|
|
// resolved inside `print_status_snapshot`.
|
|
model_flag_raw: Option<String>,
|
|
permission_mode: PermissionMode,
|
|
output_format: CliOutputFormat,
|
|
allowed_tools: Option<AllowedToolSet>,
|
|
},
|
|
Sandbox {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Prompt {
|
|
prompt: String,
|
|
model: String,
|
|
output_format: CliOutputFormat,
|
|
allowed_tools: Option<AllowedToolSet>,
|
|
permission_mode: PermissionMode,
|
|
compact: bool,
|
|
base_commit: Option<String>,
|
|
reasoning_effort: Option<String>,
|
|
allow_broad_cwd: bool,
|
|
},
|
|
Doctor {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Acp {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
State {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Init {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
// #146: `claw config` and `claw diff` are pure-local read-only
|
|
// introspection commands; wire them as standalone CLI subcommands.
|
|
Config {
|
|
section: Option<String>,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Diff {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Export {
|
|
session_reference: String,
|
|
output_path: Option<PathBuf>,
|
|
output_format: CliOutputFormat,
|
|
},
|
|
Repl {
|
|
model: String,
|
|
allowed_tools: Option<AllowedToolSet>,
|
|
permission_mode: PermissionMode,
|
|
base_commit: Option<String>,
|
|
reasoning_effort: Option<String>,
|
|
allow_broad_cwd: bool,
|
|
},
|
|
HelpTopic(LocalHelpTopic),
|
|
// prompt-mode formatting is only supported for non-interactive runs
|
|
Help {
|
|
output_format: CliOutputFormat,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum LocalHelpTopic {
|
|
Status,
|
|
Sandbox,
|
|
Doctor,
|
|
Acp,
|
|
// #141: extend the local-help pattern to every subcommand so
|
|
// `claw <subcommand> --help` has one consistent contract.
|
|
Init,
|
|
State,
|
|
Export,
|
|
Version,
|
|
SystemPrompt,
|
|
DumpManifests,
|
|
BootstrapPlan,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum CliOutputFormat {
|
|
Text,
|
|
Json,
|
|
}
|
|
|
|
impl CliOutputFormat {
|
|
fn parse(value: &str) -> Result<Self, String> {
|
|
match value {
|
|
"text" => Ok(Self::Text),
|
|
"json" => Ok(Self::Json),
|
|
other => Err(format!(
|
|
"unsupported value for --output-format: {other} (expected text or json)"
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub(crate) fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
|
let mut model = DEFAULT_MODEL.to_string();
|
|
|
|
// #148: when user passes --model/--model=, capture the raw input so we
|
|
// can attribute source: "flag" later. None means no flag was supplied.
|
|
let mut model_flag_raw: Option<String> = None;
|
|
|
|
let mut output_format = CliOutputFormat::Text;
|
|
let mut permission_mode_override = None;
|
|
let mut wants_help = false;
|
|
let mut wants_version = false;
|
|
let mut allowed_tool_values = Vec::new();
|
|
let mut compact = false;
|
|
let mut base_commit: Option<String> = None;
|
|
let mut reasoning_effort: Option<String> = None;
|
|
let mut allow_broad_cwd = false;
|
|
let mut rest: Vec<String> = Vec::new();
|
|
let mut index = 0;
|
|
|
|
while index < args.len() {
|
|
match args[index].as_str() {
|
|
"--help" | "-h" if rest.is_empty() => {
|
|
wants_help = true;
|
|
index += 1;
|
|
}
|
|
"--help" | "-h"
|
|
if !rest.is_empty()
|
|
&& matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") =>
|
|
{
|
|
// `--help` following a subcommand that would otherwise forward
|
|
// the arg to the API (e.g. `claw prompt --help`) should show
|
|
// top-level help instead. Subcommands that consume their own
|
|
// args (agents, mcp, plugins, skills) and local help-topic
|
|
// subcommands (status, sandbox, doctor, init, state, export,
|
|
// version, system-prompt, dump-manifests, bootstrap-plan) must
|
|
// NOT be intercepted here — they handle --help in their own
|
|
// dispatch paths via parse_local_help_action(). See #141.
|
|
wants_help = true;
|
|
index += 1;
|
|
}
|
|
"--version" | "-V" => {
|
|
wants_version = true;
|
|
index += 1;
|
|
}
|
|
"--model" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --model".to_string())?;
|
|
validate_model_syntax(value)?;
|
|
model = resolve_model_alias_with_config(value);
|
|
model_flag_raw = Some(value.clone()); // #148
|
|
index += 2;
|
|
}
|
|
flag if flag.starts_with("--model=") => {
|
|
let value = &flag[8..];
|
|
validate_model_syntax(value)?;
|
|
model = resolve_model_alias_with_config(value);
|
|
model_flag_raw = Some(value.to_string()); // #148
|
|
index += 1;
|
|
}
|
|
"--output-format" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --output-format".to_string())?;
|
|
output_format = CliOutputFormat::parse(value)?;
|
|
index += 2;
|
|
}
|
|
"--permission-mode" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --permission-mode".to_string())?;
|
|
permission_mode_override = Some(parse_permission_mode_arg(value)?);
|
|
index += 2;
|
|
}
|
|
flag if flag.starts_with("--output-format=") => {
|
|
output_format = CliOutputFormat::parse(&flag[16..])?;
|
|
index += 1;
|
|
}
|
|
flag if flag.starts_with("--permission-mode=") => {
|
|
permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?);
|
|
index += 1;
|
|
}
|
|
"--dangerously-skip-permissions" => {
|
|
permission_mode_override = Some(PermissionMode::DangerFullAccess);
|
|
index += 1;
|
|
}
|
|
"--compact" => {
|
|
compact = true;
|
|
index += 1;
|
|
}
|
|
"--base-commit" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --base-commit".to_string())?;
|
|
base_commit = Some(value.clone());
|
|
index += 2;
|
|
}
|
|
flag if flag.starts_with("--base-commit=") => {
|
|
base_commit = Some(flag[14..].to_string());
|
|
index += 1;
|
|
}
|
|
"--reasoning-effort" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --reasoning-effort".to_string())?;
|
|
if !matches!(value.as_str(), "low" | "medium" | "high") {
|
|
return Err(format!(
|
|
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
|
|
));
|
|
}
|
|
reasoning_effort = Some(value.clone());
|
|
index += 2;
|
|
}
|
|
flag if flag.starts_with("--reasoning-effort=") => {
|
|
let value = &flag[19..];
|
|
if !matches!(value, "low" | "medium" | "high") {
|
|
return Err(format!(
|
|
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
|
|
));
|
|
}
|
|
reasoning_effort = Some(value.to_string());
|
|
index += 1;
|
|
}
|
|
"--allow-broad-cwd" => {
|
|
allow_broad_cwd = true;
|
|
index += 1;
|
|
}
|
|
"-p" => {
|
|
// Claw Code compat: -p "prompt" = one-shot prompt
|
|
let prompt = args[index + 1..].join(" ");
|
|
if prompt.trim().is_empty() {
|
|
return Err("-p requires a prompt string".to_string());
|
|
}
|
|
return Ok(CliAction::Prompt {
|
|
prompt,
|
|
model: resolve_model_alias_with_config(&model),
|
|
output_format,
|
|
allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
|
|
permission_mode: permission_mode_override
|
|
.unwrap_or_else(default_permission_mode),
|
|
compact,
|
|
base_commit: base_commit.clone(),
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
allow_broad_cwd,
|
|
});
|
|
}
|
|
"--print" => {
|
|
// Claw Code compat: --print makes output non-interactive
|
|
output_format = CliOutputFormat::Text;
|
|
index += 1;
|
|
}
|
|
"--resume" if rest.is_empty() => {
|
|
rest.push("--resume".to_string());
|
|
index += 1;
|
|
}
|
|
flag if rest.is_empty() && flag.starts_with("--resume=") => {
|
|
rest.push("--resume".to_string());
|
|
rest.push(flag[9..].to_string());
|
|
index += 1;
|
|
}
|
|
"--acp" | "-acp" => {
|
|
rest.push("acp".to_string());
|
|
index += 1;
|
|
}
|
|
"--allowedTools" | "--allowed-tools" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --allowedTools".to_string())?;
|
|
allowed_tool_values.push(value.clone());
|
|
index += 2;
|
|
}
|
|
flag if flag.starts_with("--allowedTools=") => {
|
|
allowed_tool_values.push(flag[15..].to_string());
|
|
index += 1;
|
|
}
|
|
flag if flag.starts_with("--allowed-tools=") => {
|
|
allowed_tool_values.push(flag[16..].to_string());
|
|
index += 1;
|
|
}
|
|
other if rest.is_empty() && other.starts_with('-') => {
|
|
return Err(format_unknown_option(other))
|
|
}
|
|
other => {
|
|
rest.push(other.to_string());
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if wants_help {
|
|
return Ok(CliAction::Help { output_format });
|
|
}
|
|
|
|
if wants_version {
|
|
return Ok(CliAction::Version { output_format });
|
|
}
|
|
|
|
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
|
|
|
if rest.is_empty() {
|
|
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
|
// When stdin is not a terminal (pipe/redirect) and no prompt is given on the
|
|
// command line, read stdin as the prompt and dispatch as a one-shot Prompt
|
|
// rather than starting the interactive REPL (which would consume the pipe and
|
|
// print the startup banner, then exit without sending anything to the API).
|
|
if !std::io::stdin().is_terminal() {
|
|
let mut buf = String::new();
|
|
let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf);
|
|
let piped = buf.trim().to_string();
|
|
if !piped.is_empty() {
|
|
return Ok(CliAction::Prompt {
|
|
model,
|
|
prompt: piped,
|
|
allowed_tools,
|
|
permission_mode,
|
|
output_format,
|
|
compact: false,
|
|
base_commit,
|
|
reasoning_effort,
|
|
allow_broad_cwd,
|
|
});
|
|
}
|
|
}
|
|
return Ok(CliAction::Repl {
|
|
model,
|
|
allowed_tools,
|
|
permission_mode,
|
|
base_commit,
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
allow_broad_cwd,
|
|
});
|
|
}
|
|
if rest.first().map(String::as_str) == Some("--resume") {
|
|
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,
|
|
model_flag_raw.as_deref(),
|
|
permission_mode_override,
|
|
output_format,
|
|
allowed_tools.clone(),
|
|
) {
|
|
return action;
|
|
}
|
|
|
|
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
|
|
|
match rest[0].as_str() {
|
|
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
|
|
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
|
|
"agents" => Ok(CliAction::Agents {
|
|
args: join_optional_args(&rest[1..]),
|
|
output_format,
|
|
}),
|
|
"mcp" => Ok(CliAction::Mcp {
|
|
args: join_optional_args(&rest[1..]),
|
|
output_format,
|
|
}),
|
|
// #145: `plugins` was routed through the prompt fallback because no
|
|
// top-level parser arm produced CliAction::Plugins. That made `claw
|
|
// plugins` (and `claw plugins --help`, `claw plugins list`, ...)
|
|
// attempt an Anthropic network call, surfacing the misleading error
|
|
// `missing Anthropic credentials` even though the command is purely
|
|
// local introspection. Mirror `agents`/`mcp`/`skills`: action is the
|
|
// first positional arg, target is the second.
|
|
"plugins" => {
|
|
let tail = &rest[1..];
|
|
let action = tail.first().cloned();
|
|
let target = tail.get(1).cloned();
|
|
if tail.len() > 2 {
|
|
return Err(format!(
|
|
"unexpected extra arguments after `claw plugins {}`: {}",
|
|
tail[..2].join(" "),
|
|
tail[2..].join(" ")
|
|
));
|
|
}
|
|
Ok(CliAction::Plugins {
|
|
action,
|
|
target,
|
|
output_format,
|
|
})
|
|
}
|
|
// #146: `config` is pure-local read-only introspection (merges
|
|
// `.claw.json` + `.claw/settings.json` from disk, no network, no
|
|
// state mutation). Previously callers had to spin up a session with
|
|
// `claw --resume SESSION.jsonl /config` to see their own config,
|
|
// which is synthetic friction. Accepts an optional section name
|
|
// (env|hooks|model|plugins) matching the slash command shape.
|
|
"config" => {
|
|
let tail = &rest[1..];
|
|
let section = tail.first().cloned();
|
|
if tail.len() > 1 {
|
|
return Err(format!(
|
|
"unexpected extra arguments after `claw config {}`: {}",
|
|
tail[0],
|
|
tail[1..].join(" ")
|
|
));
|
|
}
|
|
Ok(CliAction::Config {
|
|
section,
|
|
output_format,
|
|
})
|
|
}
|
|
// #146: `diff` is pure-local (shells out to `git diff --cached` +
|
|
// `git diff`). No session needed to inspect the working tree.
|
|
"diff" => {
|
|
if rest.len() > 1 {
|
|
return Err(format!(
|
|
"unexpected extra arguments after `claw diff`: {}",
|
|
rest[1..].join(" ")
|
|
));
|
|
}
|
|
Ok(CliAction::Diff { output_format })
|
|
}
|
|
"skills" => {
|
|
let args = join_optional_args(&rest[1..]);
|
|
match classify_skills_slash_command(args.as_deref()) {
|
|
SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt {
|
|
prompt,
|
|
model,
|
|
output_format,
|
|
allowed_tools,
|
|
permission_mode,
|
|
compact,
|
|
base_commit,
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
allow_broad_cwd,
|
|
}),
|
|
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
|
args,
|
|
output_format,
|
|
}),
|
|
}
|
|
}
|
|
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
|
|
"acp" => parse_acp_args(&rest[1..], output_format),
|
|
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
|
|
"init" => Ok(CliAction::Init { output_format }),
|
|
"export" => parse_export_args(&rest[1..], output_format),
|
|
"prompt" => {
|
|
let prompt = rest[1..].join(" ");
|
|
if prompt.trim().is_empty() {
|
|
return Err("prompt subcommand requires a prompt string".to_string());
|
|
}
|
|
Ok(CliAction::Prompt {
|
|
prompt,
|
|
model,
|
|
output_format,
|
|
allowed_tools,
|
|
permission_mode,
|
|
compact,
|
|
base_commit: base_commit.clone(),
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
allow_broad_cwd,
|
|
})
|
|
}
|
|
other if other.starts_with('/') => parse_direct_slash_cli_action(
|
|
&rest,
|
|
model,
|
|
output_format,
|
|
allowed_tools,
|
|
permission_mode,
|
|
compact,
|
|
base_commit,
|
|
reasoning_effort,
|
|
allow_broad_cwd,
|
|
),
|
|
other => {
|
|
if rest.len() == 1 && looks_like_subcommand_typo(other) {
|
|
if let Some(suggestions) = suggest_similar_subcommand(other) {
|
|
let mut message = format!("unknown subcommand: {other}.");
|
|
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
|
|
message.push('\n');
|
|
message.push_str(&line);
|
|
}
|
|
message.push_str(
|
|
"\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`.",
|
|
);
|
|
return Err(message);
|
|
}
|
|
}
|
|
// #147: guard empty/whitespace-only prompts at the fallthrough
|
|
// path the same way `"prompt"` arm above does. Without this,
|
|
// `claw ""`, `claw " "`, and `claw "" ""` silently route to
|
|
// the Anthropic call and surface a misleading
|
|
// `missing Anthropic credentials` error (or burn API tokens on
|
|
// an empty prompt when credentials are present).
|
|
let joined = rest.join(" ");
|
|
if joined.trim().is_empty() {
|
|
return Err(
|
|
"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string"
|
|
.to_string(),
|
|
);
|
|
}
|
|
Ok(CliAction::Prompt {
|
|
prompt: joined,
|
|
model,
|
|
output_format,
|
|
allowed_tools,
|
|
permission_mode,
|
|
compact,
|
|
base_commit,
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
allow_broad_cwd,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse_local_help_action(rest: &[String]) -> Option<Result<CliAction, String>> {
|
|
if rest.len() != 2 || !is_help_flag(&rest[1]) {
|
|
return None;
|
|
}
|
|
|
|
let topic = match rest[0].as_str() {
|
|
"status" => LocalHelpTopic::Status,
|
|
"sandbox" => LocalHelpTopic::Sandbox,
|
|
"doctor" => LocalHelpTopic::Doctor,
|
|
"acp" => LocalHelpTopic::Acp,
|
|
// #141: add the subcommands that were previously falling back
|
|
// to global help (init/state/export/version) or erroring out
|
|
// (system-prompt/dump-manifests) or printing their primary
|
|
// output instead of help text (bootstrap-plan).
|
|
"init" => LocalHelpTopic::Init,
|
|
"state" => LocalHelpTopic::State,
|
|
"export" => LocalHelpTopic::Export,
|
|
"version" => LocalHelpTopic::Version,
|
|
"system-prompt" => LocalHelpTopic::SystemPrompt,
|
|
"dump-manifests" => LocalHelpTopic::DumpManifests,
|
|
"bootstrap-plan" => LocalHelpTopic::BootstrapPlan,
|
|
_ => return None,
|
|
};
|
|
Some(Ok(CliAction::HelpTopic(topic)))
|
|
}
|
|
|
|
pub(crate) fn is_help_flag(value: &str) -> bool {
|
|
matches!(value, "--help" | "-h")
|
|
}
|
|
|
|
pub(crate) fn parse_single_word_command_alias(
|
|
rest: &[String],
|
|
model: &str,
|
|
// #148: raw --model flag input for status provenance. None = no flag.
|
|
model_flag_raw: Option<&str>,
|
|
permission_mode_override: Option<PermissionMode>,
|
|
output_format: CliOutputFormat,
|
|
allowed_tools: Option<AllowedToolSet>,
|
|
) -> Option<Result<CliAction, String>> {
|
|
if rest.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself
|
|
// or --help / -h as a suffix. Any other suffix args are unrecognized.
|
|
let verb = &rest[0];
|
|
let is_diagnostic = matches!(
|
|
verb.as_str(),
|
|
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
|
|
);
|
|
|
|
if is_diagnostic && rest.len() > 1 {
|
|
// Diagnostic verb with trailing args: reject unrecognized suffix
|
|
if is_help_flag(&rest[1]) && rest.len() == 2 {
|
|
// "doctor --help" is valid, routed to parse_local_help_action() instead
|
|
return None;
|
|
}
|
|
// Unrecognized suffix like "--json"
|
|
let mut msg = format!(
|
|
"unrecognized argument `{}` for subcommand `{}`",
|
|
rest[1], verb
|
|
);
|
|
// #152: common mistake — users type `--json` expecting JSON output.
|
|
// Hint at the correct flag so they don't have to re-read --help.
|
|
if rest[1] == "--json" {
|
|
msg.push_str("\nDid you mean `--output-format json`?");
|
|
}
|
|
return Some(Err(msg));
|
|
}
|
|
|
|
if rest.len() != 1 {
|
|
return None;
|
|
}
|
|
|
|
match rest[0].as_str() {
|
|
"help" => Some(Ok(CliAction::Help { output_format })),
|
|
"version" => Some(Ok(CliAction::Version { output_format })),
|
|
"status" => Some(Ok(CliAction::Status {
|
|
model: model.to_string(),
|
|
model_flag_raw: model_flag_raw.map(str::to_string), // #148
|
|
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
|
|
output_format,
|
|
allowed_tools,
|
|
})),
|
|
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
|
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
|
|
"state" => Some(Ok(CliAction::State { output_format })),
|
|
// #146: let `config` and `diff` fall through to parse_subcommand
|
|
// where they are wired as pure-local introspection, instead of
|
|
// producing the "is a slash command" guidance. Zero-arg cases
|
|
// reach parse_subcommand too via this None.
|
|
"config" | "diff" => None,
|
|
other => bare_slash_command_guidance(other).map(Err),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
|
if matches!(
|
|
command_name,
|
|
"dump-manifests"
|
|
| "bootstrap-plan"
|
|
| "agents"
|
|
| "mcp"
|
|
| "skills"
|
|
| "system-prompt"
|
|
| "init"
|
|
| "prompt"
|
|
| "export"
|
|
) {
|
|
return None;
|
|
}
|
|
let slash_command = slash_command_specs()
|
|
.iter()
|
|
.find(|spec| spec.name == command_name)?;
|
|
let guidance = if slash_command.resume_supported {
|
|
format!(
|
|
"`claw {command_name}` is a slash command. Use `claw --resume SESSION.jsonl /{command_name}` or start `claw` and run `/{command_name}`."
|
|
)
|
|
} else {
|
|
format!(
|
|
"`claw {command_name}` is a slash command. Start `claw` and run `/{command_name}` inside the REPL."
|
|
)
|
|
};
|
|
Some(guidance)
|
|
}
|
|
|
|
pub(crate) fn removed_auth_surface_error(command_name: &str) -> String {
|
|
format!(
|
|
"`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead."
|
|
)
|
|
}
|
|
|
|
pub(crate) fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
|
|
match args {
|
|
[] => Ok(CliAction::Acp { output_format }),
|
|
[subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }),
|
|
_ => Err(String::from(
|
|
"unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.",
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option<String> {
|
|
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
|
|
let looks_like_skill_name = !bare_first_token.is_empty()
|
|
&& !bare_first_token.starts_with('/')
|
|
&& bare_first_token
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
|
|
if !looks_like_skill_name {
|
|
return None;
|
|
}
|
|
match resolve_skill_invocation(cwd, Some(trimmed)) {
|
|
Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn join_optional_args(args: &[String]) -> Option<String> {
|
|
let joined = args.join(" ");
|
|
let trimmed = joined.trim();
|
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
|
|
pub(crate) fn parse_direct_slash_cli_action(
|
|
rest: &[String],
|
|
model: String,
|
|
output_format: CliOutputFormat,
|
|
allowed_tools: Option<AllowedToolSet>,
|
|
permission_mode: PermissionMode,
|
|
compact: bool,
|
|
base_commit: Option<String>,
|
|
reasoning_effort: Option<String>,
|
|
allow_broad_cwd: bool,
|
|
) -> Result<CliAction, String> {
|
|
let raw = rest.join(" ");
|
|
match SlashCommand::parse(&raw) {
|
|
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,
|
|
(Some(action), None) => Some(action),
|
|
(Some(action), Some(target)) => Some(format!("{action} {target}")),
|
|
(None, Some(target)) => Some(target),
|
|
},
|
|
output_format,
|
|
}),
|
|
Ok(Some(SlashCommand::Skills { args })) => {
|
|
match classify_skills_slash_command(args.as_deref()) {
|
|
SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt {
|
|
prompt,
|
|
model,
|
|
output_format,
|
|
allowed_tools,
|
|
permission_mode,
|
|
compact,
|
|
base_commit,
|
|
reasoning_effort: reasoning_effort.clone(),
|
|
allow_broad_cwd,
|
|
}),
|
|
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
|
args,
|
|
output_format,
|
|
}),
|
|
}
|
|
}
|
|
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
|
|
Ok(Some(command)) => Err({
|
|
let _ = command;
|
|
format!(
|
|
"slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.",
|
|
command_name = rest[0],
|
|
latest = LATEST_SESSION_REFERENCE,
|
|
)
|
|
}),
|
|
Ok(None) => Err(format!("unknown subcommand: {}", rest[0])),
|
|
Err(error) => Err(error.to_string()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn format_unknown_option(option: &str) -> String {
|
|
let mut message = format!("unknown option: {option}");
|
|
if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) {
|
|
message.push_str("\nDid you mean ");
|
|
message.push_str(suggestion);
|
|
message.push('?');
|
|
}
|
|
message.push_str("\nRun `claw --help` for usage.");
|
|
message
|
|
}
|
|
|
|
pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
|
|
let mut message = format!("unknown slash command outside the REPL: /{name}");
|
|
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
|
|
{
|
|
message.push('\n');
|
|
message.push_str(&suggestions);
|
|
}
|
|
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
|
|
message.push('\n');
|
|
message.push_str(note);
|
|
}
|
|
message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help.");
|
|
message
|
|
}
|
|
|
|
pub(crate) fn format_unknown_slash_command(name: &str) -> String {
|
|
let mut message = format!("Unknown slash command: /{name}");
|
|
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
|
|
{
|
|
message.push('\n');
|
|
message.push_str(&suggestions);
|
|
}
|
|
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
|
|
message.push('\n');
|
|
message.push_str(note);
|
|
}
|
|
message.push_str("\n Help /help lists available slash commands");
|
|
message
|
|
}
|
|
|
|
pub(crate) fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> {
|
|
name.starts_with("oh-my-claudecode:")
|
|
.then_some(
|
|
"Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.",
|
|
)
|
|
}
|
|
|
|
pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option<String> {
|
|
(!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),))
|
|
}
|
|
|
|
pub(crate) fn suggest_slash_commands(input: &str) -> Vec<String> {
|
|
let mut candidates = slash_command_specs()
|
|
.iter()
|
|
.flat_map(|spec| {
|
|
std::iter::once(spec.name)
|
|
.chain(spec.aliases.iter().copied())
|
|
.map(|name| format!("/{name}"))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
candidates.sort();
|
|
candidates.dedup();
|
|
let candidate_refs = candidates.iter().map(String::as_str).collect::<Vec<_>>();
|
|
ranked_suggestions(input.trim_start_matches('/'), &candidate_refs)
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
|
|
ranked_suggestions(input, candidates).into_iter().next()
|
|
}
|
|
|
|
pub(crate) fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
|
const KNOWN_SUBCOMMANDS: &[&str] = &[
|
|
"help",
|
|
"version",
|
|
"status",
|
|
"sandbox",
|
|
"doctor",
|
|
"state",
|
|
"dump-manifests",
|
|
"bootstrap-plan",
|
|
"agents",
|
|
"mcp",
|
|
"skills",
|
|
"system-prompt",
|
|
"acp",
|
|
"init",
|
|
"export",
|
|
"prompt",
|
|
];
|
|
|
|
let normalized_input = input.to_ascii_lowercase();
|
|
let mut ranked = KNOWN_SUBCOMMANDS
|
|
.iter()
|
|
.filter_map(|candidate| {
|
|
let normalized_candidate = candidate.to_ascii_lowercase();
|
|
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
|
|
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
|
|
let substring_match = normalized_candidate.contains(&normalized_input)
|
|
|| normalized_input.contains(&normalized_candidate);
|
|
((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
|
|
ranked.dedup_by(|left, right| left.1 == right.1);
|
|
let suggestions = ranked
|
|
.into_iter()
|
|
.map(|(_, candidate)| candidate.to_string())
|
|
.take(3)
|
|
.collect::<Vec<_>>();
|
|
(!suggestions.is_empty()).then_some(suggestions)
|
|
}
|
|
|
|
pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize {
|
|
left.chars()
|
|
.zip(right.chars())
|
|
.take_while(|(l, r)| l == r)
|
|
.count()
|
|
}
|
|
|
|
pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool {
|
|
!input.is_empty()
|
|
&& input
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphabetic() || ch == '-')
|
|
}
|
|
|
|
pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
|
|
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
|
|
let mut ranked = candidates
|
|
.iter()
|
|
.filter_map(|candidate| {
|
|
let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase();
|
|
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
|
|
let prefix_bonus = usize::from(
|
|
!(normalized_candidate.starts_with(&normalized_input)
|
|
|| normalized_input.starts_with(&normalized_candidate)),
|
|
);
|
|
let score = distance + prefix_bonus;
|
|
(score <= 4).then_some((score, *candidate))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
|
|
ranked
|
|
.into_iter()
|
|
.map(|(_, candidate)| candidate)
|
|
.take(3)
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize {
|
|
if left.is_empty() {
|
|
return right.chars().count();
|
|
}
|
|
if right.is_empty() {
|
|
return left.chars().count();
|
|
}
|
|
|
|
let right_chars = right.chars().collect::<Vec<_>>();
|
|
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
|
|
let mut current = vec![0; right_chars.len() + 1];
|
|
|
|
for (left_index, left_char) in left.chars().enumerate() {
|
|
current[0] = left_index + 1;
|
|
for (right_index, right_char) in right_chars.iter().enumerate() {
|
|
let substitution_cost = usize::from(left_char != *right_char);
|
|
current[right_index + 1] = (previous[right_index + 1] + 1)
|
|
.min(current[right_index] + 1)
|
|
.min(previous[right_index] + substitution_cost);
|
|
}
|
|
previous.clone_from(¤t);
|
|
}
|
|
|
|
previous[right_chars.len()]
|
|
}
|
|
|
|
|
|
|
|
pub(crate) fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
|
|
if values.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
current_tool_registry()?.normalize_allowed_tools(values)
|
|
}
|
|
|
|
pub(crate) fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
|
|
let cwd = env::current_dir().map_err(|error| error.to_string())?;
|
|
let loader = ConfigLoader::default_for(&cwd);
|
|
let runtime_config = loader.load().map_err(|error| error.to_string())?;
|
|
let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config)
|
|
.map_err(|error| error.to_string())?;
|
|
let registry = state.tool_registry.clone();
|
|
if let Some(mcp_state) = state.mcp_state {
|
|
mcp_state
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.shutdown()
|
|
.map_err(|error| error.to_string())?;
|
|
}
|
|
Ok(registry)
|
|
}
|
|
|
|
pub(crate) fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
|
|
normalize_permission_mode(value)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
|
|
)
|
|
})
|
|
.map(permission_mode_from_label)
|
|
}
|
|
|
|
pub(crate) fn permission_mode_from_label(mode: &str) -> PermissionMode {
|
|
match mode {
|
|
"read-only" => PermissionMode::ReadOnly,
|
|
"workspace-write" => PermissionMode::WorkspaceWrite,
|
|
"danger-full-access" => PermissionMode::DangerFullAccess,
|
|
other => panic!("unsupported permission mode label: {other}"),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode {
|
|
match mode {
|
|
ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly,
|
|
ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
|
ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn default_permission_mode() -> PermissionMode {
|
|
env::var("RUSTY_CLAUDE_PERMISSION_MODE")
|
|
.ok()
|
|
.as_deref()
|
|
.and_then(normalize_permission_mode)
|
|
.map(permission_mode_from_label)
|
|
.or_else(config_permission_mode_for_current_dir)
|
|
.unwrap_or(PermissionMode::DangerFullAccess)
|
|
}
|
|
|
|
pub(crate) fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
|
|
let cwd = env::current_dir().ok()?;
|
|
let loader = ConfigLoader::default_for(&cwd);
|
|
loader
|
|
.load()
|
|
.ok()?
|
|
.permission_mode()
|
|
.map(permission_mode_from_resolved)
|
|
}
|
|
|
|
|
|
|
|
pub(crate) fn filter_tool_specs(
|
|
tool_registry: &GlobalToolRegistry,
|
|
allowed_tools: Option<&AllowedToolSet>,
|
|
) -> Vec<ToolDefinition> {
|
|
tool_registry.definitions(allowed_tools)
|
|
}
|
|
|
|
pub(crate) 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;
|
|
|
|
while index < args.len() {
|
|
match args[index].as_str() {
|
|
"--cwd" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --cwd".to_string())?;
|
|
cwd = PathBuf::from(value);
|
|
index += 2;
|
|
}
|
|
"--date" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --date".to_string())?;
|
|
date.clone_from(value);
|
|
index += 2;
|
|
}
|
|
other => {
|
|
// #152: hint `--output-format json` when user types `--json`.
|
|
let mut msg = format!("unknown system-prompt option: {other}");
|
|
if other == "--json" {
|
|
msg.push_str("\nDid you mean `--output-format json`?");
|
|
}
|
|
return Err(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(CliAction::PrintSystemPrompt {
|
|
cwd,
|
|
date,
|
|
output_format,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
|
|
let mut session_reference = LATEST_SESSION_REFERENCE.to_string();
|
|
let mut output_path: Option<PathBuf> = None;
|
|
let mut index = 0;
|
|
|
|
while index < args.len() {
|
|
match args[index].as_str() {
|
|
"--session" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| "missing value for --session".to_string())?;
|
|
session_reference.clone_from(value);
|
|
index += 2;
|
|
}
|
|
flag if flag.starts_with("--session=") => {
|
|
session_reference = flag[10..].to_string();
|
|
index += 1;
|
|
}
|
|
"--output" | "-o" => {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| format!("missing value for {}", args[index]))?;
|
|
output_path = Some(PathBuf::from(value));
|
|
index += 2;
|
|
}
|
|
flag if flag.starts_with("--output=") => {
|
|
output_path = Some(PathBuf::from(&flag[9..]));
|
|
index += 1;
|
|
}
|
|
other if other.starts_with('-') => {
|
|
return Err(format!("unknown export option: {other}"));
|
|
}
|
|
other if output_path.is_none() => {
|
|
output_path = Some(PathBuf::from(other));
|
|
index += 1;
|
|
}
|
|
other => {
|
|
return Err(format!("unexpected export argument: {other}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(CliAction::Export {
|
|
session_reference,
|
|
output_path,
|
|
output_format,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn parse_dump_manifests_args(
|
|
args: &[String],
|
|
output_format: CliOutputFormat,
|
|
) -> Result<CliAction, String> {
|
|
let mut manifests_dir: Option<PathBuf> = None;
|
|
let mut index = 0;
|
|
while index < args.len() {
|
|
let arg = &args[index];
|
|
if arg == "--manifests-dir" {
|
|
let value = args
|
|
.get(index + 1)
|
|
.ok_or_else(|| String::from("--manifests-dir requires a path"))?;
|
|
manifests_dir = Some(PathBuf::from(value));
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if let Some(value) = arg.strip_prefix("--manifests-dir=") {
|
|
if value.is_empty() {
|
|
return Err(String::from("--manifests-dir requires a path"));
|
|
}
|
|
manifests_dir = Some(PathBuf::from(value));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
return Err(format!("unknown dump-manifests option: {arg}"));
|
|
}
|
|
|
|
Ok(CliAction::DumpManifests {
|
|
output_format,
|
|
manifests_dir,
|
|
})
|
|
}
|
|
|
|
pub(crate) 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) => {
|
|
(PathBuf::from(LATEST_SESSION_REFERENCE), args)
|
|
}
|
|
Some(first) => (PathBuf::from(first), &args[1..]),
|
|
};
|
|
let mut commands = Vec::new();
|
|
let mut current_command = String::new();
|
|
|
|
for token in command_tokens {
|
|
if token.trim_start().starts_with('/') {
|
|
if resume_command_can_absorb_token(¤t_command, token) {
|
|
current_command.push(' ');
|
|
current_command.push_str(token);
|
|
continue;
|
|
}
|
|
if !current_command.is_empty() {
|
|
commands.push(current_command);
|
|
}
|
|
current_command = String::from(token.as_str());
|
|
continue;
|
|
}
|
|
|
|
if current_command.is_empty() {
|
|
return Err("--resume trailing arguments must be slash commands".to_string());
|
|
}
|
|
|
|
current_command.push(' ');
|
|
current_command.push_str(token);
|
|
}
|
|
|
|
if !current_command.is_empty() {
|
|
commands.push(current_command);
|
|
}
|
|
|
|
Ok(CliAction::ResumeSession {
|
|
session_path,
|
|
commands,
|
|
output_format,
|
|
})
|
|
}
|