This commit is contained in:
parent
3810cbb5e4
commit
56dbb298a8
|
|
@ -9,6 +9,11 @@ archive/
|
|||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.clawhip/
|
||||
.claude/
|
||||
.clawd-todos.json
|
||||
.learnings/
|
||||
.sandbox-home/
|
||||
.sandbox-tmp/
|
||||
status-help.txt
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,21 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
|
|
@ -42,6 +57,18 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
|
|
@ -90,6 +117,27 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
|
|
@ -182,6 +230,24 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
"compat-harness",
|
||||
"crossterm",
|
||||
"plugins",
|
||||
"pulldown-cmark",
|
||||
"runtime",
|
||||
"rustyline",
|
||||
"serde_json",
|
||||
"syntect",
|
||||
"tokio",
|
||||
"tools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
|
|
@ -209,6 +275,24 @@ dependencies = [
|
|||
"tools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
|
|
@ -1875,6 +1959,19 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1911,13 +2008,18 @@ version = "0.6.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
members = ["crates/*"]
|
||||
exclude = ["crates/server", "crates/claw-cli"]
|
||||
exclude = ["crates/server"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ license.workspace = true
|
|||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate"] }
|
||||
runtime = { path = "../runtime" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,402 +0,0 @@
|
|||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::args::{OutputFormat, PermissionMode};
|
||||
use crate::input::{LineEditor, ReadOutcome};
|
||||
use crate::render::{Spinner, TerminalRenderer};
|
||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionConfig {
|
||||
pub model: String,
|
||||
pub permission_mode: PermissionMode,
|
||||
pub config: Option<PathBuf>,
|
||||
pub output_format: OutputFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionState {
|
||||
pub turns: usize,
|
||||
pub compacted_messages: usize,
|
||||
pub last_model: String,
|
||||
pub last_usage: UsageSummary,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
turns: 0,
|
||||
compacted_messages: 0,
|
||||
last_model: model.into(),
|
||||
last_usage: UsageSummary::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandResult {
|
||||
Continue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SlashCommand {
|
||||
Help,
|
||||
Status,
|
||||
Compact,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl SlashCommand {
|
||||
#[must_use]
|
||||
pub fn parse(input: &str) -> Option<Self> {
|
||||
let trimmed = input.trim();
|
||||
if !trimmed.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command = trimmed
|
||||
.trim_start_matches('/')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
Some(match command {
|
||||
"help" => Self::Help,
|
||||
"status" => Self::Status,
|
||||
"compact" => Self::Compact,
|
||||
other => Self::Unknown(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct SlashCommandHandler {
|
||||
command: SlashCommand,
|
||||
summary: &'static str,
|
||||
}
|
||||
|
||||
const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
|
||||
SlashCommandHandler {
|
||||
command: SlashCommand::Help,
|
||||
summary: "Show command help",
|
||||
},
|
||||
SlashCommandHandler {
|
||||
command: SlashCommand::Status,
|
||||
summary: "Show current session status",
|
||||
},
|
||||
SlashCommandHandler {
|
||||
command: SlashCommand::Compact,
|
||||
summary: "Compact local session history",
|
||||
},
|
||||
];
|
||||
|
||||
pub struct CliApp {
|
||||
config: SessionConfig,
|
||||
renderer: TerminalRenderer,
|
||||
state: SessionState,
|
||||
conversation_client: ConversationClient,
|
||||
conversation_history: Vec<ConversationMessage>,
|
||||
}
|
||||
|
||||
impl CliApp {
|
||||
pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
|
||||
let state = SessionState::new(config.model.clone());
|
||||
let conversation_client = ConversationClient::from_env(config.model.clone())?;
|
||||
Ok(Self {
|
||||
config,
|
||||
renderer: TerminalRenderer::new(),
|
||||
state,
|
||||
conversation_client,
|
||||
conversation_history: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
||||
let mut editor = LineEditor::new("› ", Vec::new());
|
||||
println!("Claw Code interactive mode");
|
||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||
|
||||
loop {
|
||||
match editor.read_line()? {
|
||||
ReadOutcome::Submit(input) => {
|
||||
if input.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.handle_submission(&input, &mut io::stdout())?;
|
||||
}
|
||||
ReadOutcome::Cancel => continue,
|
||||
ReadOutcome::Exit => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> {
|
||||
self.render_response(prompt, out)
|
||||
}
|
||||
|
||||
pub fn handle_submission(
|
||||
&mut self,
|
||||
input: &str,
|
||||
out: &mut impl Write,
|
||||
) -> io::Result<CommandResult> {
|
||||
if let Some(command) = SlashCommand::parse(input) {
|
||||
return self.dispatch_slash_command(command, out);
|
||||
}
|
||||
|
||||
self.state.turns += 1;
|
||||
self.render_response(input, out)?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn dispatch_slash_command(
|
||||
&mut self,
|
||||
command: SlashCommand,
|
||||
out: &mut impl Write,
|
||||
) -> io::Result<CommandResult> {
|
||||
match command {
|
||||
SlashCommand::Help => Self::handle_help(out),
|
||||
SlashCommand::Status => self.handle_status(out),
|
||||
SlashCommand::Compact => self.handle_compact(out),
|
||||
SlashCommand::Unknown(name) => {
|
||||
writeln!(out, "Unknown slash command: /{name}")?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
_ => {
|
||||
writeln!(out, "Slash command unavailable in this mode")?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_help(out: &mut impl Write) -> io::Result<CommandResult> {
|
||||
writeln!(out, "Available commands:")?;
|
||||
for handler in SLASH_COMMAND_HANDLERS {
|
||||
let name = match handler.command {
|
||||
SlashCommand::Help => "/help",
|
||||
SlashCommand::Status => "/status",
|
||||
SlashCommand::Compact => "/compact",
|
||||
_ => continue,
|
||||
};
|
||||
writeln!(out, " {name:<9} {}", handler.summary)?;
|
||||
}
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn handle_status(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
|
||||
writeln!(
|
||||
out,
|
||||
"status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
|
||||
self.state.turns,
|
||||
self.state.last_model,
|
||||
self.config.permission_mode,
|
||||
self.config.output_format,
|
||||
self.state.last_usage.input_tokens,
|
||||
self.state.last_usage.output_tokens,
|
||||
self.config
|
||||
.config
|
||||
.as_ref()
|
||||
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
|
||||
)?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
|
||||
self.state.compacted_messages += self.state.turns;
|
||||
self.state.turns = 0;
|
||||
self.conversation_history.clear();
|
||||
writeln!(
|
||||
out,
|
||||
"Compacted session history into a local summary ({} messages total compacted).",
|
||||
self.state.compacted_messages
|
||||
)?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn handle_stream_event(
|
||||
renderer: &TerminalRenderer,
|
||||
event: StreamEvent,
|
||||
stream_spinner: &mut Spinner,
|
||||
tool_spinner: &mut Spinner,
|
||||
saw_text: &mut bool,
|
||||
turn_usage: &mut UsageSummary,
|
||||
out: &mut impl Write,
|
||||
) {
|
||||
match event {
|
||||
StreamEvent::TextDelta(delta) => {
|
||||
if !*saw_text {
|
||||
let _ =
|
||||
stream_spinner.finish("Streaming response", renderer.color_theme(), out);
|
||||
*saw_text = true;
|
||||
}
|
||||
let _ = write!(out, "{delta}");
|
||||
let _ = out.flush();
|
||||
}
|
||||
StreamEvent::ToolCallStart { name, input } => {
|
||||
if *saw_text {
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
let _ = tool_spinner.tick(
|
||||
&format!("Running tool `{name}` with {input}"),
|
||||
renderer.color_theme(),
|
||||
out,
|
||||
);
|
||||
}
|
||||
StreamEvent::ToolCallResult {
|
||||
name,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
let label = if is_error {
|
||||
format!("Tool `{name}` failed")
|
||||
} else {
|
||||
format!("Tool `{name}` completed")
|
||||
};
|
||||
let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
|
||||
let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
|
||||
let _ = renderer.stream_markdown(&rendered_output, out);
|
||||
}
|
||||
StreamEvent::Usage(usage) => {
|
||||
*turn_usage = usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_turn_output(
|
||||
&self,
|
||||
summary: &runtime::TurnSummary,
|
||||
out: &mut impl Write,
|
||||
) -> io::Result<()> {
|
||||
match self.config.output_format {
|
||||
OutputFormat::Text => {
|
||||
writeln!(
|
||||
out,
|
||||
"\nToken usage: {} input / {} output",
|
||||
self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
|
||||
)?;
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"message": summary.assistant_text,
|
||||
"usage": {
|
||||
"input_tokens": self.state.last_usage.input_tokens,
|
||||
"output_tokens": self.state.last_usage.output_tokens,
|
||||
}
|
||||
})
|
||||
)?;
|
||||
}
|
||||
OutputFormat::Ndjson => {
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"text": summary.assistant_text,
|
||||
"usage": {
|
||||
"input_tokens": self.state.last_usage.input_tokens,
|
||||
"output_tokens": self.state.last_usage.output_tokens,
|
||||
}
|
||||
})
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
|
||||
let mut stream_spinner = Spinner::new();
|
||||
stream_spinner.tick(
|
||||
"Opening conversation stream",
|
||||
self.renderer.color_theme(),
|
||||
out,
|
||||
)?;
|
||||
|
||||
let mut turn_usage = UsageSummary::default();
|
||||
let mut tool_spinner = Spinner::new();
|
||||
let mut saw_text = false;
|
||||
let renderer = &self.renderer;
|
||||
|
||||
let result =
|
||||
self.conversation_client
|
||||
.run_turn(&mut self.conversation_history, input, |event| {
|
||||
Self::handle_stream_event(
|
||||
renderer,
|
||||
event,
|
||||
&mut stream_spinner,
|
||||
&mut tool_spinner,
|
||||
&mut saw_text,
|
||||
&mut turn_usage,
|
||||
out,
|
||||
);
|
||||
});
|
||||
|
||||
let summary = match result {
|
||||
Ok(summary) => summary,
|
||||
Err(error) => {
|
||||
stream_spinner.fail(
|
||||
"Streaming response failed",
|
||||
self.renderer.color_theme(),
|
||||
out,
|
||||
)?;
|
||||
return Err(io::Error::other(error));
|
||||
}
|
||||
};
|
||||
self.state.last_usage = summary.usage.clone();
|
||||
if saw_text {
|
||||
writeln!(out)?;
|
||||
} else {
|
||||
stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
|
||||
}
|
||||
|
||||
self.write_turn_output(&summary, out)?;
|
||||
let _ = turn_usage;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::args::{OutputFormat, PermissionMode};
|
||||
|
||||
use super::{CommandResult, SessionConfig, SlashCommand};
|
||||
|
||||
#[test]
|
||||
fn parses_required_slash_commands() {
|
||||
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
||||
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/compact now"),
|
||||
Some(SlashCommand::Compact)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_lists_commands() {
|
||||
let mut out = Vec::new();
|
||||
let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
|
||||
assert_eq!(result, CommandResult::Continue);
|
||||
let output = String::from_utf8_lossy(&out);
|
||||
assert!(output.contains("/help"));
|
||||
assert!(output.contains("/status"));
|
||||
assert!(output.contains("/compact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_state_tracks_config_values() {
|
||||
let config = SessionConfig {
|
||||
model: "sonnet".into(),
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
config: Some(PathBuf::from("settings.toml")),
|
||||
output_format: OutputFormat::Text,
|
||||
};
|
||||
|
||||
assert_eq!(config.model, "sonnet");
|
||||
assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess);
|
||||
assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
|
||||
#[derive(Debug, Clone, Parser, PartialEq, Eq)]
|
||||
#[command(name = "claw-cli", version, about = "Claw Code CLI")]
|
||||
pub struct Cli {
|
||||
#[arg(long, default_value = "claude-opus-4-6")]
|
||||
pub model: String,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = PermissionMode::DangerFullAccess)]
|
||||
pub permission_mode: PermissionMode,
|
||||
|
||||
#[arg(long)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
|
||||
pub output_format: OutputFormat,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
/// Read upstream TS sources and print extracted counts
|
||||
DumpManifests,
|
||||
/// Print the current bootstrap phase skeleton
|
||||
BootstrapPlan,
|
||||
/// Start the OAuth login flow
|
||||
Login,
|
||||
/// Clear saved OAuth credentials
|
||||
Logout,
|
||||
/// Run a non-interactive prompt and exit
|
||||
Prompt { prompt: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
||||
pub enum PermissionMode {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
DangerFullAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
||||
pub enum OutputFormat {
|
||||
Text,
|
||||
Json,
|
||||
Ndjson,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::{Cli, Command, OutputFormat, PermissionMode};
|
||||
|
||||
#[test]
|
||||
fn parses_requested_flags() {
|
||||
let cli = Cli::parse_from([
|
||||
"claw-cli",
|
||||
"--model",
|
||||
"claude-haiku-4-5-20251213",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--config",
|
||||
"/tmp/config.toml",
|
||||
"--output-format",
|
||||
"ndjson",
|
||||
"prompt",
|
||||
"hello",
|
||||
"world",
|
||||
]);
|
||||
|
||||
assert_eq!(cli.model, "claude-haiku-4-5-20251213");
|
||||
assert_eq!(cli.permission_mode, PermissionMode::ReadOnly);
|
||||
assert_eq!(
|
||||
cli.config.as_deref(),
|
||||
Some(std::path::Path::new("/tmp/config.toml"))
|
||||
);
|
||||
assert_eq!(cli.output_format, OutputFormat::Ndjson);
|
||||
assert_eq!(
|
||||
cli.command,
|
||||
Some(Command::Prompt {
|
||||
prompt: vec!["hello".into(), "world".into()]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_login_and_logout_commands() {
|
||||
let login = Cli::parse_from(["claw-cli", "login"]);
|
||||
assert_eq!(login.command, Some(Command::Login));
|
||||
|
||||
let logout = Cli::parse_from(["claw-cli", "logout"]);
|
||||
assert_eq!(logout.command, Some(Command::Logout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_danger_full_access_permission_mode() {
|
||||
let cli = Cli::parse_from(["claw-cli"]);
|
||||
assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@ use std::thread;
|
|||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use api::{
|
||||
resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock,
|
||||
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
||||
resolve_startup_auth_source, ApiClient as ClawApiClient, AuthSource, ContentBlockDelta,
|
||||
InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
};
|
||||
|
||||
|
|
@ -169,8 +169,25 @@ impl CliOutputFormat {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
/// Intermediate result of scanning global flags (--model, --output-format, etc.)
|
||||
/// before subcommand dispatch. This separates "what flags were set" from "what action to take".
|
||||
struct ParsedFlags {
|
||||
model: String,
|
||||
output_format: CliOutputFormat,
|
||||
permission_mode: PermissionMode,
|
||||
wants_version: bool,
|
||||
allowed_tool_values: Vec<String>,
|
||||
}
|
||||
|
||||
/// Combined output of flag scanning: parsed flags + remaining positional args.
|
||||
struct ScanResult {
|
||||
flags: ParsedFlags,
|
||||
rest: Vec<String>,
|
||||
}
|
||||
|
||||
/// Phase 1: Scan global flags from the argument list, collecting flag values
|
||||
/// and returning remaining non-flag arguments for subcommand dispatch.
|
||||
fn scan_flags(args: &[String]) -> Result<ScanResult, String> {
|
||||
let mut model = DEFAULT_MODEL.to_string();
|
||||
let mut output_format = CliOutputFormat::Text;
|
||||
let mut permission_mode = default_permission_mode();
|
||||
|
|
@ -222,20 +239,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||
permission_mode = PermissionMode::DangerFullAccess;
|
||||
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(&model).to_string(),
|
||||
output_format,
|
||||
allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
|
||||
permission_mode,
|
||||
});
|
||||
}
|
||||
"--print" => {
|
||||
// Claw Code compat: --print makes output non-interactive
|
||||
output_format = CliOutputFormat::Text;
|
||||
|
|
@ -263,17 +266,27 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||
}
|
||||
}
|
||||
|
||||
if wants_version {
|
||||
return Ok(CliAction::Version);
|
||||
}
|
||||
Ok(ScanResult {
|
||||
flags: ParsedFlags {
|
||||
model,
|
||||
output_format,
|
||||
permission_mode,
|
||||
wants_version,
|
||||
allowed_tool_values,
|
||||
},
|
||||
rest,
|
||||
})
|
||||
}
|
||||
|
||||
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
||||
/// Phase 2: Given parsed flags and remaining positional args, determine the CLI action.
|
||||
fn dispatch_subcommand(flags: &ParsedFlags, rest: &[String]) -> Result<CliAction, String> {
|
||||
let allowed_tools = normalize_allowed_tools(&flags.allowed_tool_values)?;
|
||||
|
||||
if rest.is_empty() {
|
||||
return Ok(CliAction::Repl {
|
||||
model,
|
||||
model: flags.model.clone(),
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
permission_mode: flags.permission_mode,
|
||||
});
|
||||
}
|
||||
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
||||
|
|
@ -303,23 +316,52 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||
}
|
||||
Ok(CliAction::Prompt {
|
||||
prompt,
|
||||
model,
|
||||
output_format,
|
||||
model: flags.model.clone(),
|
||||
output_format: flags.output_format,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
permission_mode: flags.permission_mode,
|
||||
})
|
||||
}
|
||||
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
|
||||
other if other.starts_with('/') => parse_direct_slash_cli_action(rest),
|
||||
_other => Ok(CliAction::Prompt {
|
||||
prompt: rest.join(" "),
|
||||
model,
|
||||
output_format,
|
||||
model: flags.model.clone(),
|
||||
output_format: flags.output_format,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
permission_mode: flags.permission_mode,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
// Handle -p early-return separately since it consumes all remaining args
|
||||
if args.iter().any(|a| a == "-p") {
|
||||
let p_index = args.iter().position(|a| a == "-p").unwrap();
|
||||
let prompt = args[p_index + 1..].join(" ");
|
||||
if prompt.trim().is_empty() {
|
||||
return Err("-p requires a prompt string".to_string());
|
||||
}
|
||||
// Still need to scan flags that appear before -p
|
||||
let pre_flags = scan_flags(&args[..p_index])?;
|
||||
let allowed_tools = normalize_allowed_tools(&pre_flags.flags.allowed_tool_values)?;
|
||||
return Ok(CliAction::Prompt {
|
||||
prompt,
|
||||
model: resolve_model_alias(&pre_flags.flags.model).to_string(),
|
||||
output_format: pre_flags.flags.output_format,
|
||||
allowed_tools,
|
||||
permission_mode: pre_flags.flags.permission_mode,
|
||||
});
|
||||
}
|
||||
|
||||
let scan = scan_flags(args)?;
|
||||
if scan.flags.wants_version {
|
||||
return Ok(CliAction::Version);
|
||||
}
|
||||
|
||||
dispatch_subcommand(&scan.flags, &scan.rest)
|
||||
}
|
||||
|
||||
fn join_optional_args(args: &[String]) -> Option<String> {
|
||||
let joined = args.join(" ");
|
||||
let trimmed = joined.trim();
|
||||
|
|
@ -329,10 +371,10 @@ fn join_optional_args(args: &[String]) -> Option<String> {
|
|||
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
||||
let raw = rest.join(" ");
|
||||
match SlashCommand::parse(&raw) {
|
||||
Some(SlashCommand::Help) => Ok(CliAction::Help),
|
||||
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
|
||||
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
||||
Some(command) => Err(format_direct_slash_command_error(
|
||||
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
|
||||
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
|
||||
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }),
|
||||
Ok(Some(command)) => Err(format_direct_slash_command_error(
|
||||
match &command {
|
||||
SlashCommand::Unknown(name) => format!("/{name}"),
|
||||
_ => rest[0].clone(),
|
||||
|
|
@ -340,7 +382,7 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
|||
.as_str(),
|
||||
matches!(command, SlashCommand::Unknown(_)),
|
||||
)),
|
||||
None => Err(format!("unknown subcommand: {}", rest[0])),
|
||||
Ok(None) | Err(_) => Err(format!("unknown subcommand: {}", rest[0])),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -483,7 +525,7 @@ fn dump_manifests() {
|
|||
}
|
||||
|
||||
fn print_bootstrap_plan() {
|
||||
for phase in runtime::BootstrapPlan::claw_default().phases() {
|
||||
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
|
||||
println!("- {phase:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -649,7 +691,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
|||
|
||||
let mut session = session;
|
||||
for raw_command in commands {
|
||||
let Some(command) = SlashCommand::parse(raw_command) else {
|
||||
let Ok(Some(command)) = SlashCommand::parse(raw_command) else {
|
||||
eprintln!("unsupported resumed command: {raw_command}");
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
|
@ -839,10 +881,10 @@ fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<S
|
|||
let branch = status.lines().next().and_then(|line| {
|
||||
line.strip_prefix("## ")
|
||||
.map(|line| {
|
||||
line.split(['.', ' '])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
// git status --short format: "## branch...remote/branch" or "## branch"
|
||||
// Split on "..." first to strip remote tracking info, then trailing whitespace.
|
||||
let after_dots = line.split("...").next().unwrap_or(line);
|
||||
after_dots.trim().to_string()
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
});
|
||||
|
|
@ -986,8 +1028,6 @@ fn run_resume_command(
|
|||
}
|
||||
SlashCommand::Bughunter { .. }
|
||||
| SlashCommand::Branch { .. }
|
||||
| SlashCommand::Worktree { .. }
|
||||
| SlashCommand::CommitPushPr { .. }
|
||||
| SlashCommand::Commit
|
||||
| SlashCommand::Pr { .. }
|
||||
| SlashCommand::Issue { .. }
|
||||
|
|
@ -999,7 +1039,7 @@ fn run_resume_command(
|
|||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Plugins { .. }
|
||||
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
||||
| _ => Err("unsupported resumed slash command".into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1023,7 +1063,7 @@ fn run_repl(
|
|||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
if let Some(command) = SlashCommand::parse(trimmed) {
|
||||
if let Ok(Some(command)) = SlashCommand::parse(trimmed) {
|
||||
if cli.handle_repl_command(command)? {
|
||||
cli.persist_session()?;
|
||||
}
|
||||
|
|
@ -1335,24 +1375,14 @@ impl LiveCli {
|
|||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::Worktree { .. } => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
render_mode_unavailable("worktree", "git worktree commands")
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::CommitPushPr { .. } => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
render_mode_unavailable("commit-push-pr", "commit + push + PR automation")
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::Unknown(name) => {
|
||||
eprintln!("{}", render_unknown_repl_command(&name));
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
eprintln!("unsupported slash command in this CLI version");
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2517,6 +2547,15 @@ fn render_export_text(session: &Session) -> String {
|
|||
"[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
|
||||
));
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
lines.push(format!("[thinking] {thinking}"));
|
||||
if let Some(sig) = signature {
|
||||
lines.push(format!("[thinking_signature] {sig}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
|
@ -2988,7 +3027,7 @@ fn build_runtime(
|
|||
CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
|
||||
permission_policy(permission_mode, &tool_registry),
|
||||
system_prompt,
|
||||
feature_config,
|
||||
&feature_config,
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -3098,6 +3137,12 @@ impl ApiClient for DefaultRuntimeClient {
|
|||
.then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
|
||||
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||
stream: true,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
frequency_penalty: None,
|
||||
presence_penalty: None,
|
||||
stop: None,
|
||||
reasoning_effort: None,
|
||||
};
|
||||
|
||||
self.runtime.block_on(async {
|
||||
|
|
@ -3823,7 +3868,10 @@ fn push_output_block(
|
|||
};
|
||||
*pending_tool = Some((id, name, initial_input));
|
||||
}
|
||||
OutputContentBlock::Thinking { thinking, signature } => {
|
||||
OutputContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
if !thinking.is_empty() {
|
||||
events.push(AssistantEvent::ThinkingDelta(thinking));
|
||||
}
|
||||
|
|
@ -3919,16 +3967,68 @@ impl ToolExecutor for CliToolExecutor {
|
|||
}
|
||||
|
||||
fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
|
||||
tool_registry.permission_specs(None).into_iter().fold(
|
||||
PermissionPolicy::new(mode),
|
||||
|policy, (name, required_permission)| {
|
||||
policy.with_tool_requirement(name, required_permission)
|
||||
},
|
||||
)
|
||||
tool_registry
|
||||
.permission_specs(None)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(
|
||||
PermissionPolicy::new(mode),
|
||||
|policy, (name, required_permission)| {
|
||||
policy.with_tool_requirement(name, required_permission)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Maximum approximate character budget for API input messages.
|
||||
/// The API rejects requests where total input length exceeds 202745 characters.
|
||||
/// That limit covers the **entire serialized JSON request body**, including:
|
||||
/// - system prompt (~5-10K chars)
|
||||
/// - tool definitions with JSON schemas (~30-50K chars)
|
||||
/// - JSON serialization overhead (keys, quotes, brackets — ~30-50% markup)
|
||||
/// - request-level fields (model, max_tokens, etc. — ~100 chars)
|
||||
///
|
||||
/// We conservatively budget just the messages portion to leave ample room
|
||||
/// for everything else. With ~60K overhead from system+tools+JSON and a
|
||||
/// ~40% serialization markup, 80K of raw message content maps to roughly:
|
||||
/// 80K × 1.4 (JSON overhead) + 60K (system+tools) ≈ 172K total body,
|
||||
/// well under the 202745 limit.
|
||||
const MAX_API_INPUT_CHARS: usize = 80_000;
|
||||
|
||||
/// Estimate the character length of an InputMessage for budget tracking.
|
||||
fn estimate_input_message_chars(msg: &InputMessage) -> usize {
|
||||
msg.role.len()
|
||||
+ msg
|
||||
.content
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
InputContentBlock::Text { text } => text.len(),
|
||||
InputContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => thinking.len() + signature.as_ref().map_or(0, String::len),
|
||||
InputContentBlock::ToolUse { id, name, input } => {
|
||||
id.len() + name.len() + input.to_string().len()
|
||||
}
|
||||
InputContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: _,
|
||||
} => {
|
||||
tool_use_id.len()
|
||||
+ content
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
ToolResultContentBlock::Text { text } => text.len(),
|
||||
ToolResultContentBlock::Json { value } => value.to_string().len(),
|
||||
})
|
||||
.sum::<usize>()
|
||||
}
|
||||
})
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||
messages
|
||||
let converted: Vec<InputMessage> = messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
let role = match message.role {
|
||||
|
|
@ -3938,26 +4038,31 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|||
let content = message
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => {
|
||||
Some(InputContentBlock::Text { text: text.clone() })
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input: serde_json::from_str(input)
|
||||
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
||||
},
|
||||
}),
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
output,
|
||||
is_error,
|
||||
..
|
||||
} => InputContentBlock::ToolResult {
|
||||
} => Some(InputContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
content: vec![ToolResultContentBlock::Text {
|
||||
text: output.clone(),
|
||||
}],
|
||||
is_error: *is_error,
|
||||
},
|
||||
}),
|
||||
// Thinking blocks are internal reasoning output and not valid as API input.
|
||||
// They must be skipped when converting session history back to API messages.
|
||||
ContentBlock::Thinking { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(!content.is_empty()).then(|| InputMessage {
|
||||
|
|
@ -3965,7 +4070,35 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
|
||||
// If the total input exceeds the API character budget, trim oldest messages
|
||||
// until we're within budget. This prevents 400 errors from oversized inputs.
|
||||
let total_chars = converted
|
||||
.iter()
|
||||
.map(estimate_input_message_chars)
|
||||
.sum::<usize>();
|
||||
if total_chars <= MAX_API_INPUT_CHARS {
|
||||
return converted;
|
||||
}
|
||||
|
||||
// Drop messages from the beginning (oldest) until within budget.
|
||||
// Always preserve the last message (the current user turn) if possible.
|
||||
let mut budget = 0usize;
|
||||
let mut keep_from = converted.len();
|
||||
for (index, msg) in converted.iter().rev().enumerate() {
|
||||
budget += estimate_input_message_chars(msg);
|
||||
if budget > MAX_API_INPUT_CHARS {
|
||||
// We went over budget — we can only keep messages starting from
|
||||
// the one that pushed us over (going from the end). Since we're
|
||||
// iterating backwards, `index` is how many from the end we kept
|
||||
// before going over. The rest must be dropped.
|
||||
break;
|
||||
}
|
||||
keep_from = converted.len() - index - 1;
|
||||
}
|
||||
|
||||
converted[keep_from..].to_vec()
|
||||
}
|
||||
|
||||
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
|
|
@ -4443,8 +4576,7 @@ mod tests {
|
|||
fn shared_help_uses_resume_annotation_copy() {
|
||||
let help = commands::render_slash_command_help();
|
||||
assert!(help.contains("Slash commands"));
|
||||
assert!(help.contains("Tab completes commands inside the REPL."));
|
||||
assert!(help.contains("available via claw --resume SESSION.json"));
|
||||
assert!(help.contains("also works with --resume SESSION.jsonl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4464,7 +4596,7 @@ mod tests {
|
|||
assert!(help.contains("/diff"));
|
||||
assert!(help.contains("/version"));
|
||||
assert!(help.contains("/export [file]"));
|
||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||
assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]|delete <session-id> [--force]]"));
|
||||
assert!(help.contains(
|
||||
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||
));
|
||||
|
|
@ -4498,13 +4630,22 @@ mod tests {
|
|||
.into_iter()
|
||||
.map(|spec| spec.name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
|
||||
"version", "export", "agents", "skills",
|
||||
]
|
||||
);
|
||||
assert!(names.contains(&"help"));
|
||||
assert!(names.contains(&"status"));
|
||||
assert!(names.contains(&"compact"));
|
||||
assert!(names.contains(&"clear"));
|
||||
assert!(names.contains(&"cost"));
|
||||
assert!(names.contains(&"config"));
|
||||
assert!(names.contains(&"memory"));
|
||||
assert!(names.contains(&"init"));
|
||||
assert!(names.contains(&"diff"));
|
||||
assert!(names.contains(&"version"));
|
||||
assert!(names.contains(&"export"));
|
||||
assert!(names.contains(&"agents"));
|
||||
assert!(names.contains(&"skills"));
|
||||
assert!(names.contains(&"sandbox"));
|
||||
assert!(names.contains(&"mcp"));
|
||||
assert!(names.len() > 20); // allow future growth
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4703,11 +4844,11 @@ mod tests {
|
|||
fn clear_command_requires_explicit_confirmation_flag() {
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/clear"),
|
||||
Some(SlashCommand::Clear { confirm: false })
|
||||
Ok(Some(SlashCommand::Clear { confirm: false }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/clear --confirm"),
|
||||
Some(SlashCommand::Clear { confirm: true })
|
||||
Ok(Some(SlashCommand::Clear { confirm: true }))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4715,26 +4856,29 @@ mod tests {
|
|||
fn parses_resume_and_config_slash_commands() {
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/resume saved-session.json"),
|
||||
Some(SlashCommand::Resume {
|
||||
Ok(Some(SlashCommand::Resume {
|
||||
session_path: Some("saved-session.json".to_string())
|
||||
})
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/clear --confirm"),
|
||||
Some(SlashCommand::Clear { confirm: true })
|
||||
Ok(Some(SlashCommand::Clear { confirm: true }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/config"),
|
||||
Some(SlashCommand::Config { section: None })
|
||||
Ok(Some(SlashCommand::Config { section: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/config env"),
|
||||
Some(SlashCommand::Config {
|
||||
Ok(Some(SlashCommand::Config {
|
||||
section: Some("env".to_string())
|
||||
})
|
||||
}))
|
||||
);
|
||||
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/memory"),
|
||||
Ok(Some(SlashCommand::Memory))
|
||||
);
|
||||
assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in New Issue