Compare commits

...

13 Commits

Author SHA1 Message Date
Yeachan-Heo
8805386bea merge: clawcode-issue-9406-commands-skill-install into main 2026-04-02 13:55:42 +00:00
Yeachan-Heo
c9f26013d8 merge: clawcode-issue-9405-plugins-execution-pipeline into main 2026-04-02 13:55:42 +00:00
Yeachan-Heo
703bbeef06 merge: clawcode-issue-9404-tools-plan-worktree into main 2026-04-02 13:55:42 +00:00
Yeachan-Heo
5d8e131c14 Wire plugin hooks and lifecycle into runtime startup
PARITY.md is stale relative to the current Rust plugin pipeline: plugin manifests, tool loading, and lifecycle primitives already exist, but runtime construction only consumed plugin tools. This change routes enabled plugin hooks into the runtime feature config, initializes plugin lifecycle commands when a runtime is built, and shuts plugins down when runtimes are replaced or dropped.\n\nThe test coverage exercises the new runtime plugin-state builder and verifies init/shutdown execution without relying on global cwd or config-home mutation, so the existing CLI suite stays stable under parallel execution.\n\nConstraint: Keep the change inside the current worktree and avoid touching unrelated pre-existing edits\nRejected: Add plugin hook execution inside the tools crate directly | runtime feature merging is the existing execution boundary\nRejected: Use process-global CLAW_CONFIG_HOME/current_dir in tests | races with the existing parallel CLI test suite\nConfidence: high\nScope-risk: moderate\nReversibility: clean\nDirective: Preserve plugin runtime shutdown when rebuilding LiveCli runtimes or temporary turn runtimes\nTested: cargo test -p rusty-claude-cli build_runtime_\nTested: cargo test -p rusty-claude-cli\nNot-tested: End-to-end live REPL session with a real plugin outside the test harness
2026-04-02 10:04:54 +00:00
Yeachan-Heo
e780142886 Make /skills install reusable skill packs
The Rust commands layer could list skills, but it had no concrete install path.
This change adds /skills install <path> and matching direct CLI parsing so a
skill directory or markdown file can be copied into the user skill registry
with a normalized invocation name and a structured install report.

Constraint: Keep the enhancement inside the existing Rust commands surface without adding dependencies
Rejected: Full project-scoped registry management | larger parity surface than needed for one landed path
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If project-scoped skill installation is added later, keep the install target explicit so command discovery and tool resolution stay aligned
Tested: cargo test -p commands
Tested: cargo clippy -p commands --tests -- -D warnings
Tested: cargo test -p rusty-claude-cli parses_direct_agents_and_skills_slash_commands
Tested: cargo test -p rusty-claude-cli parses_login_and_logout_subcommands
Tested: cargo clippy -p rusty-claude-cli --tests -- -D warnings
Not-tested: End-to-end interactive REPL invocation of /skills install against a real user skill registry
2026-04-02 10:03:22 +00:00
Yeachan-Heo
5c845d582e Close the plan-mode parity gap for worktree-local tool flows
PARITY.md still flags missing plan/worktree entry-exit tools. This change adds EnterPlanMode and ExitPlanMode to the Rust tool registry, stores reversible worktree-local state under .claw/tool-state, and restores or clears the prior local permission override on exit. The round-trip tests cover both restoring an existing local override and cleaning up a tool-created override from an empty local state.

Constraint: Must keep the override worktree-local and reversible without mutating higher-scope settings
Rejected: Reuse Config alone with no state file | exit could not safely restore absent-vs-local overrides
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep plan-mode state tracking aligned with settings.local.json precedence before adding worktree enter/exit tools
Tested: cargo test -p tools
Not-tested: interactive CLI prompt-mode invocation of the new tools
2026-04-02 10:01:33 +00:00
YeonGyu-Kim
93d98ab33f fix: suppress WIP dead_code/clippy warnings in rusty-claude-cli
CLI binary has functions from multiple parity branches that aren't fully
wired up yet. Allow dead_code and related clippy lints at crate level
until the wiring is complete.
2026-04-02 18:38:47 +09:00
YeonGyu-Kim
6e642a002d Merge branch 'dori/commands-parity' into main 2026-04-02 18:37:00 +09:00
YeonGyu-Kim
b92bd88cc8 Merge branch 'dori/tools-parity' 2026-04-02 18:36:41 +09:00
YeonGyu-Kim
ef48b7e515 Merge branch 'dori/hooks-parity' into main 2026-04-02 18:36:37 +09:00
YeonGyu-Kim
d88144d4a5 feat(commands): slash-command validation, help formatting, CLI wiring
- Add centralized validate_slash_command_input for all slash commands
- Rich error messages and per-command help detail
- Wire validation into CLI entrypoints in main.rs
- Consistent /agents and /skills usage surface
- Verified: cargo test -p commands 22 passed, integration test passed, clippy clean
2026-04-02 18:24:47 +09:00
YeonGyu-Kim
73187de6ea feat(tools): error propagation, REPL timeout, edge-case validation
- Replace NotebookEdit expect() with Result-based error propagation
- Add 5-minute guard to Sleep duration
- Reject empty StructuredOutput payloads
- Enforce timeout_ms in REPL via spawn+try_wait+kill
- Add edge-case tests: excessive/zero sleep, empty output, REPL timeout
- Verified: cargo test -p tools 35 passed, clippy clean
2026-04-02 18:24:39 +09:00
YeonGyu-Kim
f2dd6521ed feat(hooks): add PostToolUseFailure propagation, validation, and tests
- Hook runner propagates execution failures as real errors, not soft warnings
- Conversation converts failed pre/post hooks into error tool results
- Plugins fully support PostToolUseFailure: aggregation, resolution, validation, execution
- Add ordering + short-circuit tests for normal and failure hook chains
- Add missing PostToolUseFailure manifest path rejection test
- Verified: cargo clippy --all-targets -- -D warnings passes, cargo test 94 passed
2026-04-02 18:24:12 +09:00
6 changed files with 1367 additions and 61 deletions

View File

