Compare commits

..

9 Commits

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

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

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

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

Confidence: high

Scope-risk: moderate

Reversibility: clean

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

Tested: cargo check; cargo test

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

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

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

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

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

Constraint: Docs must reflect the current owner positioning without introducing unsupported product claims
Constraint: Legal framing must stay explicit that this is a clean-room Rust implementation, not a direct port or copy
Rejected: Leave release notes unchanged | would keep product-positioning language inconsistent across release-facing docs
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future release-facing docs aligned on product naming and clean-room positioning before tagging releases
Tested: Reviewed README and docs/releases/0.1.0.md after edits; verified only intended docs files were staged
Not-tested: cargo check and cargo test (docs-only pass; no code changes)
2026-04-01 17:19:09 +00:00
Yeachan-Heo
7030d26e7a docs: rebalance OmX and OmO README credit 2026-04-01 13:28:36 +00:00
Yeachan-Heo
cf0047207f docs: fix OmO hyperlink target in README 2026-04-01 13:27:25 +00:00
Yeachan-Heo
16c6d23e19 docs: keep minimal OmO hyperlink in README 2026-04-01 13:26:01 +00:00
Yeachan-Heo
8947e382e1 docs: remove remaining OMO README promo block 2026-04-01 13:22:13 +00:00
7 changed files with 1363 additions and 311 deletions

View File

