|
@@ -30,12 +30,11 @@ use plugins::{PluginManager, PluginManagerConfig};
|
|
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
|
|
use runtime::{
|
|
use runtime::{
|
|
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
|
|
- parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials,
|
|
|
|
|
- ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource,
|
|
|
|
|
- ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, PromptCacheEvent,
|
|
|
|
|
- OAuthAuthorizationRequest, OAuthConfig,
|
|
|
|
|
- OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
|
|
|
|
- Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
|
|
|
|
|
|
+ parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, ApiClient,
|
|
|
|
|
+ ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
|
|
|
|
+ ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
|
|
|
|
|
+ OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent,
|
|
|
|
|
+ RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
|
|
};
|
|
};
|
|
|
use serde_json::json;
|
|
use serde_json::json;
|
|
|
use tools::GlobalToolRegistry;
|
|
use tools::GlobalToolRegistry;
|
|
@@ -56,16 +55,38 @@ const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
|
|
|
const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
|
|
const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
|
|
|
const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
|
const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
|
|
|
const LEGACY_SESSION_EXTENSION: &str = "json";
|
|
const LEGACY_SESSION_EXTENSION: &str = "json";
|
|
|
|
|
+const LATEST_SESSION_REFERENCE: &str = "latest";
|
|
|
|
|
+const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"];
|
|
|
|
|
+const CLI_OPTION_SUGGESTIONS: &[&str] = &[
|
|
|
|
|
+ "--help",
|
|
|
|
|
+ "-h",
|
|
|
|
|
+ "--version",
|
|
|
|
|
+ "-V",
|
|
|
|
|
+ "--model",
|
|
|
|
|
+ "--output-format",
|
|
|
|
|
+ "--permission-mode",
|
|
|
|
|
+ "--dangerously-skip-permissions",
|
|
|
|
|
+ "--allowedTools",
|
|
|
|
|
+ "--allowed-tools",
|
|
|
|
|
+ "--resume",
|
|
|
|
|
+ "--print",
|
|
|
|
|
+ "-p",
|
|
|
|
|
+];
|
|
|
|
|
|
|
|
type AllowedToolSet = BTreeSet<String>;
|
|
type AllowedToolSet = BTreeSet<String>;
|
|
|
|
|
|
|
|
fn main() {
|
|
fn main() {
|
|
|
if let Err(error) = run() {
|
|
if let Err(error) = run() {
|
|
|
- eprintln!(
|
|
|
|
|
- "error: {error}
|
|
|
|
|
|
|
+ let message = error.to_string();
|
|
|
|
|
+ if message.contains("`claw --help`") {
|
|
|
|
|
+ eprintln!("error: {message}");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ eprintln!(
|
|
|
|
|
+ "error: {message}
|
|
|
|
|
|
|
|
Run `claw --help` for usage."
|
|
Run `claw --help` for usage."
|
|
|
- );
|
|
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
std::process::exit(1);
|
|
std::process::exit(1);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -165,6 +186,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
let mut model = DEFAULT_MODEL.to_string();
|
|
let mut model = DEFAULT_MODEL.to_string();
|
|
|
let mut output_format = CliOutputFormat::Text;
|
|
let mut output_format = CliOutputFormat::Text;
|
|
|
let mut permission_mode = default_permission_mode();
|
|
let mut permission_mode = default_permission_mode();
|
|
|
|
|
+ let mut wants_help = false;
|
|
|
let mut wants_version = false;
|
|
let mut wants_version = false;
|
|
|
let mut allowed_tool_values = Vec::new();
|
|
let mut allowed_tool_values = Vec::new();
|
|
|
let mut rest = Vec::new();
|
|
let mut rest = Vec::new();
|
|
@@ -172,6 +194,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
|
|
|
|
|
while index < args.len() {
|
|
while index < args.len() {
|
|
|
match args[index].as_str() {
|
|
match args[index].as_str() {
|
|
|
|
|
+ "--help" | "-h" if rest.is_empty() => {
|
|
|
|
|
+ wants_help = true;
|
|
|
|
|
+ index += 1;
|
|
|
|
|
+ }
|
|
|
"--version" | "-V" => {
|
|
"--version" | "-V" => {
|
|
|
wants_version = true;
|
|
wants_version = true;
|
|
|
index += 1;
|
|
index += 1;
|
|
@@ -232,6 +258,15 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
output_format = CliOutputFormat::Text;
|
|
output_format = CliOutputFormat::Text;
|
|
|
index += 1;
|
|
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;
|
|
|
|
|
+ }
|
|
|
"--allowedTools" | "--allowed-tools" => {
|
|
"--allowedTools" | "--allowed-tools" => {
|
|
|
let value = args
|
|
let value = args
|
|
|
.get(index + 1)
|
|
.get(index + 1)
|
|
@@ -247,6 +282,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
allowed_tool_values.push(flag[16..].to_string());
|
|
allowed_tool_values.push(flag[16..].to_string());
|
|
|
index += 1;
|
|
index += 1;
|
|
|
}
|
|
}
|
|
|
|
|
+ other if rest.is_empty() && other.starts_with('-') => {
|
|
|
|
|
+ return Err(format_unknown_option(other))
|
|
|
|
|
+ }
|
|
|
other => {
|
|
other => {
|
|
|
rest.push(other.to_string());
|
|
rest.push(other.to_string());
|
|
|
index += 1;
|
|
index += 1;
|
|
@@ -254,6 +292,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if wants_help {
|
|
|
|
|
+ return Ok(CliAction::Help);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if wants_version {
|
|
if wants_version {
|
|
|
return Ok(CliAction::Version);
|
|
return Ok(CliAction::Version);
|
|
|
}
|
|
}
|
|
@@ -267,9 +309,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
permission_mode,
|
|
permission_mode,
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
- if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
|
|
|
|
- return Ok(CliAction::Help);
|
|
|
|
|
- }
|
|
|
|
|
if rest.first().map(String::as_str) == Some("--resume") {
|
|
if rest.first().map(String::as_str) == Some("--resume") {
|
|
|
return parse_resume_args(&rest[1..]);
|
|
return parse_resume_args(&rest[1..]);
|
|
|
}
|
|
}
|
|
@@ -323,17 +362,127 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
|
|
Some(SlashCommand::Help) => Ok(CliAction::Help),
|
|
Some(SlashCommand::Help) => Ok(CliAction::Help),
|
|
|
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
|
|
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
|
|
|
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
|
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
|
|
|
|
+ Some(SlashCommand::Unknown(name)) => Err(format_unknown_direct_slash_command(&name)),
|
|
|
Some(command) => Err(format!(
|
|
Some(command) => Err(format!(
|
|
|
"unsupported direct slash command outside the REPL: {command_name}",
|
|
"unsupported direct slash command outside the REPL: {command_name}",
|
|
|
- command_name = match command {
|
|
|
|
|
- SlashCommand::Unknown(name) => format!("/{name}"),
|
|
|
|
|
- _ => rest[0].clone(),
|
|
|
|
|
|
|
+ command_name = {
|
|
|
|
|
+ let _ = command;
|
|
|
|
|
+ rest[0].clone()
|
|
|
}
|
|
}
|
|
|
)),
|
|
)),
|
|
|
None => Err(format!("unknown subcommand: {}", rest[0])),
|
|
None => Err(format!("unknown subcommand: {}", rest[0])),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+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
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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);
|
|
|
|
|
+ }
|
|
|
|
|
+ message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help.");
|
|
|
|
|
+ message
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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);
|
|
|
|
|
+ }
|
|
|
|
|
+ message.push_str("\n Help /help lists available slash commands");
|
|
|
|
|
+ message
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option<String> {
|
|
|
|
|
+ (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
|
|
|
|
|
+ ranked_suggestions(input, candidates).into_iter().next()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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()]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn resolve_model_alias(model: &str) -> &str {
|
|
fn resolve_model_alias(model: &str) -> &str {
|
|
|
match model {
|
|
match model {
|
|
|
"opus" => "claude-opus-4-6",
|
|
"opus" => "claude-opus-4-6",
|
|
@@ -421,11 +570,13 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
|
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
|
|
- let session_path = args
|
|
|
|
|
- .first()
|
|
|
|
|
- .ok_or_else(|| "missing session path for --resume".to_string())
|
|
|
|
|
- .map(PathBuf::from)?;
|
|
|
|
|
- let commands = args[1..].to_vec();
|
|
|
|
|
|
|
+ let (session_path, commands) = match args.first() {
|
|
|
|
|
+ None => (PathBuf::from(LATEST_SESSION_REFERENCE), Vec::new()),
|
|
|
|
|
+ Some(first) if first.trim_start().starts_with('/') => {
|
|
|
|
|
+ (PathBuf::from(LATEST_SESSION_REFERENCE), args.to_vec())
|
|
|
|
|
+ }
|
|
|
|
|
+ Some(first) => (PathBuf::from(first), args[1..].to_vec()),
|
|
|
|
|
+ };
|
|
|
if commands
|
|
if commands
|
|
|
.iter()
|
|
.iter()
|
|
|
.any(|command| !command.trim_start().starts_with('/'))
|
|
.any(|command| !command.trim_start().starts_with('/'))
|
|
@@ -783,6 +934,15 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn render_resume_usage() -> String {
|
|
|
|
|
+ format!(
|
|
|
|
|
+ "Resume
|
|
|
|
|
+ Usage /resume <session-path|session-id|{LATEST_SESSION_REFERENCE}>
|
|
|
|
|
+ Auto-save .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}
|
|
|
|
|
+ Tip use /session list to inspect saved sessions"
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
|
|
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
|
|
|
if skipped {
|
|
if skipped {
|
|
|
format!(
|
|
format!(
|
|
@@ -1014,6 +1174,7 @@ fn run_resume_command(
|
|
|
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
|
|
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
+ SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
|
|
|
SlashCommand::Bughunter { .. }
|
|
SlashCommand::Bughunter { .. }
|
|
|
| SlashCommand::Commit
|
|
| SlashCommand::Commit
|
|
|
| SlashCommand::Pr { .. }
|
|
| SlashCommand::Pr { .. }
|
|
@@ -1025,8 +1186,7 @@ fn run_resume_command(
|
|
|
| SlashCommand::Model { .. }
|
|
| SlashCommand::Model { .. }
|
|
|
| SlashCommand::Permissions { .. }
|
|
| SlashCommand::Permissions { .. }
|
|
|
| SlashCommand::Session { .. }
|
|
| SlashCommand::Session { .. }
|
|
|
- | SlashCommand::Plugins { .. }
|
|
|
|
|
- | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
|
|
|
|
|
|
+ | SlashCommand::Plugins { .. } => Err("unsupported resumed slash command".into()),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1080,7 +1240,7 @@ struct SessionHandle {
|
|
|
struct ManagedSessionSummary {
|
|
struct ManagedSessionSummary {
|
|
|
id: String,
|
|
id: String,
|
|
|
path: PathBuf,
|
|
path: PathBuf,
|
|
|
- modified_epoch_secs: u64,
|
|
|
|
|
|
|
+ modified_epoch_millis: u128,
|
|
|
message_count: usize,
|
|
message_count: usize,
|
|
|
parent_session_id: Option<String>,
|
|
parent_session_id: Option<String>,
|
|
|
branch_name: Option<String>,
|
|
branch_name: Option<String>,
|
|
@@ -1188,6 +1348,10 @@ impl LiveCli {
|
|
|
|_| "<unknown>".to_string(),
|
|
|_| "<unknown>".to_string(),
|
|
|
|path| path.display().to_string(),
|
|
|path| path.display().to_string(),
|
|
|
);
|
|
);
|
|
|
|
|
+ let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else(
|
|
|
|
|
+ |_| self.session.path.display().to_string(),
|
|
|
|
|
+ |path| path.display().to_string(),
|
|
|
|
|
+ );
|
|
|
format!(
|
|
format!(
|
|
|
"\x1b[38;5;196m\
|
|
"\x1b[38;5;196m\
|
|
|
██████╗██╗ █████╗ ██╗ ██╗\n\
|
|
██████╗██╗ █████╗ ██╗ ██╗\n\
|
|
@@ -1199,12 +1363,14 @@ impl LiveCli {
|
|
|
\x1b[2mModel\x1b[0m {}\n\
|
|
\x1b[2mModel\x1b[0m {}\n\
|
|
|
\x1b[2mPermissions\x1b[0m {}\n\
|
|
\x1b[2mPermissions\x1b[0m {}\n\
|
|
|
\x1b[2mDirectory\x1b[0m {}\n\
|
|
\x1b[2mDirectory\x1b[0m {}\n\
|
|
|
- \x1b[2mSession\x1b[0m {}\n\n\
|
|
|
|
|
- Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline",
|
|
|
|
|
|
|
+ \x1b[2mSession\x1b[0m {}\n\
|
|
|
|
|
+ \x1b[2mAuto-save\x1b[0m {}\n\n\
|
|
|
|
|
+ Type \x1b[1m/help\x1b[0m for commands · \x1b[2m/resume latest\x1b[0m jumps back to the newest session",
|
|
|
self.model,
|
|
self.model,
|
|
|
self.permission_mode.as_str(),
|
|
self.permission_mode.as_str(),
|
|
|
cwd,
|
|
cwd,
|
|
|
self.session.id,
|
|
self.session.id,
|
|
|
|
|
+ session_path,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1416,7 +1582,7 @@ impl LiveCli {
|
|
|
false
|
|
false
|
|
|
}
|
|
}
|
|
|
SlashCommand::Unknown(name) => {
|
|
SlashCommand::Unknown(name) => {
|
|
|
- eprintln!("unknown slash command: /{name}");
|
|
|
|
|
|
|
+ eprintln!("{}", format_unknown_slash_command(&name));
|
|
|
false
|
|
false
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
@@ -1592,7 +1758,7 @@ impl LiveCli {
|
|
|
session_path: Option<String>,
|
|
session_path: Option<String>,
|
|
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
let Some(session_ref) = session_path else {
|
|
let Some(session_ref) = session_path else {
|
|
|
- println!("Usage: /resume <session-path>");
|
|
|
|
|
|
|
+ println!("{}", render_resume_usage());
|
|
|
return Ok(false);
|
|
return Ok(false);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -2002,12 +2168,23 @@ fn create_managed_session_handle(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
|
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
|
|
|
|
+ if SESSION_REFERENCE_ALIASES
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .any(|alias| reference.eq_ignore_ascii_case(alias))
|
|
|
|
|
+ {
|
|
|
|
|
+ let latest = latest_managed_session()?;
|
|
|
|
|
+ return Ok(SessionHandle {
|
|
|
|
|
+ id: latest.id,
|
|
|
|
|
+ path: latest.path,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
let direct = PathBuf::from(reference);
|
|
let direct = PathBuf::from(reference);
|
|
|
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
|
|
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
|
|
|
let path = if direct.exists() {
|
|
let path = if direct.exists() {
|
|
|
direct
|
|
direct
|
|
|
} else if looks_like_path {
|
|
} else if looks_like_path {
|
|
|
- return Err(format!("session not found: {reference}").into());
|
|
|
|
|
|
|
+ return Err(format_missing_session_reference(reference).into());
|
|
|
} else {
|
|
} else {
|
|
|
resolve_managed_session_path(reference)?
|
|
resolve_managed_session_path(reference)?
|
|
|
};
|
|
};
|
|
@@ -2031,7 +2208,7 @@ fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std
|
|
|
return Ok(path);
|
|
return Ok(path);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- Err(format!("session not found: {session_id}").into())
|
|
|
|
|
|
|
+ Err(format_missing_session_reference(session_id).into())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn is_managed_session_file(path: &Path) -> bool {
|
|
fn is_managed_session_file(path: &Path) -> bool {
|
|
@@ -2051,53 +2228,79 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
let metadata = entry.metadata()?;
|
|
let metadata = entry.metadata()?;
|
|
|
- let modified_epoch_secs = metadata
|
|
|
|
|
|
|
+ let modified_epoch_millis = metadata
|
|
|
.modified()
|
|
.modified()
|
|
|
.ok()
|
|
.ok()
|
|
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
|
- .map(|duration| duration.as_secs())
|
|
|
|
|
|
|
+ .map(|duration| duration.as_millis())
|
|
|
.unwrap_or_default();
|
|
.unwrap_or_default();
|
|
|
let (id, message_count, parent_session_id, branch_name) = Session::load_from_path(&path)
|
|
let (id, message_count, parent_session_id, branch_name) = Session::load_from_path(&path)
|
|
|
- .map(|session| {
|
|
|
|
|
- let parent_session_id = session
|
|
|
|
|
- .fork
|
|
|
|
|
- .as_ref()
|
|
|
|
|
- .map(|fork| fork.parent_session_id.clone());
|
|
|
|
|
- let branch_name = session
|
|
|
|
|
- .fork
|
|
|
|
|
- .as_ref()
|
|
|
|
|
- .and_then(|fork| fork.branch_name.clone());
|
|
|
|
|
- (
|
|
|
|
|
- session.session_id,
|
|
|
|
|
- session.messages.len(),
|
|
|
|
|
- parent_session_id,
|
|
|
|
|
- branch_name,
|
|
|
|
|
- )
|
|
|
|
|
- })
|
|
|
|
|
- .unwrap_or_else(|_| {
|
|
|
|
|
- (
|
|
|
|
|
- path.file_stem()
|
|
|
|
|
- .and_then(|value| value.to_str())
|
|
|
|
|
- .unwrap_or("unknown")
|
|
|
|
|
- .to_string(),
|
|
|
|
|
- 0,
|
|
|
|
|
- None,
|
|
|
|
|
- None,
|
|
|
|
|
- )
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ .map_or_else(
|
|
|
|
|
+ |_| {
|
|
|
|
|
+ (
|
|
|
|
|
+ path.file_stem()
|
|
|
|
|
+ .and_then(|value| value.to_str())
|
|
|
|
|
+ .unwrap_or("unknown")
|
|
|
|
|
+ .to_string(),
|
|
|
|
|
+ 0,
|
|
|
|
|
+ None,
|
|
|
|
|
+ None,
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ |session| {
|
|
|
|
|
+ let parent_session_id = session
|
|
|
|
|
+ .fork
|
|
|
|
|
+ .as_ref()
|
|
|
|
|
+ .map(|fork| fork.parent_session_id.clone());
|
|
|
|
|
+ let branch_name = session
|
|
|
|
|
+ .fork
|
|
|
|
|
+ .as_ref()
|
|
|
|
|
+ .and_then(|fork| fork.branch_name.clone());
|
|
|
|
|
+ (
|
|
|
|
|
+ session.session_id,
|
|
|
|
|
+ session.messages.len(),
|
|
|
|
|
+ parent_session_id,
|
|
|
|
|
+ branch_name,
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
sessions.push(ManagedSessionSummary {
|
|
sessions.push(ManagedSessionSummary {
|
|
|
id,
|
|
id,
|
|
|
path,
|
|
path,
|
|
|
- modified_epoch_secs,
|
|
|
|
|
|
|
+ modified_epoch_millis,
|
|
|
message_count,
|
|
message_count,
|
|
|
parent_session_id,
|
|
parent_session_id,
|
|
|
branch_name,
|
|
branch_name,
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
- sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
|
|
|
|
|
|
|
+ sessions.sort_by(|left, right| {
|
|
|
|
|
+ right
|
|
|
|
|
+ .modified_epoch_millis
|
|
|
|
|
+ .cmp(&left.modified_epoch_millis)
|
|
|
|
|
+ .then_with(|| right.id.cmp(&left.id))
|
|
|
|
|
+ });
|
|
|
Ok(sessions)
|
|
Ok(sessions)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
|
|
|
|
+ list_managed_sessions()?
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .next()
|
|
|
|
|
+ .ok_or_else(|| format_no_managed_sessions().into())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn format_missing_session_reference(reference: &str) -> String {
|
|
|
|
|
+ format!(
|
|
|
|
|
+ "session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn format_no_managed_sessions() -> String {
|
|
|
|
|
+ format!(
|
|
|
|
|
+ "no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
|
let sessions = list_managed_sessions()?;
|
|
let sessions = list_managed_sessions()?;
|
|
|
let mut lines = vec![
|
|
let mut lines = vec![
|
|
@@ -2129,7 +2332,7 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
|
|
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
|
|
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
|
|
|
id = session.id,
|
|
id = session.id,
|
|
|
msgs = session.message_count,
|
|
msgs = session.message_count,
|
|
|
- modified = session.modified_epoch_secs,
|
|
|
|
|
|
|
+ modified = format_session_modified_age(session.modified_epoch_millis),
|
|
|
lineage = lineage,
|
|
lineage = lineage,
|
|
|
path = session.path.display(),
|
|
path = session.path.display(),
|
|
|
));
|
|
));
|
|
@@ -2137,6 +2340,24 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
|
|
Ok(lines.join("\n"))
|
|
Ok(lines.join("\n"))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn format_session_modified_age(modified_epoch_millis: u128) -> String {
|
|
|
|
|
+ let now = std::time::SystemTime::now()
|
|
|
|
|
+ .duration_since(UNIX_EPOCH)
|
|
|
|
|
+ .ok()
|
|
|
|
|
+ .map_or(modified_epoch_millis, |duration| duration.as_millis());
|
|
|
|
|
+ let delta_seconds = now
|
|
|
|
|
+ .saturating_sub(modified_epoch_millis)
|
|
|
|
|
+ .checked_div(1_000)
|
|
|
|
|
+ .unwrap_or_default();
|
|
|
|
|
+ match delta_seconds {
|
|
|
|
|
+ 0..=4 => "just-now".to_string(),
|
|
|
|
|
+ 5..=59 => format!("{delta_seconds}s-ago"),
|
|
|
|
|
+ 60..=3_599 => format!("{}m-ago", delta_seconds / 60),
|
|
|
|
|
+ 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600),
|
|
|
|
|
+ _ => format!("{}d-ago", delta_seconds / 86_400),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn render_repl_help() -> String {
|
|
fn render_repl_help() -> String {
|
|
|
[
|
|
[
|
|
|
"REPL".to_string(),
|
|
"REPL".to_string(),
|
|
@@ -2146,6 +2367,9 @@ fn render_repl_help() -> String {
|
|
|
" Tab Complete slash commands".to_string(),
|
|
" Tab Complete slash commands".to_string(),
|
|
|
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
|
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
|
|
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
|
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
|
|
|
|
+ " Auto-save .claw/sessions/<session-id>.jsonl".to_string(),
|
|
|
|
|
+ " Resume latest /resume latest".to_string(),
|
|
|
|
|
+ " Browse sessions /session list".to_string(),
|
|
|
String::new(),
|
|
String::new(),
|
|
|
render_slash_command_help(),
|
|
render_slash_command_help(),
|
|
|
]
|
|
]
|
|
@@ -3146,7 +3370,8 @@ fn build_runtime(
|
|
|
allowed_tools: Option<AllowedToolSet>,
|
|
allowed_tools: Option<AllowedToolSet>,
|
|
|
permission_mode: PermissionMode,
|
|
permission_mode: PermissionMode,
|
|
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
|
|
-) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> {
|
|
|
|
|
|
|
+) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
|
|
|
|
+{
|
|
|
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
|
|
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
|
|
|
let mut runtime = ConversationRuntime::new_with_features(
|
|
let mut runtime = ConversationRuntime::new_with_features(
|
|
|
session,
|
|
session,
|
|
@@ -3286,7 +3511,6 @@ impl AnthropicRuntimeClient {
|
|
|
progress_reporter,
|
|
progress_reporter,
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
|
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
|
@@ -4023,7 +4247,9 @@ fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec<Assistant
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-fn prompt_cache_record_to_runtime_event(record: api::PromptCacheRecord) -> Option<PromptCacheEvent> {
|
|
|
|
|
|
|
+fn prompt_cache_record_to_runtime_event(
|
|
|
|
|
+ record: api::PromptCacheRecord,
|
|
|
|
|
+) -> Option<PromptCacheEvent> {
|
|
|
let cache_break = record.cache_break?;
|
|
let cache_break = record.cache_break?;
|
|
|
Some(PromptCacheEvent {
|
|
Some(PromptCacheEvent {
|
|
|
unexpected: cache_break.unexpected,
|
|
unexpected: cache_break.unexpected,
|
|
@@ -4146,6 +4372,7 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|
|
.collect()
|
|
.collect()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+#[allow(clippy::too_many_lines)]
|
|
|
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|
|
writeln!(out, "claw v{VERSION}")?;
|
|
writeln!(out, "claw v{VERSION}")?;
|
|
|
writeln!(out)?;
|
|
writeln!(out)?;
|
|
@@ -4167,7 +4394,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|
|
writeln!(out, " Shorthand non-interactive prompt mode")?;
|
|
writeln!(out, " Shorthand non-interactive prompt mode")?;
|
|
|
writeln!(
|
|
writeln!(
|
|
|
out,
|
|
out,
|
|
|
- " claw --resume SESSION.jsonl [/status] [/compact] [...]"
|
|
|
|
|
|
|
+ " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]"
|
|
|
)?;
|
|
)?;
|
|
|
writeln!(
|
|
writeln!(
|
|
|
out,
|
|
out,
|
|
@@ -4217,6 +4444,20 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|
|
.collect::<Vec<_>>()
|
|
.collect::<Vec<_>>()
|
|
|
.join(", ");
|
|
.join(", ");
|
|
|
writeln!(out, "Resume-safe commands: {resume_commands}")?;
|
|
writeln!(out, "Resume-safe commands: {resume_commands}")?;
|
|
|
|
|
+ writeln!(out)?;
|
|
|
|
|
+ writeln!(out, "Session shortcuts:")?;
|
|
|
|
|
+ writeln!(
|
|
|
|
|
+ out,
|
|
|
|
|
+ " REPL turns auto-save to .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}"
|
|
|
|
|
+ )?;
|
|
|
|
|
+ writeln!(
|
|
|
|
|
+ out,
|
|
|
|
|
+ " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session"
|
|
|
|
|
+ )?;
|
|
|
|
|
+ writeln!(
|
|
|
|
|
+ out,
|
|
|
|
|
+ " Use /session list in the REPL to browse managed sessions"
|
|
|
|
|
+ )?;
|
|
|
writeln!(out, "Examples:")?;
|
|
writeln!(out, "Examples:")?;
|
|
|
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
|
|
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
|
|
|
writeln!(
|
|
writeln!(
|
|
@@ -4227,9 +4468,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|
|
out,
|
|
out,
|
|
|
" claw --allowedTools read,glob \"summarize Cargo.toml\""
|
|
" claw --allowedTools read,glob \"summarize Cargo.toml\""
|
|
|
)?;
|
|
)?;
|
|
|
|
|
+ writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?;
|
|
|
writeln!(
|
|
writeln!(
|
|
|
out,
|
|
out,
|
|
|
- " claw --resume session.jsonl /status /diff /export notes.txt"
|
|
|
|
|
|
|
+ " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
|
|
)?;
|
|
)?;
|
|
|
writeln!(out, " claw agents")?;
|
|
writeln!(out, " claw agents")?;
|
|
|
writeln!(out, " claw /skills")?;
|
|
writeln!(out, " claw /skills")?;
|
|
@@ -4245,18 +4487,18 @@ fn print_help() {
|
|
|
#[cfg(test)]
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
mod tests {
|
|
|
use super::{
|
|
use super::{
|
|
|
- describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report,
|
|
|
|
|
- format_internal_prompt_progress_line, format_model_report, format_model_switch_report,
|
|
|
|
|
- format_permissions_report,
|
|
|
|
|
|
|
+ create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
|
|
|
|
+ format_compact_report, format_cost_report, format_internal_prompt_progress_line,
|
|
|
|
|
+ format_model_report, format_model_switch_report, format_permissions_report,
|
|
|
format_permissions_switch_report, format_resume_report, format_status_report,
|
|
format_permissions_switch_report, format_resume_report, format_status_report,
|
|
|
- format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
|
|
|
|
|
- parse_git_status_branch, parse_git_status_metadata_for, permission_policy,
|
|
|
|
|
- print_help_to, push_output_block, render_config_report, render_diff_report,
|
|
|
|
|
- render_memory_report, render_repl_help, resolve_model_alias, response_to_events,
|
|
|
|
|
|
|
+ format_tool_call_start, format_tool_result, format_unknown_slash_command,
|
|
|
|
|
+ normalize_permission_mode, parse_args, parse_git_status_branch,
|
|
|
|
|
+ parse_git_status_metadata_for, permission_policy, print_help_to, push_output_block,
|
|
|
|
|
+ render_config_report, render_diff_report, render_memory_report, render_repl_help,
|
|
|
|
|
+ render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
|
|
|
resume_supported_slash_commands, run_resume_command, status_context, CliAction,
|
|
resume_supported_slash_commands, run_resume_command, status_context, CliAction,
|
|
|
- CliOutputFormat, InternalPromptProgressEvent,
|
|
|
|
|
- InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
|
|
|
|
- create_managed_session_handle, resolve_session_reference,
|
|
|
|
|
|
|
+ CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState, SlashCommand,
|
|
|
|
|
+ StatusUsage, DEFAULT_MODEL,
|
|
|
};
|
|
};
|
|
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
|
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
|
@@ -4551,6 +4793,25 @@ mod tests {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn parses_resume_flag_without_path_as_latest_session() {
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ parse_args(&["--resume".to_string()]).expect("args should parse"),
|
|
|
|
|
+ CliAction::ResumeSession {
|
|
|
|
|
+ session_path: PathBuf::from("latest"),
|
|
|
|
|
+ commands: vec![],
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ parse_args(&["--resume".to_string(), "/status".to_string()])
|
|
|
|
|
+ .expect("resume shortcut should parse"),
|
|
|
|
|
+ CliAction::ResumeSession {
|
|
|
|
|
+ session_path: PathBuf::from("latest"),
|
|
|
|
|
+ commands: vec!["/status".to_string()],
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn parses_resume_flag_with_multiple_slash_commands() {
|
|
fn parses_resume_flag_with_multiple_slash_commands() {
|
|
|
let args = vec![
|
|
let args = vec![
|
|
@@ -4573,6 +4834,14 @@ mod tests {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn rejects_unknown_options_with_helpful_guidance() {
|
|
|
|
|
+ let error = parse_args(&["--resum".to_string()]).expect_err("unknown option should fail");
|
|
|
|
|
+ assert!(error.contains("unknown option: --resum"));
|
|
|
|
|
+ assert!(error.contains("Did you mean --resume?"));
|
|
|
|
|
+ assert!(error.contains("claw --help"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn filtered_tool_specs_respect_allowlist() {
|
|
fn filtered_tool_specs_respect_allowlist() {
|
|
|
let allowed = ["read_file", "grep_search"]
|
|
let allowed = ["read_file", "grep_search"]
|
|
@@ -4643,6 +4912,8 @@ mod tests {
|
|
|
assert!(help.contains("/agents"));
|
|
assert!(help.contains("/agents"));
|
|
|
assert!(help.contains("/skills"));
|
|
assert!(help.contains("/skills"));
|
|
|
assert!(help.contains("/exit"));
|
|
assert!(help.contains("/exit"));
|
|
|
|
|
+ assert!(help.contains("Auto-save .claw/sessions/<session-id>.jsonl"));
|
|
|
|
|
+ assert!(help.contains("Resume latest /resume latest"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -5022,8 +5293,10 @@ mod tests {
|
|
|
let mut help = Vec::new();
|
|
let mut help = Vec::new();
|
|
|
print_help_to(&mut help).expect("help should render");
|
|
print_help_to(&mut help).expect("help should render");
|
|
|
let help = String::from_utf8(help).expect("help should be utf8");
|
|
let help = String::from_utf8(help).expect("help should be utf8");
|
|
|
- assert!(help.contains("claw --resume SESSION.jsonl"));
|
|
|
|
|
- assert!(help.contains("claw --resume session.jsonl /status /diff /export notes.txt"));
|
|
|
|
|
|
|
+ assert!(help.contains("claw --resume [SESSION.jsonl|session-id|latest]"));
|
|
|
|
|
+ assert!(help.contains("Use `latest` with --resume, /resume, or /session switch"));
|
|
|
|
|
+ assert!(help.contains("claw --resume latest"));
|
|
|
|
|
+ assert!(help.contains("claw --resume latest /status /diff /export notes.txt"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -5051,14 +5324,68 @@ mod tests {
|
|
|
|
|
|
|
|
let resolved = resolve_session_reference("legacy").expect("legacy session should resolve");
|
|
let resolved = resolve_session_reference("legacy").expect("legacy session should resolve");
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
- resolved.path.canonicalize().expect("resolved path should exist"),
|
|
|
|
|
- legacy_path.canonicalize().expect("legacy path should exist")
|
|
|
|
|
|
|
+ resolved
|
|
|
|
|
+ .path
|
|
|
|
|
+ .canonicalize()
|
|
|
|
|
+ .expect("resolved path should exist"),
|
|
|
|
|
+ legacy_path
|
|
|
|
|
+ .canonicalize()
|
|
|
|
|
+ .expect("legacy path should exist")
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
std::env::set_current_dir(previous).expect("restore cwd");
|
|
std::env::set_current_dir(previous).expect("restore cwd");
|
|
|
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
|
|
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn latest_session_alias_resolves_most_recent_managed_session() {
|
|
|
|
|
+ let _guard = cwd_lock().lock().expect("cwd lock");
|
|
|
|
|
+ let workspace = temp_workspace("latest-session-alias");
|
|
|
|
|
+ std::fs::create_dir_all(&workspace).expect("workspace should create");
|
|
|
|
|
+ let previous = std::env::current_dir().expect("cwd");
|
|
|
|
|
+ std::env::set_current_dir(&workspace).expect("switch cwd");
|
|
|
|
|
+
|
|
|
|
|
+ let older = create_managed_session_handle("session-older").expect("older handle");
|
|
|
|
|
+ Session::new()
|
|
|
|
|
+ .with_persistence_path(older.path.clone())
|
|
|
|
|
+ .save_to_path(&older.path)
|
|
|
|
|
+ .expect("older session should save");
|
|
|
|
|
+ std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
+ let newer = create_managed_session_handle("session-newer").expect("newer handle");
|
|
|
|
|
+ Session::new()
|
|
|
|
|
+ .with_persistence_path(newer.path.clone())
|
|
|
|
|
+ .save_to_path(&newer.path)
|
|
|
|
|
+ .expect("newer session should save");
|
|
|
|
|
+
|
|
|
|
|
+ let resolved = resolve_session_reference("latest").expect("latest session should resolve");
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ resolved
|
|
|
|
|
+ .path
|
|
|
|
|
+ .canonicalize()
|
|
|
|
|
+ .expect("resolved path should exist"),
|
|
|
|
|
+ newer.path.canonicalize().expect("newer path should exist")
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ std::env::set_current_dir(previous).expect("restore cwd");
|
|
|
|
|
+ std::fs::remove_dir_all(workspace).expect("workspace should clean up");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn unknown_slash_command_guidance_suggests_nearby_commands() {
|
|
|
|
|
+ let message = format_unknown_slash_command("stats");
|
|
|
|
|
+ assert!(message.contains("Unknown slash command: /stats"));
|
|
|
|
|
+ assert!(message.contains("/status"));
|
|
|
|
|
+ assert!(message.contains("/help"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn resume_usage_mentions_latest_shortcut() {
|
|
|
|
|
+ let usage = render_resume_usage();
|
|
|
|
|
+ assert!(usage.contains("/resume <session-path|session-id|latest>"));
|
|
|
|
|
+ assert!(usage.contains(".claw/sessions/<session-id>.jsonl"));
|
|
|
|
|
+ assert!(usage.contains("/session list"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
fn cwd_lock() -> &'static Mutex<()> {
|
|
fn cwd_lock() -> &'static Mutex<()> {
|
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
LOCK.get_or_init(|| Mutex::new(()))
|