@ -221,14 +221,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "agents",
aliases: &[],
summary: "List configured agents",
argument_hint: None,
argument_hint: Some("[list|help]"),
resume_supported: true,
},
SlashCommandSpec {
name: "skills",
aliases: &[],
summary: "List available skills",
argument_hint: None,
summary: "List or install available skills",
argument_hint: Some("[list|install <path>|help]"),
resume_supported: true,
},
];
@ -416,7 +416,7 @@ pub fn validate_slash_command_input(
args: parse_list_or_help_args(command, remainder)?,
},
"skills" => SlashCommand::Skills {
args: parse_list_or_help_args(command, remainder)?,
args: parse_skills_args(remainder.as_deref())?,
},
other => SlashCommand::Unknown(other.to_string()),
}))
@ -633,6 +633,38 @@ fn parse_list_or_help_args(
}
}
fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandParseError> {
let Some(args) = normalize_optional_args(args) else {
return Ok(None);
};
if matches!(args, "list" | "help" | "-h" | "--help") {
return Ok(Some(args.to_string()));
}
if args == "install" {
return Err(command_error(
"Usage: /skills install <path>",
"skills",
"/skills install <path>",
));
}
if let Some(target) = args.strip_prefix("install").map(str::trim) {
if !target.is_empty() {
return Ok(Some(format!("install {target}")));
}
}
Err(command_error(
&format!(
"Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
),
"skills",
"/skills [list|install <path>|help]",
))
}
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
let usage = format!("/{command} {argument_hint}");
let usage = usage.trim_end().to_string();
@ -942,6 +974,21 @@ struct SkillRoot {
origin: SkillOrigin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstalledSkill {
invocation_name: String,
display_name: Option<String>,
source: PathBuf,
registry_root: PathBuf,
installed_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SkillInstallSource {
Directory { root: PathBuf, prompt_path: PathBuf },
MarkdownFile { path: PathBuf },
}
#[allow(clippy::too_many_lines)]
pub fn handle_plugins_slash_command(
action: Option<&str>,
@ -1073,6 +1120,15 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills))
}
Some("install") => Ok(render_skills_usage(Some("install"))),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
if target.is_empty() {
return Ok(render_skills_usage(Some("install")));
}
let install = install_skill(target, cwd)?;
Ok(render_skill_install_report(&install))
}
Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
Some(args) => Ok(render_skills_usage(Some(args))),
}
@ -1248,6 +1304,202 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
roots
}
fn install_skill(source: &str, cwd: &Path) -> std::io::Result<InstalledSkill> {
let registry_root = default_skill_install_root()?;
install_skill_into(source, cwd, &registry_root)
}
fn install_skill_into(
source: &str,
cwd: &Path,
registry_root: &Path,
) -> std::io::Result<InstalledSkill> {
let source = resolve_skill_install_source(source, cwd)?;
let prompt_path = source.prompt_path();
let contents = fs::read_to_string(prompt_path)?;
let display_name = parse_skill_frontmatter(&contents).0;
let invocation_name = derive_skill_install_name(&source, display_name.as_deref())?;
let installed_path = registry_root.join(&invocation_name);
if installed_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"skill '{invocation_name}' is already installed at {}",
installed_path.display()
),
));
}
fs::create_dir_all(&installed_path)?;
let install_result = match &source {
SkillInstallSource::Directory { root, .. } => {
copy_directory_contents(root, &installed_path)
}
SkillInstallSource::MarkdownFile { path } => {
fs::copy(path, installed_path.join("SKILL.md")).map(|_| ())
}
};
if let Err(error) = install_result {
let _ = fs::remove_dir_all(&installed_path);
return Err(error);
}
Ok(InstalledSkill {
invocation_name,
display_name,
source: source.report_path().to_path_buf(),
registry_root: registry_root.to_path_buf(),
installed_path,
})
}
fn default_skill_install_root() -> std::io::Result<PathBuf> {
if let Ok(codex_home) = env::var("CODEX_HOME") {
return Ok(PathBuf::from(codex_home).join("skills"));
}
if let Some(home) = env::var_os("HOME") {
return Ok(PathBuf::from(home).join(".codex").join("skills"));
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"unable to resolve a skills install root; set CODEX_HOME or HOME",
))
}
fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<SkillInstallSource> {
let candidate = PathBuf::from(source);
let source = if candidate.is_absolute() {
candidate
} else {
cwd.join(candidate)
};
let source = fs::canonicalize(&source)?;
if source.is_dir() {
let prompt_path = source.join("SKILL.md");
if !prompt_path.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"skill directory '{}' must contain SKILL.md",
source.display()
),
));
}
return Ok(SkillInstallSource::Directory {
root: source,
prompt_path,
});
}
if source
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
return Ok(SkillInstallSource::MarkdownFile { path: source });
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"skill source '{}' must be a directory with SKILL.md or a markdown file",
source.display()
),
))
}
fn derive_skill_install_name(
source: &SkillInstallSource,
declared_name: Option<&str>,
) -> std::io::Result<String> {
for candidate in [declared_name, source.fallback_name().as_deref()] {
if let Some(candidate) = candidate.and_then(sanitize_skill_invocation_name) {
return Ok(candidate);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"unable to derive an installable invocation name from '{}'",
source.report_path().display()
),
))
}
fn sanitize_skill_invocation_name(candidate: &str) -> Option<String> {
let trimmed = candidate
.trim()
.trim_start_matches('/')
.trim_start_matches('$');
if trimmed.is_empty() {
return None;
}
let mut sanitized = String::new();
let mut last_was_separator = false;
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
sanitized.push(ch.to_ascii_lowercase());
last_was_separator = false;
} else if (ch.is_whitespace() || matches!(ch, '/' | '\\'))
&& !last_was_separator
&& !sanitized.is_empty()
{
sanitized.push('-');
last_was_separator = true;
}
}
let sanitized = sanitized
.trim_matches(|ch| matches!(ch, '-' | '_' | '.'))
.to_string();
(!sanitized.is_empty()).then_some(sanitized)
}
fn copy_directory_contents(source: &Path, destination: &Path) -> std::io::Result<()> {
for entry in fs::read_dir(source)? {
let entry = entry?;
let entry_type = entry.file_type()?;
let destination_path = destination.join(entry.file_name());
if entry_type.is_dir() {
fs::create_dir_all(&destination_path)?;
copy_directory_contents(&entry.path(), &destination_path)?;
} else {
fs::copy(entry.path(), destination_path)?;
}
}
Ok(())
}
impl SkillInstallSource {
fn prompt_path(&self) -> &Path {
match self {
Self::Directory { prompt_path, .. } => prompt_path,
Self::MarkdownFile { path } => path,
}
}
fn fallback_name(&self) -> Option<String> {
match self {
Self::Directory { root, .. } => root
.file_name()
.map(|name| name.to_string_lossy().to_string()),
Self::MarkdownFile { path } => path
.file_stem()
.map(|name| name.to_string_lossy().to_string()),
}
}
fn report_path(&self) -> &Path {
match self {
Self::Directory { root, .. } => root,
Self::MarkdownFile { path } => path,
}
}
}
fn push_unique_root(
roots: &mut Vec<(DefinitionSource, PathBuf)>,
source: DefinitionSource,
@ -1571,6 +1823,27 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_skill_install_report(skill: &InstalledSkill) -> String {
let mut lines = vec![
"Skills".to_string(),
format!(" Result installed {}", skill.invocation_name),
format!(" Invoke as ${}", skill.invocation_name),
];
if let Some(display_name) = &skill.display_name {
lines.push(format!(" Display name {display_name}"));
}
lines.push(format!(" Source {}", skill.source.display()));
lines.push(format!(
" Registry {}",
skill.registry_root.display()
));
lines.push(format!(
" Installed path {}",
skill.installed_path.display()
));
lines.join("\n")
}
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
args.map(str::trim).filter(|value| !value.is_empty())
}
@ -1578,7 +1851,7 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
fn render_agents_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Agents".to_string(),
" Usage /agents".to_string(),
" Usage /agents [list|help]".to_string(),
" Direct CLI claw agents".to_string(),
" Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
];
@ -1591,8 +1864,9 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
" Usage /skills".to_string(),
" Direct CLI claw skills".to_string(),
" Usage /skills [list|install <path>|help]".to_string(),
" Direct CLI claw skills [list|install <path>|help]".to_string(),
" Install root $CODEX_HOME/skills or ~/.codex/skills".to_string(),
" Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
];
if let Some(args) = unexpected {
@ -1921,6 +2195,12 @@ mod tests {
target: Some("demo".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/skills install ./fixtures/help-skill"),
Ok(Some(SlashCommand::Skills {
args: Some("install ./fixtures/help-skill".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/plugins disable demo"),
Ok(Some(SlashCommand::Plugins {
@ -2014,9 +2294,9 @@ mod tests {
));
assert!(agents_error.contains(" Usage /agents [list|help]"));
assert!(skills_error.contains(
"Unexpected arguments for /skills: show help. Use /skills, /skills list, or /skills help."
"Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install <path>, or /skills help."
));
assert!(skills_error.contains(" Usage /skills [list|help]"));
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
}
#[test]
@ -2056,8 +2336,8 @@ mod tests {
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
assert!(help.contains("aliases: /plugins, /marketplace"));
assert!(help.contains("/agents"));
assert!(help.contains("/skills"));
assert!(help.contains("/agents [list|help]"));
assert!(help.contains("/skills [list|install <path>|help]"));
assert_eq!(slash_command_specs().len(), 26);
assert_eq!(resume_supported_slash_commands().len(), 14);
}
@ -2365,7 +2645,7 @@ mod tests {
let agents_help =
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
assert!(agents_help.contains("Usage /agents"));
assert!(agents_help.contains("Usage /agents [list|help]"));
assert!(agents_help.contains("Direct CLI claw agents"));
let agents_unexpected =
@ -2374,7 +2654,8 @@ mod tests {
let 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 [list|install <path>|help]"));
assert!(skills_help.contains("Install root $CODEX_HOME/skills or ~/.codex/skills"));
assert!(skills_help.contains("legacy /commands"));
let skills_unexpected =
@ -2392,6 +2673,57 @@ mod tests {
assert_eq!(description.as_deref(), Some("Quoted description"));
}
#[test]
fn installs_skill_into_user_registry_and_preserves_nested_files() {
let workspace = temp_dir("skills-install-workspace");
let source_root = workspace.join("source").join("help");
let install_root = temp_dir("skills-install-root");
write_skill(
source_root.parent().expect("parent"),
"help",
"Helpful skill",
);
let script_dir = source_root.join("scripts");
fs::create_dir_all(&script_dir).expect("script dir");
fs::write(script_dir.join("run.sh"), "#!/bin/sh\necho help\n").expect("write script");
let installed = super::install_skill_into(
source_root.to_str().expect("utf8 skill path"),
&workspace,
&install_root,
)
.expect("skill should install");
assert_eq!(installed.invocation_name, "help");
assert_eq!(installed.display_name.as_deref(), Some("help"));
assert!(installed.installed_path.ends_with(Path::new("help")));
assert!(installed.installed_path.join("SKILL.md").is_file());
assert!(installed
.installed_path
.join("scripts")
.join("run.sh")
.is_file());
let report = super::render_skill_install_report(&installed);
assert!(report.contains("Result installed help"));
assert!(report.contains("Invoke as $help"));
assert!(report.contains(&install_root.display().to_string()));
let roots = vec![SkillRoot {
source: DefinitionSource::UserCodexHome,
path: install_root.clone(),
origin: SkillOrigin::SkillsDir,
}];
let listed = render_skills_report(
&load_skills_from_roots(&roots).expect("installed skills should load"),
);
assert!(listed.contains("User ($CODEX_HOME):"));
assert!(listed.contains("help · Helpful skill"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(install_root);
}
#[test]
fn installs_plugin_from_path_and_lists_it() {
let config_home = temp_dir("home");

View File

@ -1629,6 +1629,12 @@ fn build_plugin_manifest(
let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
validate_command_entries(
root,
raw.hooks.post_tool_use_failure.iter(),
"hook",
&mut errors,
);
validate_command_entries(
root,
raw.lifecycle.init.iter(),
@ -2306,6 +2312,16 @@ mod tests {
);
}
fn write_broken_failure_hook_plugin(root: &Path, name: &str) {
write_file(
root.join(MANIFEST_RELATIVE_PATH).as_path(),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PostToolUseFailure\": [\"./hooks/missing-failure.sh\"]\n }}\n}}"
)
.as_str(),
);
}
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
let log_path = root.join("lifecycle.log");
write_file(
@ -3178,14 +3194,19 @@ mod tests {
#[test]
fn rejects_plugin_sources_with_missing_hook_paths() {
// given
let config_home = temp_dir("broken-home");
let source_root = temp_dir("broken-source");
write_broken_plugin(&source_root, "broken");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
// when
let error = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect_err("missing hook file should fail validation");
// then
assert!(error.to_string().contains("does not exist"));
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@ -3198,6 +3219,33 @@ mod tests {
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
// given
let config_home = temp_dir("broken-failure-home");
let source_root = temp_dir("broken-failure-source");
write_broken_failure_hook_plugin(&source_root, "broken-failure");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
// when
let error = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect_err("missing failure hook file should fail validation");
// then
assert!(error.to_string().contains("does not exist"));
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install_error = manager
.install(source_root.to_str().expect("utf8 path"))
.expect_err("install should reject invalid failure hook paths");
assert!(install_error.to_string().contains("does not exist"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
let config_home = temp_dir("lifecycle-home");

View File

@ -785,6 +785,7 @@ mod tests {
use crate::prompt::{ProjectContext, SystemPromptBuilder};
use crate::session::{ContentBlock, MessageRole, Session};
use crate::usage::TokenUsage;
use crate::ToolError;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
@ -1202,6 +1203,85 @@ mod tests {
);
}
#[test]
fn appends_post_tool_use_failure_hook_feedback_to_tool_result() {
struct TwoCallApiClient {
calls: usize,
}
impl ApiClient for TwoCallApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.calls += 1;
match self.calls {
1 => Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "fail".to_string(),
input: r#"{"path":"README.md"}"#.to_string(),
},
AssistantEvent::MessageStop,
]),
2 => {
assert!(request
.messages
.iter()
.any(|message| message.role == MessageRole::Tool));
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::MessageStop,
])
}
_ => Err(RuntimeError::new("unexpected extra API call")),
}
}
}
// given
let mut runtime = ConversationRuntime::new_with_features(
Session::new(),
TwoCallApiClient { calls: 0 },
StaticToolExecutor::new()
.register("fail", |_input| Err(ToolError::new("tool exploded"))),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
Vec::new(),
vec![shell_snippet("printf 'post hook should not run'")],
vec![shell_snippet("printf 'failure hook ran'")],
)),
);
// when
let summary = runtime
.run_turn("use fail", None)
.expect("tool loop succeeds");
// then
assert_eq!(summary.tool_results.len(), 1);
let ContentBlock::ToolResult {
is_error, output, ..
} = &summary.tool_results[0].blocks[0]
else {
panic!("expected tool result block");
};
assert!(
*is_error,
"failure hook path should preserve error result: {output:?}"
);
assert!(
output.contains("tool exploded"),
"tool output missing failure reason: {output:?}"
);
assert!(
output.contains("failure hook ran"),
"tool output missing failure hook feedback: {output:?}"
);
assert!(
!output.contains("post hook should not run"),
"normal post hook should not run on tool failure: {output:?}"
);
}
#[test]
fn reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi;

View File

@ -806,19 +806,50 @@ mod tests {
#[test]
fn runs_post_tool_use_failure_hooks() {
// given
let runner = HookRunner::new(RuntimeHookConfig::new(
Vec::new(),
Vec::new(),
vec![shell_snippet("printf 'failure hook ran'")],
));
// when
let result =
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
// then
assert!(!result.is_denied());
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
}
#[test]
fn stops_running_failure_hooks_after_failure() {
// given
let runner = HookRunner::new(RuntimeHookConfig::new(
Vec::new(),
Vec::new(),
vec![
shell_snippet("printf 'broken failure hook'; exit 1"),
shell_snippet("printf 'later failure hook'"),
],
));
// when
let result =
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
// then
assert!(result.is_failed());
assert!(result
.messages()
.iter()
.any(|message| message.contains("broken failure hook")));
assert!(!result
.messages()
.iter()
.any(|message| message == "later failure hook"));
}
#[test]
fn executes_hooks_in_configured_order() {
// given

View File

@ -1,3 +1,11 @@
#![allow(
dead_code,
unused_imports,
unused_variables,
clippy::unneeded_struct_pattern,
clippy::unnecessary_wraps,
clippy::unused_self
)]
mod init;
mod input;
mod render;
@ -7,6 +15,7 @@ use std::env;
use std::fs;
use std::io::{self, Read, Write};
use std::net::TcpListener;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
@ -22,11 +31,12 @@ use api::{
use commands::{
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
validate_slash_command_input, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use plugins::{PluginManager, PluginManagerConfig};
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
@ -1473,10 +1483,76 @@ struct LiveCli {
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
runtime: BuiltRuntime,
session: SessionHandle,
}
struct RuntimePluginState {
feature_config: runtime::RuntimeFeatureConfig,
tool_registry: GlobalToolRegistry,
plugin_registry: PluginRegistry,
}
struct BuiltRuntime {
runtime: Option<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>>,
plugin_registry: PluginRegistry,
plugins_active: bool,
}
impl BuiltRuntime {
fn new(
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
plugin_registry: PluginRegistry,
) -> Self {
Self {
runtime: Some(runtime),
plugin_registry,
plugins_active: true,
}
}
fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self {
let runtime = self
.runtime
.take()
.expect("runtime should exist before installing hook abort signal");
self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal));
self
}
fn shutdown_plugins(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if self.plugins_active {
self.plugin_registry.shutdown()?;
self.plugins_active = false;
}
Ok(())
}
}
impl Deref for BuiltRuntime {
type Target = ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>;
fn deref(&self) -> &Self::Target {
self.runtime
.as_ref()
.expect("runtime should exist while built runtime is alive")
}
}
impl DerefMut for BuiltRuntime {
fn deref_mut(&mut self) -> &mut Self::Target {
self.runtime
.as_mut()
.expect("runtime should exist while built runtime is alive")
}
}
impl Drop for BuiltRuntime {
fn drop(&mut self) {
let _ = self.shutdown_plugins();
}
}
struct HookAbortMonitor {
stop_tx: Option<Sender<()>>,
join_handle: Option<JoinHandle<()>>,
@ -1623,13 +1699,7 @@ impl LiveCli {
fn prepare_turn_runtime(
&self,
emit_output: bool,
) -> Result<
(
ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
HookAbortMonitor,
),
Box<dyn std::error::Error>,
> {
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
let hook_abort_signal = runtime::HookAbortSignal::new();
let runtime = build_runtime(
self.runtime.session().clone(),
@ -1648,6 +1718,12 @@ impl LiveCli {
Ok((runtime, hook_abort_monitor))
}
fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.shutdown_plugins()?;
self.runtime = runtime;
Ok(())
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let mut spinner = Spinner::new();
@ -1660,9 +1736,9 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
self.runtime = runtime;
match result {
Ok(summary) => {
self.replace_runtime(runtime)?;
spinner.finish(
"✨ Done",
TerminalRenderer::new().color_theme(),
@ -1679,6 +1755,7 @@ impl LiveCli {
Ok(())
}
Err(error) => {
runtime.shutdown_plugins()?;
spinner.fail(
"❌ Request failed",
TerminalRenderer::new().color_theme(),
@ -1706,7 +1783,7 @@ impl LiveCli {
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.runtime = runtime;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!(
"{}",
@ -1901,7 +1978,7 @@ impl LiveCli {
let previous = self.model.clone();
let session = self.runtime.session().clone();
let message_count = session.messages.len();
self.runtime = build_runtime(
let runtime = build_runtime(
session,
&self.session.id,
model.clone(),
@ -1912,6 +1989,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.model.clone_from(&model);
println!(
"{}",
@ -1946,7 +2024,7 @@ impl LiveCli {
let previous = self.permission_mode.as_str().to_string();
let session = self.runtime.session().clone();
self.permission_mode = permission_mode_from_label(normalized);
self.runtime = build_runtime(
let runtime = build_runtime(
session,
&self.session.id,
self.model.clone(),
@ -1957,6 +2035,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
println!(
"{}",
format_permissions_switch_report(&previous, normalized)
@ -1974,7 +2053,7 @@ impl LiveCli {
let session_state = Session::new();
self.session = create_managed_session_handle(&session_state.session_id)?;
self.runtime = build_runtime(
let runtime = build_runtime(
session_state.with_persistence_path(self.session.path.clone()),
&self.session.id,
self.model.clone(),
@ -1985,6 +2064,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
println!(
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
self.model,
@ -2012,7 +2092,7 @@ impl LiveCli {
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
let session_id = session.session_id.clone();
self.runtime = build_runtime(
let runtime = build_runtime(
session,
&handle.id,
self.model.clone(),
@ -2023,6 +2103,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = SessionHandle {
id: session_id,
path: handle.path,
@ -2102,7 +2183,7 @@ impl LiveCli {
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
let session_id = session.session_id.clone();
self.runtime = build_runtime(
let runtime = build_runtime(
session,
&handle.id,
self.model.clone(),
@ -2113,6 +2194,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = SessionHandle {
id: session_id,
path: handle.path,
@ -2136,7 +2218,7 @@ impl LiveCli {
let forked = forked.with_persistence_path(handle.path.clone());
let message_count = forked.messages.len();
forked.save_to_path(&handle.path)?;
self.runtime = build_runtime(
let runtime = build_runtime(
forked,
&handle.id,
self.model.clone(),
@ -2147,6 +2229,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = handle;
println!(
"Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}",
@ -2185,7 +2268,7 @@ impl LiveCli {
}
fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime = build_runtime(
let runtime = build_runtime(
self.runtime.session().clone(),
&self.session.id,
self.model.clone(),
@ -2196,6 +2279,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.persist_session()
}
@ -2204,7 +2288,7 @@ impl LiveCli {
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
self.runtime = build_runtime(
let runtime = build_runtime(
result.compacted_session,
&self.session.id,
self.model.clone(),
@ -2215,6 +2299,7 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped));
Ok(())
@ -2240,7 +2325,9 @@ impl LiveCli {
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
Ok(final_assistant_text(&summary).trim().to_string())
let text = final_assistant_text(&summary).trim().to_string();
runtime.shutdown_plugins()?;
Ok(text)
}
fn run_internal_prompt_text(
@ -3268,14 +3355,32 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
)?)
}
fn build_runtime_plugin_state(
) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box<dyn std::error::Error>> {
fn build_runtime_plugin_state() -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config)
}
fn build_runtime_plugin_state_with_loader(
cwd: &Path,
loader: &ConfigLoader,
runtime_config: &runtime::RuntimeConfig,
) -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?;
Ok((runtime_config.feature_config().clone(), tool_registry))
let plugin_registry = plugin_manager.plugin_registry()?;
let plugin_hook_config =
runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?);
let feature_config = runtime_config
.feature_config()
.clone()
.with_hooks(runtime_config.hooks().merged(&plugin_hook_config));
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?;
Ok(RuntimePluginState {
feature_config,
tool_registry,
plugin_registry,
})
}
fn build_plugin_manager(
@ -3314,6 +3419,14 @@ fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
}
}
fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig {
runtime::RuntimeHookConfig::new(
hooks.pre_tool_use,
hooks.post_tool_use,
hooks.post_tool_use_failure,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InternalPromptProgressState {
command_label: &'static str,
@ -3654,9 +3767,42 @@ fn build_runtime(
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
let runtime_plugin_state = build_runtime_plugin_state()?;
build_runtime_with_plugin_state(
session,
session_id,
model,
system_prompt,
enable_tools,
emit_output,
allowed_tools,
permission_mode,
progress_reporter,
runtime_plugin_state,
)
}
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_arguments)]
fn build_runtime_with_plugin_state(
session: Session,
session_id: &str,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
emit_output: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
runtime_plugin_state: RuntimePluginState,
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
let RuntimePluginState {
feature_config,
tool_registry,
plugin_registry,
} = runtime_plugin_state;
plugin_registry.initialize()?;
let mut runtime = ConversationRuntime::new_with_features(
session,
AnthropicRuntimeClient::new(
@ -3677,7 +3823,7 @@ fn build_runtime(
if emit_output {
runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter));
}
Ok(runtime)
Ok(BuiltRuntime::new(runtime, plugin_registry))
}
struct CliHookProgressReporter;
@ -4845,6 +4991,7 @@ fn print_help() {
#[cfg(test)]
mod tests {
use super::{
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
format_compact_report, format_cost_report, format_internal_prompt_progress_line,
@ -4863,9 +5010,12 @@ mod tests {
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
use plugins::{
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
};
use runtime::{
AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session,
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
PermissionMode, Session,
};
use serde_json::json;
use std::fs;
@ -4934,6 +5084,49 @@ mod tests {
std::env::set_current_dir(previous).expect("cwd should restore");
result
}
fn write_plugin_fixture(root: &Path, name: &str, include_hooks: bool, include_lifecycle: bool) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
if include_hooks {
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
"#!/bin/sh\nprintf 'plugin pre hook'\n",
)
.expect("write hook");
}
if include_lifecycle {
fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
fs::write(
root.join("lifecycle").join("init.sh"),
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
)
.expect("write init lifecycle");
fs::write(
root.join("lifecycle").join("shutdown.sh"),
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
)
.expect("write shutdown lifecycle");
}
let hooks = if include_hooks {
",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }"
} else {
""
};
let lifecycle = if include_lifecycle {
",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }"
} else {
""
};
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime plugin fixture\"{hooks}{lifecycle}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn defaults_to_repl_when_no_args() {
assert_eq!(
@ -5179,12 +5372,40 @@ mod tests {
args: Some("help".to_string())
}
);
assert_eq!(
parse_args(&[
"/skills".to_string(),
"install".to_string(),
"./fixtures/help-skill".to_string(),
])
.expect("/skills install should parse"),
CliAction::Skills {
args: Some("install ./fixtures/help-skill".to_string())
}
);
let error = parse_args(&["/status".to_string()])
.expect_err("/status should remain REPL-only when invoked directly");
assert!(error.contains("interactive-only"));
assert!(error.contains("claw --resume SESSION.jsonl /status"));
}
#[test]
fn direct_slash_commands_surface_shared_validation_errors() {
let compact_error = parse_args(&["/compact".to_string(), "now".to_string()])
.expect_err("invalid /compact shape should be rejected");
assert!(compact_error.contains("Unexpected arguments for /compact."));
assert!(compact_error.contains("Usage /compact"));
let plugins_error = parse_args(&[
"/plugins".to_string(),
"list".to_string(),
"extra".to_string(),
])
.expect_err("invalid /plugins list shape should be rejected");
assert!(plugins_error.contains("Usage: /plugin list"));
assert!(plugins_error.contains("Aliases /plugins, /marketplace"));
}
#[test]
fn formats_unknown_slash_command_with_suggestions() {
let report = format_unknown_slash_command_message("stats");
@ -6365,6 +6586,89 @@ UU conflicted.rs",
));
assert!(!String::from_utf8(out).expect("utf8").contains("step 1"));
}
#[test]
fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() {
let config_home = temp_dir();
let workspace = temp_dir();
let source_root = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(&source_root).expect("source root");
write_plugin_fixture(&source_root, "hook-runtime-demo", true, false);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
.install(source_root.to_str().expect("utf8 source path"))
.expect("plugin install should succeed");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("plugin state should load");
let pre_hooks = state.feature_config.hooks().pre_tool_use();
assert_eq!(pre_hooks.len(), 1);
assert!(
pre_hooks[0].ends_with("hooks/pre.sh"),
"expected installed plugin hook path, got {pre_hooks:?}"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() {
let config_home = temp_dir();
let workspace = temp_dir();
let source_root = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(&source_root).expect("source root");
write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install = manager
.install(source_root.to_str().expect("utf8 source path"))
.expect("plugin install should succeed");
let log_path = install.install_path.join("lifecycle.log");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let runtime_plugin_state =
build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("plugin state should load");
let mut runtime = build_runtime_with_plugin_state(
Session::new(),
"runtime-plugin-lifecycle",
DEFAULT_MODEL.to_string(),
vec!["test system prompt".to_string()],
true,
false,
None,
PermissionMode::DangerFullAccess,
None,
runtime_plugin_state,
)
.expect("runtime should build");
assert_eq!(
fs::read_to_string(&log_path).expect("init log should exist"),
"init\n"
);
runtime
.shutdown_plugins()
.expect("plugin shutdown should succeed");
assert_eq!(
fs::read_to_string(&log_path).expect("shutdown log should exist"),
"init\nshutdown\n"
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(source_root);
}
}
#[cfg(test)]

View File

@ -169,7 +169,6 @@ impl GlobalToolRegistry {
builtin.chain(plugin).collect()
}
#[must_use]
pub fn permission_specs(
&self,
allowed_tools: Option<&BTreeSet<String>>,
@ -505,6 +504,26 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
}),
required_permission: PermissionMode::WorkspaceWrite,
},
ToolSpec {
name: "EnterPlanMode",
description: "Enable a worktree-local planning mode override and remember the previous local setting for ExitPlanMode.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
required_permission: PermissionMode::WorkspaceWrite,
},
ToolSpec {
name: "ExitPlanMode",
description: "Restore or clear the worktree-local planning mode override created by EnterPlanMode.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
required_permission: PermissionMode::WorkspaceWrite,
},
ToolSpec {
name: "StructuredOutput",
description: "Return structured output in the requested format.",
@ -566,6 +585,8 @@ pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
"Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
"SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
"Config" => from_value::<ConfigInput>(input).and_then(run_config),
"EnterPlanMode" => from_value::<EnterPlanModeInput>(input).and_then(run_enter_plan_mode),
"ExitPlanMode" => from_value::<ExitPlanModeInput>(input).and_then(run_exit_plan_mode),
"StructuredOutput" => {
from_value::<StructuredOutputInput>(input).and_then(run_structured_output)
}
@ -648,7 +669,7 @@ fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
}
fn run_sleep(input: SleepInput) -> Result<String, String> {
to_pretty_json(execute_sleep(input))
to_pretty_json(execute_sleep(input)?)
}
fn run_brief(input: BriefInput) -> Result<String, String> {
@ -659,8 +680,16 @@ fn run_config(input: ConfigInput) -> Result<String, String> {
to_pretty_json(execute_config(input)?)
}
fn run_enter_plan_mode(input: EnterPlanModeInput) -> Result<String, String> {
to_pretty_json(execute_enter_plan_mode(input)?)
}
fn run_exit_plan_mode(input: ExitPlanModeInput) -> Result<String, String> {
to_pretty_json(execute_exit_plan_mode(input)?)
}
fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
to_pretty_json(execute_structured_output(input))
to_pretty_json(execute_structured_output(input)?)
}
fn run_repl(input: ReplInput) -> Result<String, String> {
@ -811,6 +840,14 @@ struct ConfigInput {
value: Option<ConfigValue>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct EnterPlanModeInput {}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct ExitPlanModeInput {}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ConfigValue {
@ -968,6 +1005,32 @@ struct ConfigOutput {
error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PlanModeState {
#[serde(rename = "hadLocalOverride")]
had_local_override: bool,
#[serde(rename = "previousLocalMode")]
previous_local_mode: Option<Value>,
}
#[derive(Debug, Serialize)]
struct PlanModeOutput {
success: bool,
operation: String,
changed: bool,
active: bool,
managed: bool,
message: String,
#[serde(rename = "settingsPath")]
settings_path: String,
#[serde(rename = "statePath")]
state_path: String,
#[serde(rename = "previousLocalMode")]
previous_local_mode: Option<Value>,
#[serde(rename = "currentLocalMode")]
current_local_mode: Option<Value>,
}
#[derive(Debug, Serialize)]
struct StructuredOutputResult {
data: String,
@ -2347,7 +2410,8 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
let cell_id = match edit_mode {
NotebookEditMode::Insert => {
let resolved_cell_type = resolved_cell_type.expect("insert cell type");
let resolved_cell_type = resolved_cell_type
.ok_or_else(|| String::from("insert mode requires a cell type"))?;
let new_id = make_cell_id(cells.len());
let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
let insert_at = target_index.map_or(cells.len(), |index| index + 1);
@ -2359,16 +2423,21 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
.map(ToString::to_string)
}
NotebookEditMode::Delete => {
let removed = cells.remove(target_index.expect("delete target index"));
let idx = target_index
.ok_or_else(|| String::from("delete mode requires a target cell index"))?;
let removed = cells.remove(idx);
removed
.get("id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
}
NotebookEditMode::Replace => {
let resolved_cell_type = resolved_cell_type.expect("replace cell type");
let resolved_cell_type = resolved_cell_type
.ok_or_else(|| String::from("replace mode requires a cell type"))?;
let idx = target_index
.ok_or_else(|| String::from("replace mode requires a target cell index"))?;
let cell = cells
.get_mut(target_index.expect("replace target index"))
.get_mut(idx)
.ok_or_else(|| String::from("Cell index out of range"))?;
cell["source"] = serde_json::Value::Array(source_lines(&new_source));
cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
@ -2459,13 +2528,21 @@ fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
})
}
const MAX_SLEEP_DURATION_MS: u64 = 300_000;
#[allow(clippy::needless_pass_by_value)]
fn execute_sleep(input: SleepInput) -> SleepOutput {
fn execute_sleep(input: SleepInput) -> Result<SleepOutput, String> {
if input.duration_ms > MAX_SLEEP_DURATION_MS {
return Err(format!(
"duration_ms {} exceeds maximum allowed sleep of {MAX_SLEEP_DURATION_MS}ms",
input.duration_ms,
));
}
std::thread::sleep(Duration::from_millis(input.duration_ms));
SleepOutput {
Ok(SleepOutput {
duration_ms: input.duration_ms,
message: format!("Slept for {}ms", input.duration_ms),
}
})
}
fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
@ -2562,25 +2639,204 @@ fn execute_config(input: ConfigInput) -> Result<ConfigOutput, String> {
}
}
fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
StructuredOutputResult {
const PERMISSION_DEFAULT_MODE_PATH: &[&str] = &["permissions", "defaultMode"];
fn execute_enter_plan_mode(_input: EnterPlanModeInput) -> Result<PlanModeOutput, String> {
let settings_path = config_file_for_scope(ConfigScope::Settings)?;
let state_path = plan_mode_state_file()?;
let mut document = read_json_object(&settings_path)?;
let current_local_mode = get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned();
let current_is_plan =
matches!(current_local_mode.as_ref(), Some(Value::String(value)) if value == "plan");
if let Some(state) = read_plan_mode_state(&state_path)? {
if current_is_plan {
return Ok(PlanModeOutput {
success: true,
operation: String::from("enter"),
changed: false,
active: true,
managed: true,
message: String::from("Plan mode override is already active for this worktree."),
settings_path: settings_path.display().to_string(),
state_path: state_path.display().to_string(),
previous_local_mode: state.previous_local_mode,
current_local_mode,
});
}
clear_plan_mode_state(&state_path)?;
}
if current_is_plan {
return Ok(PlanModeOutput {
success: true,
operation: String::from("enter"),
changed: false,
active: true,
managed: false,
message: String::from(
"Worktree-local plan mode is already enabled outside EnterPlanMode; leaving it unchanged.",
),
settings_path: settings_path.display().to_string(),
state_path: state_path.display().to_string(),
previous_local_mode: None,
current_local_mode,
});
}
let state = PlanModeState {
had_local_override: current_local_mode.is_some(),
previous_local_mode: current_local_mode.clone(),
};
write_plan_mode_state(&state_path, &state)?;
set_nested_value(
&mut document,
PERMISSION_DEFAULT_MODE_PATH,
Value::String(String::from("plan")),
);
write_json_object(&settings_path, &document)?;
Ok(PlanModeOutput {
success: true,
operation: String::from("enter"),
changed: true,
active: true,
managed: true,
message: String::from("Enabled worktree-local plan mode override."),
settings_path: settings_path.display().to_string(),
state_path: state_path.display().to_string(),
previous_local_mode: state.previous_local_mode,
current_local_mode: get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(),
})
}
fn execute_exit_plan_mode(_input: ExitPlanModeInput) -> Result<PlanModeOutput, String> {
let settings_path = config_file_for_scope(ConfigScope::Settings)?;
let state_path = plan_mode_state_file()?;
let mut document = read_json_object(&settings_path)?;
let current_local_mode = get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned();
let current_is_plan =
matches!(current_local_mode.as_ref(), Some(Value::String(value)) if value == "plan");
let Some(state) = read_plan_mode_state(&state_path)? else {
return Ok(PlanModeOutput {
success: true,
operation: String::from("exit"),
changed: false,
active: current_is_plan,
managed: false,
message: String::from("No EnterPlanMode override is active for this worktree."),
settings_path: settings_path.display().to_string(),
state_path: state_path.display().to_string(),
previous_local_mode: None,
current_local_mode,
});
};
if !current_is_plan {
clear_plan_mode_state(&state_path)?;
return Ok(PlanModeOutput {
success: true,
operation: String::from("exit"),
changed: false,
active: false,
managed: false,
message: String::from(
"Cleared stale EnterPlanMode state because plan mode was already changed outside the tool.",
),
settings_path: settings_path.display().to_string(),
state_path: state_path.display().to_string(),
previous_local_mode: state.previous_local_mode,
current_local_mode,
});
}
if state.had_local_override {
if let Some(previous_local_mode) = state.previous_local_mode.clone() {
set_nested_value(
&mut document,
PERMISSION_DEFAULT_MODE_PATH,
previous_local_mode,
);
} else {
remove_nested_value(&mut document, PERMISSION_DEFAULT_MODE_PATH);
}
} else {
remove_nested_value(&mut document, PERMISSION_DEFAULT_MODE_PATH);
}
write_json_object(&settings_path, &document)?;
clear_plan_mode_state(&state_path)?;
Ok(PlanModeOutput {
success: true,
operation: String::from("exit"),
changed: true,
active: false,
managed: false,
message: String::from("Restored the prior worktree-local plan mode setting."),
settings_path: settings_path.display().to_string(),
state_path: state_path.display().to_string(),
previous_local_mode: state.previous_local_mode,
current_local_mode: get_nested_value(&document, PERMISSION_DEFAULT_MODE_PATH).cloned(),
})
}
fn execute_structured_output(
input: StructuredOutputInput,
) -> Result<StructuredOutputResult, String> {
if input.0.is_empty() {
return Err(String::from("structured output payload must not be empty"));
}
Ok(StructuredOutputResult {
data: String::from("Structured output provided successfully"),
structured_output: input.0,
}
})
}
fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
if input.code.trim().is_empty() {
return Err(String::from("code must not be empty"));
}
let _ = input.timeout_ms;
let runtime = resolve_repl_runtime(&input.language)?;
let started = Instant::now();
let output = Command::new(runtime.program)
let mut process = Command::new(runtime.program);
process
.args(runtime.args)
.arg(&input.code)
.output()
.map_err(|error| error.to_string())?;
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let output = if let Some(timeout_ms) = input.timeout_ms {
let mut child = process.spawn().map_err(|error| error.to_string())?;
loop {
if child
.try_wait()
.map_err(|error| error.to_string())?
.is_some()
{
break child
.wait_with_output()
.map_err(|error| error.to_string())?;
}
if started.elapsed() >= Duration::from_millis(timeout_ms) {
child.kill().map_err(|error| error.to_string())?;
child
.wait_with_output()
.map_err(|error| error.to_string())?;
return Err(format!(
"REPL execution exceeded timeout of {timeout_ms} ms"
));
}
std::thread::sleep(Duration::from_millis(10));
}
} else {
process
.spawn()
.map_err(|error| error.to_string())?
.wait_with_output()
.map_err(|error| error.to_string())?
};
Ok(ReplOutput {
language: input.language,
@ -2852,6 +3108,72 @@ fn set_nested_value(root: &mut serde_json::Map<String, Value>, path: &[&str], ne
set_nested_value(map, rest, new_value);
}
fn remove_nested_value(root: &mut serde_json::Map<String, Value>, path: &[&str]) -> bool {
let Some((first, rest)) = path.split_first() else {
return false;
};
if rest.is_empty() {
return root.remove(*first).is_some();
}
let mut should_remove_parent = false;
let removed = root.get_mut(*first).is_some_and(|entry| {
entry.as_object_mut().is_some_and(|map| {
let removed = remove_nested_value(map, rest);
should_remove_parent = removed && map.is_empty();
removed
})
});
if should_remove_parent {
root.remove(*first);
}
removed
}
fn plan_mode_state_file() -> Result<PathBuf, String> {
Ok(config_file_for_scope(ConfigScope::Settings)?
.parent()
.ok_or_else(|| String::from("settings.local.json has no parent directory"))?
.join("tool-state")
.join("plan-mode.json"))
}
fn read_plan_mode_state(path: &Path) -> Result<Option<PlanModeState>, String> {
match std::fs::read_to_string(path) {
Ok(contents) => {
if contents.trim().is_empty() {
return Ok(None);
}
serde_json::from_str(&contents)
.map(Some)
.map_err(|error| error.to_string())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.to_string()),
}
}
fn write_plan_mode_state(path: &Path, state: &PlanModeState) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
std::fs::write(
path,
serde_json::to_string_pretty(state).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())
}
fn clear_plan_mode_state(path: &Path) -> Result<(), String> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.to_string()),
}
}
fn iso8601_timestamp() -> String {
if let Ok(output) = Command::new("date")
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
@ -3140,6 +3462,8 @@ mod tests {
assert!(names.contains(&"Sleep"));
assert!(names.contains(&"SendUserMessage"));
assert!(names.contains(&"Config"));
assert!(names.contains(&"EnterPlanMode"));
assert!(names.contains(&"ExitPlanMode"));
assert!(names.contains(&"StructuredOutput"));
assert!(names.contains(&"REPL"));
assert!(names.contains(&"PowerShell"));
@ -4226,6 +4550,21 @@ mod tests {
assert!(elapsed >= Duration::from_millis(15));
}
#[test]
fn given_excessive_duration_when_sleep_then_rejects_with_error() {
let result = execute_tool("Sleep", &json!({"duration_ms": 999_999_999_u64}));
let error = result.expect_err("excessive sleep should fail");
assert!(error.contains("exceeds maximum allowed sleep"));
}
#[test]
fn given_zero_duration_when_sleep_then_succeeds() {
let result =
execute_tool("Sleep", &json!({"duration_ms": 0})).expect("0ms sleep should succeed");
let output: serde_json::Value = serde_json::from_str(&result).expect("json");
assert_eq!(output["duration_ms"], 0);
}
#[test]
fn brief_returns_sent_message_and_attachment_metadata() {
let attachment = std::env::temp_dir().join(format!(
@ -4320,6 +4659,140 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn enter_and_exit_plan_mode_round_trip_existing_local_override() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let root = std::env::temp_dir().join(format!(
"clawd-plan-mode-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let home = root.join("home");
let cwd = root.join("cwd");
std::fs::create_dir_all(home.join(".claw")).expect("home dir");
std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir");
std::fs::write(
cwd.join(".claw").join("settings.local.json"),
r#"{"permissions":{"defaultMode":"acceptEdits"}}"#,
)
.expect("write local settings");
let original_home = std::env::var("HOME").ok();
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::set_current_dir(&cwd).expect("set cwd");
let enter = execute_tool("EnterPlanMode", &json!({})).expect("enter plan mode");
let enter_output: serde_json::Value = serde_json::from_str(&enter).expect("json");
assert_eq!(enter_output["changed"], true);
assert_eq!(enter_output["managed"], true);
assert_eq!(enter_output["previousLocalMode"], "acceptEdits");
assert_eq!(enter_output["currentLocalMode"], "plan");
let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json"))
.expect("local settings after enter");
assert!(local_settings.contains(r#""defaultMode": "plan""#));
let state =
std::fs::read_to_string(cwd.join(".claw").join("tool-state").join("plan-mode.json"))
.expect("plan mode state");
assert!(state.contains(r#""hadLocalOverride": true"#));
assert!(state.contains(r#""previousLocalMode": "acceptEdits""#));
let exit = execute_tool("ExitPlanMode", &json!({})).expect("exit plan mode");
let exit_output: serde_json::Value = serde_json::from_str(&exit).expect("json");
assert_eq!(exit_output["changed"], true);
assert_eq!(exit_output["managed"], false);
assert_eq!(exit_output["previousLocalMode"], "acceptEdits");
assert_eq!(exit_output["currentLocalMode"], "acceptEdits");
let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json"))
.expect("local settings after exit");
assert!(local_settings.contains(r#""defaultMode": "acceptEdits""#));
assert!(!cwd
.join(".claw")
.join("tool-state")
.join("plan-mode.json")
.exists());
std::env::set_current_dir(&original_dir).expect("restore cwd");
match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn exit_plan_mode_clears_override_when_enter_created_it_from_empty_local_state() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let root = std::env::temp_dir().join(format!(
"clawd-plan-mode-empty-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let home = root.join("home");
let cwd = root.join("cwd");
std::fs::create_dir_all(home.join(".claw")).expect("home dir");
std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir");
let original_home = std::env::var("HOME").ok();
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::set_current_dir(&cwd).expect("set cwd");
let enter = execute_tool("EnterPlanMode", &json!({})).expect("enter plan mode");
let enter_output: serde_json::Value = serde_json::from_str(&enter).expect("json");
assert_eq!(enter_output["previousLocalMode"], serde_json::Value::Null);
assert_eq!(enter_output["currentLocalMode"], "plan");
let exit = execute_tool("ExitPlanMode", &json!({})).expect("exit plan mode");
let exit_output: serde_json::Value = serde_json::from_str(&exit).expect("json");
assert_eq!(exit_output["changed"], true);
assert_eq!(exit_output["currentLocalMode"], serde_json::Value::Null);
let local_settings = std::fs::read_to_string(cwd.join(".claw").join("settings.local.json"))
.expect("local settings after exit");
let local_settings_json: serde_json::Value =
serde_json::from_str(&local_settings).expect("valid settings json");
assert_eq!(
local_settings_json.get("permissions"),
None,
"permissions override should be removed on exit"
);
assert!(!cwd
.join(".claw")
.join("tool-state")
.join("plan-mode.json")
.exists());
std::env::set_current_dir(&original_dir).expect("restore cwd");
match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn structured_output_echoes_input_payload() {
let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]}))
@ -4330,6 +4803,13 @@ mod tests {
assert_eq!(output["structured_output"]["items"][1], 2);
}
#[test]
fn given_empty_payload_when_structured_output_then_rejects_with_error() {
let result = execute_tool("StructuredOutput", &json!({}));
let error = result.expect_err("empty payload should fail");
assert!(error.contains("must not be empty"));
}
#[test]
fn repl_executes_python_code() {
let result = execute_tool(
@ -4343,6 +4823,37 @@ mod tests {
assert!(output["stdout"].as_str().expect("stdout").contains('2'));
}
#[test]
fn given_empty_code_when_repl_then_rejects_with_error() {
let result = execute_tool("REPL", &json!({"language": "python", "code": " "}));
let error = result.expect_err("empty REPL code should fail");
assert!(error.contains("code must not be empty"));
}
#[test]
fn given_unsupported_language_when_repl_then_rejects_with_error() {
let result = execute_tool("REPL", &json!({"language": "ruby", "code": "puts 1"}));
let error = result.expect_err("unsupported REPL language should fail");
assert!(error.contains("unsupported REPL language: ruby"));
}
#[test]
fn given_timeout_ms_when_repl_blocks_then_returns_timeout_error() {
let result = execute_tool(
"REPL",
&json!({
"language": "python",
"code": "import time\ntime.sleep(1)",
"timeout_ms": 10
}),
);
let error = result.expect_err("timed out REPL execution should fail");
assert!(error.contains("REPL execution exceeded timeout of 10 ms"));
}
#[test]
fn powershell_runs_via_stub_shell() {
let _guard = env_lock()