Compare commits
13 Commits
12bf23b440
...
8805386bea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8805386bea | ||
|
|
c9f26013d8 | ||
|
|
703bbeef06 | ||
|
|
5d8e131c14 | ||
|
|
e780142886 | ||
|
|
5c845d582e | ||
|
|
93d98ab33f | ||
|
|
6e642a002d | ||
|
|
b92bd88cc8 | ||
|
|
ef48b7e515 | ||
|
|
d88144d4a5 | ||
|
|
73187de6ea | ||
|
|
f2dd6521ed |
@ -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, ®istry_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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user