@@ -33,38 +33,6 @@
--- ---
## Built with oh-my-opencode
<p align="center">
<a href="https://github.com/code-yeongyu/oh-my-openagent">
<img src="https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/.github/assets/omo.png" width="600" />
</a>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/oh-my-openagent"><strong>oh-my-opencode</strong></a> — the agent orchestration layer that makes AI coding actually work.
<br />
<em>Sisyphus doesn't stop until the task is done. Every test passes. Every review clears.</em>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/oh-my-openagent"><img src="https://img.shields.io/github/stars/code-yeongyu/oh-my-openagent?color=ffcb47&labelColor=black&style=for-the-badge&logo=github" /></a>
<a href="https://www.npmjs.com/package/oh-my-opencode"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fohmyopenagent.com%2Fapi%2Fnpm-downloads&style=for-the-badge" /></a>
<a href="https://discord.gg/PUwSMR9XNk"><img src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=for-the-badge" /></a>
</p>
The **entire Rust port** was built by oh-my-opencode's **Sisyphus** agent in `ultrawork` mode.
> *"If Claude Code does in 7 days what a human does in 3 months, Sisyphus does it in 1 hour."* — B, Quant Researcher
> *"Oh My OpenCode Is Actually Insane"* — [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
**Credits:** [@code-yeongyu](https://github.com/code-yeongyu) (oh-my-opencode creator) · **Sisyphus** (autonomous coding agent) · **Jobdori**
<p align="center"><code>npx oh-my-opencode@latest</code></p>
---
## Rust Port ## Rust Port
The Rust workspace under `rust/` is the current systems-language port of the project. The Rust workspace under `rust/` is the current systems-language port of the project.
@@ -94,7 +62,7 @@ The whole thing was orchestrated end-to-end using [oh-my-codex (OmX)](https://gi
The result is a clean-room Python rewrite that captures the architectural patterns of Claw Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.** The result is a clean-room Python rewrite that captures the architectural patterns of Claw Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.**
The Rust port was built separately using [oh-my-opencode (OMO)](https://github.com/code-yeongyu/oh-my-opencode) by [@q_yeon_gyu_kim](https://x.com/q_yeon_gyu_kim) ([@code-yeongyu](https://github.com/code-yeongyu)), which orchestrates [opencode](https://opencode.ai) agents. **The scaffolding and architecture direction were established with [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex),** and the **Sisyphus** agent then handled implementation work across the API client, runtime engine, CLI, plugin system, MCP integration, and the cleanroom pass in `ultrawork` mode. The Rust port was developed with both [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) and [oh-my-opencode (OmO)](https://github.com/code-yeongyu/oh-my-openagent): OmX drove scaffolding, orchestration, and architecture direction, while OmO was used for later implementation acceleration and verification support.
https://github.com/instructkr/claw-code https://github.com/instructkr/claw-code
@@ -220,8 +188,8 @@ The port now mirrors the archived root-entry file surface, top-level subsystem n
This repository's porting, cleanroom hardening, and verification workflow was AI-assisted with Yeachan Heo's tooling stack, with **oh-my-codex (OmX)** as the primary scaffolding and orchestration layer. This repository's porting, cleanroom hardening, and verification workflow was AI-assisted with Yeachan Heo's tooling stack, with **oh-my-codex (OmX)** as the primary scaffolding and orchestration layer.
- [**oh-my-codex (OmX)**](https://github.com/Yeachan-Heo/oh-my-codex) — main branch credit: primary scaffolding, orchestration, and core porting workflow - [**oh-my-codex (OmX)**](https://github.com/Yeachan-Heo/oh-my-codex) — scaffolding, orchestration, architecture direction, and core porting workflow
- [**oh-my-opencode (OmO)**](https://github.com/instructkr/oh-my-opencode) — implementation acceleration, cleanup passes, and verification support - [**oh-my-opencode (OmO)**](https://github.com/code-yeongyu/oh-my-openagent) — implementation acceleration, cleanup, and verification support
Key workflow patterns used during the port: Key workflow patterns used during the port:

View File

@@ -1,149 +1,122 @@
# 🦞 Claw Code — Rust Implementation # Claw Code
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution. Claw Code is a local coding-agent CLI implemented in safe Rust. It is **Claude Code inspired** and developed as a **clean-room implementation**: it aims for a strong local agent experience, but it is **not** a direct port or copy of Claude Code.
## Quick Start The Rust workspace is the current main product surface. The `claw` binary provides interactive sessions, one-shot prompts, workspace-aware tools, local agent workflows, and plugin-capable operation from a single workspace.
## Current status
- **Version:** `0.1.0`
- **Release stage:** initial public release, source-build distribution
- **Primary implementation:** Rust workspace in this repository
- **Platform focus:** macOS and Linux developer workstations
## Install, build, and run
### Prerequisites
- Rust stable toolchain
- Cargo
- Provider credentials for the model you want to use
### Authentication
Anthropic-compatible models:
```bash ```bash
# Build export ANTHROPIC_API_KEY="..."
cd rust/ # Optional when using a compatible endpoint
cargo build --release export ANTHROPIC_BASE_URL="https://api.anthropic.com"
```
# Run interactive REPL Grok models:
```bash
export XAI_API_KEY="..."
# Optional when using a compatible endpoint
export XAI_BASE_URL="https://api.x.ai"
```
OAuth login is also available:
```bash
cargo run --bin claw -- login
```
### Install locally
```bash
cargo install --path crates/claw-cli --locked
```
### Build from source
```bash
cargo build --release -p claw-cli
```
### Run
From the workspace:
```bash
cargo run --bin claw -- --help
cargo run --bin claw --
cargo run --bin claw -- prompt "summarize this workspace"
cargo run --bin claw -- --model sonnet "review the latest changes"
```
From the release build:
```bash
./target/release/claw ./target/release/claw
./target/release/claw prompt "explain crates/runtime"
# One-shot prompt
./target/release/claw prompt "explain this codebase"
# With specific model
./target/release/claw --model sonnet prompt "fix the bug in main.rs"
``` ```
## Configuration ## Supported capabilities
Set your API credentials: - Interactive REPL and one-shot prompt execution
- Saved-session inspection and resume flows
- Built-in workspace tools for shell, file read/write/edit, search, web fetch/search, todos, and notebook updates
- Slash commands for status, compaction, config inspection, diff, export, session management, and version reporting
- Local agent and skill discovery with `claw agents` and `claw skills`
- Plugin discovery and management through the CLI and slash-command surfaces
- OAuth login/logout plus model/provider selection from the command line
- Workspace-aware instruction/config loading (`CLAW.md`, config files, permissions, plugin settings)
```bash ## Current limitations
export ANTHROPIC_API_KEY="sk-ant-..."
# Or use a proxy
export ANTHROPIC_BASE_URL="https://your-proxy.com"
```
Or authenticate via OAuth: - Public distribution is **source-build only** today; this workspace is not set up for crates.io publishing
- GitHub CI verifies `cargo check`, `cargo test`, and release builds, but automated release packaging is not yet present
- Current CI targets Ubuntu and macOS; Windows release readiness is still to be established
- Some live-provider integration coverage is opt-in because it requires external credentials and network access
- The command surface may continue to evolve during the `0.x` series
```bash ## Implementation
claw login
```
## Features The Rust workspace is the active product implementation. It currently includes these crates:
| Feature | Status | - `claw-cli` — user-facing binary
|---------|--------| - `api` — provider clients and streaming
| API + streaming | ✅ | - `runtime` — sessions, config, permissions, prompts, and runtime loop
| OAuth login/logout | ✅ | - `tools` — built-in tool implementations
| Interactive REPL (rustyline) | ✅ | - `commands` — slash-command registry and handlers
| Tool system (bash, read, write, edit, grep, glob) | ✅ | - `plugins` — plugin discovery, registry, and lifecycle support
| Web tools (search, fetch) | ✅ | - `lsp` — language-server protocol support types and process helpers
| Sub-agent orchestration | ✅ | - `server` and `compat-harness` — supporting services and compatibility tooling
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAW.md / project memory | ✅ |
| Config file hierarchy (.claw.json) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle | ✅ |
| Session persistence + resume | ✅ |
| Extended thinking (thinking blocks) | ✅ |
| Cost tracking + usage display | ✅ |
| Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ |
| Slash commands (/status, /compact, /clear, etc.) | ✅ |
| Hooks (PreToolUse/PostToolUse) | 🔧 Config only |
| Plugin system | 📋 Planned |
| Skills registry | 📋 Planned |
## Model Aliases ## Roadmap
Short names resolve to the latest model versions: - Publish packaged release artifacts for public installs
- Add a repeatable release workflow and longer-lived changelog discipline
- Expand platform verification beyond the current CI matrix
- Add more task-focused examples and operator documentation
- Continue tightening feature coverage and UX polish across the Rust implementation
| Alias | Resolves To | ## Release notes
|-------|------------|
| `opus` | `claude-opus-4-6` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags - Draft 0.1.0 release notes: [`docs/releases/0.1.0.md`](docs/releases/0.1.0.md)
```
claw [OPTIONS] [COMMAND]
Options:
--model MODEL Set the model (alias or full name)
--dangerously-skip-permissions Skip all permission checks
--permission-mode MODE Set read-only, workspace-write, or danger-full-access
--allowedTools TOOLS Restrict enabled tools
--output-format FORMAT Output format (text or json)
--version, -V Print version info
Commands:
prompt <text> One-shot prompt (non-interactive)
login Authenticate via OAuth
logout Clear stored credentials
init Initialize project config
doctor Check environment health
self-update Update to latest version
```
## Slash Commands (REPL)
| Command | Description |
|---------|-------------|
| `/help` | Show help |
| `/status` | Show session status (model, tokens, cost) |
| `/cost` | Show cost breakdown |
| `/compact` | Compact conversation history |
| `/clear` | Clear conversation |
| `/model [name]` | Show or switch model |
| `/permissions` | Show or switch permission mode |
| `/config [section]` | Show config (env, hooks, model) |
| `/memory` | Show CLAW.md contents |
| `/diff` | Show git diff |
| `/export [path]` | Export conversation |
| `/session [id]` | Resume a previous session |
| `/version` | Show version |
## Workspace Layout
```
rust/
├── Cargo.toml # Workspace root
├── Cargo.lock
└── crates/
├── api/ # API client + SSE streaming
├── commands/ # Shared slash-command registry
├── compat-harness/ # TS manifest extraction harness
├── runtime/ # Session, config, permissions, MCP, prompts
├── claw-cli/ # Main CLI binary (`claw`)
└── tools/ # Built-in tool implementations
```
### Crate Responsibilities
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer)
- **commands** — Slash command definitions and help text generation
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
- **claw-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
- **tools** — Tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, REPL runtimes
## Stats
- **~20K lines** of Rust
- **6 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-6`
- **Default permissions:** `danger-full-access`
## License ## License
See repository root. See the repository root for licensing details.

View File

@@ -1015,22 +1015,22 @@ fn run_repl(
loop { loop {
match editor.read_line()? { match editor.read_line()? {
input::ReadOutcome::Submit(input) => { input::ReadOutcome::Submit(input) => {
let trimmed = input.trim().to_string(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
continue; continue;
} }
if matches!(trimmed.as_str(), "/exit" | "/quit") { if matches!(trimmed, "/exit" | "/quit") {
cli.persist_session()?; cli.persist_session()?;
break; break;
} }
if let Some(command) = SlashCommand::parse(&trimmed) { if let Some(command) = SlashCommand::parse(trimmed) {
if cli.handle_repl_command(command)? { if cli.handle_repl_command(command)? {
cli.persist_session()?; cli.persist_session()?;
} }
continue; continue;
} }
editor.push_history(input); editor.push_history(&input);
cli.run_turn(&trimmed)?; cli.run_turn(&input)?;
} }
input::ReadOutcome::Cancel => {} input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => { input::ReadOutcome::Exit => {
@@ -1350,7 +1350,7 @@ impl LiveCli {
false false
} }
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("{}", render_unknown_slash_command(&name)); eprintln!("{}", render_unknown_repl_command(&name));
false false
} }
}) })
@@ -2007,15 +2007,31 @@ fn append_slash_command_suggestions(lines: &mut Vec<String>, name: &str) {
); );
} }
fn render_unknown_slash_command(name: &str) -> String { fn render_unknown_repl_command(name: &str) -> String {
let mut lines = vec![ let mut lines = vec![
"Unknown slash command".to_string(), "Unknown slash command".to_string(),
format!(" Command /{name}"), format!(" Command /{name}"),
]; ];
append_slash_command_suggestions(&mut lines, name); append_repl_command_suggestions(&mut lines, name);
lines.join("\n") lines.join("\n")
} }
fn append_repl_command_suggestions(lines: &mut Vec<String>, name: &str) {
let suggestions = suggest_repl_commands(name);
if suggestions.is_empty() {
lines.push(" Try /help shows the full slash command map".to_string());
return;
}
lines.push(" Try /help shows the full slash command map".to_string());
lines.push("Suggestions".to_string());
lines.extend(
suggestions
.into_iter()
.map(|suggestion| format!(" {suggestion}")),
);
}
fn render_mode_unavailable(command: &str, label: &str) -> String { fn render_mode_unavailable(command: &str, label: &str) -> String {
[ [
"Command unavailable in this REPL mode".to_string(), "Command unavailable in this REPL mode".to_string(),
@@ -3277,10 +3293,70 @@ fn slash_command_completion_candidates() -> Vec<String> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
candidates.push("/vim".to_string()); candidates.extend([
String::from("/vim"),
String::from("/exit"),
String::from("/quit"),
]);
candidates.sort();
candidates.dedup();
candidates candidates
} }
fn suggest_repl_commands(name: &str) -> Vec<String> {
let normalized = name.trim().trim_start_matches('/').to_ascii_lowercase();
if normalized.is_empty() {
return Vec::new();
}
let mut ranked = slash_command_completion_candidates()
.into_iter()
.filter_map(|candidate| {
let raw = candidate.trim_start_matches('/').to_ascii_lowercase();
let distance = edit_distance(&normalized, &raw);
let prefix_match = raw.starts_with(&normalized) || normalized.starts_with(&raw);
let near_match = distance <= 2;
(prefix_match || near_match).then_some((distance, candidate))
})
.collect::<Vec<_>>();
ranked.sort();
ranked.dedup_by(|left, right| left.1 == right.1);
ranked
.into_iter()
.map(|(_, candidate)| candidate)
.take(3)
.collect()
}
fn edit_distance(left: &str, right: &str) -> usize {
if left == right {
return 0;
}
if left.is_empty() {
return right.chars().count();
}
if right.is_empty() {
return left.chars().count();
}
let right_chars = right.chars().collect::<Vec<_>>();
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
let mut current = vec![0; right_chars.len() + 1];
for (left_index, left_char) in left.chars().enumerate() {
current[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let substitution_cost = usize::from(left_char != *right_char);
current[right_index + 1] = (previous[right_index + 1] + 1)
.min(current[right_index] + 1)
.min(previous[right_index] + substitution_cost);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right_chars.len()]
}
fn format_tool_call_start(name: &str, input: &str) -> String { fn format_tool_call_start(name: &str, input: &str) -> String {
let parsed: serde_json::Value = let parsed: serde_json::Value =
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
@@ -4038,9 +4114,10 @@ mod tests {
format_status_report, format_tool_call_start, format_tool_result, format_status_report, format_tool_call_start, format_tool_result,
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
print_help_to, push_output_block, render_config_report, render_memory_report, print_help_to, push_output_block, render_config_report, render_memory_report,
render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent, resume_supported_slash_commands, slash_command_completion_candidates, status_context,
InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use api::{MessageResponse, OutputContentBlock, Usage}; use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
@@ -4355,7 +4432,7 @@ mod tests {
let help = commands::render_slash_command_help(); let help = commands::render_slash_command_help();
assert!(help.contains("Slash commands")); assert!(help.contains("Slash commands"));
assert!(help.contains("Tab completes commands inside the REPL.")); assert!(help.contains("Tab completes commands inside the REPL."));
assert!(help.contains("works with --resume SESSION.json")); assert!(help.contains("available via claw --resume SESSION.json"));
} }
#[test] #[test]
@@ -4386,6 +4463,23 @@ mod tests {
assert!(help.contains("Tab cycles slash command matches")); assert!(help.contains("Tab cycles slash command matches"));
} }
#[test]
fn completion_candidates_include_repl_only_exit_commands() {
let candidates = slash_command_completion_candidates();
assert!(candidates.contains(&"/help".to_string()));
assert!(candidates.contains(&"/vim".to_string()));
assert!(candidates.contains(&"/exit".to_string()));
assert!(candidates.contains(&"/quit".to_string()));
}
#[test]
fn unknown_repl_command_suggestions_include_repl_shortcuts() {
let rendered = render_unknown_repl_command("exi");
assert!(rendered.contains("Unknown slash command"));
assert!(rendered.contains("/exit"));
assert!(rendered.contains("/help"));
}
#[test] #[test]
fn resume_supported_command_list_matches_expected_surface() { fn resume_supported_command_list_matches_expected_surface() {
let names = resume_supported_slash_commands() let names = resume_supported_slash_commands()

View File

@@ -6,8 +6,10 @@ 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::{
use runtime::{compact_session, CompactionConfig, Session}; load_plugin_from_directory, PluginError, PluginManager, PluginManagerConfig, PluginSummary,
};
use runtime::{compact_session, CompactionConfig, ConfigLoader, Session};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandManifestEntry { pub struct CommandManifestEntry {
@@ -488,7 +490,7 @@ pub fn render_slash_command_help() -> String {
let mut lines = vec![ let mut lines = vec![
"Slash commands".to_string(), "Slash commands".to_string(),
" Tab completes commands inside the REPL.".to_string(), " Tab completes commands inside the REPL.".to_string(),
" [resume] works with --resume SESSION.json.".to_string(), " [resume] = also available via claw --resume SESSION.json".to_string(),
]; ];
for category in [ for category in [
@@ -631,6 +633,7 @@ enum DefinitionSource {
UserCodexHome, UserCodexHome,
UserCodex, UserCodex,
UserClaw, UserClaw,
Plugin,
} }
impl DefinitionSource { impl DefinitionSource {
@@ -641,6 +644,7 @@ impl DefinitionSource {
Self::UserCodexHome => "User ($CODEX_HOME)", Self::UserCodexHome => "User ($CODEX_HOME)",
Self::UserCodex => "User (~/.codex)", Self::UserCodex => "User (~/.codex)",
Self::UserClaw => "User (~/.claw)", Self::UserClaw => "User (~/.claw)",
Self::Plugin => "Plugins",
} }
} }
} }
@@ -684,6 +688,14 @@ struct SkillRoot {
source: DefinitionSource, source: DefinitionSource,
path: PathBuf, path: PathBuf,
origin: SkillOrigin, origin: SkillOrigin,
name_prefix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AgentRoot {
source: DefinitionSource,
path: PathBuf,
name_prefix: Option<String>,
} }
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
@@ -801,7 +813,7 @@ pub fn handle_plugins_slash_command(
pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> { pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
match normalize_optional_args(args) { match normalize_optional_args(args) {
None | Some("list") => { None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents"); let roots = discover_agent_roots(cwd);
let agents = load_agents_from_roots(&roots)?; let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents)) Ok(render_agents_report(&agents))
} }
@@ -1301,6 +1313,20 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
roots roots
} }
fn discover_agent_roots(cwd: &Path) -> Vec<AgentRoot> {
let mut roots = discover_definition_roots(cwd, "agents")
.into_iter()
.map(|(source, path)| AgentRoot {
source,
path,
name_prefix: None,
})
.collect::<Vec<_>>();
extend_plugin_agent_roots(cwd, &mut roots);
roots
}
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> { fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
let mut roots = Vec::new(); let mut roots = Vec::new();
@@ -1310,24 +1336,28 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
DefinitionSource::ProjectCodex, DefinitionSource::ProjectCodex,
ancestor.join(".codex").join("skills"), ancestor.join(".codex").join("skills"),
SkillOrigin::SkillsDir, SkillOrigin::SkillsDir,
None,
); );
push_unique_skill_root( push_unique_skill_root(
&mut roots, &mut roots,
DefinitionSource::ProjectClaw, DefinitionSource::ProjectClaw,
ancestor.join(".claw").join("skills"), ancestor.join(".claw").join("skills"),
SkillOrigin::SkillsDir, SkillOrigin::SkillsDir,
None,
); );
push_unique_skill_root( push_unique_skill_root(
&mut roots, &mut roots,
DefinitionSource::ProjectCodex, DefinitionSource::ProjectCodex,
ancestor.join(".codex").join("commands"), ancestor.join(".codex").join("commands"),
SkillOrigin::LegacyCommandsDir, SkillOrigin::LegacyCommandsDir,
None,
); );
push_unique_skill_root( push_unique_skill_root(
&mut roots, &mut roots,
DefinitionSource::ProjectClaw, DefinitionSource::ProjectClaw,
ancestor.join(".claw").join("commands"), ancestor.join(".claw").join("commands"),
SkillOrigin::LegacyCommandsDir, SkillOrigin::LegacyCommandsDir,
None,
); );
} }
@@ -1338,12 +1368,14 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
DefinitionSource::UserCodexHome, DefinitionSource::UserCodexHome,
codex_home.join("skills"), codex_home.join("skills"),
SkillOrigin::SkillsDir, SkillOrigin::SkillsDir,
None,
); );
push_unique_skill_root( push_unique_skill_root(
&mut roots, &mut roots,
DefinitionSource::UserCodexHome, DefinitionSource::UserCodexHome,
codex_home.join("commands"), codex_home.join("commands"),
SkillOrigin::LegacyCommandsDir, SkillOrigin::LegacyCommandsDir,
None,
); );
} }
@@ -1354,27 +1386,32 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
DefinitionSource::UserCodex, DefinitionSource::UserCodex,
home.join(".codex").join("skills"), home.join(".codex").join("skills"),
SkillOrigin::SkillsDir, SkillOrigin::SkillsDir,
None,
); );
push_unique_skill_root( push_unique_skill_root(
&mut roots, &mut roots,
DefinitionSource::UserCodex, DefinitionSource::UserCodex,
home.join(".codex").join("commands"), home.join(".codex").join("commands"),
SkillOrigin::LegacyCommandsDir, SkillOrigin::LegacyCommandsDir,
None,
); );
push_unique_skill_root( push_unique_skill_root(
&mut roots, &mut roots,
DefinitionSource::UserClaw, DefinitionSource::UserClaw,
home.join(".claw").join("skills"), home.join(".claw").join("skills"),
SkillOrigin::SkillsDir, SkillOrigin::SkillsDir,
None,
); );
push_unique_skill_root( push_unique_skill_root(
&mut roots, &mut roots,
DefinitionSource::UserClaw, DefinitionSource::UserClaw,
home.join(".claw").join("commands"), home.join(".claw").join("commands"),
SkillOrigin::LegacyCommandsDir, SkillOrigin::LegacyCommandsDir,
None,
); );
} }
extend_plugin_skill_roots(cwd, &mut roots);
roots roots
} }
@@ -1393,43 +1430,162 @@ fn push_unique_skill_root(
source: DefinitionSource, source: DefinitionSource,
path: PathBuf, path: PathBuf,
origin: SkillOrigin, origin: SkillOrigin,
name_prefix: Option<String>,
) { ) {
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { if path.exists() && !roots.iter().any(|existing| existing.path == path) {
roots.push(SkillRoot { roots.push(SkillRoot {
source, source,
path, path,
origin, origin,
name_prefix,
}); });
} }
} }
fn load_agents_from_roots( fn push_unique_agent_root(
roots: &[(DefinitionSource, PathBuf)], roots: &mut Vec<AgentRoot>,
) -> std::io::Result<Vec<AgentSummary>> { source: DefinitionSource,
path: PathBuf,
name_prefix: Option<String>,
) {
if path.exists() && !roots.iter().any(|existing| existing.path == path) {
roots.push(AgentRoot {
source,
path,
name_prefix,
});
}
}
fn extend_plugin_agent_roots(cwd: &Path, roots: &mut Vec<AgentRoot>) {
for plugin in enabled_plugins_for_cwd(cwd) {
let Some(root) = &plugin.metadata.root else {
continue;
};
push_unique_agent_root(
roots,
DefinitionSource::Plugin,
root.join("agents"),
Some(plugin.metadata.name.clone()),
);
if let Ok(manifest) = load_plugin_from_directory(root) {
for relative in manifest.agents {
push_unique_agent_root(
roots,
DefinitionSource::Plugin,
resolve_plugin_component_path(root, &relative),
Some(plugin.metadata.name.clone()),
);
}
}
}
}
fn extend_plugin_skill_roots(cwd: &Path, roots: &mut Vec<SkillRoot>) {
for plugin in enabled_plugins_for_cwd(cwd) {
let Some(root) = &plugin.metadata.root else {
continue;
};
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
root.join("skills"),
SkillOrigin::SkillsDir,
Some(plugin.metadata.name.clone()),
);
if let Ok(manifest) = load_plugin_from_directory(root) {
for relative in manifest.skills {
let path = resolve_plugin_component_path(root, &relative);
let origin = if path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
SkillOrigin::LegacyCommandsDir
} else {
SkillOrigin::SkillsDir
};
push_unique_skill_root(
roots,
DefinitionSource::Plugin,
path,
origin,
Some(plugin.metadata.name.clone()),
);
}
}
}
}
fn enabled_plugins_for_cwd(cwd: &Path) -> Vec<PluginSummary> {
let Some(manager) = plugin_manager_for_cwd(cwd) else {
return Vec::new();
};
manager
.list_installed_plugins()
.map(|plugins| {
plugins
.into_iter()
.filter(|plugin| plugin.enabled)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn plugin_manager_for_cwd(cwd: &Path) -> Option<PluginManager> {
let loader = ConfigLoader::default_for(cwd);
let runtime_config = loader.load().ok()?;
let plugin_settings = runtime_config.plugins();
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
plugin_config.external_dirs = plugin_settings
.external_directories()
.iter()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
.collect();
plugin_config.install_root = plugin_settings
.install_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.registry_path = plugin_settings
.registry_path()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.bundled_root = plugin_settings
.bundled_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
Some(PluginManager::new(plugin_config))
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = Path::new(value);
if path.is_absolute() {
path.to_path_buf()
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
}
fn resolve_plugin_component_path(root: &Path, value: &str) -> PathBuf {
let path = Path::new(value);
if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
}
}
fn load_agents_from_roots(roots: &[AgentRoot]) -> std::io::Result<Vec<AgentSummary>> {
let mut agents = Vec::new(); let mut agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new(); let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for (source, root) in roots { for root in roots {
let mut root_agents = Vec::new(); let mut root_agents = Vec::new();
for entry in fs::read_dir(root)? { collect_agents(root, &root.path, &mut root_agents)?;
let entry = entry?;
if entry.path().extension().is_none_or(|ext| ext != "toml") {
continue;
}
let contents = fs::read_to_string(entry.path())?;
let fallback_name = entry.path().file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
shadowed_by: None,
});
}
root_agents.sort_by(|left, right| left.name.cmp(&right.name)); root_agents.sort_by(|left, right| left.name.cmp(&right.name));
for mut agent in root_agents { for mut agent in root_agents {
@@ -1452,61 +1608,7 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
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)? { collect_skills(root, &root.path, &mut root_skills)?;
let entry = entry?;
match root.origin {
SkillOrigin::SkillsDir => {
if !entry.path().is_dir() {
continue;
}
let skill_path = entry.path().join("SKILL.md");
if !skill_path.is_file() {
continue;
}
let contents = fs::read_to_string(skill_path)?;
let (name, description) = parse_skill_frontmatter(&contents);
root_skills.push(SkillSummary {
name: name
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
description,
source: root.source,
shadowed_by: None,
origin: root.origin,
});
}
SkillOrigin::LegacyCommandsDir => {
let path = entry.path();
let markdown_path = if path.is_dir() {
let skill_path = path.join("SKILL.md");
if !skill_path.is_file() {
continue;
}
skill_path
} else if path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
path
} else {
continue;
};
let contents = fs::read_to_string(&markdown_path)?;
let fallback_name = markdown_path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
let (name, description) = parse_skill_frontmatter(&contents);
root_skills.push(SkillSummary {
name: name.unwrap_or(fallback_name),
description,
source: root.source,
shadowed_by: None,
origin: root.origin,
});
}
}
}
root_skills.sort_by(|left, right| left.name.cmp(&right.name)); root_skills.sort_by(|left, right| left.name.cmp(&right.name));
for mut skill in root_skills { for mut skill in root_skills {
@@ -1523,6 +1625,205 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
Ok(skills) Ok(skills)
} }
fn collect_agents(
root: &AgentRoot,
path: &Path,
agents: &mut Vec<AgentSummary>,
) -> std::io::Result<()> {
if path.is_file() {
if let Some(agent) = load_agent_summary(path, &root.path, root)? {
agents.push(agent);
}
return Ok(());
}
let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let entry_path = entry.path();
if entry_path.is_dir() {
collect_agents(root, &entry_path, agents)?;
} else if let Some(agent) = load_agent_summary(&entry_path, &root.path, root)? {
agents.push(agent);
}
}
Ok(())
}
fn load_agent_summary(
path: &Path,
base_root: &Path,
root: &AgentRoot,
) -> std::io::Result<Option<AgentSummary>> {
let extension = path
.extension()
.map(|ext| ext.to_string_lossy().to_ascii_lowercase());
let Some(extension) = extension else {
return Ok(None);
};
if extension != "toml" && extension != "md" {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
let base_name = if extension == "toml" {
parse_toml_string(&contents, "name").unwrap_or_else(|| fallback_file_stem(path))
} else {
let (name, _) = parse_skill_frontmatter(&contents);
name.unwrap_or_else(|| fallback_file_stem(path))
};
let description = if extension == "toml" {
parse_toml_string(&contents, "description")
} else {
let (_, description) = parse_skill_frontmatter(&contents);
description
};
let model = if extension == "toml" {
parse_toml_string(&contents, "model")
} else {
parse_frontmatter_key(&contents, "model")
};
let reasoning_effort = if extension == "toml" {
parse_toml_string(&contents, "model_reasoning_effort")
} else {
parse_frontmatter_key(&contents, "effort")
};
Ok(Some(AgentSummary {
name: prefixed_definition_name(
root.name_prefix.as_deref(),
namespace_for_file(path, base_root, false),
&base_name,
),
description,
model,
reasoning_effort,
source: root.source,
shadowed_by: None,
}))
}
fn collect_skills(
root: &SkillRoot,
path: &Path,
skills: &mut Vec<SkillSummary>,
) -> std::io::Result<()> {
if path.is_file() {
if let Some(skill) = load_skill_summary(path, &root.path, root)? {
skills.push(skill);
}
return Ok(());
}
let skill_md_path = path.join("SKILL.md");
if skill_md_path.is_file() {
if let Some(skill) = load_skill_summary(&skill_md_path, &root.path, root)? {
skills.push(skill);
}
return Ok(());
}
let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let entry_path = entry.path();
if entry_path.is_dir() {
collect_skills(root, &entry_path, skills)?;
} else if root.origin == SkillOrigin::LegacyCommandsDir {
if let Some(skill) = load_skill_summary(&entry_path, &root.path, root)? {
skills.push(skill);
}
}
}
Ok(())
}
fn load_skill_summary(
path: &Path,
base_root: &Path,
root: &SkillRoot,
) -> std::io::Result<Option<SkillSummary>> {
if !path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
return Ok(None);
}
let is_skill_file = path
.file_name()
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case("SKILL.md"));
if root.origin == SkillOrigin::SkillsDir && !is_skill_file {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
let (name, description) = parse_skill_frontmatter(&contents);
let base_name = if is_skill_file {
path.parent().and_then(Path::file_name).map_or_else(
|| fallback_file_stem(path),
|name| name.to_string_lossy().to_string(),
)
} else {
fallback_file_stem(path)
};
let namespace = namespace_for_file(path, base_root, is_skill_file);
Ok(Some(SkillSummary {
name: prefixed_definition_name(
root.name_prefix.as_deref(),
namespace,
&name.unwrap_or(base_name),
),
description,
source: root.source,
shadowed_by: None,
origin: root.origin,
}))
}
fn namespace_for_file(path: &Path, base_root: &Path, is_skill_file: bool) -> Option<String> {
let relative_parent = if is_skill_file {
path.parent()
.and_then(Path::parent)
.and_then(|parent| parent.strip_prefix(base_root).ok())
} else {
path.parent()
.and_then(|parent| parent.strip_prefix(base_root).ok())
}?;
let segments = relative_parent
.iter()
.map(|segment| segment.to_string_lossy())
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_string())
.collect::<Vec<_>>();
(!segments.is_empty()).then(|| segments.join(":"))
}
fn prefixed_definition_name(
prefix: Option<&str>,
namespace: Option<String>,
base_name: &str,
) -> String {
let mut parts = Vec::new();
if let Some(prefix) = prefix.filter(|prefix| !prefix.is_empty()) {
parts.push(prefix.to_string());
}
if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) {
parts.push(namespace);
}
parts.push(base_name.to_string());
parts.join(":")
}
fn fallback_file_stem(path: &Path) -> String {
path.file_stem()
.map_or_else(String::new, |stem| stem.to_string_lossy().to_string())
}
fn parse_toml_string(contents: &str, key: &str) -> Option<String> { fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
let prefix = format!("{key} ="); let prefix = format!("{key} =");
for line in contents.lines() { for line in contents.lines() {
@@ -1548,34 +1849,32 @@ fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
} }
fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) { fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
(
parse_frontmatter_key(contents, "name"),
parse_frontmatter_key(contents, "description"),
)
}
fn parse_frontmatter_key(contents: &str, key: &str) -> Option<String> {
let mut lines = contents.lines(); let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") { if lines.next().map(str::trim) != Some("---") {
return (None, None); return None;
} }
let mut name = None;
let mut description = None;
for line in lines { for line in lines {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed == "---" { if trimmed == "---" {
break; break;
} }
if let Some(value) = trimmed.strip_prefix("name:") { if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) {
let value = unquote_frontmatter_value(value.trim()); let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() { if !value.is_empty() {
name = Some(value); return Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("description:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
description = Some(value);
} }
} }
} }
(name, description) None
} }
fn unquote_frontmatter_value(value: &str) -> String { fn unquote_frontmatter_value(value: &str) -> String {
@@ -1613,6 +1912,7 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
DefinitionSource::UserCodexHome, DefinitionSource::UserCodexHome,
DefinitionSource::UserCodex, DefinitionSource::UserCodex,
DefinitionSource::UserClaw, DefinitionSource::UserClaw,
DefinitionSource::Plugin,
] { ] {
let group = agents let group = agents
.iter() .iter()
@@ -1671,6 +1971,7 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
DefinitionSource::UserCodexHome, DefinitionSource::UserCodexHome,
DefinitionSource::UserCodex, DefinitionSource::UserCodex,
DefinitionSource::UserClaw, DefinitionSource::UserClaw,
DefinitionSource::Plugin,
] { ] {
let group = skills let group = skills
.iter() .iter()
@@ -1710,7 +2011,8 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
"Agents".to_string(), "Agents".to_string(),
" Usage /agents".to_string(), " Usage /agents".to_string(),
" Direct CLI claw agents".to_string(), " Direct CLI claw agents".to_string(),
" Sources .codex/agents, .claw/agents, $CODEX_HOME/agents".to_string(), " Sources .codex/agents, .claw/agents, $CODEX_HOME/agents, enabled plugin agents"
.to_string(),
]; ];
if let Some(args) = unexpected { if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}")); lines.push(format!(" Unexpected {args}"));
@@ -1723,7 +2025,8 @@ fn render_skills_usage(unexpected: Option<&str>) -> String {
"Skills".to_string(), "Skills".to_string(),
" Usage /skills".to_string(), " Usage /skills".to_string(),
" Direct CLI claw skills".to_string(), " Direct CLI claw skills".to_string(),
" Sources .codex/skills, .claw/skills, legacy /commands".to_string(), " Sources .codex/skills, .claw/skills, legacy /commands, enabled plugin skills"
.to_string(),
]; ];
if let Some(args) = unexpected { if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}")); lines.push(format!(" Unexpected {args}"));
@@ -1795,8 +2098,8 @@ mod tests {
handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots, handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots,
render_agents_report, render_plugins_report, render_skills_report, 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, AgentRoot, CommitPushPrRequest, DefinitionSource, SkillOrigin,
SlashCommand, SkillRoot, 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};
@@ -1937,6 +2240,17 @@ mod tests {
.expect("write agent"); .expect("write agent");
} }
fn write_markdown_agent(root: &Path, name: &str, description: &str, model: &str, effort: &str) {
fs::create_dir_all(root).expect("agent root");
fs::write(
root.join(format!("{name}.md")),
format!(
"---\nname: {name}\ndescription: {description}\nmodel: {model}\neffort: {effort}\n---\n\n# {name}\n"
),
)
.expect("write markdown agent");
}
fn write_skill(root: &Path, name: &str, description: &str) { fn write_skill(root: &Path, name: &str, description: &str) {
let skill_root = root.join(name); let skill_root = root.join(name);
fs::create_dir_all(&skill_root).expect("skill root"); fs::create_dir_all(&skill_root).expect("skill root");
@@ -2108,7 +2422,7 @@ mod tests {
#[test] #[test]
fn renders_help_from_shared_specs() { fn renders_help_from_shared_specs() {
let help = render_slash_command_help(); let help = render_slash_command_help();
assert!(help.contains("works with --resume SESSION.json")); assert!(help.contains("available via claw --resume SESSION.json"));
assert!(help.contains("Core flow")); assert!(help.contains("Core flow"));
assert!(help.contains("Workspace & memory")); assert!(help.contains("Workspace & memory"));
assert!(help.contains("Sessions & output")); assert!(help.contains("Sessions & output"));
@@ -2336,8 +2650,16 @@ mod tests {
); );
let roots = vec![ let roots = vec![
(DefinitionSource::ProjectCodex, project_agents), AgentRoot {
(DefinitionSource::UserCodex, user_agents), source: DefinitionSource::ProjectCodex,
path: project_agents,
name_prefix: None,
},
AgentRoot {
source: DefinitionSource::UserCodex,
path: user_agents,
name_prefix: None,
},
]; ];
let report = let report =
render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load")); render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
@@ -2372,16 +2694,19 @@ mod tests {
source: DefinitionSource::ProjectCodex, source: DefinitionSource::ProjectCodex,
path: project_skills, path: project_skills,
origin: SkillOrigin::SkillsDir, origin: SkillOrigin::SkillsDir,
name_prefix: None,
}, },
SkillRoot { SkillRoot {
source: DefinitionSource::ProjectClaw, source: DefinitionSource::ProjectClaw,
path: project_commands, path: project_commands,
origin: SkillOrigin::LegacyCommandsDir, origin: SkillOrigin::LegacyCommandsDir,
name_prefix: None,
}, },
SkillRoot { SkillRoot {
source: DefinitionSource::UserCodex, source: DefinitionSource::UserCodex,
path: user_skills, path: user_skills,
origin: SkillOrigin::SkillsDir, origin: SkillOrigin::SkillsDir,
name_prefix: None,
}, },
]; ];
let report = let report =
@@ -2401,6 +2726,71 @@ mod tests {
let _ = fs::remove_dir_all(user_home); let _ = fs::remove_dir_all(user_home);
} }
#[test]
fn discovers_namespaced_local_and_plugin_skills_and_agents() {
let _guard = env_lock();
let workspace = temp_dir("plugin-discovery-workspace");
let config_home = temp_dir("plugin-discovery-home");
let plugin_source = temp_dir("plugin-discovery-source");
let nested_skill_root = workspace.join(".codex").join("skills").join("ops");
write_skill(&nested_skill_root, "deploy", "Nested deployment guidance");
write_external_plugin(&plugin_source, "demo-plugin", "1.0.0");
write_skill(
&plugin_source.join("skills").join("reviews"),
"audit",
"Plugin audit guidance",
);
write_markdown_agent(
&plugin_source.join("agents").join("ops"),
"triage",
"Plugin triage agent",
"gpt-5.4-mini",
"high",
);
let previous_home = env::var_os("HOME");
let previous_claw_config_home = env::var_os("CLAW_CONFIG_HOME");
env::set_var("HOME", temp_dir("plugin-discovery-user-home"));
env::set_var("CLAW_CONFIG_HOME", &config_home);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
handle_plugins_slash_command(
Some("install"),
Some(plugin_source.to_str().expect("utf8 path")),
&mut manager,
)
.expect("plugin install should succeed");
let skills_report =
super::handle_skills_slash_command(None, &workspace).expect("skills should render");
assert!(skills_report.contains("ops:deploy · Nested deployment guidance"));
assert!(skills_report.contains("Plugins:"));
assert!(skills_report.contains("demo-plugin:reviews:audit · Plugin audit guidance"));
let agents_report =
super::handle_agents_slash_command(None, &workspace).expect("agents should render");
assert!(agents_report.contains("Plugins:"));
assert!(agents_report
.contains("demo-plugin:ops:triage · Plugin triage agent · gpt-5.4-mini · high"));
if let Some(value) = previous_home {
env::set_var("HOME", value);
} else {
env::remove_var("HOME");
}
if let Some(value) = previous_claw_config_home {
env::set_var("CLAW_CONFIG_HOME", value);
} else {
env::remove_var("CLAW_CONFIG_HOME");
}
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(plugin_source);
}
#[test] #[test]
fn agents_and_skills_usage_support_help_and_unexpected_args() { fn agents_and_skills_usage_support_help_and_unexpected_args() {
let cwd = temp_dir("slash-usage"); let cwd = temp_dir("slash-usage");
@@ -2418,6 +2808,7 @@ mod tests {
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
assert!(skills_help.contains("Usage /skills")); assert!(skills_help.contains("Usage /skills"));
assert!(skills_help.contains("legacy /commands")); assert!(skills_help.contains("legacy /commands"));
assert!(skills_help.contains("enabled plugin skills"));
let skills_unexpected = let skills_unexpected =
super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage"); super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
# Claw Code 0.1.0 release notes (draft)
## Summary
Claw Code `0.1.0` is the first public release-prep milestone for the current Rust implementation. Claw Code is Claude Code inspired and built as a clean-room Rust implementation; it is not a direct port or copy. This release centers on a usable local CLI experience: interactive sessions, non-interactive prompts, workspace tools, configuration loading, sessions, plugins, and local agent/skill discovery.
## Highlights
- Initial public `0.1.0` release candidate for Claw Code
- Safe-Rust implementation as the current primary product surface
- `claw` CLI for interactive and one-shot coding-agent workflows
- Built-in workspace tools for shell, file operations, search, web fetch/search, todo tracking, and notebook updates
- Slash-command surface for status, compaction, config inspection, sessions, diff/export, and version info
- Local plugin, agent, and skill discovery/management surfaces
- OAuth login/logout plus model/provider selection
## Install and run
This release is currently intended for source builds:
```bash
cargo install --path crates/claw-cli --locked
# or
cargo build --release -p claw-cli
```
Run:
```bash
claw
claw prompt "summarize this repository"
```
## Known limitations
- Source-build distribution only; packaged release artifacts are not yet published
- CI currently covers Ubuntu and macOS release builds, checks, and tests
- Windows release readiness is not yet established
- Some integration coverage is opt-in because live provider credentials and network access are required
- Public interfaces may continue to evolve during the `0.x` release line
## Recommended release framing
Position `0.1.0` as the first public release of Claw Code in its current Rust implementation for early adopters who are comfortable building from source. The feature surface is broad enough for real usage, while packaging and release automation can continue to improve in later releases.
## Verification used for this draft
- Workspace version verified from `Cargo.toml`
- `claw` binary/package path verified from `cargo metadata`
- CLI command surface verified from `cargo run --quiet --bin claw -- --help`
- CI coverage verified from `.github/workflows/ci.yml`