Compare commits

..

5 Commits

Author SHA1 Message Date
Yeachan-Heo
ded9057ed9 Align Rust plugin skill and agent loading with upstream routing semantics
Rust was still treating local skills as flat roots and had no plugin-backed discovery for /skills, /agents, or Skill tool resolution. This patch adds plugin manifest component paths, recursive namespaced discovery, plugin-prefixed skill/agent listing, and bare-name invoke routing that falls back to unique namespaced suffix matches.

The implementation stays narrow to loading and routing: plugin tools and UI flows remain unchanged. Focused tests cover manifest parsing, plugin/local discovery, plugin-prefixed reports, unique plugin suffix resolution, and ambiguous bare-name failures.

Constraint: Keep scope limited to plugin/skill/agent loading and invoke routing parity; no UI work

Rejected: Introduce a new shared discovery crate | unnecessary drift for a parity patch

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep plugin skill and agent names prefixed with the plugin manifest name so bare-name suffix resolution stays deterministic

Tested: cargo check; cargo test

Not-tested: Runtime interactive UI rendering for /skills and /agents beyond report output
2026-04-01 22:07:17 +00:00
Yeachan-Heo
95e1290d23 merge: release/0.1.0 2026-04-01 21:05:52 +00:00
Yeachan-Heo
9415d9c9af Converge the release REPL hardening onto the redesigned CLI
The release branch keeps feat/uiux-redesign as the primary UX surface and only reapplies the hardening changes that still add value there. REPL turns now preserve raw user input, REPL-only unknown slash command guidance can suggest exit shortcuts alongside shared commands, slash completion includes /exit and /quit, and the shared help copy keeps the grouped redesign while making resume guidance a little clearer.

The release-facing README and 0.1.0 draft notes already matched the current release-doc wording, so no extra docs delta was needed in this convergence commit.

Constraint: Keep the redesigned startup/help/status surfaces intact for release/0.1.0
Constraint: Do not reintroduce blanket prompt trimming before runtime submission
Rejected: Port the hardening branch's editor-mode/config path wholesale | it diverged from the redesigned custom line editor and would have regressed the release UX
Rejected: Flatten grouped slash help back into per-command blocks | weaker fit for the redesign's operator-style help surface
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep REPL-only suggestions and completion candidates aligned when adding or removing /vim, /exit, or /quit behavior
Tested: cargo check
Tested: cargo test
Not-tested: Live provider-backed REPL turns and interactive terminal manual QA
2026-04-01 20:11:13 +00:00
Yeachan-Heo
a121285a0e Make the CLI feel guided and navigable before release
This redesign pass tightens the first-run and interactive experience
without changing the core execution model. The startup banner is now a
compact readiness summary instead of a large logo block, help output is
layered into quick-start and grouped slash-command sections, status and
permissions views read like operator dashboards, and direct/interactive
error surfaces now point users toward the next useful action.

The REPL also gains cycling slash-command completion so discoverability
improves even before a user has memorized the command set. Shared slash
command metadata now drives grouped help rendering and lightweight
command suggestions, which keeps interactive and non-interactive copy in
sync.

Constraint: Pre-release UX pass had to stay inside the existing Rust workspace with no new dependencies
Constraint: Existing slash command behavior and tests had to remain compatible while improving presentation
Rejected: Introduce a full-screen TUI command palette | too large and risky for this release pass
Rejected: Add trailing-space smart completion for argument-taking commands | conflicted with reliable completion cycling
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep startup hints, grouped slash help, and completion behavior aligned with slash_command_specs as commands evolve
Tested: cargo check
Tested: cargo test
Tested: Manual QA of `claw --help`, piped REPL `/help` `/status` `/permissions` `/session list` `/wat`, direct `/wat`, and interactive Tab cycling in the REPL
Not-tested: Live network-backed conversation turns and long streaming sessions
2026-04-01 17:19:09 +00:00
Yeachan-Heo
c0d30934e7 Present Claw Code as the current Rust product
The release-prep docs still framed the workspace as a Rust variant,
which understated the owner's current product position. This update
rewrites the README title and positioning so Claw Code is presented
as the current product surface, while keeping the legal framing clear:
Claude Code inspired, implemented clean-room in Rust, and not a direct
port or copy. The draft 0.1.0 release notes now mirror that language.

Constraint: Docs must reflect the current owner positioning without introducing unsupported product claims
Constraint: Legal framing must stay explicit that this is a clean-room Rust implementation, not a direct port or copy
Rejected: Leave release notes unchanged | would keep product-positioning language inconsistent across release-facing docs
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future release-facing docs aligned on product naming and clean-room positioning before tagging releases
Tested: Reviewed README and docs/releases/0.1.0.md after edits; verified only intended docs files were staged
Not-tested: cargo check and cargo test (docs-only pass; no code changes)
2026-04-01 17:19:09 +00:00
5 changed files with 1826 additions and 324 deletions

View File

