mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
Compare commits
8 Commits
fix/plugin
...
fix/ui-par
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa81684707 | ||
|
|
b8d78c9a53 | ||
|
|
a2f22b1ece | ||
|
|
05bcf6751b | ||
|
|
fdd06e814b | ||
|
|
8599bac67b | ||
|
|
06ee5a2dc4 | ||
|
|
bcaf6e0771 |
48
PARITY.md
48
PARITY.md
@@ -59,15 +59,18 @@ Evidence:
|
|||||||
### Rust exists
|
### Rust exists
|
||||||
Evidence:
|
Evidence:
|
||||||
- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`.
|
- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`.
|
||||||
- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`.
|
- Shell-command `PreToolUse` / `PostToolUse` hooks execute via `rust/crates/runtime/src/hooks.rs`.
|
||||||
|
- Conversation runtime runs pre/post hooks around tool execution in `rust/crates/runtime/src/conversation.rs`.
|
||||||
|
- Hook config can now be inspected through a dedicated Rust `/hooks` report in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`.
|
||||||
- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`.
|
- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`.
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`.
|
- No TS-style matcher-based hook config model; Rust only supports merged string command lists under `settings.hooks.PreToolUse` and `PostToolUse`.
|
||||||
- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior.
|
- No TS-style prompt/agent/http hook types, `PostToolUseFailure`, `PermissionDenied`, or richer hook lifecycle surfaces.
|
||||||
- No Rust `/hooks` parity command.
|
- No TS-equivalent interactive `/hooks` browser/editor; Rust currently provides inspection/reporting only.
|
||||||
|
- No PreToolUse/PostToolUse input rewrite, MCP-output mutation, or continuation-stop behavior beyond allow/deny plus feedback text.
|
||||||
|
|
||||||
**Status:** config-only; runtime behavior missing.
|
**Status:** basic shell hook runtime plus `/hooks` inspection; richer TS hook model still missing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,16 +84,19 @@ Evidence:
|
|||||||
|
|
||||||
### Rust exists
|
### Rust exists
|
||||||
Evidence:
|
Evidence:
|
||||||
- No dedicated plugin subsystem appears under `rust/crates/`.
|
- Local plugin manifests, registry/state, install/update/uninstall flows, and bundled/external discovery live in `rust/crates/plugins/src/lib.rs`.
|
||||||
- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions.
|
- Runtime config parses plugin settings (`enabledPlugins`, external directories, install root, registry path, bundled root) in `rust/crates/runtime/src/config.rs`.
|
||||||
|
- CLI wiring builds a `PluginManager`, exposes `/plugin` inspection/reporting, and now exposes `/reload-plugins` runtime rebuild/reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`.
|
||||||
|
- Plugin-provided tools are merged into the runtime tool registry in `rust/crates/claw-cli/src/main.rs` and `rust/crates/tools/src/lib.rs`.
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No plugin loader.
|
- No TS-style marketplace/discovery/editor UI; current surfaces are local manifest/reporting oriented.
|
||||||
- No marketplace install/update/enable/disable flow.
|
- Plugin-defined slash commands are validated from manifests but not exposed in the CLI runtime.
|
||||||
- No `/plugin` or `/reload-plugins` parity.
|
- Plugin hooks and lifecycle commands are validated but not wired into the conversation runtime startup/shutdown or hook runner.
|
||||||
- No plugin-provided hook/tool/command/MCP extension path.
|
- No plugin-provided MCP/server extension path.
|
||||||
|
- `/reload-plugins` only rebuilds the current local runtime; it is not a richer TS hot-reload/plugin-browser flow.
|
||||||
|
|
||||||
**Status:** missing.
|
**Status:** local plugin discovery/install/inspection exists; TS marketplace/runtime-extension parity is still partial.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,18 +110,18 @@ Evidence:
|
|||||||
|
|
||||||
### Rust exists
|
### Rust exists
|
||||||
Evidence:
|
Evidence:
|
||||||
- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files.
|
- `Skill` tool in `rust/crates/tools/src/lib.rs` now resolves workspace-local `.codex/.claw` skills plus legacy `/commands` entries through shared runtime discovery.
|
||||||
|
- `/skills` exists in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`, listing discoverable local skills and checked skill directories in the current workspace context.
|
||||||
- CLAW.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`.
|
- CLAW.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`.
|
||||||
- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`.
|
- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`.
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No bundled skill registry equivalent.
|
- No bundled skill registry equivalent.
|
||||||
- No `/skills` command.
|
|
||||||
- No MCP skill-builder pipeline.
|
- No MCP skill-builder pipeline.
|
||||||
- No TS-style live skill discovery/reload/change handling.
|
- No TS-style live skill discovery/reload/change handling.
|
||||||
- No comparable session-memory / team-memory integration around skills.
|
- No comparable session-memory / team-memory integration around skills.
|
||||||
|
|
||||||
**Status:** basic local skill loading only.
|
**Status:** local/workspace skill loading plus minimal `/skills` discovery; bundled/MCP parity still missing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,14 +136,14 @@ Evidence:
|
|||||||
### Rust exists
|
### Rust exists
|
||||||
Evidence:
|
Evidence:
|
||||||
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
|
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
|
||||||
- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`.
|
- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `hooks`, `memory`, `init`, `diff`, `version`, `export`, `session`, `plugin`, `reload-plugins`, `agents`, and `skills`.
|
||||||
- Main CLI/repl/prompt handling lives in `rust/crates/claw-cli/src/main.rs`.
|
- Main CLI/repl/prompt handling lives in `rust/crates/claw-cli/src/main.rs`.
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others.
|
- Missing major TS command families: `/hooks`, `/mcp`, `/plan`, `/review`, `/tasks`, and many others.
|
||||||
- No Rust equivalent to TS structured IO / remote transport layers.
|
- No Rust equivalent to TS structured IO / remote transport layers.
|
||||||
- No TS-style handler decomposition for auth/plugins/MCP/agents.
|
- No TS-style handler decomposition for auth/plugins/MCP/agents.
|
||||||
- JSON prompt mode is improved on this branch, but still not clean transport parity: empirical verification shows tool-capable JSON output can emit human-readable tool-result lines before the final JSON object.
|
- JSON prompt mode now maintains clean transport output in tool-capable runs; targeted CLI coverage should guard against regressions.
|
||||||
|
|
||||||
**Status:** functional local CLI core, much narrower than TS.
|
**Status:** functional local CLI core, much narrower than TS.
|
||||||
|
|
||||||
@@ -161,7 +167,7 @@ Evidence:
|
|||||||
- No TS-style hook-aware orchestration layer.
|
- No TS-style hook-aware orchestration layer.
|
||||||
- No TS structured/remote assistant transport stack.
|
- No TS structured/remote assistant transport stack.
|
||||||
- No richer TS assistant/session-history/background-task integration.
|
- No richer TS assistant/session-history/background-task integration.
|
||||||
- JSON output path is no longer single-turn only on this branch, but output cleanliness still lags TS transport expectations.
|
- JSON output path is no longer single-turn only on this branch, and tool-capable prompt output now stays transport-clean like the TypeScript behavior.
|
||||||
|
|
||||||
**Status:** strong core loop, missing orchestration layers.
|
**Status:** strong core loop, missing orchestration layers.
|
||||||
|
|
||||||
@@ -209,6 +215,6 @@ Evidence:
|
|||||||
- **Unlimited max_iterations**
|
- **Unlimited max_iterations**
|
||||||
- Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`.
|
- Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`.
|
||||||
|
|
||||||
### Remaining notable parity issue
|
### JSON prompt output cleanliness status
|
||||||
- **JSON prompt output cleanliness**
|
- **JSON prompt output cleanliness**
|
||||||
- Tool-capable JSON mode now loops, but empirical verification still shows pre-JSON human-readable tool-result output when tools fire.
|
- Verified clean in tool-capable prompt mode: stdout remains a single final JSON object when tools fire.
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -26,6 +26,27 @@
|
|||||||
<a href="https://github.com/sponsors/instructkr"><img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github&style=for-the-badge" alt="Sponsor on GitHub" /></a>
|
<a href="https://github.com/sponsors/instructkr"><img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github&style=for-the-badge" alt="Sponsor on GitHub" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
If you are staring at the generated files, you are looking at the wrong layer.
|
||||||
|
|
||||||
|
The Python rewrite was a byproduct, and the Rust port is too. The real subject of this repository is the **agent coordination system** that made both possible: a human giving direction in Discord, agents decomposing the work, implementing in parallel, reviewing each other, fixing failures, and shipping without constant babysitting.
|
||||||
|
|
||||||
|
That is the point of this project.
|
||||||
|
|
||||||
|
- **the code is evidence, not the product**
|
||||||
|
- **the system that produces the code is the thing worth studying**
|
||||||
|
- **architectural clarity, task decomposition, and coordination matter more than typing speed**
|
||||||
|
- **clean-room rebuilding is valuable because it exposes process, not because it preserves an archive**
|
||||||
|
- **the future of software work is better agent orchestration, not more manual pane babysitting**
|
||||||
|
|
||||||
|
This repository exists to document and improve that loop: direction from the human, execution by the agents, notifications routed outside the context window, and repeated verification until the result is good enough to ship.
|
||||||
|
|
||||||
|
In other words: **stop staring at the files**. Study the workflow that produced them.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **This repository is not affiliated with any coin, token, NFT, or crypto project.** It is software infrastructure work only, and any attempt to frame it as a cryptocurrency project is incorrect.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Rust port is now in progress** on the [`dev/rust`](https://github.com/instructkr/claw-code/tree/dev/rust) branch and is expected to be merged into main today. The Rust implementation aims to deliver a faster, memory-safe harness runtime. Stay tuned — this will be the definitive version of the project.
|
> **Rust port is now in progress** on the [`dev/rust`](https://github.com/instructkr/claw-code/tree/dev/rust) branch and is expected to be merged into main today. The Rust implementation aims to deliver a faster, memory-safe harness runtime. Stay tuned — this will be the definitive version of the project.
|
||||||
|
|
||||||
@@ -68,21 +89,33 @@ https://github.com/instructkr/claw-code
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## The Creators Featured in Wall Street Journal For Avid Claw Code Fans
|
## Related Projects
|
||||||
|
|
||||||
I've been deeply interested in **harness engineering** — studying how agent systems wire tools, orchestrate tasks, and manage runtime context. This isn't a sudden thing. The Wall Street Journal featured my work earlier this month, documenting how I've been one of the most active power users exploring these systems:
|
This repository sits inside a broader harness-engineering stack. If you want the surrounding tooling rather than only this port, start here:
|
||||||
|
|
||||||
> AI startup worker Sigrid Jin, who attended the Seoul dinner, single-handedly used 25 billion of Claw Code tokens last year. At the time, usage limits were looser, allowing early enthusiasts to reach tens of billions of tokens at a very low cost.
|
### oh-my-codex (OmX)
|
||||||
>
|
|
||||||
> Despite his countless hours with Claw Code, Jin isn't faithful to any one AI lab. The tools available have different strengths and weaknesses, he said. Codex is better at reasoning, while Claw Code generates cleaner, more shareable code.
|
|
||||||
>
|
|
||||||
> Jin flew to San Francisco in February for Claw Code's first birthday party, where attendees waited in line to compare notes with Cherny. The crowd included a practicing cardiologist from Belgium who had built an app to help patients navigate care, and a California lawyer who made a tool for automating building permit approvals using Claw Code.
|
|
||||||
>
|
|
||||||
> "It was basically like a sharing party," Jin said. "There were lawyers, there were doctors, there were dentists. They did not have software engineering backgrounds."
|
|
||||||
>
|
|
||||||
> — *The Wall Street Journal*, March 21, 2026, [*"The Trillion Dollar Race to Automate Our Entire Lives"*](https://lnkd.in/gs9td3qd)
|
|
||||||
|
|
||||||

|
[](https://github.com/Yeachan-Heo/oh-my-codex)
|
||||||
|
|
||||||
|
Primary orchestration layer for planning, delegation, verification loops, and long-running execution patterns such as `$team` and `$ralph`.
|
||||||
|
|
||||||
|
### oh-my-claudecode (OmC)
|
||||||
|
|
||||||
|
[](https://github.com/Yeachan-Heo/oh-my-claudecode)
|
||||||
|
|
||||||
|
Companion workflow layer for Claude Code-centered orchestration and multi-agent terminal workflows.
|
||||||
|
|
||||||
|
### clawhip
|
||||||
|
|
||||||
|
[](https://github.com/Yeachan-Heo/clawhip)
|
||||||
|
|
||||||
|
Event-to-channel routing for commits, PRs, issues, tmux sessions, and agent lifecycle updates — keeping monitoring traffic out of the active agent context window.
|
||||||
|
|
||||||
|
### oh-my-opencode (OmO)
|
||||||
|
|
||||||
|
[](https://github.com/code-yeongyu/oh-my-openagent)
|
||||||
|
|
||||||
|
Used here for later-pass implementation acceleration and verification support alongside OmX.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,31 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifier
|
|||||||
use crossterm::queue;
|
use crossterm::queue;
|
||||||
use crossterm::terminal::{self, Clear, ClearType};
|
use crossterm::terminal::{self, Clear, ClearType};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SlashCommandDescriptor {
|
||||||
|
pub command: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub argument_hint: Option<String>,
|
||||||
|
pub aliases: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlashCommandDescriptor {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn simple(command: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
command: command.into(),
|
||||||
|
description: None,
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn triggers(&self) -> impl Iterator<Item = &str> {
|
||||||
|
std::iter::once(self.command.as_str()).chain(self.aliases.iter().map(String::as_str))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ReadOutcome {
|
pub enum ReadOutcome {
|
||||||
Submit(String),
|
Submit(String),
|
||||||
@@ -178,14 +203,21 @@ impl EditSession {
|
|||||||
out: &mut impl Write,
|
out: &mut impl Write,
|
||||||
base_prompt: &str,
|
base_prompt: &str,
|
||||||
vim_enabled: bool,
|
vim_enabled: bool,
|
||||||
|
assist_lines: &[String],
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
self.clear_render(out)?;
|
self.clear_render(out)?;
|
||||||
|
|
||||||
let prompt = self.prompt(base_prompt, vim_enabled);
|
let prompt = self.prompt(base_prompt, vim_enabled);
|
||||||
let buffer = self.visible_buffer();
|
let buffer = self.visible_buffer();
|
||||||
write!(out, "{prompt}{buffer}")?;
|
write!(out, "{prompt}{buffer}")?;
|
||||||
|
if !assist_lines.is_empty() {
|
||||||
|
for line in assist_lines {
|
||||||
|
write!(out, "\r\n{line}")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (cursor_row, cursor_col, total_lines) = self.cursor_layout(prompt.as_ref());
|
let (cursor_row, cursor_col, total_lines) =
|
||||||
|
self.cursor_layout(prompt.as_ref(), assist_lines.len());
|
||||||
let rows_to_move_up = total_lines.saturating_sub(cursor_row + 1);
|
let rows_to_move_up = total_lines.saturating_sub(cursor_row + 1);
|
||||||
if rows_to_move_up > 0 {
|
if rows_to_move_up > 0 {
|
||||||
queue!(out, MoveUp(to_u16(rows_to_move_up)?))?;
|
queue!(out, MoveUp(to_u16(rows_to_move_up)?))?;
|
||||||
@@ -211,7 +243,7 @@ impl EditSession {
|
|||||||
writeln!(out)
|
writeln!(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cursor_layout(&self, prompt: &str) -> (usize, usize, usize) {
|
fn cursor_layout(&self, prompt: &str, assist_line_count: usize) -> (usize, usize, usize) {
|
||||||
let active_text = self.active_text();
|
let active_text = self.active_text();
|
||||||
let cursor = if self.mode == EditorMode::Command {
|
let cursor = if self.mode == EditorMode::Command {
|
||||||
self.command_cursor
|
self.command_cursor
|
||||||
@@ -225,7 +257,8 @@ impl EditSession {
|
|||||||
Some((_, suffix)) => suffix.chars().count(),
|
Some((_, suffix)) => suffix.chars().count(),
|
||||||
None => prompt.chars().count() + cursor_prefix.chars().count(),
|
None => prompt.chars().count() + cursor_prefix.chars().count(),
|
||||||
};
|
};
|
||||||
let total_lines = active_text.bytes().filter(|byte| *byte == b'\n').count() + 1;
|
let total_lines =
|
||||||
|
active_text.bytes().filter(|byte| *byte == b'\n').count() + 1 + assist_line_count;
|
||||||
(cursor_row, cursor_col, total_lines)
|
(cursor_row, cursor_col, total_lines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,7 +273,7 @@ enum KeyAction {
|
|||||||
|
|
||||||
pub struct LineEditor {
|
pub struct LineEditor {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
completions: Vec<String>,
|
slash_commands: Vec<SlashCommandDescriptor>,
|
||||||
history: Vec<String>,
|
history: Vec<String>,
|
||||||
yank_buffer: YankBuffer,
|
yank_buffer: YankBuffer,
|
||||||
vim_enabled: bool,
|
vim_enabled: bool,
|
||||||
@@ -255,11 +288,24 @@ struct CompletionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LineEditor {
|
impl LineEditor {
|
||||||
|
#[allow(dead_code)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
||||||
|
let slash_commands = completions
|
||||||
|
.into_iter()
|
||||||
|
.map(SlashCommandDescriptor::simple)
|
||||||
|
.collect();
|
||||||
|
Self::with_slash_commands(prompt, slash_commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_slash_commands(
|
||||||
|
prompt: impl Into<String>,
|
||||||
|
slash_commands: Vec<SlashCommandDescriptor>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
prompt: prompt.into(),
|
prompt: prompt.into(),
|
||||||
completions,
|
slash_commands,
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
yank_buffer: YankBuffer::default(),
|
yank_buffer: YankBuffer::default(),
|
||||||
vim_enabled: false,
|
vim_enabled: false,
|
||||||
@@ -284,7 +330,12 @@ impl LineEditor {
|
|||||||
let _raw_mode = RawModeGuard::new()?;
|
let _raw_mode = RawModeGuard::new()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut session = EditSession::new(self.vim_enabled);
|
let mut session = EditSession::new(self.vim_enabled);
|
||||||
session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.render(
|
||||||
|
&mut stdout,
|
||||||
|
&self.prompt,
|
||||||
|
self.vim_enabled,
|
||||||
|
&self.command_assist_lines(&session),
|
||||||
|
)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let Event::Key(key) = event::read()? else {
|
let Event::Key(key) = event::read()? else {
|
||||||
@@ -296,7 +347,12 @@ impl LineEditor {
|
|||||||
|
|
||||||
match self.handle_key_event(&mut session, key) {
|
match self.handle_key_event(&mut session, key) {
|
||||||
KeyAction::Continue => {
|
KeyAction::Continue => {
|
||||||
session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.render(
|
||||||
|
&mut stdout,
|
||||||
|
&self.prompt,
|
||||||
|
self.vim_enabled,
|
||||||
|
&self.command_assist_lines(&session),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
KeyAction::Submit(line) => {
|
KeyAction::Submit(line) => {
|
||||||
session.finalize_render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.finalize_render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
||||||
@@ -325,7 +381,12 @@ impl LineEditor {
|
|||||||
}
|
}
|
||||||
)?;
|
)?;
|
||||||
session = EditSession::new(self.vim_enabled);
|
session = EditSession::new(self.vim_enabled);
|
||||||
session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.render(
|
||||||
|
&mut stdout,
|
||||||
|
&self.prompt,
|
||||||
|
self.vim_enabled,
|
||||||
|
&self.command_assist_lines(&session),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,25 +760,21 @@ impl LineEditor {
|
|||||||
state
|
state
|
||||||
.matches
|
.matches
|
||||||
.iter()
|
.iter()
|
||||||
.any(|candidate| candidate == &session.text)
|
.any(|candidate| session.text == *candidate || session.text == format!("{candidate} "))
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
let candidate = state.matches[state.next_index % state.matches.len()].clone();
|
let candidate = state.matches[state.next_index % state.matches.len()].clone();
|
||||||
state.next_index += 1;
|
state.next_index += 1;
|
||||||
session.text.replace_range(..session.cursor, &candidate);
|
let replacement = completed_command(&candidate);
|
||||||
session.cursor = candidate.len();
|
session.text.replace_range(..session.cursor, &replacement);
|
||||||
|
session.cursor = replacement.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;
|
self.completion_state = None;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let matches = self
|
let matches = self.matching_commands(prefix);
|
||||||
.completions
|
|
||||||
.iter()
|
|
||||||
.filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
self.completion_state = None;
|
self.completion_state = None;
|
||||||
return;
|
return;
|
||||||
@@ -741,8 +798,111 @@ impl LineEditor {
|
|||||||
candidate
|
candidate
|
||||||
};
|
};
|
||||||
|
|
||||||
session.text.replace_range(..session.cursor, &candidate);
|
let replacement = completed_command(&candidate);
|
||||||
session.cursor = candidate.len();
|
session.text.replace_range(..session.cursor, &replacement);
|
||||||
|
session.cursor = replacement.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matching_commands(&self, prefix: &str) -> Vec<String> {
|
||||||
|
let normalized = prefix.to_ascii_lowercase();
|
||||||
|
let mut ranked = self
|
||||||
|
.slash_commands
|
||||||
|
.iter()
|
||||||
|
.filter_map(|descriptor| {
|
||||||
|
let command = descriptor.command.clone();
|
||||||
|
let mut best_rank = None::<(u8, usize)>;
|
||||||
|
for trigger in descriptor.triggers() {
|
||||||
|
let trigger_lower = trigger.to_ascii_lowercase();
|
||||||
|
let rank = if trigger_lower == normalized {
|
||||||
|
if trigger == descriptor.command {
|
||||||
|
Some((0, trigger.len()))
|
||||||
|
} else {
|
||||||
|
Some((1, trigger.len()))
|
||||||
|
}
|
||||||
|
} else if trigger_lower.starts_with(&normalized) {
|
||||||
|
if trigger == descriptor.command {
|
||||||
|
Some((2, trigger.len()))
|
||||||
|
} else {
|
||||||
|
Some((3, trigger.len()))
|
||||||
|
}
|
||||||
|
} else if trigger_lower.contains(&normalized) {
|
||||||
|
Some((4, trigger.len()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(rank) = rank {
|
||||||
|
best_rank = Some(best_rank.map_or(rank, |current| current.min(rank)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best_rank.map(|(bucket, len)| (bucket, len, command))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
ranked.sort_by(|left, right| left.cmp(right));
|
||||||
|
ranked.dedup_by(|left, right| left.2 == right.2);
|
||||||
|
ranked.into_iter().map(|(_, _, command)| command).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_assist_lines(&self, session: &EditSession) -> Vec<String> {
|
||||||
|
if session.mode == EditorMode::Command || session.cursor != session.text.len() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = session.text.as_str();
|
||||||
|
if !input.starts_with('/') {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((command, args)) = command_and_args(input) {
|
||||||
|
if input.ends_with(' ') && args.is_empty() {
|
||||||
|
if let Some(descriptor) = self.find_command_descriptor(command) {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
if let Some(argument_hint) = &descriptor.argument_hint {
|
||||||
|
lines.push(dimmed_line(format!("Arguments: {argument_hint}")));
|
||||||
|
}
|
||||||
|
if let Some(description) = &descriptor.description {
|
||||||
|
lines.push(dimmed_line(description));
|
||||||
|
}
|
||||||
|
if !lines.is_empty() {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.contains(char::is_whitespace) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = self.matching_commands(input);
|
||||||
|
if matches.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = vec![dimmed_line("Suggestions")];
|
||||||
|
lines.extend(matches.into_iter().take(3).map(|command| {
|
||||||
|
let description = self
|
||||||
|
.find_command_descriptor(command.trim_start_matches('/'))
|
||||||
|
.and_then(|descriptor| descriptor.description.as_deref())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if description.is_empty() {
|
||||||
|
dimmed_line(format!(" {command}"))
|
||||||
|
} else {
|
||||||
|
dimmed_line(format!(" {command:<18} {description}"))
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_command_descriptor(&self, name: &str) -> Option<&SlashCommandDescriptor> {
|
||||||
|
let normalized = name.trim().trim_start_matches('/').to_ascii_lowercase();
|
||||||
|
self.slash_commands.iter().find(|descriptor| {
|
||||||
|
descriptor.command.trim_start_matches('/').eq_ignore_ascii_case(&normalized)
|
||||||
|
|| descriptor
|
||||||
|
.aliases
|
||||||
|
.iter()
|
||||||
|
.any(|alias| alias.trim_start_matches('/').eq_ignore_ascii_case(&normalized))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn history_up(&self, session: &mut EditSession) {
|
fn history_up(&self, session: &mut EditSession) {
|
||||||
@@ -964,6 +1124,27 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
|
|||||||
Some(prefix)
|
Some(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn command_and_args(input: &str) -> Option<(&str, &str)> {
|
||||||
|
let trimmed = input.trim_start();
|
||||||
|
let without_slash = trimmed.strip_prefix('/')?;
|
||||||
|
let (command, args) = without_slash
|
||||||
|
.split_once(' ')
|
||||||
|
.map_or((without_slash, ""), |(command, args)| (command, args));
|
||||||
|
Some((command, args))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn completed_command(command: &str) -> String {
|
||||||
|
if command.ends_with(' ') {
|
||||||
|
command.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{command} ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dimmed_line(text: impl AsRef<str>) -> String {
|
||||||
|
format!("\x1b[2m{}\x1b[0m", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
fn to_u16(value: usize) -> io::Result<u16> {
|
fn to_u16(value: usize) -> io::Result<u16> {
|
||||||
u16::try_from(value).map_err(|_| {
|
u16::try_from(value).map_err(|_| {
|
||||||
io::Error::new(
|
io::Error::new(
|
||||||
@@ -977,6 +1158,7 @@ fn to_u16(value: usize) -> io::Result<u16> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
selection_bounds, slash_command_prefix, EditSession, EditorMode, KeyAction, LineEditor,
|
selection_bounds, slash_command_prefix, EditSession, EditorMode, KeyAction, LineEditor,
|
||||||
|
SlashCommandDescriptor,
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
@@ -1148,8 +1330,8 @@ mod tests {
|
|||||||
editor.complete_slash_command(&mut session);
|
editor.complete_slash_command(&mut session);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(session.text, "/help");
|
assert_eq!(session.text, "/help ");
|
||||||
assert_eq!(session.cursor, 5);
|
assert_eq!(session.cursor, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1171,8 +1353,65 @@ mod tests {
|
|||||||
let second = session.text.clone();
|
let second = session.text.clone();
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(first, "/permissions");
|
assert_eq!(first, "/plugin ");
|
||||||
assert_eq!(second, "/plugin");
|
assert_eq!(second, "/permissions ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_completion_prefers_canonical_command_over_alias() {
|
||||||
|
let mut editor = LineEditor::with_slash_commands(
|
||||||
|
"> ",
|
||||||
|
vec![SlashCommandDescriptor {
|
||||||
|
command: "/plugin".to_string(),
|
||||||
|
description: Some("Manage plugins".to_string()),
|
||||||
|
argument_hint: Some("[list]".to_string()),
|
||||||
|
aliases: vec!["/plugins".to_string(), "/marketplace".to_string()],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let mut session = EditSession::new(false);
|
||||||
|
session.text = "/plugins".to_string();
|
||||||
|
session.cursor = session.text.len();
|
||||||
|
|
||||||
|
editor.complete_slash_command(&mut session);
|
||||||
|
|
||||||
|
assert_eq!(session.text, "/plugin ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_assist_lines_show_suggestions_and_argument_hints() {
|
||||||
|
let editor = LineEditor::with_slash_commands(
|
||||||
|
"> ",
|
||||||
|
vec![
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/help".to_string(),
|
||||||
|
description: Some("Show help and available commands".to_string()),
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
},
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/model".to_string(),
|
||||||
|
description: Some("Show or switch the active model".to_string()),
|
||||||
|
argument_hint: Some("[model]".to_string()),
|
||||||
|
aliases: Vec::new(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut prefix_session = EditSession::new(false);
|
||||||
|
prefix_session.text = "/h".to_string();
|
||||||
|
prefix_session.cursor = prefix_session.text.len();
|
||||||
|
let prefix_lines = editor.command_assist_lines(&prefix_session);
|
||||||
|
assert!(prefix_lines.iter().any(|line| line.contains("Suggestions")));
|
||||||
|
assert!(prefix_lines.iter().any(|line| line.contains("/help")));
|
||||||
|
|
||||||
|
let mut hint_session = EditSession::new(false);
|
||||||
|
hint_session.text = "/model ".to_string();
|
||||||
|
hint_session.cursor = hint_session.text.len();
|
||||||
|
let hint_lines = editor.command_assist_lines(&hint_session);
|
||||||
|
assert!(hint_lines.iter().any(|line| line.contains("Arguments: [model]")));
|
||||||
|
assert!(hint_lines
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.contains("Show or switch the active model")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ use api::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
|
handle_agents_slash_command, handle_hooks_slash_command, handle_plugins_slash_command,
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
handle_skills_slash_command, render_plugin_inspection_report, render_slash_command_help,
|
||||||
suggest_slash_commands, SlashCommand,
|
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;
|
||||||
|
use input::SlashCommandDescriptor;
|
||||||
use plugins::{PluginManager, PluginManagerConfig};
|
use plugins::{PluginManager, PluginManagerConfig};
|
||||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
@@ -85,6 +86,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
CliAction::DumpManifests => dump_manifests(),
|
CliAction::DumpManifests => dump_manifests(),
|
||||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||||
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
|
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
|
||||||
|
CliAction::Hooks { args } => LiveCli::print_hooks(args.as_deref())?,
|
||||||
CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?,
|
CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?,
|
||||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||||
CliAction::Version => print_version(),
|
CliAction::Version => print_version(),
|
||||||
@@ -120,6 +122,9 @@ enum CliAction {
|
|||||||
Agents {
|
Agents {
|
||||||
args: Option<String>,
|
args: Option<String>,
|
||||||
},
|
},
|
||||||
|
Hooks {
|
||||||
|
args: Option<String>,
|
||||||
|
},
|
||||||
Skills {
|
Skills {
|
||||||
args: Option<String>,
|
args: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -289,6 +294,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
"agents" => Ok(CliAction::Agents {
|
"agents" => Ok(CliAction::Agents {
|
||||||
args: join_optional_args(&rest[1..]),
|
args: join_optional_args(&rest[1..]),
|
||||||
}),
|
}),
|
||||||
|
"hooks" => Ok(CliAction::Hooks {
|
||||||
|
args: join_optional_args(&rest[1..]),
|
||||||
|
}),
|
||||||
"skills" => Ok(CliAction::Skills {
|
"skills" => Ok(CliAction::Skills {
|
||||||
args: join_optional_args(&rest[1..]),
|
args: join_optional_args(&rest[1..]),
|
||||||
}),
|
}),
|
||||||
@@ -331,6 +339,7 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
|||||||
match SlashCommand::parse(&raw) {
|
match SlashCommand::parse(&raw) {
|
||||||
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::Hooks { args }) => Ok(CliAction::Hooks { args }),
|
||||||
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
||||||
Some(command) => Err(format_direct_slash_command_error(
|
Some(command) => Err(format_direct_slash_command_error(
|
||||||
match &command {
|
match &command {
|
||||||
@@ -942,6 +951,13 @@ fn run_resume_command(
|
|||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_config_report(section.as_deref())?),
|
message: Some(render_config_report(section.as_deref())?),
|
||||||
}),
|
}),
|
||||||
|
SlashCommand::Hooks { args } => {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
Ok(ResumeCommandOutcome {
|
||||||
|
session: session.clone(),
|
||||||
|
message: Some(handle_hooks_slash_command(args.as_deref(), &cwd)?),
|
||||||
|
})
|
||||||
|
}
|
||||||
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_memory_report()?),
|
message: Some(render_memory_report()?),
|
||||||
@@ -999,6 +1015,7 @@ fn run_resume_command(
|
|||||||
| SlashCommand::Permissions { .. }
|
| SlashCommand::Permissions { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
| SlashCommand::Plugins { .. }
|
| SlashCommand::Plugins { .. }
|
||||||
|
| SlashCommand::ReloadPlugins
|
||||||
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1009,7 +1026,7 @@ fn run_repl(
|
|||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
||||||
let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates());
|
let mut editor = input::LineEditor::with_slash_commands("> ", slash_command_descriptors());
|
||||||
println!("{}", cli.startup_banner());
|
println!("{}", cli.startup_banner());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -1141,13 +1158,13 @@ impl LiveCli {
|
|||||||
format!(
|
format!(
|
||||||
" Quick start {}",
|
" Quick start {}",
|
||||||
if has_claw_md {
|
if has_claw_md {
|
||||||
"/help · /status · ask for a task"
|
"Type / to browse commands · /help for shortcuts · ask for a task"
|
||||||
} else {
|
} else {
|
||||||
"/init · /help · /status"
|
"/init · then type / to browse commands"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
" Editor Tab completes slash commands · /vim toggles modal editing"
|
" Autocomplete Type / for command suggestions · Tab accepts or cycles".to_string(),
|
||||||
.to_string(),
|
" Editor /vim toggles modal editing · Esc clears menus first".to_string(),
|
||||||
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
|
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
|
||||||
];
|
];
|
||||||
if !has_claw_md {
|
if !has_claw_md {
|
||||||
@@ -1294,6 +1311,10 @@ impl LiveCli {
|
|||||||
Self::print_config(section.as_deref())?;
|
Self::print_config(section.as_deref())?;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
SlashCommand::Hooks { args } => {
|
||||||
|
Self::print_hooks(args.as_deref())?;
|
||||||
|
false
|
||||||
|
}
|
||||||
SlashCommand::Memory => {
|
SlashCommand::Memory => {
|
||||||
Self::print_memory()?;
|
Self::print_memory()?;
|
||||||
false
|
false
|
||||||
@@ -1320,6 +1341,7 @@ impl LiveCli {
|
|||||||
SlashCommand::Plugins { action, target } => {
|
SlashCommand::Plugins { action, target } => {
|
||||||
self.handle_plugins_command(action.as_deref(), target.as_deref())?
|
self.handle_plugins_command(action.as_deref(), target.as_deref())?
|
||||||
}
|
}
|
||||||
|
SlashCommand::ReloadPlugins => self.reload_plugins_command()?,
|
||||||
SlashCommand::Agents { args } => {
|
SlashCommand::Agents { args } => {
|
||||||
Self::print_agents(args.as_deref())?;
|
Self::print_agents(args.as_deref())?;
|
||||||
false
|
false
|
||||||
@@ -1555,6 +1577,12 @@ impl LiveCli {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_hooks(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
println!("{}", handle_hooks_slash_command(args, &cwd)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
println!("{}", handle_skills_slash_command(args, &cwd)?);
|
println!("{}", handle_skills_slash_command(args, &cwd)?);
|
||||||
@@ -1645,6 +1673,22 @@ impl LiveCli {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reload_plugins_command(&mut self) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
|
self.reload_runtime_features()?;
|
||||||
|
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
|
let runtime_config = loader.load()?;
|
||||||
|
let manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
|
let inspection = manager.inspect()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Plugin runtime reloaded from local manifests.\n{}",
|
||||||
|
render_plugin_inspection_report(&inspection)
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
self.runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
self.runtime.session().clone(),
|
self.runtime.session().clone(),
|
||||||
@@ -1973,14 +2017,15 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
|||||||
fn render_repl_help() -> String {
|
fn render_repl_help() -> String {
|
||||||
[
|
[
|
||||||
"Interactive REPL".to_string(),
|
"Interactive REPL".to_string(),
|
||||||
" Quick start Ask a task in plain English or use one of the core commands below."
|
" Quick start Ask a task in plain English, or type / to browse slash commands."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
" Core commands /help · /status · /model · /permissions · /compact".to_string(),
|
" Core commands /help · /status · /model · /permissions · /compact".to_string(),
|
||||||
" Exit /exit or /quit".to_string(),
|
" Exit /exit or /quit".to_string(),
|
||||||
|
" Autocomplete Type / for suggestions · Tab accepts or cycles matches".to_string(),
|
||||||
" Vim mode /vim toggles modal editing".to_string(),
|
" Vim mode /vim toggles modal editing".to_string(),
|
||||||
" History Up/Down recalls previous prompts".to_string(),
|
" History Up/Down recalls previous prompts".to_string(),
|
||||||
" Completion Tab cycles slash command matches".to_string(),
|
" Cancel Esc dismisses menus first · Ctrl-C clears input (or exits on empty)"
|
||||||
" Cancel Ctrl-C clears input (or exits on an empty prompt)".to_string(),
|
.to_string(),
|
||||||
" Multiline Shift+Enter or Ctrl+J inserts a newline".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(),
|
||||||
@@ -3283,21 +3328,48 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn slash_command_completion_candidates() -> Vec<String> {
|
fn slash_command_descriptors() -> Vec<SlashCommandDescriptor> {
|
||||||
let mut candidates = slash_command_specs()
|
let mut descriptors = slash_command_specs()
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|spec| {
|
.map(|spec| SlashCommandDescriptor {
|
||||||
std::iter::once(spec.name)
|
command: format!("/{}", spec.name),
|
||||||
.chain(spec.aliases.iter().copied())
|
description: Some(spec.summary.to_string()),
|
||||||
.map(|name| format!("/{name}"))
|
argument_hint: spec.argument_hint.map(ToOwned::to_owned),
|
||||||
|
aliases: spec
|
||||||
|
.aliases
|
||||||
|
.iter()
|
||||||
|
.map(|alias| format!("/{alias}"))
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
descriptors.extend([
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/vim".to_string(),
|
||||||
|
description: Some("Toggle modal editing".to_string()),
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
},
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/exit".to_string(),
|
||||||
|
description: Some("Exit the interactive REPL".to_string()),
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: vec!["/quit".to_string()],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
descriptors.sort_by(|left, right| left.command.cmp(&right.command));
|
||||||
|
descriptors.dedup_by(|left, right| left.command == right.command);
|
||||||
|
descriptors
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slash_command_completion_candidates() -> Vec<String> {
|
||||||
|
let mut candidates = slash_command_descriptors()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|descriptor| {
|
||||||
|
std::iter::once(descriptor.command)
|
||||||
|
.chain(descriptor.aliases)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
candidates.extend([
|
|
||||||
String::from("/vim"),
|
|
||||||
String::from("/exit"),
|
|
||||||
String::from("/quit"),
|
|
||||||
]);
|
|
||||||
candidates.sort();
|
candidates.sort();
|
||||||
candidates.dedup();
|
candidates.dedup();
|
||||||
candidates
|
candidates
|
||||||
@@ -3986,6 +4058,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
out,
|
out,
|
||||||
" /help Browse the full slash command map"
|
" /help Browse the full slash command map"
|
||||||
)?;
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" / Open slash suggestions in the REPL"
|
||||||
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" /status Inspect session + workspace state"
|
" /status Inspect session + workspace state"
|
||||||
@@ -4000,7 +4076,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" Tab Complete slash commands"
|
" Tab Accept or cycle slash command suggestions"
|
||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
@@ -4026,7 +4102,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" claw skills List installed skills"
|
" claw hooks Inspect configured tool hooks"
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" claw skills List discoverable local skills"
|
||||||
)?;
|
)?;
|
||||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
@@ -4095,6 +4175,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
" claw --resume session.json /status /diff /export notes.txt"
|
" claw --resume session.json /status /diff /export notes.txt"
|
||||||
)?;
|
)?;
|
||||||
writeln!(out, " claw agents")?;
|
writeln!(out, " claw agents")?;
|
||||||
|
writeln!(out, " claw hooks")?;
|
||||||
writeln!(out, " claw /skills")?;
|
writeln!(out, " claw /skills")?;
|
||||||
writeln!(out, " claw login")?;
|
writeln!(out, " claw login")?;
|
||||||
writeln!(out, " claw init")?;
|
writeln!(out, " claw init")?;
|
||||||
@@ -4115,9 +4196,10 @@ mod tests {
|
|||||||
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, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
||||||
resume_supported_slash_commands, slash_command_completion_candidates, status_context,
|
resume_supported_slash_commands, slash_command_completion_candidates,
|
||||||
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
|
slash_command_descriptors, status_context, CliAction, CliOutputFormat,
|
||||||
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
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};
|
||||||
@@ -4321,6 +4403,10 @@ mod tests {
|
|||||||
parse_args(&["agents".to_string()]).expect("agents should parse"),
|
parse_args(&["agents".to_string()]).expect("agents should parse"),
|
||||||
CliAction::Agents { args: None }
|
CliAction::Agents { args: None }
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["hooks".to_string()]).expect("hooks should parse"),
|
||||||
|
CliAction::Hooks { args: None }
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["skills".to_string()]).expect("skills should parse"),
|
parse_args(&["skills".to_string()]).expect("skills should parse"),
|
||||||
CliAction::Skills { args: None }
|
CliAction::Skills { args: None }
|
||||||
@@ -4340,6 +4426,10 @@ mod tests {
|
|||||||
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
|
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
|
||||||
CliAction::Agents { args: None }
|
CliAction::Agents { args: None }
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["/hooks".to_string()]).expect("/hooks should parse"),
|
||||||
|
CliAction::Hooks { args: None }
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["/skills".to_string()]).expect("/skills should parse"),
|
parse_args(&["/skills".to_string()]).expect("/skills should parse"),
|
||||||
CliAction::Skills { args: None }
|
CliAction::Skills { args: None }
|
||||||
@@ -4439,6 +4529,7 @@ mod tests {
|
|||||||
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("Interactive REPL"));
|
assert!(help.contains("Interactive REPL"));
|
||||||
|
assert!(help.contains("type / to browse slash commands"));
|
||||||
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]"));
|
||||||
@@ -4447,6 +4538,7 @@ mod tests {
|
|||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-path>"));
|
||||||
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
||||||
|
assert!(help.contains("/hooks"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
@@ -4454,13 +4546,15 @@ mod tests {
|
|||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||||
assert!(help.contains(
|
assert!(help.contains(
|
||||||
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
"/plugin [inspect|list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
));
|
));
|
||||||
|
assert!(help.contains("/reload-plugins"));
|
||||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||||
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"));
|
assert!(help.contains("Type / for suggestions"));
|
||||||
|
assert!(help.contains("Tab accepts or cycles matches"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4472,6 +4566,36 @@ mod tests {
|
|||||||
assert!(candidates.contains(&"/quit".to_string()));
|
assert!(candidates.contains(&"/quit".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_command_descriptors_include_descriptions_and_aliases() {
|
||||||
|
let descriptors = slash_command_descriptors();
|
||||||
|
let plugin = descriptors
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.command == "/plugin")
|
||||||
|
.expect("plugin descriptor should exist");
|
||||||
|
assert_eq!(
|
||||||
|
plugin.description.as_deref(),
|
||||||
|
Some("Inspect and manage local Claw Code plugins")
|
||||||
|
);
|
||||||
|
assert!(plugin.aliases.contains(&"/plugins".to_string()));
|
||||||
|
assert!(plugin.aliases.contains(&"/marketplace".to_string()));
|
||||||
|
|
||||||
|
let reload = descriptors
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.command == "/reload-plugins")
|
||||||
|
.expect("reload plugins descriptor should exist");
|
||||||
|
assert_eq!(
|
||||||
|
reload.description.as_deref(),
|
||||||
|
Some("Reload plugin-derived runtime features and print current support")
|
||||||
|
);
|
||||||
|
|
||||||
|
let exit = descriptors
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.command == "/exit")
|
||||||
|
.expect("exit descriptor should exist");
|
||||||
|
assert!(exit.aliases.contains(&"/quit".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_repl_command_suggestions_include_repl_shortcuts() {
|
fn unknown_repl_command_suggestions_include_repl_shortcuts() {
|
||||||
let rendered = render_unknown_repl_command("exi");
|
let rendered = render_unknown_repl_command("exi");
|
||||||
@@ -4489,8 +4613,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
names,
|
names,
|
||||||
vec![
|
vec![
|
||||||
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
|
"help", "status", "compact", "clear", "cost", "config", "hooks", "memory", "init",
|
||||||
"version", "export", "agents", "skills",
|
"diff", "version", "export", "agents", "skills",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4559,7 +4683,9 @@ mod tests {
|
|||||||
print_help_to(&mut help).expect("help should render");
|
print_help_to(&mut help).expect("help should render");
|
||||||
let help = String::from_utf8(help).expect("help should be utf8");
|
let help = String::from_utf8(help).expect("help should be utf8");
|
||||||
assert!(help.contains("claw init"));
|
assert!(help.contains("claw init"));
|
||||||
|
assert!(help.contains("Open slash suggestions in the REPL"));
|
||||||
assert!(help.contains("claw agents"));
|
assert!(help.contains("claw agents"));
|
||||||
|
assert!(help.contains("claw hooks"));
|
||||||
assert!(help.contains("claw skills"));
|
assert!(help.contains("claw skills"));
|
||||||
assert!(help.contains("claw /skills"));
|
assert!(help.contains("claw /skills"));
|
||||||
}
|
}
|
||||||
@@ -4762,7 +4888,7 @@ 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 cycles"));
|
assert!(help.contains("Tab accepts or cycles"));
|
||||||
assert!(help.contains("Shift+Enter or Ctrl+J"));
|
assert!(help.contains("Shift+Enter or Ctrl+J"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
299
rust/crates/claw-cli/tests/prompt_json_transport.rs
Normal file
299
rust/crates/claw-cli/tests/prompt_json_transport.rs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_json_with_tool_use_writes_clean_transport_output() {
|
||||||
|
let fixture_root = unique_temp_dir("claw-json-transport");
|
||||||
|
fs::create_dir_all(&fixture_root).expect("create fixture root");
|
||||||
|
fs::write(fixture_root.join("fixture.txt"), "fixture contents\n").expect("write fixture file");
|
||||||
|
fs::create_dir_all(fixture_root.join("config")).expect("create config dir");
|
||||||
|
|
||||||
|
let server = TestServer::spawn(vec![
|
||||||
|
sse_response(
|
||||||
|
"req_tool",
|
||||||
|
&tool_use_stream("read_file", json!({ "path": "fixture.txt" })),
|
||||||
|
),
|
||||||
|
sse_response("req_done", &text_stream("done")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||||
|
.current_dir(&fixture_root)
|
||||||
|
.env("ANTHROPIC_BASE_URL", server.base_url())
|
||||||
|
.env("ANTHROPIC_API_KEY", "test-key")
|
||||||
|
.env("CLAW_CONFIG_HOME", fixture_root.join("config"))
|
||||||
|
.arg("--output-format")
|
||||||
|
.arg("json")
|
||||||
|
.arg("prompt")
|
||||||
|
.arg("use a tool")
|
||||||
|
.output()
|
||||||
|
.expect("run claw prompt json");
|
||||||
|
|
||||||
|
server.finish();
|
||||||
|
|
||||||
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
|
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"status: {:?}\nstderr:\n{stderr}",
|
||||||
|
output.status
|
||||||
|
);
|
||||||
|
assert!(stderr.trim().is_empty(), "unexpected stderr: {stderr}");
|
||||||
|
assert!(
|
||||||
|
stdout.trim_start().starts_with('{'),
|
||||||
|
"stdout should begin with JSON object, got:\n{stdout}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed: Value = serde_json::from_str(stdout.trim())
|
||||||
|
.expect("full stdout should be a single parseable JSON object");
|
||||||
|
|
||||||
|
assert_eq!(parsed["message"], "done");
|
||||||
|
assert_eq!(parsed["iterations"], 2);
|
||||||
|
assert_eq!(parsed["tool_uses"].as_array().map(Vec::len), Some(1));
|
||||||
|
assert_eq!(parsed["tool_results"].as_array().map(Vec::len), Some(1));
|
||||||
|
assert_eq!(parsed["tool_uses"][0]["name"], "read_file");
|
||||||
|
assert_eq!(parsed["tool_results"][0]["tool_name"], "read_file");
|
||||||
|
assert_eq!(parsed["tool_results"][0]["is_error"], false);
|
||||||
|
|
||||||
|
let tool_output = parsed["tool_results"][0]["output"]
|
||||||
|
.as_str()
|
||||||
|
.expect("tool result output string");
|
||||||
|
assert!(tool_output.contains("fixture contents"));
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("📄 Read"),
|
||||||
|
"stdout leaked human-readable tool rendering:\n{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestServer {
|
||||||
|
base_url: String,
|
||||||
|
join_handle: thread::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestServer {
|
||||||
|
fn spawn(responses: Vec<String>) -> Self {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||||
|
listener
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.expect("set nonblocking listener");
|
||||||
|
let address = listener.local_addr().expect("listener addr");
|
||||||
|
let join_handle = thread::spawn(move || {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
let mut served = 0usize;
|
||||||
|
|
||||||
|
while served < responses.len() && Instant::now() < deadline {
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((mut stream, _)) => {
|
||||||
|
drain_http_request(&mut stream);
|
||||||
|
stream
|
||||||
|
.write_all(responses[served].as_bytes())
|
||||||
|
.expect("write response");
|
||||||
|
served += 1;
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
Err(error) => panic!("accept failed: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
served,
|
||||||
|
responses.len(),
|
||||||
|
"server did not observe expected request count"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
base_url: format!("http://{address}"),
|
||||||
|
join_handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_url(&self) -> &str {
|
||||||
|
&self.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) {
|
||||||
|
self.join_handle.join().expect("join server thread");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_http_request(stream: &mut std::net::TcpStream) {
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.expect("set read timeout");
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut header_end = None;
|
||||||
|
|
||||||
|
while header_end.is_none() {
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
let read = stream.read(&mut chunk).expect("read request chunk");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer.extend_from_slice(&chunk[..read]);
|
||||||
|
header_end = find_header_end(&buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_end = header_end.expect("request should contain headers");
|
||||||
|
let headers = String::from_utf8(buffer[..header_end].to_vec()).expect("header utf8");
|
||||||
|
let content_length = headers
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| {
|
||||||
|
line.split_once(':').and_then(|(name, value)| {
|
||||||
|
name.eq_ignore_ascii_case("content-length")
|
||||||
|
.then(|| value.trim().parse::<usize>().expect("content length"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut body = buffer[(header_end + 4)..].to_vec();
|
||||||
|
while body.len() < content_length {
|
||||||
|
let mut chunk = vec![0_u8; content_length - body.len()];
|
||||||
|
let read = stream.read(&mut chunk).expect("read request body");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk[..read]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||||
|
buffer.windows(4).position(|window| window == b"\r\n\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_response(request_id: &str, body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nrequest-id: {request_id}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_use_stream(tool_name: &str, input: Value) -> String {
|
||||||
|
let mut body = String::new();
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"message_start",
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": "msg_tool",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [],
|
||||||
|
"model": "claude-opus-4-6",
|
||||||
|
"stop_reason": null,
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {"input_tokens": 8, "output_tokens": 0}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"content_block_start",
|
||||||
|
json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": 0,
|
||||||
|
"content_block": {
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_1",
|
||||||
|
"name": tool_name,
|
||||||
|
"input": {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"content_block_delta",
|
||||||
|
json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"type": "input_json_delta",
|
||||||
|
"partial_json": input.to_string()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"content_block_stop",
|
||||||
|
json!({"type": "content_block_stop", "index": 0}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"message_delta",
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {"stop_reason": "tool_use", "stop_sequence": null},
|
||||||
|
"usage": {"input_tokens": 8, "output_tokens": 1}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event("message_stop", json!({"type": "message_stop"})));
|
||||||
|
body.push_str("data: [DONE]\n\n");
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_stream(text: &str) -> String {
|
||||||
|
let mut body = String::new();
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"message_start",
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": "msg_done",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [],
|
||||||
|
"model": "claude-opus-4-6",
|
||||||
|
"stop_reason": null,
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {"input_tokens": 20, "output_tokens": 0}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"content_block_start",
|
||||||
|
json!({
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": 0,
|
||||||
|
"content_block": {"type": "text", "text": ""}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"content_block_delta",
|
||||||
|
json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"type": "text_delta", "text": text}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"content_block_stop",
|
||||||
|
json!({"type": "content_block_stop", "index": 0}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event(
|
||||||
|
"message_delta",
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {"stop_reason": "end_turn", "stop_sequence": null},
|
||||||
|
"usage": {"input_tokens": 20, "output_tokens": 2}
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
body.push_str(&sse_event("message_stop", json!({"type": "message_stop"})));
|
||||||
|
body.push_str("data: [DONE]\n\n");
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_event(event_name: &str, payload: Value) -> String {
|
||||||
|
format!("event: {event_name}\ndata: {payload}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock should be after epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("{prefix}-{nanos}"))
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use plugins::{PluginError, PluginManager, PluginSummary};
|
use plugins::{PluginError, PluginInspection, PluginManager, PluginSummary};
|
||||||
use runtime::{compact_session, CompactionConfig, Session};
|
use runtime::{
|
||||||
|
compact_session, discover_skill_roots, CompactionConfig, ConfigLoader, ConfigSource,
|
||||||
|
RuntimeConfig, Session, SkillDiscoveryRoot, SkillDiscoverySource, SkillRootKind,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CommandManifestEntry {
|
pub struct CommandManifestEntry {
|
||||||
@@ -143,6 +146,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
category: SlashCommandCategory::Workspace,
|
category: SlashCommandCategory::Workspace,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "hooks",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "Inspect configured tool hooks",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
category: SlashCommandCategory::Workspace,
|
||||||
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "memory",
|
name: "memory",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -274,13 +285,21 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "plugin",
|
name: "plugin",
|
||||||
aliases: &["plugins", "marketplace"],
|
aliases: &["plugins", "marketplace"],
|
||||||
summary: "Manage Claw Code plugins",
|
summary: "Inspect and manage local Claw Code plugins",
|
||||||
argument_hint: Some(
|
argument_hint: Some(
|
||||||
"[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
|
"[inspect|list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
|
||||||
),
|
),
|
||||||
resume_supported: false,
|
resume_supported: false,
|
||||||
category: SlashCommandCategory::Automation,
|
category: SlashCommandCategory::Automation,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "reload-plugins",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "Reload plugin-derived runtime features and print current support",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: false,
|
||||||
|
category: SlashCommandCategory::Automation,
|
||||||
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "agents",
|
name: "agents",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -349,6 +368,9 @@ pub enum SlashCommand {
|
|||||||
Config {
|
Config {
|
||||||
section: Option<String>,
|
section: Option<String>,
|
||||||
},
|
},
|
||||||
|
Hooks {
|
||||||
|
args: Option<String>,
|
||||||
|
},
|
||||||
Memory,
|
Memory,
|
||||||
Init,
|
Init,
|
||||||
Diff,
|
Diff,
|
||||||
@@ -364,6 +386,7 @@ pub enum SlashCommand {
|
|||||||
action: Option<String>,
|
action: Option<String>,
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
},
|
},
|
||||||
|
ReloadPlugins,
|
||||||
Agents {
|
Agents {
|
||||||
args: Option<String>,
|
args: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -432,6 +455,9 @@ impl SlashCommand {
|
|||||||
"config" => Self::Config {
|
"config" => Self::Config {
|
||||||
section: parts.next().map(ToOwned::to_owned),
|
section: parts.next().map(ToOwned::to_owned),
|
||||||
},
|
},
|
||||||
|
"hooks" => Self::Hooks {
|
||||||
|
args: remainder_after_command(trimmed, command),
|
||||||
|
},
|
||||||
"memory" => Self::Memory,
|
"memory" => Self::Memory,
|
||||||
"init" => Self::Init,
|
"init" => Self::Init,
|
||||||
"diff" => Self::Diff,
|
"diff" => Self::Diff,
|
||||||
@@ -450,6 +476,7 @@ impl SlashCommand {
|
|||||||
(!remainder.is_empty()).then_some(remainder)
|
(!remainder.is_empty()).then_some(remainder)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"reload-plugins" => Self::ReloadPlugins,
|
||||||
"agents" => Self::Agents {
|
"agents" => Self::Agents {
|
||||||
args: remainder_after_command(trimmed, command),
|
args: remainder_after_command(trimmed, command),
|
||||||
},
|
},
|
||||||
@@ -659,31 +686,9 @@ struct AgentSummary {
|
|||||||
struct SkillSummary {
|
struct SkillSummary {
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
source: DefinitionSource,
|
source: SkillDiscoverySource,
|
||||||
shadowed_by: Option<DefinitionSource>,
|
shadowed_by: Option<SkillDiscoverySource>,
|
||||||
origin: SkillOrigin,
|
origin: SkillRootKind,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum SkillOrigin {
|
|
||||||
SkillsDir,
|
|
||||||
LegacyCommandsDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SkillOrigin {
|
|
||||||
fn detail_label(self) -> Option<&'static str> {
|
|
||||||
match self {
|
|
||||||
Self::SkillsDir => None,
|
|
||||||
Self::LegacyCommandsDir => Some("legacy /commands"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct SkillRoot {
|
|
||||||
source: DefinitionSource,
|
|
||||||
path: PathBuf,
|
|
||||||
origin: SkillOrigin,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
@@ -693,7 +698,11 @@ pub fn handle_plugins_slash_command(
|
|||||||
manager: &mut PluginManager,
|
manager: &mut PluginManager,
|
||||||
) -> Result<PluginsCommandResult, PluginError> {
|
) -> Result<PluginsCommandResult, PluginError> {
|
||||||
match action {
|
match action {
|
||||||
None | Some("list") => Ok(PluginsCommandResult {
|
None | Some("inspect" | "status") => Ok(PluginsCommandResult {
|
||||||
|
message: render_plugin_inspection_report(&manager.inspect()?),
|
||||||
|
reload_runtime: false,
|
||||||
|
}),
|
||||||
|
Some("list") => Ok(PluginsCommandResult {
|
||||||
message: render_plugins_report(&manager.list_installed_plugins()?),
|
message: render_plugins_report(&manager.list_installed_plugins()?),
|
||||||
reload_runtime: false,
|
reload_runtime: false,
|
||||||
}),
|
}),
|
||||||
@@ -791,7 +800,7 @@ pub fn handle_plugins_slash_command(
|
|||||||
}
|
}
|
||||||
Some(other) => Ok(PluginsCommandResult {
|
Some(other) => Ok(PluginsCommandResult {
|
||||||
message: format!(
|
message: format!(
|
||||||
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
|
"Unknown /plugins action '{other}'. Use inspect, list, install, enable, disable, uninstall, or update."
|
||||||
),
|
),
|
||||||
reload_runtime: false,
|
reload_runtime: false,
|
||||||
}),
|
}),
|
||||||
@@ -815,13 +824,30 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|||||||
None | Some("list") => {
|
None | Some("list") => {
|
||||||
let roots = discover_skill_roots(cwd);
|
let roots = discover_skill_roots(cwd);
|
||||||
let skills = load_skills_from_roots(&roots)?;
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
Ok(render_skills_report(&skills))
|
Ok(render_skills_report(&skills, &roots))
|
||||||
}
|
}
|
||||||
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
|
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
|
||||||
Some(args) => Ok(render_skills_usage(Some(args))),
|
Some(args) => Ok(render_skills_usage(Some(args))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn handle_hooks_slash_command(
|
||||||
|
args: Option<&str>,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> Result<String, runtime::ConfigError> {
|
||||||
|
let args = normalize_optional_args(args);
|
||||||
|
if matches!(args, Some("-h" | "--help" | "help")) {
|
||||||
|
return Ok(render_hooks_usage(None));
|
||||||
|
}
|
||||||
|
if let Some(unexpected) = args {
|
||||||
|
return Ok(render_hooks_usage(Some(unexpected)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let loader = ConfigLoader::default_for(cwd);
|
||||||
|
let runtime_config = loader.load()?;
|
||||||
|
Ok(render_hooks_report(cwd, &runtime_config))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CommitPushPrRequest {
|
pub struct CommitPushPrRequest {
|
||||||
pub commit_message: Option<String>,
|
pub commit_message: Option<String>,
|
||||||
@@ -1230,6 +1256,87 @@ pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_plugin_inspection_report(inspection: &PluginInspection) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"Plugins".to_string(),
|
||||||
|
" Current support Local manifest discovery plus install/update/uninstall and enable/disable state".to_string(),
|
||||||
|
" Runtime wiring Plugin tools load on runtime rebuild; manifest-defined hooks, lifecycle, slash commands, and MCP extensions are not wired yet".to_string(),
|
||||||
|
format!(
|
||||||
|
" Discoverable {} total",
|
||||||
|
inspection.discoverable_plugins.len()
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
" Installed {} total",
|
||||||
|
inspection.installed_plugins.len()
|
||||||
|
),
|
||||||
|
" Checked locations".to_string(),
|
||||||
|
render_report_path("Install root", &inspection.install_root, inspection.install_root.exists()),
|
||||||
|
render_report_path("Bundled root", &inspection.bundled_root, inspection.bundled_root.exists()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if inspection.external_dirs.is_empty() {
|
||||||
|
lines.push(" External dirs none configured".to_string());
|
||||||
|
} else {
|
||||||
|
for (index, directory) in inspection.external_dirs.iter().enumerate() {
|
||||||
|
lines.push(render_report_path(
|
||||||
|
if index == 0 {
|
||||||
|
"External dirs"
|
||||||
|
} else {
|
||||||
|
"External dir"
|
||||||
|
},
|
||||||
|
directory,
|
||||||
|
directory.exists(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(render_report_path(
|
||||||
|
"Registry file",
|
||||||
|
&inspection.registry_path,
|
||||||
|
inspection.registry_path.exists(),
|
||||||
|
));
|
||||||
|
lines.push(render_report_path(
|
||||||
|
"Settings file",
|
||||||
|
&inspection.settings_path,
|
||||||
|
inspection.settings_path.exists(),
|
||||||
|
));
|
||||||
|
|
||||||
|
lines.push(" Missing parity".to_string());
|
||||||
|
lines.push(" TS marketplace/discovery UI is not implemented.".to_string());
|
||||||
|
lines.push(
|
||||||
|
" Plugin-defined slash commands are parsed from manifests but not exposed.".to_string(),
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
" Plugin hooks/lifecycle are validated but not attached to the conversation runtime."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
" No plugin hot-swap beyond /reload-plugins rebuilding the current runtime.".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.push(" Installed plugins".to_string());
|
||||||
|
if inspection.installed_plugins.is_empty() {
|
||||||
|
lines.push(" none".to_string());
|
||||||
|
} else {
|
||||||
|
for plugin in &inspection.installed_plugins {
|
||||||
|
lines.push(format!(
|
||||||
|
" - {} · {} v{} · {}",
|
||||||
|
plugin.metadata.id,
|
||||||
|
plugin.metadata.kind,
|
||||||
|
plugin.metadata.version,
|
||||||
|
if plugin.enabled {
|
||||||
|
"enabled"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
|
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
|
||||||
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
|
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
|
||||||
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
|
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
|
||||||
@@ -1260,6 +1367,14 @@ fn resolve_plugin_target(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_report_path(label: &str, path: &Path, exists: bool) -> String {
|
||||||
|
format!(
|
||||||
|
" {label:<15} {} ({})",
|
||||||
|
path.display(),
|
||||||
|
if exists { "present" } else { "missing" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
|
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
@@ -1301,83 +1416,6 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
|
|||||||
roots
|
roots
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|
||||||
let mut roots = Vec::new();
|
|
||||||
|
|
||||||
for ancestor in cwd.ancestors() {
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::ProjectCodex,
|
|
||||||
ancestor.join(".codex").join("skills"),
|
|
||||||
SkillOrigin::SkillsDir,
|
|
||||||
);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::ProjectClaw,
|
|
||||||
ancestor.join(".claw").join("skills"),
|
|
||||||
SkillOrigin::SkillsDir,
|
|
||||||
);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::ProjectCodex,
|
|
||||||
ancestor.join(".codex").join("commands"),
|
|
||||||
SkillOrigin::LegacyCommandsDir,
|
|
||||||
);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::ProjectClaw,
|
|
||||||
ancestor.join(".claw").join("commands"),
|
|
||||||
SkillOrigin::LegacyCommandsDir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
|
||||||
let codex_home = PathBuf::from(codex_home);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::UserCodexHome,
|
|
||||||
codex_home.join("skills"),
|
|
||||||
SkillOrigin::SkillsDir,
|
|
||||||
);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::UserCodexHome,
|
|
||||||
codex_home.join("commands"),
|
|
||||||
SkillOrigin::LegacyCommandsDir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(home) = env::var_os("HOME") {
|
|
||||||
let home = PathBuf::from(home);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::UserCodex,
|
|
||||||
home.join(".codex").join("skills"),
|
|
||||||
SkillOrigin::SkillsDir,
|
|
||||||
);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::UserCodex,
|
|
||||||
home.join(".codex").join("commands"),
|
|
||||||
SkillOrigin::LegacyCommandsDir,
|
|
||||||
);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::UserClaw,
|
|
||||||
home.join(".claw").join("skills"),
|
|
||||||
SkillOrigin::SkillsDir,
|
|
||||||
);
|
|
||||||
push_unique_skill_root(
|
|
||||||
&mut roots,
|
|
||||||
DefinitionSource::UserClaw,
|
|
||||||
home.join(".claw").join("commands"),
|
|
||||||
SkillOrigin::LegacyCommandsDir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
roots
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_unique_root(
|
fn push_unique_root(
|
||||||
roots: &mut Vec<(DefinitionSource, PathBuf)>,
|
roots: &mut Vec<(DefinitionSource, PathBuf)>,
|
||||||
source: DefinitionSource,
|
source: DefinitionSource,
|
||||||
@@ -1388,21 +1426,6 @@ fn push_unique_root(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_unique_skill_root(
|
|
||||||
roots: &mut Vec<SkillRoot>,
|
|
||||||
source: DefinitionSource,
|
|
||||||
path: PathBuf,
|
|
||||||
origin: SkillOrigin,
|
|
||||||
) {
|
|
||||||
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
|
|
||||||
roots.push(SkillRoot {
|
|
||||||
source,
|
|
||||||
path,
|
|
||||||
origin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_agents_from_roots(
|
fn load_agents_from_roots(
|
||||||
roots: &[(DefinitionSource, PathBuf)],
|
roots: &[(DefinitionSource, PathBuf)],
|
||||||
) -> std::io::Result<Vec<AgentSummary>> {
|
) -> std::io::Result<Vec<AgentSummary>> {
|
||||||
@@ -1446,16 +1469,16 @@ fn load_agents_from_roots(
|
|||||||
Ok(agents)
|
Ok(agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
|
fn load_skills_from_roots(roots: &[SkillDiscoveryRoot]) -> std::io::Result<Vec<SkillSummary>> {
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
|
let mut active_sources = BTreeMap::<String, SkillDiscoverySource>::new();
|
||||||
|
|
||||||
for root in roots {
|
for root in roots {
|
||||||
let mut root_skills = Vec::new();
|
let mut root_skills = Vec::new();
|
||||||
for entry in fs::read_dir(&root.path)? {
|
for entry in fs::read_dir(&root.path)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
match root.origin {
|
match root.kind {
|
||||||
SkillOrigin::SkillsDir => {
|
SkillRootKind::SkillsDir => {
|
||||||
if !entry.path().is_dir() {
|
if !entry.path().is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1471,10 +1494,10 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
|||||||
description,
|
description,
|
||||||
source: root.source,
|
source: root.source,
|
||||||
shadowed_by: None,
|
shadowed_by: None,
|
||||||
origin: root.origin,
|
origin: root.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
SkillOrigin::LegacyCommandsDir => {
|
SkillRootKind::LegacyCommandsDir => {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let markdown_path = if path.is_dir() {
|
let markdown_path = if path.is_dir() {
|
||||||
let skill_path = path.join("SKILL.md");
|
let skill_path = path.join("SKILL.md");
|
||||||
@@ -1502,7 +1525,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
|
|||||||
description,
|
description,
|
||||||
source: root.source,
|
source: root.source,
|
||||||
shadowed_by: None,
|
shadowed_by: None,
|
||||||
origin: root.origin,
|
origin: root.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1650,9 +1673,19 @@ fn agent_detail(agent: &AgentSummary) -> String {
|
|||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_skills_report(skills: &[SkillSummary]) -> String {
|
fn render_skills_report(skills: &[SkillSummary], roots: &[SkillDiscoveryRoot]) -> String {
|
||||||
if skills.is_empty() {
|
if skills.is_empty() {
|
||||||
return "No skills found.".to_string();
|
let mut lines = vec!["Skills".to_string(), " No skills found.".to_string()];
|
||||||
|
let checked_paths = skill_root_paths(roots);
|
||||||
|
if !checked_paths.is_empty() {
|
||||||
|
lines.push(" Checked".to_string());
|
||||||
|
lines.extend(
|
||||||
|
checked_paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| format!(" {}", path.display())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_active = skills
|
let total_active = skills
|
||||||
@@ -1666,11 +1699,11 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for source in [
|
for source in [
|
||||||
DefinitionSource::ProjectCodex,
|
SkillDiscoverySource::ProjectCodex,
|
||||||
DefinitionSource::ProjectClaw,
|
SkillDiscoverySource::ProjectClaw,
|
||||||
DefinitionSource::UserCodexHome,
|
SkillDiscoverySource::UserCodexHome,
|
||||||
DefinitionSource::UserCodex,
|
SkillDiscoverySource::UserCodex,
|
||||||
DefinitionSource::UserClaw,
|
SkillDiscoverySource::UserClaw,
|
||||||
] {
|
] {
|
||||||
let group = skills
|
let group = skills
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1681,6 +1714,9 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(format!("{}:", source.label()));
|
lines.push(format!("{}:", source.label()));
|
||||||
|
for path in skill_root_paths_for_source(roots, source) {
|
||||||
|
lines.push(format!(" Path {}", path.display()));
|
||||||
|
}
|
||||||
for skill in group {
|
for skill in group {
|
||||||
let mut parts = vec![skill.name.clone()];
|
let mut parts = vec![skill.name.clone()];
|
||||||
if let Some(description) = &skill.description {
|
if let Some(description) = &skill.description {
|
||||||
@@ -1701,6 +1737,21 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
lines.join("\n").trim_end().to_string()
|
lines.join("\n").trim_end().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn skill_root_paths(roots: &[SkillDiscoveryRoot]) -> Vec<PathBuf> {
|
||||||
|
roots.iter().map(|root| root.path.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skill_root_paths_for_source(
|
||||||
|
roots: &[SkillDiscoveryRoot],
|
||||||
|
source: SkillDiscoverySource,
|
||||||
|
) -> Vec<PathBuf> {
|
||||||
|
roots
|
||||||
|
.iter()
|
||||||
|
.filter(|root| root.source == source)
|
||||||
|
.map(|root| root.path.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
||||||
args.map(str::trim).filter(|value| !value.is_empty())
|
args.map(str::trim).filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
@@ -1731,6 +1782,77 @@ fn render_skills_usage(unexpected: Option<&str>) -> String {
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_hooks_usage(unexpected: Option<&str>) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"Hooks".to_string(),
|
||||||
|
" Usage /hooks".to_string(),
|
||||||
|
" Direct CLI claw hooks".to_string(),
|
||||||
|
" Runtime support PreToolUse, PostToolUse".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(args) = unexpected {
|
||||||
|
lines.push(format!(" Unexpected {args}"));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_hooks_report(cwd: &Path, runtime_config: &RuntimeConfig) -> String {
|
||||||
|
let pre_tool_use = runtime_config.hooks().pre_tool_use();
|
||||||
|
let post_tool_use = runtime_config.hooks().post_tool_use();
|
||||||
|
let configured_events =
|
||||||
|
usize::from(!pre_tool_use.is_empty()) + usize::from(!post_tool_use.is_empty());
|
||||||
|
let total_hooks = pre_tool_use.len() + post_tool_use.len();
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
"Hooks".to_string(),
|
||||||
|
format!(" Working directory {}", cwd.display()),
|
||||||
|
format!(
|
||||||
|
" Loaded files {}",
|
||||||
|
runtime_config.loaded_entries().len()
|
||||||
|
),
|
||||||
|
format!(" Configured hooks {total_hooks}"),
|
||||||
|
format!(" Events {configured_events}"),
|
||||||
|
" Runtime support PreToolUse, PostToolUse shell commands".to_string(),
|
||||||
|
String::new(),
|
||||||
|
"Loaded config files".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if runtime_config.loaded_entries().is_empty() {
|
||||||
|
lines.push(" (none)".to_string());
|
||||||
|
} else {
|
||||||
|
for entry in runtime_config.loaded_entries() {
|
||||||
|
let source = match entry.source {
|
||||||
|
ConfigSource::User => "user",
|
||||||
|
ConfigSource::Project => "project",
|
||||||
|
ConfigSource::Local => "local",
|
||||||
|
};
|
||||||
|
lines.push(format!(" {source:<7} {}", entry.path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_hooks == 0 {
|
||||||
|
lines.push(String::new());
|
||||||
|
lines.push("No hooks configured.".to_string());
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
render_hook_event_section(&mut lines, "PreToolUse", pre_tool_use);
|
||||||
|
render_hook_event_section(&mut lines, "PostToolUse", post_tool_use);
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_hook_event_section(lines: &mut Vec<String>, event_name: &str, commands: &[String]) {
|
||||||
|
if commands.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(String::new());
|
||||||
|
lines.push(event_name.to_string());
|
||||||
|
lines.push(format!(" Count {}", commands.len()));
|
||||||
|
for (index, command) in commands.iter().enumerate() {
|
||||||
|
lines.push(format!(" {}. {}", index + 1, command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn handle_slash_command(
|
pub fn handle_slash_command(
|
||||||
input: &str,
|
input: &str,
|
||||||
@@ -1774,6 +1896,7 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Cost
|
| SlashCommand::Cost
|
||||||
| SlashCommand::Resume { .. }
|
| SlashCommand::Resume { .. }
|
||||||
| SlashCommand::Config { .. }
|
| SlashCommand::Config { .. }
|
||||||
|
| SlashCommand::Hooks { .. }
|
||||||
| SlashCommand::Memory
|
| SlashCommand::Memory
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
| SlashCommand::Diff
|
| SlashCommand::Diff
|
||||||
@@ -1781,6 +1904,7 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Export { .. }
|
| SlashCommand::Export { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
| SlashCommand::Plugins { .. }
|
| SlashCommand::Plugins { .. }
|
||||||
|
| SlashCommand::ReloadPlugins
|
||||||
| SlashCommand::Agents { .. }
|
| SlashCommand::Agents { .. }
|
||||||
| SlashCommand::Skills { .. }
|
| SlashCommand::Skills { .. }
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Unknown(_) => None,
|
||||||
@@ -1791,15 +1915,17 @@ pub fn handle_slash_command(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
handle_branch_slash_command, handle_commit_push_pr_slash_command,
|
handle_branch_slash_command, handle_commit_push_pr_slash_command,
|
||||||
handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command,
|
handle_commit_slash_command, handle_hooks_slash_command, handle_plugins_slash_command,
|
||||||
handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots,
|
handle_slash_command, handle_worktree_slash_command, load_agents_from_roots,
|
||||||
render_agents_report, render_plugins_report, render_skills_report,
|
load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||||
suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot,
|
suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SlashCommand,
|
||||||
SlashCommand,
|
|
||||||
};
|
};
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
use runtime::{
|
||||||
|
CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||||
|
SkillDiscoveryRoot, SkillDiscoverySource, SkillRootKind,
|
||||||
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -2058,6 +2184,16 @@ mod tests {
|
|||||||
section: Some("env".to_string())
|
section: Some("env".to_string())
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/hooks"),
|
||||||
|
Some(SlashCommand::Hooks { args: None })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/hooks help"),
|
||||||
|
Some(SlashCommand::Hooks {
|
||||||
|
args: Some("help".to_string())
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
||||||
assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
|
assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
|
||||||
@@ -2103,6 +2239,10 @@ mod tests {
|
|||||||
target: Some("demo".to_string())
|
target: Some("demo".to_string())
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/reload-plugins"),
|
||||||
|
Some(SlashCommand::ReloadPlugins)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2133,6 +2273,7 @@ mod tests {
|
|||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-path>"));
|
||||||
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
||||||
|
assert!(help.contains("/hooks"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
@@ -2140,13 +2281,14 @@ mod tests {
|
|||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||||
assert!(help.contains(
|
assert!(help.contains(
|
||||||
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
"/plugin [inspect|list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
));
|
));
|
||||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||||
|
assert!(help.contains("/reload-plugins"));
|
||||||
assert!(help.contains("/agents"));
|
assert!(help.contains("/agents"));
|
||||||
assert!(help.contains("/skills"));
|
assert!(help.contains("/skills"));
|
||||||
assert_eq!(slash_command_specs().len(), 28);
|
assert_eq!(slash_command_specs().len(), 30);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 13);
|
assert_eq!(resume_supported_slash_commands().len(), 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2253,6 +2395,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
||||||
);
|
);
|
||||||
|
assert!(handle_slash_command("/hooks", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2265,6 +2408,10 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
handle_slash_command("/reload-plugins", &session, CompactionConfig::default())
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2306,6 +2453,47 @@ mod tests {
|
|||||||
assert!(rendered.contains("disabled"));
|
assert!(rendered.contains("disabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_plugin_action_renders_inspection_report() {
|
||||||
|
let config_home = temp_dir("plugins-inspect-home");
|
||||||
|
let bundled_root = temp_dir("plugins-inspect-bundled");
|
||||||
|
let bundled_plugin = bundled_root.join("starter");
|
||||||
|
write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
|
||||||
|
|
||||||
|
let mut config = PluginManagerConfig::new(&config_home);
|
||||||
|
config.bundled_root = Some(bundled_root.clone());
|
||||||
|
config.external_dirs = vec![config_home.join("external")];
|
||||||
|
let mut manager = PluginManager::new(config);
|
||||||
|
|
||||||
|
let inspection = handle_plugins_slash_command(None, None, &mut manager)
|
||||||
|
.expect("inspect command should succeed");
|
||||||
|
assert!(!inspection.reload_runtime);
|
||||||
|
assert!(inspection.message.contains("Current support"));
|
||||||
|
assert!(inspection.message.contains("Checked locations"));
|
||||||
|
assert!(inspection
|
||||||
|
.message
|
||||||
|
.contains(&manager.install_root().display().to_string()));
|
||||||
|
assert!(inspection
|
||||||
|
.message
|
||||||
|
.contains(&manager.bundled_root_path().display().to_string()));
|
||||||
|
assert!(inspection
|
||||||
|
.message
|
||||||
|
.contains(&manager.registry_path().display().to_string()));
|
||||||
|
assert!(inspection
|
||||||
|
.message
|
||||||
|
.contains(&manager.settings_path().display().to_string()));
|
||||||
|
assert!(inspection
|
||||||
|
.message
|
||||||
|
.contains("Plugin-defined slash commands are parsed from manifests but not exposed."));
|
||||||
|
assert!(inspection.message.contains(
|
||||||
|
"Plugin hooks/lifecycle are validated but not attached to the conversation runtime."
|
||||||
|
));
|
||||||
|
assert!(inspection.message.contains("starter@bundled"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(bundled_root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lists_agents_from_project_and_user_roots() {
|
fn lists_agents_from_project_and_user_roots() {
|
||||||
let workspace = temp_dir("agents-workspace");
|
let workspace = temp_dir("agents-workspace");
|
||||||
@@ -2368,30 +2556,38 @@ mod tests {
|
|||||||
write_skill(&user_skills, "help", "Help guidance");
|
write_skill(&user_skills, "help", "Help guidance");
|
||||||
|
|
||||||
let roots = vec![
|
let roots = vec![
|
||||||
SkillRoot {
|
SkillDiscoveryRoot {
|
||||||
source: DefinitionSource::ProjectCodex,
|
source: SkillDiscoverySource::ProjectCodex,
|
||||||
path: project_skills,
|
path: project_skills,
|
||||||
origin: SkillOrigin::SkillsDir,
|
kind: SkillRootKind::SkillsDir,
|
||||||
},
|
},
|
||||||
SkillRoot {
|
SkillDiscoveryRoot {
|
||||||
source: DefinitionSource::ProjectClaw,
|
source: SkillDiscoverySource::ProjectClaw,
|
||||||
path: project_commands,
|
path: project_commands,
|
||||||
origin: SkillOrigin::LegacyCommandsDir,
|
kind: SkillRootKind::LegacyCommandsDir,
|
||||||
},
|
},
|
||||||
SkillRoot {
|
SkillDiscoveryRoot {
|
||||||
source: DefinitionSource::UserCodex,
|
source: SkillDiscoverySource::UserCodex,
|
||||||
path: user_skills,
|
path: user_skills,
|
||||||
origin: SkillOrigin::SkillsDir,
|
kind: SkillRootKind::SkillsDir,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let report =
|
let skills = load_skills_from_roots(&roots).expect("skill roots should load");
|
||||||
render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
|
let report = render_skills_report(&skills, &roots);
|
||||||
|
|
||||||
assert!(report.contains("Skills"));
|
assert!(report.contains("Skills"));
|
||||||
assert!(report.contains("3 available skills"));
|
assert!(report.contains("3 available skills"));
|
||||||
assert!(report.contains("Project (.codex):"));
|
assert!(report.contains("Project (.codex):"));
|
||||||
|
assert!(report.contains(&format!(
|
||||||
|
"Path {}",
|
||||||
|
workspace.join(".codex").join("skills").display()
|
||||||
|
)));
|
||||||
assert!(report.contains("plan · Project planning guidance"));
|
assert!(report.contains("plan · Project planning guidance"));
|
||||||
assert!(report.contains("Project (.claw):"));
|
assert!(report.contains("Project (.claw):"));
|
||||||
|
assert!(report.contains(&format!(
|
||||||
|
"Path {}",
|
||||||
|
workspace.join(".claw").join("commands").display()
|
||||||
|
)));
|
||||||
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
||||||
assert!(report.contains("User (~/.codex):"));
|
assert!(report.contains("User (~/.codex):"));
|
||||||
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
|
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
|
||||||
@@ -2402,7 +2598,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agents_and_skills_usage_support_help_and_unexpected_args() {
|
fn agents_skills_and_hooks_usage_support_help_and_unexpected_args() {
|
||||||
let cwd = temp_dir("slash-usage");
|
let cwd = temp_dir("slash-usage");
|
||||||
|
|
||||||
let agents_help =
|
let agents_help =
|
||||||
@@ -2423,9 +2619,76 @@ mod tests {
|
|||||||
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
|
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
|
||||||
assert!(skills_unexpected.contains("Unexpected show help"));
|
assert!(skills_unexpected.contains("Unexpected show help"));
|
||||||
|
|
||||||
|
let hooks_help = handle_hooks_slash_command(Some("help"), &cwd).expect("hooks help");
|
||||||
|
assert!(hooks_help.contains("Usage /hooks"));
|
||||||
|
assert!(hooks_help.contains("Direct CLI claw hooks"));
|
||||||
|
|
||||||
|
let hooks_unexpected = handle_hooks_slash_command(Some("show"), &cwd).expect("hooks usage");
|
||||||
|
assert!(hooks_unexpected.contains("Unexpected show"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(cwd);
|
let _ = fs::remove_dir_all(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hooks_report_lists_configured_commands() {
|
||||||
|
let _guard = env_lock();
|
||||||
|
let workspace = temp_dir("hooks-report-workspace");
|
||||||
|
let home = temp_dir("hooks-report-home");
|
||||||
|
fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
env::set_var("CLAW_CONFIG_HOME", &home);
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
home.join("settings.json"),
|
||||||
|
r#"{"hooks":{"PreToolUse":["echo pre"],"PostToolUse":["echo post"]}}"#,
|
||||||
|
)
|
||||||
|
.expect("write home hooks");
|
||||||
|
fs::write(
|
||||||
|
workspace.join(".claw").join("settings.local.json"),
|
||||||
|
r#"{"hooks":{"PostToolUse":["echo local post"]}}"#,
|
||||||
|
)
|
||||||
|
.expect("write local hooks");
|
||||||
|
|
||||||
|
let report = handle_hooks_slash_command(None, &workspace).expect("hooks report");
|
||||||
|
assert!(report.contains("Hooks"));
|
||||||
|
assert!(report.contains("Configured hooks 2"));
|
||||||
|
assert!(report.contains("Runtime support PreToolUse, PostToolUse shell commands"));
|
||||||
|
assert!(report.contains("PreToolUse"));
|
||||||
|
assert!(report.contains("1. echo pre"));
|
||||||
|
assert!(report.contains("PostToolUse"));
|
||||||
|
assert!(report.contains("1. echo local post"));
|
||||||
|
assert!(report.contains("Loaded config files"));
|
||||||
|
|
||||||
|
env::remove_var("CLAW_CONFIG_HOME");
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
let _ = fs::remove_dir_all(home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_skills_report_lists_checked_directories() {
|
||||||
|
let workspace = temp_dir("skills-empty");
|
||||||
|
let nested = workspace.join("apps").join("ui");
|
||||||
|
fs::create_dir_all(&nested).expect("nested cwd");
|
||||||
|
fs::create_dir_all(workspace.join(".claw").join("skills")).expect("claw skills");
|
||||||
|
fs::create_dir_all(workspace.join(".codex").join("commands")).expect("codex commands");
|
||||||
|
|
||||||
|
let roots = runtime::discover_skill_roots(&nested);
|
||||||
|
let report = render_skills_report(&[], &roots);
|
||||||
|
|
||||||
|
assert!(report.contains("Skills"));
|
||||||
|
assert!(report.contains("No skills found."));
|
||||||
|
assert!(report.contains(&workspace.join(".claw").join("skills").display().to_string()));
|
||||||
|
assert!(report.contains(
|
||||||
|
&workspace
|
||||||
|
.join(".codex")
|
||||||
|
.join("commands")
|
||||||
|
.display()
|
||||||
|
.to_string()
|
||||||
|
));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_quoted_skill_frontmatter_values() {
|
fn parses_quoted_skill_frontmatter_values() {
|
||||||
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
||||||
|
|||||||
@@ -648,6 +648,17 @@ pub struct PluginSummary {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PluginInspection {
|
||||||
|
pub install_root: PathBuf,
|
||||||
|
pub registry_path: PathBuf,
|
||||||
|
pub settings_path: PathBuf,
|
||||||
|
pub bundled_root: PathBuf,
|
||||||
|
pub external_dirs: Vec<PathBuf>,
|
||||||
|
pub discoverable_plugins: Vec<PluginSummary>,
|
||||||
|
pub installed_plugins: Vec<PluginSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct PluginRegistry {
|
pub struct PluginRegistry {
|
||||||
plugins: Vec<RegisteredPlugin>,
|
plugins: Vec<RegisteredPlugin>,
|
||||||
@@ -934,6 +945,31 @@ impl PluginManager {
|
|||||||
self.config.config_home.join(SETTINGS_FILE_NAME)
|
self.config.config_home.join(SETTINGS_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bundled_root_path(&self) -> PathBuf {
|
||||||
|
self.config
|
||||||
|
.bundled_root
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(Self::bundled_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn external_dirs(&self) -> &[PathBuf] {
|
||||||
|
&self.config.external_dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect(&self) -> Result<PluginInspection, PluginError> {
|
||||||
|
Ok(PluginInspection {
|
||||||
|
install_root: self.install_root(),
|
||||||
|
registry_path: self.registry_path(),
|
||||||
|
settings_path: self.settings_path(),
|
||||||
|
bundled_root: self.bundled_root_path(),
|
||||||
|
external_dirs: self.external_dirs().to_vec(),
|
||||||
|
discoverable_plugins: self.list_plugins()?,
|
||||||
|
installed_plugins: self.list_installed_plugins()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
||||||
Ok(PluginRegistry::new(
|
Ok(PluginRegistry::new(
|
||||||
self.discover_plugins()?
|
self.discover_plugins()?
|
||||||
|
|||||||
@@ -15,12 +15,9 @@ mod prompt;
|
|||||||
mod remote;
|
mod remote;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod skills;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use lsp::{
|
|
||||||
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig,
|
|
||||||
SymbolLocation, WorkspaceDiagnostics,
|
|
||||||
};
|
|
||||||
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
|
||||||
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
|
||||||
pub use compact::{
|
pub use compact::{
|
||||||
@@ -28,8 +25,8 @@ pub use compact::{
|
|||||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
|
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||||
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||||
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
||||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
@@ -44,12 +41,16 @@ pub use file_ops::{
|
|||||||
WriteFileOutput,
|
WriteFileOutput,
|
||||||
};
|
};
|
||||||
pub use hooks::{HookEvent, HookRunResult, HookRunner};
|
pub use hooks::{HookEvent, HookRunResult, HookRunner};
|
||||||
|
pub use lsp::{
|
||||||
|
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig, SymbolLocation,
|
||||||
|
WorkspaceDiagnostics,
|
||||||
|
};
|
||||||
pub use mcp::{
|
pub use mcp::{
|
||||||
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
|
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
|
||||||
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
|
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
|
||||||
};
|
};
|
||||||
pub use mcp_client::{
|
pub use mcp_client::{
|
||||||
McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
|
McpClientAuth, McpClientBootstrap, McpClientTransport, McpManagedProxyTransport,
|
||||||
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
|
||||||
};
|
};
|
||||||
pub use mcp_stdio::{
|
pub use mcp_stdio::{
|
||||||
@@ -81,6 +82,10 @@ pub use remote::{
|
|||||||
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
||||||
};
|
};
|
||||||
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
|
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
|
||||||
|
pub use skills::{
|
||||||
|
discover_skill_roots, resolve_skill_path, SkillDiscoveryRoot, SkillDiscoverySource,
|
||||||
|
SkillRootKind,
|
||||||
|
};
|
||||||
pub use usage::{
|
pub use usage::{
|
||||||
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
||||||
};
|
};
|
||||||
|
|||||||
313
rust/crates/runtime/src/skills.rs
Normal file
313
rust/crates/runtime/src/skills.rs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum SkillDiscoverySource {
|
||||||
|
ProjectCodex,
|
||||||
|
ProjectClaw,
|
||||||
|
UserCodexHome,
|
||||||
|
UserCodex,
|
||||||
|
UserClaw,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillDiscoverySource {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ProjectCodex => "Project (.codex)",
|
||||||
|
Self::ProjectClaw => "Project (.claw)",
|
||||||
|
Self::UserCodexHome => "User ($CODEX_HOME)",
|
||||||
|
Self::UserCodex => "User (~/.codex)",
|
||||||
|
Self::UserClaw => "User (~/.claw)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SkillRootKind {
|
||||||
|
SkillsDir,
|
||||||
|
LegacyCommandsDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillRootKind {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn detail_label(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::SkillsDir => None,
|
||||||
|
Self::LegacyCommandsDir => Some("legacy /commands"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SkillDiscoveryRoot {
|
||||||
|
pub source: SkillDiscoverySource,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub kind: SkillRootKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_skill_roots(cwd: &Path) -> Vec<SkillDiscoveryRoot> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
for ancestor in cwd.ancestors() {
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::ProjectCodex,
|
||||||
|
ancestor.join(".codex").join("skills"),
|
||||||
|
SkillRootKind::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::ProjectClaw,
|
||||||
|
ancestor.join(".claw").join("skills"),
|
||||||
|
SkillRootKind::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::ProjectCodex,
|
||||||
|
ancestor.join(".codex").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommandsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::ProjectClaw,
|
||||||
|
ancestor.join(".claw").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommandsDir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(codex_home) = env::var("CODEX_HOME") {
|
||||||
|
let codex_home = PathBuf::from(codex_home);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::UserCodexHome,
|
||||||
|
codex_home.join("skills"),
|
||||||
|
SkillRootKind::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::UserCodexHome,
|
||||||
|
codex_home.join("commands"),
|
||||||
|
SkillRootKind::LegacyCommandsDir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(home) = env::var_os("HOME") {
|
||||||
|
let home = PathBuf::from(home);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::UserCodex,
|
||||||
|
home.join(".codex").join("skills"),
|
||||||
|
SkillRootKind::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::UserCodex,
|
||||||
|
home.join(".codex").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommandsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::UserClaw,
|
||||||
|
home.join(".claw").join("skills"),
|
||||||
|
SkillRootKind::SkillsDir,
|
||||||
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
SkillDiscoverySource::UserClaw,
|
||||||
|
home.join(".claw").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommandsDir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_skill_path(skill: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||||
|
let requested = normalize_requested_skill_name(skill)?;
|
||||||
|
|
||||||
|
for root in discover_skill_roots(cwd) {
|
||||||
|
match root.kind {
|
||||||
|
SkillRootKind::SkillsDir => {
|
||||||
|
let direct = root.path.join(&requested).join("SKILL.md");
|
||||||
|
if direct.is_file() {
|
||||||
|
return Ok(direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&root.path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path().join("SKILL.md");
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.eq_ignore_ascii_case(&requested)
|
||||||
|
{
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SkillRootKind::LegacyCommandsDir => {
|
||||||
|
let direct_markdown = root.path.join(format!("{requested}.md"));
|
||||||
|
if direct_markdown.is_file() {
|
||||||
|
return Ok(direct_markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
let direct_skill_dir = root.path.join(&requested).join("SKILL.md");
|
||||||
|
if direct_skill_dir.is_file() {
|
||||||
|
return Ok(direct_skill_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&root.path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
let skill_path = path.join("SKILL.md");
|
||||||
|
if !skill_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.eq_ignore_ascii_case(&requested)
|
||||||
|
{
|
||||||
|
return Ok(skill_path);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(stem) = path.file_stem() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if stem.to_string_lossy().eq_ignore_ascii_case(&requested) {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("unknown skill: {requested}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_requested_skill_name(skill: &str) -> Result<String, String> {
|
||||||
|
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||||
|
if requested.is_empty() {
|
||||||
|
return Err(String::from("skill must not be empty"));
|
||||||
|
}
|
||||||
|
Ok(requested.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unique_skill_root(
|
||||||
|
roots: &mut Vec<SkillDiscoveryRoot>,
|
||||||
|
source: SkillDiscoverySource,
|
||||||
|
path: PathBuf,
|
||||||
|
kind: SkillRootKind,
|
||||||
|
) {
|
||||||
|
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
|
||||||
|
roots.push(SkillDiscoveryRoot { source, path, kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
discover_skill_roots, resolve_skill_path, SkillDiscoveryRoot, SkillDiscoverySource,
|
||||||
|
SkillRootKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("runtime-skills-{label}-{nanos}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_skill(root: &Path, name: &str) {
|
||||||
|
let skill_root = root.join(name);
|
||||||
|
fs::create_dir_all(&skill_root).expect("skill root");
|
||||||
|
fs::write(skill_root.join("SKILL.md"), format!("# {name}\n")).expect("write skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_legacy_markdown(root: &Path, name: &str) {
|
||||||
|
fs::create_dir_all(root).expect("legacy root");
|
||||||
|
fs::write(root.join(format!("{name}.md")), format!("# {name}\n")).expect("write command");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_workspace_and_user_skill_roots() {
|
||||||
|
let _guard = crate::test_env_lock();
|
||||||
|
let workspace = temp_dir("workspace");
|
||||||
|
let nested = workspace.join("apps").join("ui");
|
||||||
|
let user_home = temp_dir("home");
|
||||||
|
|
||||||
|
fs::create_dir_all(&nested).expect("nested cwd");
|
||||||
|
fs::create_dir_all(workspace.join(".codex").join("skills")).expect("project codex skills");
|
||||||
|
fs::create_dir_all(workspace.join(".claw").join("commands"))
|
||||||
|
.expect("project claw commands");
|
||||||
|
fs::create_dir_all(user_home.join(".codex").join("skills")).expect("user codex skills");
|
||||||
|
|
||||||
|
std::env::set_var("HOME", &user_home);
|
||||||
|
std::env::remove_var("CODEX_HOME");
|
||||||
|
|
||||||
|
let roots = discover_skill_roots(&nested);
|
||||||
|
|
||||||
|
assert!(roots.contains(&SkillDiscoveryRoot {
|
||||||
|
source: SkillDiscoverySource::ProjectCodex,
|
||||||
|
path: workspace.join(".codex").join("skills"),
|
||||||
|
kind: SkillRootKind::SkillsDir,
|
||||||
|
}));
|
||||||
|
assert!(roots.contains(&SkillDiscoveryRoot {
|
||||||
|
source: SkillDiscoverySource::ProjectClaw,
|
||||||
|
path: workspace.join(".claw").join("commands"),
|
||||||
|
kind: SkillRootKind::LegacyCommandsDir,
|
||||||
|
}));
|
||||||
|
assert!(roots.contains(&SkillDiscoveryRoot {
|
||||||
|
source: SkillDiscoverySource::UserCodex,
|
||||||
|
path: user_home.join(".codex").join("skills"),
|
||||||
|
kind: SkillRootKind::SkillsDir,
|
||||||
|
}));
|
||||||
|
|
||||||
|
std::env::remove_var("HOME");
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
let _ = fs::remove_dir_all(user_home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_workspace_skills_and_legacy_commands() {
|
||||||
|
let _guard = crate::test_env_lock();
|
||||||
|
let workspace = temp_dir("resolve");
|
||||||
|
let nested = workspace.join("apps").join("ui");
|
||||||
|
let original_dir = std::env::current_dir().expect("cwd");
|
||||||
|
|
||||||
|
fs::create_dir_all(&nested).expect("nested cwd");
|
||||||
|
write_skill(&workspace.join(".claw").join("skills"), "review");
|
||||||
|
write_legacy_markdown(&workspace.join(".codex").join("commands"), "deploy");
|
||||||
|
|
||||||
|
std::env::set_current_dir(&nested).expect("set cwd");
|
||||||
|
let review = resolve_skill_path("review", &nested).expect("workspace skill");
|
||||||
|
let deploy = resolve_skill_path("/deploy", &nested).expect("legacy command");
|
||||||
|
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||||
|
|
||||||
|
assert!(review.ends_with(".claw/skills/review/SKILL.md"));
|
||||||
|
assert!(deploy.ends_with(".codex/commands/deploy.md"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,11 @@ use api::{
|
|||||||
use plugins::PluginTool;
|
use plugins::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,
|
||||||
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
|
resolve_skill_path as resolve_runtime_skill_path, write_file, ApiClient, ApiRequest,
|
||||||
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
|
AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, ConversationRuntime,
|
||||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
GrepSearchInput, MessageRole, PermissionMode, 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 +92,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 +104,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 +159,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 +183,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| {
|
||||||
(
|
(
|
||||||
@@ -1455,47 +1465,8 @@ fn todo_store_path() -> Result<std::path::PathBuf, 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 cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
if requested.is_empty() {
|
resolve_runtime_skill_path(skill, &cwd)
|
||||||
return Err(String::from("skill must not be empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
|
||||||
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
|
|
||||||
}
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
|
|
||||||
|
|
||||||
for root in candidates {
|
|
||||||
let direct = root.join(requested).join("SKILL.md");
|
|
||||||
if direct.exists() {
|
|
||||||
return Ok(direct);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(format!("unknown skill: {requested}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||||
@@ -3488,6 +3459,65 @@ mod tests {
|
|||||||
.ends_with("/help/SKILL.md"));
|
.ends_with("/help/SKILL.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_resolves_workspace_skill_and_legacy_command() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let root = temp_path("workspace-skills");
|
||||||
|
let cwd = root.join("apps").join("ui");
|
||||||
|
let original_dir = std::env::current_dir().expect("cwd");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(root.join(".claw").join("skills").join("review"))
|
||||||
|
.expect("workspace skill dir");
|
||||||
|
std::fs::write(
|
||||||
|
root.join(".claw")
|
||||||
|
.join("skills")
|
||||||
|
.join("review")
|
||||||
|
.join("SKILL.md"),
|
||||||
|
"---\ndescription: Workspace review guidance\n---\n# review\n",
|
||||||
|
)
|
||||||
|
.expect("write workspace skill");
|
||||||
|
std::fs::create_dir_all(root.join(".codex").join("commands")).expect("legacy root");
|
||||||
|
std::fs::write(
|
||||||
|
root.join(".codex").join("commands").join("deploy.md"),
|
||||||
|
"---\ndescription: Deploy command guidance\n---\n# deploy\n",
|
||||||
|
)
|
||||||
|
.expect("write legacy command");
|
||||||
|
std::fs::create_dir_all(&cwd).expect("cwd");
|
||||||
|
|
||||||
|
std::env::set_current_dir(&cwd).expect("set cwd");
|
||||||
|
|
||||||
|
let workspace_skill = execute_tool("Skill", &json!({ "skill": "review" }))
|
||||||
|
.expect("workspace skill should resolve");
|
||||||
|
let workspace_output: serde_json::Value =
|
||||||
|
serde_json::from_str(&workspace_skill).expect("valid json");
|
||||||
|
assert_eq!(
|
||||||
|
workspace_output["description"].as_str(),
|
||||||
|
Some("Workspace review guidance")
|
||||||
|
);
|
||||||
|
assert!(workspace_output["path"]
|
||||||
|
.as_str()
|
||||||
|
.expect("path")
|
||||||
|
.ends_with(".claw/skills/review/SKILL.md"));
|
||||||
|
|
||||||
|
let legacy_skill = execute_tool("Skill", &json!({ "skill": "/deploy" }))
|
||||||
|
.expect("legacy command should resolve");
|
||||||
|
let legacy_output: serde_json::Value =
|
||||||
|
serde_json::from_str(&legacy_skill).expect("valid json");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_output["description"].as_str(),
|
||||||
|
Some("Deploy command guidance")
|
||||||
|
);
|
||||||
|
assert!(legacy_output["path"]
|
||||||
|
.as_str()
|
||||||
|
.expect("path")
|
||||||
|
.ends_with(".codex/commands/deploy.md"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[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(
|
||||||
|
|||||||
Reference in New Issue
Block a user