Compare commits

..

No commits in common. "8805386beac22cfdfa362f10ffb499a1153ddde3" and "12bf23b4409833cdeaa4c0309a637efb43192a45" have entirely different histories.

6 changed files with 61 additions and 1367 deletions

View File

@ -221,14 +221,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "agents",
aliases: &[],
summary: "List configured agents",
argument_hint: Some("[list|help]"),
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "skills",
aliases: &[],
summary: "List or install available skills",
argument_hint: Some("[list|install <path>|help]"),
summary: "List available skills",
argument_hint: None,
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_skills_args(remainder.as_deref())?,
args: parse_list_or_help_args(command, remainder)?,
},
other => SlashCommand::Unknown(other.to_string()),
}))
@ -633,38 +633,6 @@ 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();
@ -974,21 +942,6 @@ 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>,
@ -1120,15 +1073,6 @@ 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))),
}
@ -1304,202 +1248,6 @@ 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,
@ -1823,27 +1571,6 @@ 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())
}
@ -1851,7 +1578,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 [list|help]".to_string(),
" Usage /agents".to_string(),
" Direct CLI claw agents".to_string(),
" Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
];
@ -1864,9 +1591,8 @@ 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 [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(),
" Usage /skills".to_string(),
" Direct CLI claw skills".to_string(),
" Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
];
if let Some(args) = unexpected {
@ -2195,12 +1921,6 @@ 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 {
@ -2294,9 +2014,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, /skills install <path>, or /skills help."
"Unexpected arguments for /skills: show help. Use /skills, /skills list, or /skills help."
));
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
assert!(skills_error.contains(" Usage /skills [list|help]"));
}
#[test]
@ -2336,8 +2056,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 [list|help]"));
assert!(help.contains("/skills [list|install <path>|help]"));
assert!(help.contains("/agents"));
assert!(help.contains("/skills"));
assert_eq!(slash_command_specs().len(), 26);
assert_eq!(resume_supported_slash_commands().len(), 14);
}
@ -2645,7 +2365,7 @@ mod tests {
let agents_help =
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
assert!(agents_help.contains("Usage /agents [list|help]"));
assert!(agents_help.contains("Usage /agents"));
assert!(agents_help.contains("Direct CLI claw agents"));
let agents_unexpected =
@ -2654,8 +2374,7 @@ mod tests {
let skills_help =
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
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("Usage /skills"));
assert!(skills_help.contains("legacy /commands"));
let skills_unexpected =
@ -2673,57 +2392,6 @@ 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,12 +1629,6 @@ 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(),
@ -2312,16 +2306,6 @@ 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(
@ -3194,19 +3178,14 @@ 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));
@ -3219,33 +3198,6 @@ 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,7 +785,6 @@ 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;
@ -1203,85 +1202,6 @@ 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,50 +806,19 @@ 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,11 +1,3 @@
#![allow(
dead_code,
unused_imports,
unused_variables,
clippy::unneeded_struct_pattern,
clippy::unnecessary_wraps,
clippy::unused_self
)]
mod init;
mod input;
mod render;
@ -15,7 +7,6 @@ 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};
@ -31,12 +22,11 @@ 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,
validate_slash_command_input, SlashCommand,
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use plugins::{PluginManager, PluginManagerConfig};
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
@ -1483,76 +1473,10 @@ struct LiveCli {
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>,
runtime: BuiltRuntime,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
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<()>>,
@ -1699,7 +1623,13 @@ impl LiveCli {
fn prepare_turn_runtime(
&self,
emit_output: bool,
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
) -> Result<
(
ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
HookAbortMonitor,
),
Box<dyn std::error::Error>,
> {
let hook_abort_signal = runtime::HookAbortSignal::new();
let runtime = build_runtime(
self.runtime.session().clone(),
@ -1718,12 +1648,6 @@ 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();
@ -1736,9 +1660,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(),
@ -1755,7 +1679,6 @@ impl LiveCli {
Ok(())
}
Err(error) => {
runtime.shutdown_plugins()?;
spinner.fail(
"❌ Request failed",
TerminalRenderer::new().color_theme(),
@ -1783,7 +1706,7 @@ impl LiveCli {
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.runtime = runtime;
self.persist_session()?;
println!(
"{}",
@ -1978,7 +1901,7 @@ impl LiveCli {
let previous = self.model.clone();
let session = self.runtime.session().clone();
let message_count = session.messages.len();
let runtime = build_runtime(
self.runtime = build_runtime(
session,
&self.session.id,
model.clone(),
@ -1989,7 +1912,6 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.model.clone_from(&model);
println!(
"{}",
@ -2024,7 +1946,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);
let runtime = build_runtime(
self.runtime = build_runtime(
session,
&self.session.id,
self.model.clone(),
@ -2035,7 +1957,6 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
println!(
"{}",
format_permissions_switch_report(&previous, normalized)
@ -2053,7 +1974,7 @@ impl LiveCli {
let session_state = Session::new();
self.session = create_managed_session_handle(&session_state.session_id)?;
let runtime = build_runtime(
self.runtime = build_runtime(
session_state.with_persistence_path(self.session.path.clone()),
&self.session.id,
self.model.clone(),
@ -2064,7 +1985,6 @@ 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,
@ -2092,7 +2012,7 @@ impl LiveCli {
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
let session_id = session.session_id.clone();
let runtime = build_runtime(
self.runtime = build_runtime(
session,
&handle.id,
self.model.clone(),
@ -2103,7 +2023,6 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = SessionHandle {
id: session_id,
path: handle.path,
@ -2183,7 +2102,7 @@ impl LiveCli {
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
let session_id = session.session_id.clone();
let runtime = build_runtime(
self.runtime = build_runtime(
session,
&handle.id,
self.model.clone(),
@ -2194,7 +2113,6 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.session = SessionHandle {
id: session_id,
path: handle.path,
@ -2218,7 +2136,7 @@ impl LiveCli {
let forked = forked.with_persistence_path(handle.path.clone());
let message_count = forked.messages.len();
forked.save_to_path(&handle.path)?;
let runtime = build_runtime(
self.runtime = build_runtime(
forked,
&handle.id,
self.model.clone(),
@ -2229,7 +2147,6 @@ 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 {}",
@ -2268,7 +2185,7 @@ impl LiveCli {
}
fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let runtime = build_runtime(
self.runtime = build_runtime(
self.runtime.session().clone(),
&self.session.id,
self.model.clone(),
@ -2279,7 +2196,6 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.persist_session()
}
@ -2288,7 +2204,7 @@ impl LiveCli {
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
let runtime = build_runtime(
self.runtime = build_runtime(
result.compacted_session,
&self.session.id,
self.model.clone(),
@ -2299,7 +2215,6 @@ impl LiveCli {
self.permission_mode,
None,
)?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped));
Ok(())
@ -2325,9 +2240,7 @@ impl LiveCli {
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
let text = final_assistant_text(&summary).trim().to_string();
runtime.shutdown_plugins()?;
Ok(text)
Ok(final_assistant_text(&summary).trim().to_string())
}
fn run_internal_prompt_text(
@ -3355,32 +3268,14 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
)?)
}
fn build_runtime_plugin_state() -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
fn build_runtime_plugin_state(
) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), 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 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,
})
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?;
Ok((runtime_config.feature_config().clone(), tool_registry))
}
fn build_plugin_manager(
@ -3419,14 +3314,6 @@ 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,
@ -3767,42 +3654,9 @@ fn build_runtime(
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> 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()?;
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
let mut runtime = ConversationRuntime::new_with_features(
session,
AnthropicRuntimeClient::new(
@ -3823,7 +3677,7 @@ fn build_runtime_with_plugin_state(
if emit_output {
runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter));
}
Ok(BuiltRuntime::new(runtime, plugin_registry))
Ok(runtime)
}
struct CliHookProgressReporter;
@ -4991,7 +4845,6 @@ 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,
@ -5010,12 +4863,9 @@ mod tests {
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
use runtime::{
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
PermissionMode, Session,
AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session,
};
use serde_json::json;
use std::fs;
@ -5084,49 +4934,6 @@ 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!(
@ -5372,40 +5179,12 @@ 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");
@ -6586,89 +6365,6 @@ 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,6 +169,7 @@ impl GlobalToolRegistry {
builtin.chain(plugin).collect()
}
#[must_use]
pub fn permission_specs(
&self,
allowed_tools: Option<&BTreeSet<String>>,
@ -504,26 +505,6 @@ 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.",
@ -585,8 +566,6 @@ 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)
}
@ -669,7 +648,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> {
@ -680,16 +659,8 @@ 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> {
@ -840,14 +811,6 @@ 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 {
@ -1005,32 +968,6 @@ 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,
@ -2410,8 +2347,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
let cell_id = match edit_mode {
NotebookEditMode::Insert => {
let resolved_cell_type = resolved_cell_type
.ok_or_else(|| String::from("insert mode requires a cell type"))?;
let resolved_cell_type = resolved_cell_type.expect("insert 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);
@ -2423,21 +2359,16 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
.map(ToString::to_string)
}
NotebookEditMode::Delete => {
let idx = target_index
.ok_or_else(|| String::from("delete mode requires a target cell index"))?;
let removed = cells.remove(idx);
let removed = cells.remove(target_index.expect("delete target index"));
removed
.get("id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
}
NotebookEditMode::Replace => {
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 resolved_cell_type = resolved_cell_type.expect("replace cell type");
let cell = cells
.get_mut(idx)
.get_mut(target_index.expect("replace target index"))
.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 {
@ -2528,21 +2459,13 @@ 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) -> 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,
));
}
fn execute_sleep(input: SleepInput) -> SleepOutput {
std::thread::sleep(Duration::from_millis(input.duration_ms));
Ok(SleepOutput {
SleepOutput {
duration_ms: input.duration_ms,
message: format!("Slept for {}ms", input.duration_ms),
})
}
}
fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
@ -2639,204 +2562,25 @@ fn execute_config(input: ConfigInput) -> Result<ConfigOutput, String> {
}
}
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 {
fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
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 mut process = Command::new(runtime.program);
process
let output = Command::new(runtime.program)
.args(runtime.args)
.arg(&input.code)
.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())?
};
.output()
.map_err(|error| error.to_string())?;
Ok(ReplOutput {
language: input.language,
@ -3108,72 +2852,6 @@ 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"])
@ -3462,8 +3140,6 @@ 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"));
@ -4550,21 +4226,6 @@ 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!(
@ -4659,140 +4320,6 @@ 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]}))
@ -4803,13 +4330,6 @@ 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(
@ -4823,37 +4343,6 @@ 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()