@@ -244,6 +244,14 @@ pub struct LineEditor {
history: Vec<String>, history: Vec<String>,
yank_buffer: YankBuffer, yank_buffer: YankBuffer,
vim_enabled: bool, vim_enabled: bool,
completion_state: Option<CompletionState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompletionState {
prefix: String,
matches: Vec<String>,
next_index: usize,
} }
impl LineEditor { impl LineEditor {
@@ -255,6 +263,7 @@ impl LineEditor {
history: Vec::new(), history: Vec::new(),
yank_buffer: YankBuffer::default(), yank_buffer: YankBuffer::default(),
vim_enabled: false, vim_enabled: false,
completion_state: None,
} }
} }
@@ -357,6 +366,10 @@ impl LineEditor {
} }
fn handle_key_event(&mut self, session: &mut EditSession, key: KeyEvent) -> KeyAction { fn handle_key_event(&mut self, session: &mut EditSession, key: KeyEvent) -> KeyAction {
if key.code != KeyCode::Tab {
self.completion_state = None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) { if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code { match key.code {
KeyCode::Char('c') | KeyCode::Char('C') => { KeyCode::Char('c') | KeyCode::Char('C') => {
@@ -673,22 +686,62 @@ impl LineEditor {
session.cursor = insert_at + self.yank_buffer.text.len(); session.cursor = insert_at + self.yank_buffer.text.len();
} }
fn complete_slash_command(&self, session: &mut EditSession) { fn complete_slash_command(&mut self, session: &mut EditSession) {
if session.mode == EditorMode::Command { if session.mode == EditorMode::Command {
self.completion_state = None;
return;
}
if let Some(state) = self
.completion_state
.as_mut()
.filter(|_| session.cursor == session.text.len())
.filter(|state| {
state
.matches
.iter()
.any(|candidate| candidate == &session.text)
})
{
let candidate = state.matches[state.next_index % state.matches.len()].clone();
state.next_index += 1;
session.text.replace_range(..session.cursor, &candidate);
session.cursor = candidate.len();
return; return;
} }
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else { let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
self.completion_state = None;
return; return;
}; };
let Some(candidate) = self let matches = self
.completions .completions
.iter() .iter()
.find(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix) .filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
else { .cloned()
.collect::<Vec<_>>();
if matches.is_empty() {
self.completion_state = None;
return; return;
}
let candidate = if let Some(state) = self
.completion_state
.as_mut()
.filter(|state| state.prefix == prefix && state.matches == matches)
{
let index = state.next_index % state.matches.len();
state.next_index += 1;
state.matches[index].clone()
} else {
let candidate = matches[0].clone();
self.completion_state = Some(CompletionState {
prefix: prefix.to_string(),
matches,
next_index: 1,
});
candidate
}; };
session.text.replace_range(..session.cursor, candidate); session.text.replace_range(..session.cursor, &candidate);
session.cursor = candidate.len(); session.cursor = candidate.len();
} }
@@ -1086,7 +1139,7 @@ mod tests {
#[test] #[test]
fn tab_completes_matching_slash_commands() { fn tab_completes_matching_slash_commands() {
// given // given
let editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]); let mut editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
let mut session = EditSession::new(false); let mut session = EditSession::new(false);
session.text = "/he".to_string(); session.text = "/he".to_string();
session.cursor = session.text.len(); session.cursor = session.text.len();
@@ -1099,6 +1152,29 @@ mod tests {
assert_eq!(session.cursor, 5); assert_eq!(session.cursor, 5);
} }
#[test]
fn tab_cycles_between_matching_slash_commands() {
// given
let mut editor = LineEditor::new(
"> ",
vec!["/permissions".to_string(), "/plugin".to_string()],
);
let mut session = EditSession::new(false);
session.text = "/p".to_string();
session.cursor = session.text.len();
// when
editor.complete_slash_command(&mut session);
let first = session.text.clone();
session.cursor = session.text.len();
editor.complete_slash_command(&mut session);
let second = session.text.clone();
// then
assert_eq!(first, "/permissions");
assert_eq!(second, "/plugin");
}
#[test] #[test]
fn ctrl_c_cancels_when_input_exists() { fn ctrl_c_cancels_when_input_exists() {
// given // given

View File

@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
use std::env; use std::env;
use std::fmt::Write as _; use std::fmt::Write as _;
use std::fs; use std::fs;
use std::io::{self, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -16,14 +16,15 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use api::{ use api::{
resolve_startup_auth_source, ClawApiClient, AuthSource, ContentBlockDelta, InputContentBlock, resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
}; };
use commands::{ use commands::{
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
suggest_slash_commands, SlashCommand,
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo; use init::initialize_repo;
@@ -59,15 +60,25 @@ type AllowedToolSet = BTreeSet<String>;
fn main() { fn main() {
if let Err(error) = run() { if let Err(error) = run() {
eprintln!( eprintln!("{}", render_cli_error(&error.to_string()));
"error: {error}
Run `claw --help` for usage."
);
std::process::exit(1); std::process::exit(1);
} }
} }
fn render_cli_error(problem: &str) -> String {
let mut lines = vec!["Error".to_string()];
for (index, line) in problem.lines().enumerate() {
let label = if index == 0 {
" Problem "
} else {
" "
};
lines.push(format!("{label}{line}"));
}
lines.push(" Help claw --help".to_string());
lines.join("\n")
}
fn run() -> Result<(), Box<dyn std::error::Error>> { fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect(); let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? { match parse_args(&args)? {
@@ -321,17 +332,36 @@ 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(command) => Err(format!( Some(command) => Err(format_direct_slash_command_error(
"unsupported direct slash command outside the REPL: {command_name}", match &command {
command_name = match command {
SlashCommand::Unknown(name) => format!("/{name}"), SlashCommand::Unknown(name) => format!("/{name}"),
_ => rest[0].clone(), _ => rest[0].clone(),
} }
.as_str(),
matches!(command, SlashCommand::Unknown(_)),
)), )),
None => Err(format!("unknown subcommand: {}", rest[0])), None => Err(format!("unknown subcommand: {}", rest[0])),
} }
} }
fn format_direct_slash_command_error(command: &str, is_unknown: bool) -> String {
let trimmed = command.trim().trim_start_matches('/');
let mut lines = vec![
"Direct slash command unavailable".to_string(),
format!(" Command /{trimmed}"),
];
if is_unknown {
append_slash_command_suggestions(&mut lines, trimmed);
} else {
lines.push(" Try Start `claw` to use interactive slash commands".to_string());
lines.push(
" Tip Resume-safe commands also work with `claw --resume SESSION.json ...`"
.to_string(),
);
}
lines.join("\n")
}
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",
@@ -670,13 +700,17 @@ struct StatusUsage {
fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!( format!(
"Model "Model
Current model {model} Current {model}
Session messages {message_count} Session {message_count} messages · {turns} turns
Session turns {turns}
Usage Aliases
Inspect current model with /model opus claude-opus-4-6
Switch models with /model <name>" sonnet claude-sonnet-4-6
haiku claude-haiku-4-5-20251213
Next
/model Show the current model
/model <name> Switch models for this REPL session"
) )
} }
@@ -685,7 +719,8 @@ fn format_model_switch_report(previous: &str, next: &str, message_count: usize)
"Model updated "Model updated
Previous {previous} Previous {previous}
Current {next} Current {next}
Preserved msgs {message_count}" Preserved {message_count} messages
Tip Existing conversation context stayed attached"
) )
} }
@@ -718,28 +753,34 @@ fn format_permissions_report(mode: &str) -> String {
", ",
); );
let effect = match mode {
"read-only" => "Only read/search tools can run automatically",
"workspace-write" => "Editing tools can modify files in the workspace",
"danger-full-access" => "All tools can run without additional sandbox limits",
_ => "Unknown permission mode",
};
format!( format!(
"Permissions "Permissions
Active mode {mode} Active mode {mode}
Mode status live session default Effect {effect}
Modes Modes
{modes} {modes}
Usage Next
Inspect current mode with /permissions /permissions Show the current mode
Switch modes with /permissions <mode>" /permissions <mode> Switch modes for subsequent tool calls"
) )
} }
fn format_permissions_switch_report(previous: &str, next: &str) -> String { fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!( format!(
"Permissions updated "Permissions updated
Result mode switched
Previous mode {previous} Previous mode {previous}
Active mode {next} Active mode {next}
Applies to subsequent tool calls Applies to Subsequent tool calls in this REPL
Usage /permissions to inspect current mode" Tip Run /permissions to review all available modes"
) )
} }
@@ -750,7 +791,11 @@ fn format_cost_report(usage: TokenUsage) -> String {
Output tokens {} Output tokens {}
Cache create {} Cache create {}
Cache read {} Cache read {}
Total tokens {}", Total tokens {}
Next
/status See session + workspace context
/compact Trim local history if the session is getting large",
usage.input_tokens, usage.input_tokens,
usage.output_tokens, usage.output_tokens,
usage.cache_creation_input_tokens, usage.cache_creation_input_tokens,
@@ -763,8 +808,8 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
format!( format!(
"Session resumed "Session resumed
Session file {session_path} Session file {session_path}
Messages {message_count} History {message_count} messages · {turns} turns
Turns {turns}" Next /status · /diff · /export"
) )
} }
@@ -773,7 +818,7 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
format!( format!(
"Compact "Compact
Result skipped Result skipped
Reason session below compaction threshold Reason Session is already below the compaction threshold
Messages kept {resulting_messages}" Messages kept {resulting_messages}"
) )
} else { } else {
@@ -781,7 +826,8 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
"Compact "Compact
Result compacted Result compacted
Messages removed {removed} Messages removed {removed}
Messages kept {resulting_messages}" Messages kept {resulting_messages}
Tip Use /status to review the trimmed session"
) )
} }
} }
@@ -969,22 +1015,22 @@ fn run_repl(
loop { loop {
match editor.read_line()? { match editor.read_line()? {
input::ReadOutcome::Submit(input) => { input::ReadOutcome::Submit(input) => {
let trimmed = input.trim().to_string(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
continue; continue;
} }
if matches!(trimmed.as_str(), "/exit" | "/quit") { if matches!(trimmed, "/exit" | "/quit") {
cli.persist_session()?; cli.persist_session()?;
break; break;
} }
if let Some(command) = SlashCommand::parse(&trimmed) { if let Some(command) = SlashCommand::parse(trimmed) {
if cli.handle_repl_command(command)? { if cli.handle_repl_command(command)? {
cli.persist_session()?; cli.persist_session()?;
} }
continue; continue;
} }
editor.push_history(input); editor.push_history(&input);
cli.run_turn(&trimmed)?; cli.run_turn(&input)?;
} }
input::ReadOutcome::Cancel => {} input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => { input::ReadOutcome::Exit => {
@@ -1052,28 +1098,65 @@ impl LiveCli {
} }
fn startup_banner(&self) -> String { fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else( let color = io::stdout().is_terminal();
|_| "<unknown>".to_string(), let cwd = env::current_dir().ok();
let cwd_display = cwd.as_ref().map_or_else(
|| "<unknown>".to_string(),
|path| path.display().to_string(), |path| path.display().to_string(),
); );
format!( let workspace_name = cwd
"\x1b[38;5;196m\ .as_ref()
██████╗██╗ █████╗ ██╗ ██╗\n\ .and_then(|path| path.file_name())
██╔════╝██║ ██╔══██╗██║ ██║\n\ .and_then(|name| name.to_str())
██║ ██║ ███████║██║ █╗ ██║\n\ .unwrap_or("workspace");
██║ ██║ ██╔══██║██║███╗██║\n\ let git_branch = status_context(Some(&self.session.path))
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\ .ok()
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ .and_then(|context| context.git_branch);
\x1b[2mModel\x1b[0m {}\n\ let workspace_summary = git_branch.as_deref().map_or_else(
\x1b[2mPermissions\x1b[0m {}\n\ || workspace_name.to_string(),
\x1b[2mDirectory\x1b[0m {}\n\ |branch| format!("{workspace_name} · {branch}"),
\x1b[2mSession\x1b[0m {}\n\n\ );
Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline", let has_claw_md = cwd
self.model, .as_ref()
self.permission_mode.as_str(), .is_some_and(|path| path.join("CLAW.md").is_file());
cwd, let mut lines = vec![
self.session.id, format!(
) "{} {}",
if color {
"\x1b[1;38;5;45m🦞 Claw Code\x1b[0m"
} else {
"Claw Code"
},
if color {
"\x1b[2m· ready\x1b[0m"
} else {
"· ready"
}
),
format!(" Workspace {workspace_summary}"),
format!(" Directory {cwd_display}"),
format!(" Model {}", self.model),
format!(" Permissions {}", self.permission_mode.as_str()),
format!(" Session {}", self.session.id),
format!(
" Quick start {}",
if has_claw_md {
"/help · /status · ask for a task"
} else {
"/init · /help · /status"
}
),
" Editor Tab completes slash commands · /vim toggles modal editing"
.to_string(),
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
];
if !has_claw_md {
lines.push(
" First run /init scaffolds CLAW.md, .claw.json, and local session files"
.to_string(),
);
}
lines.join("\n")
} }
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
@@ -1246,19 +1329,28 @@ impl LiveCli {
false false
} }
SlashCommand::Branch { .. } => { SlashCommand::Branch { .. } => {
eprintln!("git branch commands not yet wired to REPL"); eprintln!(
"{}",
render_mode_unavailable("branch", "git branch commands")
);
false false
} }
SlashCommand::Worktree { .. } => { SlashCommand::Worktree { .. } => {
eprintln!("git worktree commands not yet wired to REPL"); eprintln!(
"{}",
render_mode_unavailable("worktree", "git worktree commands")
);
false false
} }
SlashCommand::CommitPushPr { .. } => { SlashCommand::CommitPushPr { .. } => {
eprintln!("commit-push-pr not yet wired to REPL"); eprintln!(
"{}",
render_mode_unavailable("commit-push-pr", "commit + push + PR automation")
);
false false
} }
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}"); eprintln!("{}", render_unknown_repl_command(&name));
false false
} }
}) })
@@ -1837,6 +1929,20 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
Ok(sessions) Ok(sessions)
} }
fn format_relative_timestamp(epoch_secs: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(epoch_secs);
let elapsed = now.saturating_sub(epoch_secs);
match elapsed {
0..=59 => format!("{elapsed}s ago"),
60..=3_599 => format!("{}m ago", elapsed / 60),
3_600..=86_399 => format!("{}h ago", elapsed / 3_600),
_ => format!("{}d ago", elapsed / 86_400),
}
}
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![
@@ -1854,26 +1960,28 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
"○ saved" "○ saved"
}; };
lines.push(format!( lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", " {id:<20} {marker:<10} {msgs:>3} msgs · updated {modified}",
id = session.id, id = session.id,
msgs = session.message_count, msgs = session.message_count,
modified = session.modified_epoch_secs, modified = format_relative_timestamp(session.modified_epoch_secs),
path = session.path.display(),
)); ));
lines.push(format!(" {}", session.path.display()));
} }
Ok(lines.join("\n")) Ok(lines.join("\n"))
} }
fn render_repl_help() -> String { fn render_repl_help() -> String {
[ [
"REPL".to_string(), "Interactive REPL".to_string(),
" /exit Quit the REPL".to_string(), " Quick start Ask a task in plain English or use one of the core commands below."
" /quit Quit the REPL".to_string(), .to_string(),
" /vim Toggle Vim keybindings".to_string(), " Core commands /help · /status · /model · /permissions · /compact".to_string(),
" Up/Down Navigate prompt history".to_string(), " Exit /exit or /quit".to_string(),
" Tab Complete slash commands".to_string(), " Vim mode /vim toggles modal editing".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(), " History Up/Down recalls previous prompts".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(), " Completion Tab cycles slash command matches".to_string(),
" Cancel Ctrl-C clears input (or exits on an empty prompt)".to_string(),
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
String::new(), String::new(),
render_slash_command_help(), render_slash_command_help(),
] ]
@@ -1883,6 +1991,57 @@ fn render_repl_help() -> String {
) )
} }
fn append_slash_command_suggestions(lines: &mut Vec<String>, name: &str) {
let suggestions = suggest_slash_commands(name, 3);
if suggestions.is_empty() {
lines.push(" Try /help shows the full slash command map".to_string());
return;
}
lines.push(" Try /help shows the full slash command map".to_string());
lines.push("Suggestions".to_string());
lines.extend(
suggestions
.into_iter()
.map(|suggestion| format!(" {suggestion}")),
);
}
fn render_unknown_repl_command(name: &str) -> String {
let mut lines = vec![
"Unknown slash command".to_string(),
format!(" Command /{name}"),
];
append_repl_command_suggestions(&mut lines, name);
lines.join("\n")
}
fn append_repl_command_suggestions(lines: &mut Vec<String>, name: &str) {
let suggestions = suggest_repl_commands(name);
if suggestions.is_empty() {
lines.push(" Try /help shows the full slash command map".to_string());
return;
}
lines.push(" Try /help shows the full slash command map".to_string());
lines.push("Suggestions".to_string());
lines.extend(
suggestions
.into_iter()
.map(|suggestion| format!(" {suggestion}")),
);
}
fn render_mode_unavailable(command: &str, label: &str) -> String {
[
"Command unavailable in this REPL mode".to_string(),
format!(" Command /{command}"),
format!(" Feature {label}"),
" Tip Use /help to find currently wired REPL commands".to_string(),
]
.join("\n")
}
fn status_context( fn status_context(
session_path: Option<&Path>, session_path: Option<&Path>,
) -> Result<StatusContext, Box<dyn std::error::Error>> { ) -> Result<StatusContext, Box<dyn std::error::Error>> {
@@ -1912,33 +2071,41 @@ fn format_status_report(
) -> String { ) -> String {
[ [
format!( format!(
"Status "Session
Model {model} Model {model}
Permission mode {permission_mode} Permissions {permission_mode}
Messages {} Activity {} messages · {} turns
Turns {} Tokens est {} · latest {} · total {}",
Estimated tokens {}", usage.message_count,
usage.message_count, usage.turns, usage.estimated_tokens, usage.turns,
), usage.estimated_tokens,
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cumulative total {}",
usage.latest.total_tokens(), usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(), usage.cumulative.total_tokens(),
), ),
format!(
"Usage
Cumulative input {}
Cumulative output {}
Cache create {}
Cache read {}",
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.cache_creation_input_tokens,
usage.cumulative.cache_read_input_tokens,
),
format!( format!(
"Workspace "Workspace
Cwd {} Folder {}
Project root {} Project root {}
Git branch {} Git branch {}
Session {} Session file {}
Config files loaded {}/{} Config files loaded {}/{}
Memory files {}", Memory files {}
Next
/help Browse commands
/session list Inspect saved sessions
/diff Review current workspace changes",
context.cwd.display(), context.cwd.display(),
context context
.project_root .project_root
@@ -2053,8 +2220,7 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
if project_context.instruction_files.is_empty() { if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string()); lines.push("Discovered files".to_string());
lines.push( lines.push(
" No CLAW instruction files discovered in the current directory ancestry." " No CLAW instruction files discovered in the current directory ancestry.".to_string(),
.to_string(),
); );
} else { } else {
lines.push("Discovered files".to_string()); lines.push("Discovered files".to_string());
@@ -2321,7 +2487,7 @@ fn render_version_report() -> String {
let git_sha = GIT_SHA.unwrap_or("unknown"); let git_sha = GIT_SHA.unwrap_or("unknown");
let target = BUILD_TARGET.unwrap_or("unknown"); let target = BUILD_TARGET.unwrap_or("unknown");
format!( format!(
"Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}\n\nSupport\n Help claw --help\n REPL /help"
) )
} }
@@ -2806,7 +2972,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<DefaultRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> { ) -> Result<ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
let (feature_config, tool_registry) = build_runtime_plugin_state()?; let (feature_config, tool_registry) = build_runtime_plugin_state()?;
Ok(ConversationRuntime::new_with_features( Ok(ConversationRuntime::new_with_features(
session, session,
@@ -3126,10 +3293,70 @@ fn slash_command_completion_candidates() -> Vec<String> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
candidates.push("/vim".to_string()); candidates.extend([
String::from("/vim"),
String::from("/exit"),
String::from("/quit"),
]);
candidates.sort();
candidates.dedup();
candidates candidates
} }
fn suggest_repl_commands(name: &str) -> Vec<String> {
let normalized = name.trim().trim_start_matches('/').to_ascii_lowercase();
if normalized.is_empty() {
return Vec::new();
}
let mut ranked = slash_command_completion_candidates()
.into_iter()
.filter_map(|candidate| {
let raw = candidate.trim_start_matches('/').to_ascii_lowercase();
let distance = edit_distance(&normalized, &raw);
let prefix_match = raw.starts_with(&normalized) || normalized.starts_with(&raw);
let near_match = distance <= 2;
(prefix_match || near_match).then_some((distance, candidate))
})
.collect::<Vec<_>>();
ranked.sort();
ranked.dedup_by(|left, right| left.1 == right.1);
ranked
.into_iter()
.map(|(_, candidate)| candidate)
.take(3)
.collect()
}
fn edit_distance(left: &str, right: &str) -> usize {
if left == right {
return 0;
}
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);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right_chars.len()]
}
fn format_tool_call_start(name: &str, input: &str) -> String { fn format_tool_call_start(name: &str, input: &str) -> String {
let parsed: serde_json::Value = let parsed: serde_json::Value =
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
@@ -3730,65 +3957,118 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
} }
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 Code CLI v{VERSION}")?;
writeln!(
out,
" Interactive coding assistant for the current workspace."
)?;
writeln!(out)?; writeln!(out)?;
writeln!(out, "Usage:")?; writeln!(out, "Quick start")?;
writeln!( writeln!(
out, out,
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" " claw Start the interactive REPL"
)?;
writeln!(out, " Start the interactive REPL")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] prompt TEXT"
)?;
writeln!(out, " Send one prompt and exit")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] TEXT"
)?;
writeln!(out, " Shorthand non-interactive prompt mode")?;
writeln!(
out,
" claw --resume SESSION.json [/status] [/compact] [...]"
)?; )?;
writeln!( writeln!(
out, out,
" Inspect or maintain a saved session without entering the REPL" " claw \"summarize this repo\" Run one prompt and exit"
)?;
writeln!(
out,
" claw prompt \"explain src/main.rs\" Explicit one-shot prompt"
)?;
writeln!(
out,
" claw --resume SESSION.json /status Inspect a saved session"
)?;
writeln!(out)?;
writeln!(out, "Interactive essentials")?;
writeln!(
out,
" /help Browse the full slash command map"
)?;
writeln!(
out,
" /status Inspect session + workspace state"
)?;
writeln!(
out,
" /model <name> Switch models mid-session"
)?;
writeln!(
out,
" /permissions <mode> Adjust tool access"
)?;
writeln!(
out,
" Tab Complete slash commands"
)?;
writeln!(
out,
" /vim Toggle modal editing"
)?;
writeln!(
out,
" Shift+Enter / Ctrl+J Insert a newline"
)?;
writeln!(out)?;
writeln!(out, "Commands")?;
writeln!(
out,
" claw dump-manifests Read upstream TS sources and print extracted counts"
)?;
writeln!(
out,
" claw bootstrap-plan Print the bootstrap phase skeleton"
)?;
writeln!(
out,
" claw agents List configured agents"
)?;
writeln!(
out,
" claw skills List installed skills"
)?; )?;
writeln!(out, " claw dump-manifests")?;
writeln!(out, " claw bootstrap-plan")?;
writeln!(out, " claw agents")?;
writeln!(out, " claw skills")?;
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
writeln!(out, " claw login")?;
writeln!(out, " claw logout")?;
writeln!(out, " claw init")?;
writeln!(out)?;
writeln!(out, "Flags:")?;
writeln!( writeln!(
out, out,
" --model MODEL Override the active model" " claw login Start the OAuth login flow"
)?; )?;
writeln!( writeln!(
out, out,
" --output-format FORMAT Non-interactive output format: text or json" " claw logout Clear saved OAuth credentials"
)?; )?;
writeln!( writeln!(
out, out,
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access" " claw init Scaffold CLAW.md + local files"
)?;
writeln!(
out,
" --dangerously-skip-permissions Skip all permission checks"
)?;
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
writeln!(
out,
" --version, -V Print version and build information locally"
)?; )?;
writeln!(out)?; writeln!(out)?;
writeln!(out, "Interactive slash commands:")?; writeln!(out, "Flags")?;
writeln!(
out,
" --model MODEL Override the active model"
)?;
writeln!(
out,
" --output-format FORMAT Non-interactive output: text or json"
)?;
writeln!(
out,
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
)?;
writeln!(
out,
" --dangerously-skip-permissions Skip all permission checks"
)?;
writeln!(
out,
" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"
)?;
writeln!(
out,
" --version, -V Print version and build information"
)?;
writeln!(out)?;
writeln!(out, "Slash command reference")?;
writeln!(out, "{}", render_slash_command_help())?; writeln!(out, "{}", render_slash_command_help())?;
writeln!(out)?; writeln!(out)?;
let resume_commands = resume_supported_slash_commands() let resume_commands = resume_supported_slash_commands()
@@ -3800,7 +4080,7 @@ 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, "Examples:")?; writeln!(out, "Examples")?;
writeln!(out, " claw --model opus \"summarize this repo\"")?; writeln!(out, " claw --model opus \"summarize this repo\"")?;
writeln!( writeln!(
out, out,
@@ -3834,9 +4114,10 @@ mod tests {
format_status_report, format_tool_call_start, format_tool_result, format_status_report, format_tool_call_start, format_tool_result,
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
print_help_to, push_output_block, render_config_report, render_memory_report, print_help_to, push_output_block, render_config_report, render_memory_report,
render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent, resume_supported_slash_commands, slash_command_completion_candidates, status_context,
InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, CliAction, 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};
@@ -4072,7 +4353,8 @@ mod tests {
); );
let error = parse_args(&["/status".to_string()]) let error = parse_args(&["/status".to_string()])
.expect_err("/status should remain REPL-only when invoked directly"); .expect_err("/status should remain REPL-only when invoked directly");
assert!(error.contains("unsupported direct slash command")); assert!(error.contains("Direct slash command unavailable"));
assert!(error.contains("/status"));
} }
#[test] #[test]
@@ -4149,13 +4431,14 @@ mod tests {
fn shared_help_uses_resume_annotation_copy() { fn shared_help_uses_resume_annotation_copy() {
let help = commands::render_slash_command_help(); let help = commands::render_slash_command_help();
assert!(help.contains("Slash commands")); assert!(help.contains("Slash commands"));
assert!(help.contains("works with --resume SESSION.json")); assert!(help.contains("Tab completes commands inside the REPL."));
assert!(help.contains("available via claw --resume SESSION.json"));
} }
#[test] #[test]
fn repl_help_includes_shared_commands_and_exit() { fn repl_help_includes_shared_commands_and_exit() {
let help = render_repl_help(); let help = render_repl_help();
assert!(help.contains("REPL")); assert!(help.contains("Interactive REPL"));
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
@@ -4177,6 +4460,24 @@ 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("Tab cycles slash command matches"));
}
#[test]
fn completion_candidates_include_repl_only_exit_commands() {
let candidates = slash_command_completion_candidates();
assert!(candidates.contains(&"/help".to_string()));
assert!(candidates.contains(&"/vim".to_string()));
assert!(candidates.contains(&"/exit".to_string()));
assert!(candidates.contains(&"/quit".to_string()));
}
#[test]
fn unknown_repl_command_suggestions_include_repl_shortcuts() {
let rendered = render_unknown_repl_command("exi");
assert!(rendered.contains("Unknown slash command"));
assert!(rendered.contains("/exit"));
assert!(rendered.contains("/help"));
} }
#[test] #[test]
@@ -4199,8 +4500,8 @@ mod tests {
let report = format_resume_report("session.json", 14, 6); let report = format_resume_report("session.json", 14, 6);
assert!(report.contains("Session resumed")); assert!(report.contains("Session resumed"));
assert!(report.contains("Session file session.json")); assert!(report.contains("Session file session.json"));
assert!(report.contains("Messages 14")); assert!(report.contains("History 14 messages · 6 turns"));
assert!(report.contains("Turns 6")); assert!(report.contains("/status · /diff · /export"));
} }
#[test] #[test]
@@ -4209,6 +4510,7 @@ mod tests {
assert!(compacted.contains("Compact")); assert!(compacted.contains("Compact"));
assert!(compacted.contains("Result compacted")); assert!(compacted.contains("Result compacted"));
assert!(compacted.contains("Messages removed 8")); assert!(compacted.contains("Messages removed 8"));
assert!(compacted.contains("Use /status"));
let skipped = format_compact_report(0, 3, true); let skipped = format_compact_report(0, 3, true);
assert!(skipped.contains("Result skipped")); assert!(skipped.contains("Result skipped"));
} }
@@ -4227,6 +4529,7 @@ mod tests {
assert!(report.contains("Cache create 3")); assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1")); assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32")); assert!(report.contains("Total tokens 32"));
assert!(report.contains("/compact"));
} }
#[test] #[test]
@@ -4234,6 +4537,7 @@ mod tests {
let report = format_permissions_report("workspace-write"); let report = format_permissions_report("workspace-write");
assert!(report.contains("Permissions")); assert!(report.contains("Permissions"));
assert!(report.contains("Active mode workspace-write")); assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Effect Editing tools can modify files in the workspace"));
assert!(report.contains("Modes")); assert!(report.contains("Modes"));
assert!(report.contains("read-only ○ available Read/search tools only")); assert!(report.contains("read-only ○ available Read/search tools only"));
assert!(report.contains("workspace-write ● current Edit files inside the workspace")); assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
@@ -4244,10 +4548,9 @@ mod tests {
fn permissions_switch_report_is_structured() { fn permissions_switch_report_is_structured() {
let report = format_permissions_switch_report("read-only", "workspace-write"); let report = format_permissions_switch_report("read-only", "workspace-write");
assert!(report.contains("Permissions updated")); assert!(report.contains("Permissions updated"));
assert!(report.contains("Result mode switched"));
assert!(report.contains("Previous mode read-only")); assert!(report.contains("Previous mode read-only"));
assert!(report.contains("Active mode workspace-write")); assert!(report.contains("Active mode workspace-write"));
assert!(report.contains("Applies to subsequent tool calls")); assert!(report.contains("Applies to Subsequent tool calls in this REPL"));
} }
#[test] #[test]
@@ -4265,9 +4568,10 @@ mod tests {
fn model_report_uses_sectioned_layout() { fn model_report_uses_sectioned_layout() {
let report = format_model_report("sonnet", 12, 4); let report = format_model_report("sonnet", 12, 4);
assert!(report.contains("Model")); assert!(report.contains("Model"));
assert!(report.contains("Current model sonnet")); assert!(report.contains("Current sonnet"));
assert!(report.contains("Session messages 12")); assert!(report.contains("Session 12 messages · 4 turns"));
assert!(report.contains("Switch models with /model <name>")); assert!(report.contains("Aliases"));
assert!(report.contains("/model <name> Switch models for this REPL session"));
} }
#[test] #[test]
@@ -4276,7 +4580,7 @@ mod tests {
assert!(report.contains("Model updated")); assert!(report.contains("Model updated"));
assert!(report.contains("Previous sonnet")); assert!(report.contains("Previous sonnet"));
assert!(report.contains("Current opus")); assert!(report.contains("Current opus"));
assert!(report.contains("Preserved msgs 9")); assert!(report.contains("Preserved 9 messages"));
} }
#[test] #[test]
@@ -4311,18 +4615,18 @@ mod tests {
git_branch: Some("main".to_string()), git_branch: Some("main".to_string()),
}, },
); );
assert!(status.contains("Status")); assert!(status.contains("Session"));
assert!(status.contains("Model sonnet")); assert!(status.contains("Model sonnet"));
assert!(status.contains("Permission mode workspace-write")); assert!(status.contains("Permissions workspace-write"));
assert!(status.contains("Messages 7")); assert!(status.contains("Activity 7 messages · 3 turns"));
assert!(status.contains("Latest total 10")); assert!(status.contains("Tokens est 128 · latest 10 · total 31"));
assert!(status.contains("Cumulative total 31")); assert!(status.contains("Folder /tmp/project"));
assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp")); assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main")); assert!(status.contains("Git branch main"));
assert!(status.contains("Session session.json")); assert!(status.contains("Session file session.json"));
assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4")); assert!(status.contains("Memory files 4"));
assert!(status.contains("/session list"));
} }
#[test] #[test]
@@ -4458,8 +4762,8 @@ mod tests {
fn repl_help_mentions_history_completion_and_multiline() { fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help(); let help = render_repl_help();
assert!(help.contains("Up/Down")); assert!(help.contains("Up/Down"));
assert!(help.contains("Tab")); assert!(help.contains("Tab cycles"));
assert!(help.contains("Shift+Enter/Ctrl+J")); assert!(help.contains("Shift+Enter or Ctrl+J"));
} }
#[test] #[test]

