mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 08:04:50 +08:00
Compare commits
5 Commits
feat/relea
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded9057ed9 | ||
|
|
95e1290d23 | ||
|
|
9415d9c9af | ||
|
|
a121285a0e | ||
|
|
c0d30934e7 |
@@ -244,6 +244,14 @@ pub struct LineEditor {
|
||||
history: Vec<String>,
|
||||
yank_buffer: YankBuffer,
|
||||
vim_enabled: bool,
|
||||
completion_state: Option<CompletionState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CompletionState {
|
||||
prefix: String,
|
||||
matches: Vec<String>,
|
||||
next_index: usize,
|
||||
}
|
||||
|
||||
impl LineEditor {
|
||||
@@ -255,6 +263,7 @@ impl LineEditor {
|
||||
history: Vec::new(),
|
||||
yank_buffer: YankBuffer::default(),
|
||||
vim_enabled: false,
|
||||
completion_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +366,10 @@ impl LineEditor {
|
||||
}
|
||||
|
||||
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) {
|
||||
match key.code {
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => {
|
||||
@@ -673,22 +686,62 @@ impl LineEditor {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
|
||||
self.completion_state = None;
|
||||
return;
|
||||
};
|
||||
let Some(candidate) = self
|
||||
let matches = self
|
||||
.completions
|
||||
.iter()
|
||||
.find(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
|
||||
else {
|
||||
.filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if matches.is_empty() {
|
||||
self.completion_state = None;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1086,7 +1139,7 @@ mod tests {
|
||||
#[test]
|
||||
fn tab_completes_matching_slash_commands() {
|
||||
// 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);
|
||||
session.text = "/he".to_string();
|
||||
session.cursor = session.text.len();
|
||||
@@ -1099,6 +1152,29 @@ mod tests {
|
||||
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]
|
||||
fn ctrl_c_cancels_when_input_exists() {
|
||||
// given
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -16,14 +16,15 @@ use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use api::{
|
||||
resolve_startup_auth_source, ClawApiClient, AuthSource, ContentBlockDelta, InputContentBlock,
|
||||
resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock,
|
||||
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
};
|
||||
|
||||
use commands::{
|
||||
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 init::initialize_repo;
|
||||
@@ -59,15 +60,25 @@ type AllowedToolSet = BTreeSet<String>;
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!(
|
||||
"error: {error}
|
||||
|
||||
Run `claw --help` for usage."
|
||||
);
|
||||
eprintln!("{}", render_cli_error(&error.to_string()));
|
||||
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>> {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
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::Agents { args }) => Ok(CliAction::Agents { args }),
|
||||
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
||||
Some(command) => Err(format!(
|
||||
"unsupported direct slash command outside the REPL: {command_name}",
|
||||
command_name = match command {
|
||||
Some(command) => Err(format_direct_slash_command_error(
|
||||
match &command {
|
||||
SlashCommand::Unknown(name) => format!("/{name}"),
|
||||
_ => rest[0].clone(),
|
||||
}
|
||||
.as_str(),
|
||||
matches!(command, SlashCommand::Unknown(_)),
|
||||
)),
|
||||
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 {
|
||||
match model {
|
||||
"opus" => "claude-opus-4-6",
|
||||
@@ -670,13 +700,17 @@ struct StatusUsage {
|
||||
fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
|
||||
format!(
|
||||
"Model
|
||||
Current model {model}
|
||||
Session messages {message_count}
|
||||
Session turns {turns}
|
||||
Current {model}
|
||||
Session {message_count} messages · {turns} turns
|
||||
|
||||
Usage
|
||||
Inspect current model with /model
|
||||
Switch models with /model <name>"
|
||||
Aliases
|
||||
opus claude-opus-4-6
|
||||
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
|
||||
Previous {previous}
|
||||
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!(
|
||||
"Permissions
|
||||
Active mode {mode}
|
||||
Mode status live session default
|
||||
Effect {effect}
|
||||
|
||||
Modes
|
||||
{modes}
|
||||
|
||||
Usage
|
||||
Inspect current mode with /permissions
|
||||
Switch modes with /permissions <mode>"
|
||||
Next
|
||||
/permissions Show the current mode
|
||||
/permissions <mode> Switch modes for subsequent tool calls"
|
||||
)
|
||||
}
|
||||
|
||||
fn format_permissions_switch_report(previous: &str, next: &str) -> String {
|
||||
format!(
|
||||
"Permissions updated
|
||||
Result mode switched
|
||||
Previous mode {previous}
|
||||
Active mode {next}
|
||||
Applies to subsequent tool calls
|
||||
Usage /permissions to inspect current mode"
|
||||
Applies to Subsequent tool calls in this REPL
|
||||
Tip Run /permissions to review all available modes"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -750,7 +791,11 @@ fn format_cost_report(usage: TokenUsage) -> String {
|
||||
Output tokens {}
|
||||
Cache create {}
|
||||
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.output_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
@@ -763,8 +808,8 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
|
||||
format!(
|
||||
"Session resumed
|
||||
Session file {session_path}
|
||||
Messages {message_count}
|
||||
Turns {turns}"
|
||||
History {message_count} messages · {turns} turns
|
||||
Next /status · /diff · /export"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -773,7 +818,7 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
|
||||
format!(
|
||||
"Compact
|
||||
Result skipped
|
||||
Reason session below compaction threshold
|
||||
Reason Session is already below the compaction threshold
|
||||
Messages kept {resulting_messages}"
|
||||
)
|
||||
} else {
|
||||
@@ -781,7 +826,8 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
|
||||
"Compact
|
||||
Result compacted
|
||||
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 {
|
||||
match editor.read_line()? {
|
||||
input::ReadOutcome::Submit(input) => {
|
||||
let trimmed = input.trim().to_string();
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
||||
if matches!(trimmed, "/exit" | "/quit") {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
if let Some(command) = SlashCommand::parse(&trimmed) {
|
||||
if let Some(command) = SlashCommand::parse(trimmed) {
|
||||
if cli.handle_repl_command(command)? {
|
||||
cli.persist_session()?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
editor.push_history(input);
|
||||
cli.run_turn(&trimmed)?;
|
||||
editor.push_history(&input);
|
||||
cli.run_turn(&input)?;
|
||||
}
|
||||
input::ReadOutcome::Cancel => {}
|
||||
input::ReadOutcome::Exit => {
|
||||
@@ -1052,28 +1098,65 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn startup_banner(&self) -> String {
|
||||
let cwd = env::current_dir().map_or_else(
|
||||
|_| "<unknown>".to_string(),
|
||||
let color = io::stdout().is_terminal();
|
||||
let cwd = env::current_dir().ok();
|
||||
let cwd_display = cwd.as_ref().map_or_else(
|
||||
|| "<unknown>".to_string(),
|
||||
|path| path.display().to_string(),
|
||||
);
|
||||
format!(
|
||||
"\x1b[38;5;196m\
|
||||
██████╗██╗ █████╗ ██╗ ██╗\n\
|
||||
██╔════╝██║ ██╔══██╗██║ ██║\n\
|
||||
██║ ██║ ███████║██║ █╗ ██║\n\
|
||||
██║ ██║ ██╔══██║██║███╗██║\n\
|
||||
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
|
||||
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
|
||||
\x1b[2mModel\x1b[0m {}\n\
|
||||
\x1b[2mPermissions\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",
|
||||
self.model,
|
||||
self.permission_mode.as_str(),
|
||||
cwd,
|
||||
self.session.id,
|
||||
)
|
||||
let workspace_name = cwd
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("workspace");
|
||||
let git_branch = status_context(Some(&self.session.path))
|
||||
.ok()
|
||||
.and_then(|context| context.git_branch);
|
||||
let workspace_summary = git_branch.as_deref().map_or_else(
|
||||
|| workspace_name.to_string(),
|
||||
|branch| format!("{workspace_name} · {branch}"),
|
||||
);
|
||||
let has_claw_md = cwd
|
||||
.as_ref()
|
||||
.is_some_and(|path| path.join("CLAW.md").is_file());
|
||||
let mut lines = vec![
|
||||
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>> {
|
||||
@@ -1246,19 +1329,28 @@ impl LiveCli {
|
||||
false
|
||||
}
|
||||
SlashCommand::Branch { .. } => {
|
||||
eprintln!("git branch commands not yet wired to REPL");
|
||||
eprintln!(
|
||||
"{}",
|
||||
render_mode_unavailable("branch", "git branch commands")
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::Worktree { .. } => {
|
||||
eprintln!("git worktree commands not yet wired to REPL");
|
||||
eprintln!(
|
||||
"{}",
|
||||
render_mode_unavailable("worktree", "git worktree commands")
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::CommitPushPr { .. } => {
|
||||
eprintln!("commit-push-pr not yet wired to REPL");
|
||||
eprintln!(
|
||||
"{}",
|
||||
render_mode_unavailable("commit-push-pr", "commit + push + PR automation")
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::Unknown(name) => {
|
||||
eprintln!("unknown slash command: /{name}");
|
||||
eprintln!("{}", render_unknown_repl_command(&name));
|
||||
false
|
||||
}
|
||||
})
|
||||
@@ -1837,6 +1929,20 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
||||
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>> {
|
||||
let sessions = list_managed_sessions()?;
|
||||
let mut lines = vec![
|
||||
@@ -1854,26 +1960,28 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
||||
"○ saved"
|
||||
};
|
||||
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,
|
||||
msgs = session.message_count,
|
||||
modified = session.modified_epoch_secs,
|
||||
path = session.path.display(),
|
||||
modified = format_relative_timestamp(session.modified_epoch_secs),
|
||||
));
|
||||
lines.push(format!(" {}", session.path.display()));
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn render_repl_help() -> String {
|
||||
[
|
||||
"REPL".to_string(),
|
||||
" /exit Quit the REPL".to_string(),
|
||||
" /quit Quit the REPL".to_string(),
|
||||
" /vim Toggle Vim keybindings".to_string(),
|
||||
" Up/Down Navigate prompt history".to_string(),
|
||||
" Tab Complete slash commands".to_string(),
|
||||
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
||||
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
||||
"Interactive REPL".to_string(),
|
||||
" Quick start Ask a task in plain English or use one of the core commands below."
|
||||
.to_string(),
|
||||
" Core commands /help · /status · /model · /permissions · /compact".to_string(),
|
||||
" Exit /exit or /quit".to_string(),
|
||||
" Vim mode /vim toggles modal editing".to_string(),
|
||||
" History Up/Down recalls previous prompts".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(),
|
||||
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(
|
||||
session_path: Option<&Path>,
|
||||
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
||||
@@ -1912,33 +2071,41 @@ fn format_status_report(
|
||||
) -> String {
|
||||
[
|
||||
format!(
|
||||
"Status
|
||||
"Session
|
||||
Model {model}
|
||||
Permission mode {permission_mode}
|
||||
Messages {}
|
||||
Turns {}
|
||||
Estimated tokens {}",
|
||||
usage.message_count, usage.turns, usage.estimated_tokens,
|
||||
),
|
||||
format!(
|
||||
"Usage
|
||||
Latest total {}
|
||||
Cumulative input {}
|
||||
Cumulative output {}
|
||||
Cumulative total {}",
|
||||
Permissions {permission_mode}
|
||||
Activity {} messages · {} turns
|
||||
Tokens est {} · latest {} · total {}",
|
||||
usage.message_count,
|
||||
usage.turns,
|
||||
usage.estimated_tokens,
|
||||
usage.latest.total_tokens(),
|
||||
usage.cumulative.input_tokens,
|
||||
usage.cumulative.output_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!(
|
||||
"Workspace
|
||||
Cwd {}
|
||||
Folder {}
|
||||
Project root {}
|
||||
Git branch {}
|
||||
Session {}
|
||||
Session file {}
|
||||
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
|
||||
.project_root
|
||||
@@ -2053,8 +2220,7 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
if project_context.instruction_files.is_empty() {
|
||||
lines.push("Discovered files".to_string());
|
||||
lines.push(
|
||||
" No CLAW instruction files discovered in the current directory ancestry."
|
||||
.to_string(),
|
||||
" No CLAW instruction files discovered in the current directory ancestry.".to_string(),
|
||||
);
|
||||
} else {
|
||||
lines.push("Discovered files".to_string());
|
||||
@@ -2321,7 +2487,7 @@ fn render_version_report() -> String {
|
||||
let git_sha = GIT_SHA.unwrap_or("unknown");
|
||||
let target = BUILD_TARGET.unwrap_or("unknown");
|
||||
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>,
|
||||
permission_mode: PermissionMode,
|
||||
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()?;
|
||||
Ok(ConversationRuntime::new_with_features(
|
||||
session,
|
||||
@@ -3126,10 +3293,70 @@ fn slash_command_completion_candidates() -> Vec<String> {
|
||||
.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
|
||||
}
|
||||
|
||||
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 {
|
||||
let parsed: serde_json::Value =
|
||||
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<()> {
|
||||
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, "Usage:")?;
|
||||
writeln!(out, "Quick start")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
|
||||
)?;
|
||||
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] [...]"
|
||||
" claw Start the interactive REPL"
|
||||
)?;
|
||||
writeln!(
|
||||
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 login")?;
|
||||
writeln!(out, " claw logout")?;
|
||||
writeln!(out, " claw init")?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Flags:")?;
|
||||
writeln!(
|
||||
out,
|
||||
" --model MODEL Override the active model"
|
||||
" claw login Start the OAuth login flow"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --output-format FORMAT Non-interactive output format: text or json"
|
||||
" claw logout Clear saved OAuth credentials"
|
||||
)?;
|
||||
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 locally"
|
||||
" claw init Scaffold CLAW.md + local files"
|
||||
)?;
|
||||
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)?;
|
||||
let resume_commands = resume_supported_slash_commands()
|
||||
@@ -3800,7 +4080,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
writeln!(out, "Resume-safe commands: {resume_commands}")?;
|
||||
writeln!(out, "Examples:")?;
|
||||
writeln!(out, "Examples")?;
|
||||
writeln!(out, " claw --model opus \"summarize this repo\"")?;
|
||||
writeln!(
|
||||
out,
|
||||
@@ -3834,9 +4114,10 @@ mod tests {
|
||||
format_status_report, format_tool_call_start, format_tool_result,
|
||||
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
|
||||
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,
|
||||
status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent,
|
||||
InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
||||
resume_supported_slash_commands, slash_command_completion_candidates, status_context,
|
||||
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
|
||||
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
};
|
||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
||||
@@ -4072,7 +4353,8 @@ mod tests {
|
||||
);
|
||||
let error = parse_args(&["/status".to_string()])
|
||||
.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]
|
||||
@@ -4149,13 +4431,14 @@ mod tests {
|
||||
fn shared_help_uses_resume_annotation_copy() {
|
||||
let help = commands::render_slash_command_help();
|
||||
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]
|
||||
fn repl_help_includes_shared_commands_and_exit() {
|
||||
let help = render_repl_help();
|
||||
assert!(help.contains("REPL"));
|
||||
assert!(help.contains("Interactive REPL"));
|
||||
assert!(help.contains("/help"));
|
||||
assert!(help.contains("/status"));
|
||||
assert!(help.contains("/model [model]"));
|
||||
@@ -4177,6 +4460,24 @@ mod tests {
|
||||
assert!(help.contains("/agents"));
|
||||
assert!(help.contains("/skills"));
|
||||
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]
|
||||
@@ -4199,8 +4500,8 @@ mod tests {
|
||||
let report = format_resume_report("session.json", 14, 6);
|
||||
assert!(report.contains("Session resumed"));
|
||||
assert!(report.contains("Session file session.json"));
|
||||
assert!(report.contains("Messages 14"));
|
||||
assert!(report.contains("Turns 6"));
|
||||
assert!(report.contains("History 14 messages · 6 turns"));
|
||||
assert!(report.contains("/status · /diff · /export"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4209,6 +4510,7 @@ mod tests {
|
||||
assert!(compacted.contains("Compact"));
|
||||
assert!(compacted.contains("Result compacted"));
|
||||
assert!(compacted.contains("Messages removed 8"));
|
||||
assert!(compacted.contains("Use /status"));
|
||||
let skipped = format_compact_report(0, 3, true);
|
||||
assert!(skipped.contains("Result skipped"));
|
||||
}
|
||||
@@ -4227,6 +4529,7 @@ mod tests {
|
||||
assert!(report.contains("Cache create 3"));
|
||||
assert!(report.contains("Cache read 1"));
|
||||
assert!(report.contains("Total tokens 32"));
|
||||
assert!(report.contains("/compact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4234,6 +4537,7 @@ mod tests {
|
||||
let report = format_permissions_report("workspace-write");
|
||||
assert!(report.contains("Permissions"));
|
||||
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("read-only ○ available Read/search tools only"));
|
||||
assert!(report.contains("workspace-write ● current Edit files inside the workspace"));
|
||||
@@ -4244,10 +4548,9 @@ mod tests {
|
||||
fn permissions_switch_report_is_structured() {
|
||||
let report = format_permissions_switch_report("read-only", "workspace-write");
|
||||
assert!(report.contains("Permissions updated"));
|
||||
assert!(report.contains("Result mode switched"));
|
||||
assert!(report.contains("Previous mode read-only"));
|
||||
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]
|
||||
@@ -4265,9 +4568,10 @@ mod tests {
|
||||
fn model_report_uses_sectioned_layout() {
|
||||
let report = format_model_report("sonnet", 12, 4);
|
||||
assert!(report.contains("Model"));
|
||||
assert!(report.contains("Current model sonnet"));
|
||||
assert!(report.contains("Session messages 12"));
|
||||
assert!(report.contains("Switch models with /model <name>"));
|
||||
assert!(report.contains("Current sonnet"));
|
||||
assert!(report.contains("Session 12 messages · 4 turns"));
|
||||
assert!(report.contains("Aliases"));
|
||||
assert!(report.contains("/model <name> Switch models for this REPL session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4276,7 +4580,7 @@ mod tests {
|
||||
assert!(report.contains("Model updated"));
|
||||
assert!(report.contains("Previous sonnet"));
|
||||
assert!(report.contains("Current opus"));
|
||||
assert!(report.contains("Preserved msgs 9"));
|
||||
assert!(report.contains("Preserved 9 messages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4311,18 +4615,18 @@ mod tests {
|
||||
git_branch: Some("main".to_string()),
|
||||
},
|
||||
);
|
||||
assert!(status.contains("Status"));
|
||||
assert!(status.contains("Session"));
|
||||
assert!(status.contains("Model sonnet"));
|
||||
assert!(status.contains("Permission mode workspace-write"));
|
||||
assert!(status.contains("Messages 7"));
|
||||
assert!(status.contains("Latest total 10"));
|
||||
assert!(status.contains("Cumulative total 31"));
|
||||
assert!(status.contains("Cwd /tmp/project"));
|
||||
assert!(status.contains("Permissions workspace-write"));
|
||||
assert!(status.contains("Activity 7 messages · 3 turns"));
|
||||
assert!(status.contains("Tokens est 128 · latest 10 · total 31"));
|
||||
assert!(status.contains("Folder /tmp/project"));
|
||||
assert!(status.contains("Project root /tmp"));
|
||||
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("Memory files 4"));
|
||||
assert!(status.contains("/session list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4458,8 +4762,8 @@ mod tests {
|
||||
fn repl_help_mentions_history_completion_and_multiline() {
|
||||
let help = render_repl_help();
|
||||
assert!(help.contains("Up/Down"));
|
||||
assert!(help.contains("Tab"));
|
||||
assert!(help.contains("Shift+Enter/Ctrl+J"));
|
||||
assert!(help.contains("Tab cycles"));
|
||||
assert!(help.contains("Shift+Enter or Ctrl+J"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,10 @@ pub struct PluginManifest {
|
||||
pub tools: Vec<PluginToolManifest>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -228,6 +232,10 @@ struct RawPluginManifest {
|
||||
pub tools: Vec<RawPluginToolManifest>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -246,6 +254,24 @@ struct RawPluginToolManifest {
|
||||
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)]
|
||||
pub struct PluginTool {
|
||||
plugin_id: String,
|
||||
@@ -1461,6 +1487,8 @@ fn build_plugin_manifest(
|
||||
"lifecycle command",
|
||||
&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 commands = build_manifest_commands(root, raw.commands, &mut errors);
|
||||
|
||||
@@ -1478,6 +1506,8 @@ fn build_plugin_manifest(
|
||||
lifecycle: raw.lifecycle,
|
||||
tools,
|
||||
commands,
|
||||
agents,
|
||||
skills,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1593,6 +1623,47 @@ fn build_manifest_tools(
|
||||
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(
|
||||
root: &Path,
|
||||
commands: Vec<PluginCommandManifest>,
|
||||
@@ -2227,6 +2298,38 @@ mod tests {
|
||||
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]
|
||||
fn load_plugin_from_directory_defaults_optional_fields() {
|
||||
let root = temp_dir("manifest-defaults");
|
||||
|
||||
@@ -8,13 +8,15 @@ use api::{
|
||||
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
};
|
||||
use plugins::PluginTool;
|
||||
use plugins::{
|
||||
load_plugin_from_directory, PluginManager, PluginManagerConfig, PluginSummary, PluginTool,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use runtime::{
|
||||
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
|
||||
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
|
||||
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
|
||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
||||
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
|
||||
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
|
||||
PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -91,7 +93,10 @@ impl GlobalToolRegistry {
|
||||
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() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -100,7 +105,11 @@ impl GlobalToolRegistry {
|
||||
let canonical_names = builtin_specs
|
||||
.iter()
|
||||
.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<_>>();
|
||||
let mut name_map = canonical_names
|
||||
.iter()
|
||||
@@ -151,7 +160,8 @@ impl GlobalToolRegistry {
|
||||
.plugin_tools
|
||||
.iter()
|
||||
.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 {
|
||||
name: tool.definition().name.clone(),
|
||||
@@ -174,7 +184,8 @@ impl GlobalToolRegistry {
|
||||
.plugin_tools
|
||||
.iter()
|
||||
.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| {
|
||||
(
|
||||
@@ -1454,48 +1465,391 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
||||
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> {
|
||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||
if requested.is_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();
|
||||
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") {
|
||||
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") {
|
||||
let home = std::path::PathBuf::from(home);
|
||||
candidates.push(home.join(".agents").join("skills"));
|
||||
candidates.push(home.join(".config").join("opencode").join("skills"));
|
||||
candidates.push(home.join(".codex").join("skills"));
|
||||
let home = PathBuf::from(home);
|
||||
push_skill_candidate_root(
|
||||
&mut roots,
|
||||
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 {
|
||||
let direct = root.join(requested).join("SKILL.md");
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&root) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("SKILL.md");
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(requested)
|
||||
fn extend_plugin_skill_candidate_roots(cwd: &Path, roots: &mut Vec<SkillCandidateRoot>) {
|
||||
for plugin in enabled_plugins_for_cwd(cwd) {
|
||||
let Some(root) = &plugin.metadata.root else {
|
||||
continue;
|
||||
};
|
||||
|
||||
push_skill_candidate_root(
|
||||
roots,
|
||||
root.join("skills"),
|
||||
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";
|
||||
@@ -3092,6 +3446,27 @@ mod tests {
|
||||
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]
|
||||
fn exposes_mvp_tools() {
|
||||
let names = mvp_tool_specs()
|
||||
@@ -3488,6 +3863,103 @@ mod tests {
|
||||
.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]
|
||||
fn tool_search_supports_keyword_and_select_queries() {
|
||||
let keyword = execute_tool(
|
||||
|
||||
Reference in New Issue
Block a user