Compare commits
No commits in common. "8805386beac22cfdfa362f10ffb499a1153ddde3" and "12bf23b4409833cdeaa4c0309a637efb43192a45" have entirely different histories.
8805386bea
...
12bf23b440
@ -221,14 +221,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
name: "agents",
|
name: "agents",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
summary: "List configured agents",
|
summary: "List configured agents",
|
||||||
argument_hint: Some("[list|help]"),
|
argument_hint: None,
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "skills",
|
name: "skills",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
summary: "List or install available skills",
|
summary: "List available skills",
|
||||||
argument_hint: Some("[list|install <path>|help]"),
|
argument_hint: None,
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -416,7 +416,7 @@ pub fn validate_slash_command_input(
|
|||||||
args: parse_list_or_help_args(command, remainder)?,
|
args: parse_list_or_help_args(command, remainder)?,
|
||||||
},
|
},
|
||||||
"skills" => SlashCommand::Skills {
|
"skills" => SlashCommand::Skills {
|
||||||
args: parse_skills_args(remainder.as_deref())?,
|
args: parse_list_or_help_args(command, remainder)?,
|
||||||
},
|
},
|
||||||
other => SlashCommand::Unknown(other.to_string()),
|
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 {
|
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
|
||||||
let usage = format!("/{command} {argument_hint}");
|
let usage = format!("/{command} {argument_hint}");
|
||||||
let usage = usage.trim_end().to_string();
|
let usage = usage.trim_end().to_string();
|
||||||
@ -974,21 +942,6 @@ struct SkillRoot {
|
|||||||
origin: SkillOrigin,
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn handle_plugins_slash_command(
|
pub fn handle_plugins_slash_command(
|
||||||
action: Option<&str>,
|
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)?;
|
let skills = load_skills_from_roots(&roots)?;
|
||||||
Ok(render_skills_report(&skills))
|
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("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
|
||||||
Some(args) => Ok(render_skills_usage(Some(args))),
|
Some(args) => Ok(render_skills_usage(Some(args))),
|
||||||
}
|
}
|
||||||
@ -1304,202 +1248,6 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
roots
|
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(
|
fn push_unique_root(
|
||||||
roots: &mut Vec<(DefinitionSource, PathBuf)>,
|
roots: &mut Vec<(DefinitionSource, PathBuf)>,
|
||||||
source: DefinitionSource,
|
source: DefinitionSource,
|
||||||
@ -1823,27 +1571,6 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
|||||||
lines.join("\n").trim_end().to_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> {
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
||||||
args.map(str::trim).filter(|value| !value.is_empty())
|
args.map(str::trim).filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
@ -1851,7 +1578,7 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
|||||||
fn render_agents_usage(unexpected: Option<&str>) -> String {
|
fn render_agents_usage(unexpected: Option<&str>) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Agents".to_string(),
|
"Agents".to_string(),
|
||||||
" Usage /agents [list|help]".to_string(),
|
" Usage /agents".to_string(),
|
||||||
" Direct CLI claw agents".to_string(),
|
" Direct CLI claw agents".to_string(),
|
||||||
" Sources .codex/agents, .claude/agents, $CODEX_HOME/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 {
|
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Skills".to_string(),
|
"Skills".to_string(),
|
||||||
" Usage /skills [list|install <path>|help]".to_string(),
|
" Usage /skills".to_string(),
|
||||||
" Direct CLI claw skills [list|install <path>|help]".to_string(),
|
" Direct CLI claw skills".to_string(),
|
||||||
" Install root $CODEX_HOME/skills or ~/.codex/skills".to_string(),
|
|
||||||
" Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
|
" Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
|
||||||
];
|
];
|
||||||
if let Some(args) = unexpected {
|
if let Some(args) = unexpected {
|
||||||
@ -2195,12 +1921,6 @@ mod tests {
|
|||||||
target: Some("demo".to_string())
|
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!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/plugins disable demo"),
|
SlashCommand::parse("/plugins disable demo"),
|
||||||
Ok(Some(SlashCommand::Plugins {
|
Ok(Some(SlashCommand::Plugins {
|
||||||
@ -2294,9 +2014,9 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
||||||
assert!(skills_error.contains(
|
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]
|
#[test]
|
||||||
@ -2336,8 +2056,8 @@ mod tests {
|
|||||||
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
));
|
));
|
||||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||||
assert!(help.contains("/agents [list|help]"));
|
assert!(help.contains("/agents"));
|
||||||
assert!(help.contains("/skills [list|install <path>|help]"));
|
assert!(help.contains("/skills"));
|
||||||
assert_eq!(slash_command_specs().len(), 26);
|
assert_eq!(slash_command_specs().len(), 26);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 14);
|
assert_eq!(resume_supported_slash_commands().len(), 14);
|
||||||
}
|
}
|
||||||
@ -2645,7 +2365,7 @@ mod tests {
|
|||||||
|
|
||||||
let agents_help =
|
let agents_help =
|
||||||
super::handle_agents_slash_command(Some("help"), &cwd).expect("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"));
|
assert!(agents_help.contains("Direct CLI claw agents"));
|
||||||
|
|
||||||
let agents_unexpected =
|
let agents_unexpected =
|
||||||
@ -2654,8 +2374,7 @@ mod tests {
|
|||||||
|
|
||||||
let skills_help =
|
let skills_help =
|
||||||
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
||||||
assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
|
assert!(skills_help.contains("Usage /skills"));
|
||||||
assert!(skills_help.contains("Install root $CODEX_HOME/skills or ~/.codex/skills"));
|
|
||||||
assert!(skills_help.contains("legacy /commands"));
|
assert!(skills_help.contains("legacy /commands"));
|
||||||
|
|
||||||
let skills_unexpected =
|
let skills_unexpected =
|
||||||
@ -2673,57 +2392,6 @@ mod tests {
|
|||||||
assert_eq!(description.as_deref(), Some("Quoted description"));
|
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]
|
#[test]
|
||||||
fn installs_plugin_from_path_and_lists_it() {
|
fn installs_plugin_from_path_and_lists_it() {
|
||||||
let config_home = temp_dir("home");
|
let config_home = temp_dir("home");
|
||||||
|
|||||||
@ -1629,12 +1629,6 @@ fn build_plugin_manifest(
|
|||||||
let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
|
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.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.iter(), "hook", &mut errors);
|
||||||
validate_command_entries(
|
|
||||||
root,
|
|
||||||
raw.hooks.post_tool_use_failure.iter(),
|
|
||||||
"hook",
|
|
||||||
&mut errors,
|
|
||||||
);
|
|
||||||
validate_command_entries(
|
validate_command_entries(
|
||||||
root,
|
root,
|
||||||
raw.lifecycle.init.iter(),
|
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 {
|
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
||||||
let log_path = root.join("lifecycle.log");
|
let log_path = root.join("lifecycle.log");
|
||||||
write_file(
|
write_file(
|
||||||
@ -3194,19 +3178,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_plugin_sources_with_missing_hook_paths() {
|
fn rejects_plugin_sources_with_missing_hook_paths() {
|
||||||
// given
|
|
||||||
let config_home = temp_dir("broken-home");
|
let config_home = temp_dir("broken-home");
|
||||||
let source_root = temp_dir("broken-source");
|
let source_root = temp_dir("broken-source");
|
||||||
write_broken_plugin(&source_root, "broken");
|
write_broken_plugin(&source_root, "broken");
|
||||||
|
|
||||||
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
|
||||||
// when
|
|
||||||
let error = manager
|
let error = manager
|
||||||
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
|
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
|
||||||
.expect_err("missing hook file should fail validation");
|
.expect_err("missing hook file should fail validation");
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(error.to_string().contains("does not exist"));
|
assert!(error.to_string().contains("does not exist"));
|
||||||
|
|
||||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
@ -3219,33 +3198,6 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(source_root);
|
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]
|
#[test]
|
||||||
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
||||||
let config_home = temp_dir("lifecycle-home");
|
let config_home = temp_dir("lifecycle-home");
|
||||||
|
|||||||
@ -785,7 +785,6 @@ mod tests {
|
|||||||
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
||||||
use crate::session::{ContentBlock, MessageRole, Session};
|
use crate::session::{ContentBlock, MessageRole, Session};
|
||||||
use crate::usage::TokenUsage;
|
use crate::usage::TokenUsage;
|
||||||
use crate::ToolError;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
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]
|
#[test]
|
||||||
fn reconstructs_usage_tracker_from_restored_session() {
|
fn reconstructs_usage_tracker_from_restored_session() {
|
||||||
struct SimpleApi;
|
struct SimpleApi;
|
||||||
|
|||||||
@ -806,50 +806,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runs_post_tool_use_failure_hooks() {
|
fn runs_post_tool_use_failure_hooks() {
|
||||||
// given
|
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![shell_snippet("printf 'failure hook ran'")],
|
vec![shell_snippet("printf 'failure hook ran'")],
|
||||||
));
|
));
|
||||||
|
|
||||||
// when
|
|
||||||
let result =
|
let result =
|
||||||
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
|
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
|
||||||
|
|
||||||
// then
|
|
||||||
assert!(!result.is_denied());
|
assert!(!result.is_denied());
|
||||||
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
|
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]
|
#[test]
|
||||||
fn executes_hooks_in_configured_order() {
|
fn executes_hooks_in_configured_order() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@ -1,11 +1,3 @@
|
|||||||
#![allow(
|
|
||||||
dead_code,
|
|
||||||
unused_imports,
|
|
||||||
unused_variables,
|
|
||||||
clippy::unneeded_struct_pattern,
|
|
||||||
clippy::unnecessary_wraps,
|
|
||||||
clippy::unused_self
|
|
||||||
)]
|
|
||||||
mod init;
|
mod init;
|
||||||
mod input;
|
mod input;
|
||||||
mod render;
|
mod render;
|
||||||
@ -15,7 +7,6 @@ use std::env;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::ops::{Deref, DerefMut};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||||
@ -31,12 +22,11 @@ use api::{
|
|||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
|
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
|
||||||
validate_slash_command_input, SlashCommand,
|
|
||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
use plugins::{PluginManager, PluginManagerConfig};
|
||||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||||
@ -1483,76 +1473,10 @@ struct LiveCli {
|
|||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
system_prompt: Vec<String>,
|
system_prompt: Vec<String>,
|
||||||
runtime: BuiltRuntime,
|
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||||
session: SessionHandle,
|
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 {
|
struct HookAbortMonitor {
|
||||||
stop_tx: Option<Sender<()>>,
|
stop_tx: Option<Sender<()>>,
|
||||||
join_handle: Option<JoinHandle<()>>,
|
join_handle: Option<JoinHandle<()>>,
|
||||||
@ -1699,7 +1623,13 @@ impl LiveCli {
|
|||||||
fn prepare_turn_runtime(
|
fn prepare_turn_runtime(
|
||||||
&self,
|
&self,
|
||||||
emit_output: bool,
|
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 hook_abort_signal = runtime::HookAbortSignal::new();
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
self.runtime.session().clone(),
|
self.runtime.session().clone(),
|
||||||
@ -1718,12 +1648,6 @@ impl LiveCli {
|
|||||||
Ok((runtime, hook_abort_monitor))
|
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>> {
|
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 runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||||
let mut spinner = Spinner::new();
|
let mut spinner = Spinner::new();
|
||||||
@ -1736,9 +1660,9 @@ impl LiveCli {
|
|||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
hook_abort_monitor.stop();
|
hook_abort_monitor.stop();
|
||||||
|
self.runtime = runtime;
|
||||||
match result {
|
match result {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
spinner.finish(
|
spinner.finish(
|
||||||
"✨ Done",
|
"✨ Done",
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
@ -1755,7 +1679,6 @@ impl LiveCli {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
runtime.shutdown_plugins()?;
|
|
||||||
spinner.fail(
|
spinner.fail(
|
||||||
"❌ Request failed",
|
"❌ Request failed",
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
@ -1783,7 +1706,7 @@ impl LiveCli {
|
|||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
hook_abort_monitor.stop();
|
hook_abort_monitor.stop();
|
||||||
let summary = result?;
|
let summary = result?;
|
||||||
self.replace_runtime(runtime)?;
|
self.runtime = runtime;
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
@ -1978,7 +1901,7 @@ impl LiveCli {
|
|||||||
let previous = self.model.clone();
|
let previous = self.model.clone();
|
||||||
let session = self.runtime.session().clone();
|
let session = self.runtime.session().clone();
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
let runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
session,
|
session,
|
||||||
&self.session.id,
|
&self.session.id,
|
||||||
model.clone(),
|
model.clone(),
|
||||||
@ -1989,7 +1912,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
self.model.clone_from(&model);
|
self.model.clone_from(&model);
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
@ -2024,7 +1946,7 @@ impl LiveCli {
|
|||||||
let previous = self.permission_mode.as_str().to_string();
|
let previous = self.permission_mode.as_str().to_string();
|
||||||
let session = self.runtime.session().clone();
|
let session = self.runtime.session().clone();
|
||||||
self.permission_mode = permission_mode_from_label(normalized);
|
self.permission_mode = permission_mode_from_label(normalized);
|
||||||
let runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
session,
|
session,
|
||||||
&self.session.id,
|
&self.session.id,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@ -2035,7 +1957,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
format_permissions_switch_report(&previous, normalized)
|
format_permissions_switch_report(&previous, normalized)
|
||||||
@ -2053,7 +1974,7 @@ impl LiveCli {
|
|||||||
|
|
||||||
let session_state = Session::new();
|
let session_state = Session::new();
|
||||||
self.session = create_managed_session_handle(&session_state.session_id)?;
|
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()),
|
session_state.with_persistence_path(self.session.path.clone()),
|
||||||
&self.session.id,
|
&self.session.id,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@ -2064,7 +1985,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
println!(
|
println!(
|
||||||
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
|
||||||
self.model,
|
self.model,
|
||||||
@ -2092,7 +2012,7 @@ impl LiveCli {
|
|||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
let session_id = session.session_id.clone();
|
let session_id = session.session_id.clone();
|
||||||
let runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
session,
|
session,
|
||||||
&handle.id,
|
&handle.id,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@ -2103,7 +2023,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
self.session = SessionHandle {
|
self.session = SessionHandle {
|
||||||
id: session_id,
|
id: session_id,
|
||||||
path: handle.path,
|
path: handle.path,
|
||||||
@ -2183,7 +2102,7 @@ impl LiveCli {
|
|||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
let session_id = session.session_id.clone();
|
let session_id = session.session_id.clone();
|
||||||
let runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
session,
|
session,
|
||||||
&handle.id,
|
&handle.id,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@ -2194,7 +2113,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
self.session = SessionHandle {
|
self.session = SessionHandle {
|
||||||
id: session_id,
|
id: session_id,
|
||||||
path: handle.path,
|
path: handle.path,
|
||||||
@ -2218,7 +2136,7 @@ impl LiveCli {
|
|||||||
let forked = forked.with_persistence_path(handle.path.clone());
|
let forked = forked.with_persistence_path(handle.path.clone());
|
||||||
let message_count = forked.messages.len();
|
let message_count = forked.messages.len();
|
||||||
forked.save_to_path(&handle.path)?;
|
forked.save_to_path(&handle.path)?;
|
||||||
let runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
forked,
|
forked,
|
||||||
&handle.id,
|
&handle.id,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@ -2229,7 +2147,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
self.session = handle;
|
self.session = handle;
|
||||||
println!(
|
println!(
|
||||||
"Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}",
|
"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>> {
|
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.runtime.session().clone(),
|
||||||
&self.session.id,
|
&self.session.id,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@ -2279,7 +2196,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
self.persist_session()
|
self.persist_session()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2288,7 +2204,7 @@ impl LiveCli {
|
|||||||
let removed = result.removed_message_count;
|
let removed = result.removed_message_count;
|
||||||
let kept = result.compacted_session.messages.len();
|
let kept = result.compacted_session.messages.len();
|
||||||
let skipped = removed == 0;
|
let skipped = removed == 0;
|
||||||
let runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
result.compacted_session,
|
result.compacted_session,
|
||||||
&self.session.id,
|
&self.session.id,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@ -2299,7 +2215,6 @@ impl LiveCli {
|
|||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
self.replace_runtime(runtime)?;
|
|
||||||
self.persist_session()?;
|
self.persist_session()?;
|
||||||
println!("{}", format_compact_report(removed, kept, skipped));
|
println!("{}", format_compact_report(removed, kept, skipped));
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -2325,9 +2240,7 @@ impl LiveCli {
|
|||||||
)?;
|
)?;
|
||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
|
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
|
||||||
let text = final_assistant_text(&summary).trim().to_string();
|
Ok(final_assistant_text(&summary).trim().to_string())
|
||||||
runtime.shutdown_plugins()?;
|
|
||||||
Ok(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_internal_prompt_text(
|
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 cwd = env::current_dir()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let runtime_config = loader.load()?;
|
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_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
let plugin_registry = plugin_manager.plugin_registry()?;
|
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?;
|
||||||
let plugin_hook_config =
|
Ok((runtime_config.feature_config().clone(), tool_registry))
|
||||||
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(
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct InternalPromptProgressState {
|
struct InternalPromptProgressState {
|
||||||
command_label: &'static str,
|
command_label: &'static str,
|
||||||
@ -3767,42 +3654,9 @@ fn build_runtime(
|
|||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||||
let runtime_plugin_state = build_runtime_plugin_state()?;
|
{
|
||||||
build_runtime_with_plugin_state(
|
let (feature_config, tool_registry) = build_runtime_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(
|
let mut runtime = ConversationRuntime::new_with_features(
|
||||||
session,
|
session,
|
||||||
AnthropicRuntimeClient::new(
|
AnthropicRuntimeClient::new(
|
||||||
@ -3823,7 +3677,7 @@ fn build_runtime_with_plugin_state(
|
|||||||
if emit_output {
|
if emit_output {
|
||||||
runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter));
|
runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter));
|
||||||
}
|
}
|
||||||
Ok(BuiltRuntime::new(runtime, plugin_registry))
|
Ok(runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CliHookProgressReporter;
|
struct CliHookProgressReporter;
|
||||||
@ -4991,7 +4845,6 @@ fn print_help() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
|
||||||
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
||||||
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
||||||
format_compact_report, format_cost_report, format_internal_prompt_progress_line,
|
format_compact_report, format_cost_report, format_internal_prompt_progress_line,
|
||||||
@ -5010,12 +4863,9 @@ mod tests {
|
|||||||
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
||||||
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
|
||||||
};
|
|
||||||
use runtime::{
|
use runtime::{
|
||||||
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
|
AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session,
|
||||||
PermissionMode, Session,
|
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@ -5084,49 +4934,6 @@ mod tests {
|
|||||||
std::env::set_current_dir(previous).expect("cwd should restore");
|
std::env::set_current_dir(previous).expect("cwd should restore");
|
||||||
result
|
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]
|
#[test]
|
||||||
fn defaults_to_repl_when_no_args() {
|
fn defaults_to_repl_when_no_args() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -5372,40 +5179,12 @@ mod tests {
|
|||||||
args: Some("help".to_string())
|
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()])
|
let error = parse_args(&["/status".to_string()])
|
||||||
.expect_err("/status should remain REPL-only when invoked directly");
|
.expect_err("/status should remain REPL-only when invoked directly");
|
||||||
assert!(error.contains("interactive-only"));
|
assert!(error.contains("interactive-only"));
|
||||||
assert!(error.contains("claw --resume SESSION.jsonl /status"));
|
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]
|
#[test]
|
||||||
fn formats_unknown_slash_command_with_suggestions() {
|
fn formats_unknown_slash_command_with_suggestions() {
|
||||||
let report = format_unknown_slash_command_message("stats");
|
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"));
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -169,6 +169,7 @@ impl GlobalToolRegistry {
|
|||||||
builtin.chain(plugin).collect()
|
builtin.chain(plugin).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn permission_specs(
|
pub fn permission_specs(
|
||||||
&self,
|
&self,
|
||||||
allowed_tools: Option<&BTreeSet<String>>,
|
allowed_tools: Option<&BTreeSet<String>>,
|
||||||
@ -504,26 +505,6 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
}),
|
}),
|
||||||
required_permission: PermissionMode::WorkspaceWrite,
|
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 {
|
ToolSpec {
|
||||||
name: "StructuredOutput",
|
name: "StructuredOutput",
|
||||||
description: "Return structured output in the requested format.",
|
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),
|
"Sleep" => from_value::<SleepInput>(input).and_then(run_sleep),
|
||||||
"SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
|
"SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief),
|
||||||
"Config" => from_value::<ConfigInput>(input).and_then(run_config),
|
"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" => {
|
"StructuredOutput" => {
|
||||||
from_value::<StructuredOutputInput>(input).and_then(run_structured_output)
|
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> {
|
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> {
|
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)?)
|
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> {
|
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> {
|
fn run_repl(input: ReplInput) -> Result<String, String> {
|
||||||
@ -840,14 +811,6 @@ struct ConfigInput {
|
|||||||
value: Option<ConfigValue>,
|
value: Option<ConfigValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
struct EnterPlanModeInput {}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
struct ExitPlanModeInput {}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
enum ConfigValue {
|
enum ConfigValue {
|
||||||
@ -1005,32 +968,6 @@ struct ConfigOutput {
|
|||||||
error: Option<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct StructuredOutputResult {
|
struct StructuredOutputResult {
|
||||||
data: String,
|
data: String,
|
||||||
@ -2410,8 +2347,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
|||||||
|
|
||||||
let cell_id = match edit_mode {
|
let cell_id = match edit_mode {
|
||||||
NotebookEditMode::Insert => {
|
NotebookEditMode::Insert => {
|
||||||
let resolved_cell_type = resolved_cell_type
|
let resolved_cell_type = resolved_cell_type.expect("insert cell type");
|
||||||
.ok_or_else(|| String::from("insert mode requires a cell type"))?;
|
|
||||||
let new_id = make_cell_id(cells.len());
|
let new_id = make_cell_id(cells.len());
|
||||||
let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
|
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);
|
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)
|
.map(ToString::to_string)
|
||||||
}
|
}
|
||||||
NotebookEditMode::Delete => {
|
NotebookEditMode::Delete => {
|
||||||
let idx = target_index
|
let removed = cells.remove(target_index.expect("delete target index"));
|
||||||
.ok_or_else(|| String::from("delete mode requires a target cell index"))?;
|
|
||||||
let removed = cells.remove(idx);
|
|
||||||
removed
|
removed
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
}
|
}
|
||||||
NotebookEditMode::Replace => {
|
NotebookEditMode::Replace => {
|
||||||
let resolved_cell_type = resolved_cell_type
|
let resolved_cell_type = resolved_cell_type.expect("replace 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
|
let cell = cells
|
||||||
.get_mut(idx)
|
.get_mut(target_index.expect("replace target index"))
|
||||||
.ok_or_else(|| String::from("Cell index out of range"))?;
|
.ok_or_else(|| String::from("Cell index out of range"))?;
|
||||||
cell["source"] = serde_json::Value::Array(source_lines(&new_source));
|
cell["source"] = serde_json::Value::Array(source_lines(&new_source));
|
||||||
cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
|
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)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn execute_sleep(input: SleepInput) -> Result<SleepOutput, String> {
|
fn execute_sleep(input: SleepInput) -> SleepOutput {
|
||||||
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));
|
std::thread::sleep(Duration::from_millis(input.duration_ms));
|
||||||
Ok(SleepOutput {
|
SleepOutput {
|
||||||
duration_ms: input.duration_ms,
|
duration_ms: input.duration_ms,
|
||||||
message: format!("Slept for {}ms", input.duration_ms),
|
message: format!("Slept for {}ms", input.duration_ms),
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
|
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_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
|
||||||
|
StructuredOutputResult {
|
||||||
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"),
|
data: String::from("Structured output provided successfully"),
|
||||||
structured_output: input.0,
|
structured_output: input.0,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
|
fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
|
||||||
if input.code.trim().is_empty() {
|
if input.code.trim().is_empty() {
|
||||||
return Err(String::from("code must not be empty"));
|
return Err(String::from("code must not be empty"));
|
||||||
}
|
}
|
||||||
|
let _ = input.timeout_ms;
|
||||||
let runtime = resolve_repl_runtime(&input.language)?;
|
let runtime = resolve_repl_runtime(&input.language)?;
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let mut process = Command::new(runtime.program);
|
let output = Command::new(runtime.program)
|
||||||
process
|
|
||||||
.args(runtime.args)
|
.args(runtime.args)
|
||||||
.arg(&input.code)
|
.arg(&input.code)
|
||||||
.stdin(std::process::Stdio::null())
|
.output()
|
||||||
.stdout(std::process::Stdio::piped())
|
.map_err(|error| error.to_string())?;
|
||||||
.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 {
|
Ok(ReplOutput {
|
||||||
language: input.language,
|
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);
|
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 {
|
fn iso8601_timestamp() -> String {
|
||||||
if let Ok(output) = Command::new("date")
|
if let Ok(output) = Command::new("date")
|
||||||
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
|
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
|
||||||
@ -3462,8 +3140,6 @@ mod tests {
|
|||||||
assert!(names.contains(&"Sleep"));
|
assert!(names.contains(&"Sleep"));
|
||||||
assert!(names.contains(&"SendUserMessage"));
|
assert!(names.contains(&"SendUserMessage"));
|
||||||
assert!(names.contains(&"Config"));
|
assert!(names.contains(&"Config"));
|
||||||
assert!(names.contains(&"EnterPlanMode"));
|
|
||||||
assert!(names.contains(&"ExitPlanMode"));
|
|
||||||
assert!(names.contains(&"StructuredOutput"));
|
assert!(names.contains(&"StructuredOutput"));
|
||||||
assert!(names.contains(&"REPL"));
|
assert!(names.contains(&"REPL"));
|
||||||
assert!(names.contains(&"PowerShell"));
|
assert!(names.contains(&"PowerShell"));
|
||||||
@ -4550,21 +4226,6 @@ mod tests {
|
|||||||
assert!(elapsed >= Duration::from_millis(15));
|
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]
|
#[test]
|
||||||
fn brief_returns_sent_message_and_attachment_metadata() {
|
fn brief_returns_sent_message_and_attachment_metadata() {
|
||||||
let attachment = std::env::temp_dir().join(format!(
|
let attachment = std::env::temp_dir().join(format!(
|
||||||
@ -4659,140 +4320,6 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(root);
|
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]
|
#[test]
|
||||||
fn structured_output_echoes_input_payload() {
|
fn structured_output_echoes_input_payload() {
|
||||||
let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]}))
|
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);
|
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]
|
#[test]
|
||||||
fn repl_executes_python_code() {
|
fn repl_executes_python_code() {
|
||||||
let result = execute_tool(
|
let result = execute_tool(
|
||||||
@ -4823,37 +4343,6 @@ mod tests {
|
|||||||
assert!(output["stdout"].as_str().expect("stdout").contains('2'));
|
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]
|
#[test]
|
||||||
fn powershell_runs_via_stub_shell() {
|
fn powershell_runs_via_stub_shell() {
|
||||||
let _guard = env_lock()
|
let _guard = env_lock()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user