File diff suppressed because it is too large Load Diff

View File

@@ -119,6 +119,10 @@ pub struct PluginManifest {
pub tools: Vec<PluginToolManifest>, pub tools: Vec<PluginToolManifest>,
#[serde(default)] #[serde(default)]
pub commands: Vec<PluginCommandManifest>, pub commands: Vec<PluginCommandManifest>,
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub skills: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -228,6 +232,10 @@ struct RawPluginManifest {
pub tools: Vec<RawPluginToolManifest>, pub tools: Vec<RawPluginToolManifest>,
#[serde(default)] #[serde(default)]
pub commands: Vec<PluginCommandManifest>, pub commands: Vec<PluginCommandManifest>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub agents: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub skills: Vec<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -246,6 +254,24 @@ struct RawPluginToolManifest {
pub required_permission: String, pub required_permission: String,
} }
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringList {
One(String),
Many(Vec<String>),
}
Ok(match Option::<StringList>::deserialize(deserializer)? {
Some(StringList::One(value)) => vec![value],
Some(StringList::Many(values)) => values,
None => Vec::new(),
})
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct PluginTool { pub struct PluginTool {
plugin_id: String, plugin_id: String,
@@ -1461,6 +1487,8 @@ fn build_plugin_manifest(
"lifecycle command", "lifecycle command",
&mut errors, &mut errors,
); );
let agents = build_manifest_paths(root, raw.agents, "agent", &mut errors);
let skills = build_manifest_paths(root, raw.skills, "skill", &mut errors);
let tools = build_manifest_tools(root, raw.tools, &mut errors); let tools = build_manifest_tools(root, raw.tools, &mut errors);
let commands = build_manifest_commands(root, raw.commands, &mut errors); let commands = build_manifest_commands(root, raw.commands, &mut errors);
@@ -1478,6 +1506,8 @@ fn build_plugin_manifest(
lifecycle: raw.lifecycle, lifecycle: raw.lifecycle,
tools, tools,
commands, commands,
agents,
skills,
}) })
} }
@@ -1593,6 +1623,47 @@ fn build_manifest_tools(
validated validated
} }
fn build_manifest_paths(
root: &Path,
paths: Vec<String>,
kind: &'static str,
errors: &mut Vec<PluginManifestValidationError>,
) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut validated = Vec::new();
for path in paths {
let trimmed = path.trim();
if trimmed.is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind,
field: "path",
name: None,
});
continue;
}
let resolved = if Path::new(trimmed).is_absolute() {
PathBuf::from(trimmed)
} else {
root.join(trimmed)
};
if !resolved.exists() {
errors.push(PluginManifestValidationError::MissingPath {
kind,
path: resolved,
});
continue;
}
if seen.insert(trimmed.to_string()) {
validated.push(trimmed.to_string());
}
}
validated
}
fn build_manifest_commands( fn build_manifest_commands(
root: &Path, root: &Path,
commands: Vec<PluginCommandManifest>, commands: Vec<PluginCommandManifest>,
@@ -2227,6 +2298,38 @@ mod tests {
let _ = fs::remove_dir_all(root); let _ = fs::remove_dir_all(root);
} }
#[test]
fn load_plugin_from_directory_parses_agent_and_skill_paths() {
let root = temp_dir("manifest-component-paths");
write_file(
root.join("agents").join("ops").join("triage.md").as_path(),
"---\nname: triage\ndescription: triage agent\n---\n",
);
write_file(
root.join("skills")
.join("review")
.join("SKILL.md")
.as_path(),
"---\nname: review\ndescription: review skill\n---\n",
);
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "component-paths",
"version": "1.0.0",
"description": "Manifest component paths",
"agents": "./agents/ops/triage.md",
"skills": ["./skills"]
}"#,
);
let manifest = load_plugin_from_directory(&root).expect("manifest should load");
assert_eq!(manifest.agents, vec!["./agents/ops/triage.md"]);
assert_eq!(manifest.skills, vec!["./skills"]);
let _ = fs::remove_dir_all(root);
}
#[test] #[test]
fn load_plugin_from_directory_defaults_optional_fields() { fn load_plugin_from_directory_defaults_optional_fields() {
let root = temp_dir("manifest-defaults"); let root = temp_dir("manifest-defaults");

View File

@@ -8,13 +8,15 @@ use api::{
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
}; };
use plugins::PluginTool; use plugins::{
load_plugin_from_directory, PluginManager, PluginManagerConfig, PluginSummary, PluginTool,
};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use runtime::{ use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -91,7 +93,10 @@ impl GlobalToolRegistry {
Ok(Self { plugin_tools }) Ok(Self { plugin_tools })
} }
pub fn normalize_allowed_tools(&self, values: &[String]) -> Result<Option<BTreeSet<String>>, String> { pub fn normalize_allowed_tools(
&self,
values: &[String],
) -> Result<Option<BTreeSet<String>>, String> {
if values.is_empty() { if values.is_empty() {
return Ok(None); return Ok(None);
} }
@@ -100,7 +105,11 @@ impl GlobalToolRegistry {
let canonical_names = builtin_specs let canonical_names = builtin_specs
.iter() .iter()
.map(|spec| spec.name.to_string()) .map(|spec| spec.name.to_string())
.chain(self.plugin_tools.iter().map(|tool| tool.definition().name.clone())) .chain(
self.plugin_tools
.iter()
.map(|tool| tool.definition().name.clone()),
)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut name_map = canonical_names let mut name_map = canonical_names
.iter() .iter()
@@ -151,7 +160,8 @@ impl GlobalToolRegistry {
.plugin_tools .plugin_tools
.iter() .iter()
.filter(|tool| { .filter(|tool| {
allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) allowed_tools
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
}) })
.map(|tool| ToolDefinition { .map(|tool| ToolDefinition {
name: tool.definition().name.clone(), name: tool.definition().name.clone(),
@@ -174,7 +184,8 @@ impl GlobalToolRegistry {
.plugin_tools .plugin_tools
.iter() .iter()
.filter(|tool| { .filter(|tool| {
allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) allowed_tools
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
}) })
.map(|tool| { .map(|tool| {
( (
@@ -1454,48 +1465,391 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
Ok(cwd.join(".claw-todos.json")) Ok(cwd.join(".claw-todos.json"))
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SkillRootKind {
Skills,
LegacyCommands,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillCandidate {
name: String,
path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillCandidateRoot {
path: PathBuf,
kind: SkillRootKind,
name_prefix: Option<String>,
}
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> { fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
if requested.is_empty() { if requested.is_empty() {
return Err(String::from("skill must not be empty")); return Err(String::from("skill must not be empty"));
} }
let candidates = discover_skill_candidates().map_err(|error| error.to_string())?;
if let Some(candidate) = candidates
.iter()
.find(|candidate| candidate.name.eq_ignore_ascii_case(requested))
{
return Ok(candidate.path.clone());
}
let suffix = format!(":{requested}");
let suffix_matches = candidates
.iter()
.filter(|candidate| candidate.name.ends_with(&suffix))
.collect::<Vec<_>>();
match suffix_matches.as_slice() {
[candidate] => Ok(candidate.path.clone()),
[] => Err(format!("unknown skill: {requested}")),
matches => Err(format!(
"ambiguous skill `{requested}`; use one of: {}",
matches
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)),
}
}
fn discover_skill_candidates() -> std::io::Result<Vec<SkillCandidate>> {
let cwd = std::env::current_dir()?;
let mut roots = local_skill_candidate_roots(&cwd);
extend_plugin_skill_candidate_roots(&cwd, &mut roots);
let mut candidates = Vec::new(); let mut candidates = Vec::new();
for root in &roots {
collect_skill_candidates(root, &root.path, &mut candidates)?;
}
Ok(candidates)
}
fn local_skill_candidate_roots(cwd: &Path) -> Vec<SkillCandidateRoot> {
let mut roots = Vec::new();
for ancestor in cwd.ancestors() {
push_skill_candidate_root(
&mut roots,
ancestor.join(".codex").join("skills"),
SkillRootKind::Skills,
None,
);
push_skill_candidate_root(
&mut roots,
ancestor.join(".claw").join("skills"),
SkillRootKind::Skills,
None,
);
push_skill_candidate_root(
&mut roots,
ancestor.join(".codex").join("commands"),
SkillRootKind::LegacyCommands,
None,
);
push_skill_candidate_root(
&mut roots,
ancestor.join(".claw").join("commands"),
SkillRootKind::LegacyCommands,
None,
);
}
if let Ok(codex_home) = std::env::var("CODEX_HOME") { if let Ok(codex_home) = std::env::var("CODEX_HOME") {
candidates.push(std::path::PathBuf::from(codex_home).join("skills")); let codex_home = PathBuf::from(codex_home);
push_skill_candidate_root(
&mut roots,
codex_home.join("skills"),
SkillRootKind::Skills,
None,
);
push_skill_candidate_root(
&mut roots,
codex_home.join("commands"),
SkillRootKind::LegacyCommands,
None,
);
} }
if let Ok(home) = std::env::var("HOME") { if let Ok(home) = std::env::var("HOME") {
let home = std::path::PathBuf::from(home); let home = PathBuf::from(home);
candidates.push(home.join(".agents").join("skills")); push_skill_candidate_root(
candidates.push(home.join(".config").join("opencode").join("skills")); &mut roots,
candidates.push(home.join(".codex").join("skills")); home.join(".agents").join("skills"),
SkillRootKind::Skills,
None,
);
push_skill_candidate_root(
&mut roots,
home.join(".config").join("opencode").join("skills"),
SkillRootKind::Skills,
None,
);
push_skill_candidate_root(
&mut roots,
home.join(".codex").join("skills"),
SkillRootKind::Skills,
None,
);
push_skill_candidate_root(
&mut roots,
home.join(".claw").join("skills"),
SkillRootKind::Skills,
None,
);
push_skill_candidate_root(
&mut roots,
home.join(".codex").join("commands"),
SkillRootKind::LegacyCommands,
None,
);
push_skill_candidate_root(
&mut roots,
home.join(".claw").join("commands"),
SkillRootKind::LegacyCommands,
None,
);
} }
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills")); push_skill_candidate_root(
&mut roots,
PathBuf::from("/home/bellman/.codex/skills"),
SkillRootKind::Skills,
None,
);
for root in candidates { roots
let direct = root.join(requested).join("SKILL.md"); }
if direct.exists() {
return Ok(direct);
}
if let Ok(entries) = std::fs::read_dir(&root) { fn extend_plugin_skill_candidate_roots(cwd: &Path, roots: &mut Vec<SkillCandidateRoot>) {
for entry in entries.flatten() { for plugin in enabled_plugins_for_cwd(cwd) {
let path = entry.path().join("SKILL.md"); let Some(root) = &plugin.metadata.root else {
if !path.exists() { continue;
continue; };
}
if entry push_skill_candidate_root(
.file_name() roots,
.to_string_lossy() root.join("skills"),
.eq_ignore_ascii_case(requested) SkillRootKind::Skills,
Some(plugin.metadata.name.clone()),
);
if let Ok(manifest) = load_plugin_from_directory(root) {
for relative in manifest.skills {
let path = resolve_plugin_component_path(root, &relative);
let kind = if path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{ {
return Ok(path); SkillRootKind::LegacyCommands
} } else {
SkillRootKind::Skills
};
push_skill_candidate_root(roots, path, kind, Some(plugin.metadata.name.clone()));
}
}
}
}
fn push_skill_candidate_root(
roots: &mut Vec<SkillCandidateRoot>,
path: PathBuf,
kind: SkillRootKind,
name_prefix: Option<String>,
) {
if path.exists() && !roots.iter().any(|existing| existing.path == path) {
roots.push(SkillCandidateRoot {
path,
kind,
name_prefix,
});
}
}
fn collect_skill_candidates(
root: &SkillCandidateRoot,
path: &Path,
candidates: &mut Vec<SkillCandidate>,
) -> std::io::Result<()> {
if path.is_file() {
if let Some(candidate) = load_skill_candidate(root, path, &root.path)? {
candidates.push(candidate);
}
return Ok(());
}
let skill_md = path.join("SKILL.md");
if skill_md.is_file() {
if let Some(candidate) = load_skill_candidate(root, &skill_md, &root.path)? {
candidates.push(candidate);
}
return Ok(());
}
let mut entries = std::fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let entry_path = entry.path();
if entry_path.is_dir() {
collect_skill_candidates(root, &entry_path, candidates)?;
} else if root.kind == SkillRootKind::LegacyCommands {
if let Some(candidate) = load_skill_candidate(root, &entry_path, &root.path)? {
candidates.push(candidate);
} }
} }
} }
Err(format!("unknown skill: {requested}")) Ok(())
}
fn load_skill_candidate(
root: &SkillCandidateRoot,
path: &Path,
base_root: &Path,
) -> std::io::Result<Option<SkillCandidate>> {
if !path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
return Ok(None);
}
let is_skill_file = path
.file_name()
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case("SKILL.md"));
if root.kind == SkillRootKind::Skills && !is_skill_file {
return Ok(None);
}
let name = skill_candidate_name(root, path, base_root, is_skill_file);
Ok(Some(SkillCandidate {
name,
path: path.to_path_buf(),
}))
}
fn skill_candidate_name(
root: &SkillCandidateRoot,
path: &Path,
base_root: &Path,
is_skill_file: bool,
) -> String {
let base_name = if is_skill_file {
path.parent().and_then(Path::file_name).map_or_else(
|| fallback_file_stem(path),
|segment| segment.to_string_lossy().to_string(),
)
} else {
fallback_file_stem(path)
};
prefixed_definition_name(
root.name_prefix.as_deref(),
namespace_for_file(path, base_root, is_skill_file),
&base_name,
)
}
fn namespace_for_file(path: &Path, base_root: &Path, is_skill_file: bool) -> Option<String> {
let relative_parent = if is_skill_file {
path.parent()
.and_then(Path::parent)
.and_then(|parent| parent.strip_prefix(base_root).ok())
} else {
path.parent()
.and_then(|parent| parent.strip_prefix(base_root).ok())
}?;
let segments = relative_parent
.iter()
.map(|segment| segment.to_string_lossy())
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_string())
.collect::<Vec<_>>();
(!segments.is_empty()).then(|| segments.join(":"))
}
fn prefixed_definition_name(
prefix: Option<&str>,
namespace: Option<String>,
base_name: &str,
) -> String {
let mut parts = Vec::new();
if let Some(prefix) = prefix.filter(|prefix| !prefix.is_empty()) {
parts.push(prefix.to_string());
}
if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) {
parts.push(namespace);
}
parts.push(base_name.to_string());
parts.join(":")
}
fn fallback_file_stem(path: &Path) -> String {
path.file_stem()
.map_or_else(String::new, |stem| stem.to_string_lossy().to_string())
}
fn enabled_plugins_for_cwd(cwd: &Path) -> Vec<PluginSummary> {
let Some(manager) = plugin_manager_for_cwd(cwd) else {
return Vec::new();
};
manager
.list_installed_plugins()
.map(|plugins| {
plugins
.into_iter()
.filter(|plugin| plugin.enabled)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn plugin_manager_for_cwd(cwd: &Path) -> Option<PluginManager> {
let loader = ConfigLoader::default_for(cwd);
let runtime_config = loader.load().ok()?;
let plugin_settings = runtime_config.plugins();
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
plugin_config.external_dirs = plugin_settings
.external_directories()
.iter()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
.collect();
plugin_config.install_root = plugin_settings
.install_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.registry_path = plugin_settings
.registry_path()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.bundled_root = plugin_settings
.bundled_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
Some(PluginManager::new(plugin_config))
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = Path::new(value);
if path.is_absolute() {
path.to_path_buf()
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
}
fn resolve_plugin_component_path(root: &Path, value: &str) -> PathBuf {
let path = Path::new(value);
if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
}
} }
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6"; const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
@@ -3092,6 +3446,27 @@ mod tests {
std::env::temp_dir().join(format!("claw-tools-{unique}-{name}")) std::env::temp_dir().join(format!("claw-tools-{unique}-{name}"))
} }
fn write_skill(root: &std::path::Path, name: &str, description: &str) {
let skill_root = root.join(name);
fs::create_dir_all(&skill_root).expect("skill root");
fs::write(
skill_root.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("write skill");
}
fn write_plugin_manifest(root: &std::path::Path, name: &str, extra_fields: &str) {
fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir");
fs::write(
root.join(".claw-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"test plugin\"{extra_fields}\n}}"
),
)
.expect("write plugin manifest");
}
#[test] #[test]
fn exposes_mvp_tools() { fn exposes_mvp_tools() {
let names = mvp_tool_specs() let names = mvp_tool_specs()
@@ -3488,6 +3863,103 @@ mod tests {
.ends_with("/help/SKILL.md")); .ends_with("/help/SKILL.md"));
} }
#[test]
fn skill_resolves_namespaced_plugin_skill_by_unique_suffix() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let workspace = temp_path("skill-plugin-workspace");
let config_home = temp_path("skill-plugin-home");
let install_root = config_home.join("plugins").join("installed");
let plugin_root = install_root.join("demo-plugin");
fs::create_dir_all(&config_home).expect("config home");
fs::write(
config_home.join("settings.json"),
r#"{"plugins":{"enabled":{"demo-plugin@external":true}}}"#,
)
.expect("write settings");
write_plugin_manifest(&plugin_root, "demo-plugin", ",\n \"defaultEnabled\": true");
write_skill(
&plugin_root.join("skills").join("ops"),
"review",
"Plugin review guidance",
);
fs::create_dir_all(&workspace).expect("workspace");
let previous_cwd = std::env::current_dir().expect("cwd");
let previous_claw_config_home = std::env::var_os("CLAW_CONFIG_HOME");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::set_current_dir(&workspace).expect("set cwd");
let result = execute_tool("Skill", &json!({ "skill": "review" }))
.expect("plugin skill should resolve");
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
let expected_path = plugin_root
.join("skills/ops/review/SKILL.md")
.display()
.to_string();
assert_eq!(output["path"].as_str(), Some(expected_path.as_str()));
std::env::set_current_dir(previous_cwd).expect("restore cwd");
if let Some(value) = previous_claw_config_home {
std::env::set_var("CLAW_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAW_CONFIG_HOME");
}
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);
}
#[test]
fn skill_reports_ambiguous_bare_name_for_multiple_namespaced_matches() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let workspace = temp_path("skill-ambiguous-workspace");
let config_home = temp_path("skill-ambiguous-home");
let install_root = config_home.join("plugins").join("installed");
let plugin_root = install_root.join("demo-plugin");
fs::create_dir_all(&config_home).expect("config home");
fs::write(
config_home.join("settings.json"),
r#"{"plugins":{"enabled":{"demo-plugin@external":true}}}"#,
)
.expect("write settings");
write_skill(
&workspace.join(".codex").join("skills").join("ops"),
"review",
"Local review",
);
write_plugin_manifest(&plugin_root, "demo-plugin", ",\n \"defaultEnabled\": true");
write_skill(
&plugin_root.join("skills").join("ops"),
"review",
"Plugin review guidance",
);
let previous_cwd = std::env::current_dir().expect("cwd");
let previous_claw_config_home = std::env::var_os("CLAW_CONFIG_HOME");
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::set_current_dir(&workspace).expect("set cwd");
let error = execute_tool("Skill", &json!({ "skill": "review" }))
.expect_err("review should be ambiguous");
assert!(error.contains("ambiguous skill `review`"));
assert!(error.contains("ops:review"));
assert!(error.contains("demo-plugin:ops:review"));
std::env::set_current_dir(previous_cwd).expect("restore cwd");
if let Some(value) = previous_claw_config_home {
std::env::set_var("CLAW_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAW_CONFIG_HOME");
}
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);
}
#[test] #[test]
fn tool_search_supports_keyword_and_select_queries() { fn tool_search_supports_keyword_and_select_queries() {
let keyword = execute_tool( let keyword = execute_tool(