mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
Compare commits
1 Commits
fix/skill-
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded9057ed9 |
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -1753,7 +1753,6 @@ name = "tools"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"commands",
|
|
||||||
"plugins",
|
"plugins",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"runtime",
|
"runtime",
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ use api::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
classify_agents_slash_command, classify_skills_slash_command, handle_agents_slash_command,
|
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
|
||||||
handle_plugins_slash_command, handle_skills_slash_command, render_slash_command_help,
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||||
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
suggest_slash_commands, SlashCommand,
|
||||||
InvokeCommandAction, SlashCommand,
|
|
||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
@@ -287,30 +286,12 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
match rest[0].as_str() {
|
match rest[0].as_str() {
|
||||||
"dump-manifests" => Ok(CliAction::DumpManifests),
|
"dump-manifests" => Ok(CliAction::DumpManifests),
|
||||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
|
||||||
"agents" => match classify_agents_slash_command(join_optional_args(&rest[1..]).as_deref()) {
|
"agents" => Ok(CliAction::Agents {
|
||||||
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
|
args: join_optional_args(&rest[1..]),
|
||||||
prompt,
|
}),
|
||||||
model,
|
"skills" => Ok(CliAction::Skills {
|
||||||
output_format,
|
args: join_optional_args(&rest[1..]),
|
||||||
allowed_tools,
|
}),
|
||||||
permission_mode,
|
|
||||||
}),
|
|
||||||
_ => Ok(CliAction::Agents {
|
|
||||||
args: join_optional_args(&rest[1..]),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"skills" => match classify_skills_slash_command(join_optional_args(&rest[1..]).as_deref()) {
|
|
||||||
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
|
|
||||||
prompt,
|
|
||||||
model,
|
|
||||||
output_format,
|
|
||||||
allowed_tools,
|
|
||||||
permission_mode,
|
|
||||||
}),
|
|
||||||
_ => Ok(CliAction::Skills {
|
|
||||||
args: join_optional_args(&rest[1..]),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"system-prompt" => parse_system_prompt_args(&rest[1..]),
|
"system-prompt" => parse_system_prompt_args(&rest[1..]),
|
||||||
"login" => Ok(CliAction::Login),
|
"login" => Ok(CliAction::Login),
|
||||||
"logout" => Ok(CliAction::Logout),
|
"logout" => Ok(CliAction::Logout),
|
||||||
@@ -328,13 +309,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
permission_mode,
|
permission_mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
other if other.starts_with('/') => parse_direct_slash_cli_action(
|
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
|
||||||
&rest,
|
|
||||||
model,
|
|
||||||
output_format,
|
|
||||||
allowed_tools,
|
|
||||||
permission_mode,
|
|
||||||
),
|
|
||||||
_other => Ok(CliAction::Prompt {
|
_other => Ok(CliAction::Prompt {
|
||||||
prompt: rest.join(" "),
|
prompt: rest.join(" "),
|
||||||
model,
|
model,
|
||||||
@@ -351,40 +326,12 @@ fn join_optional_args(args: &[String]) -> Option<String> {
|
|||||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_direct_slash_cli_action(
|
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
||||||
rest: &[String],
|
|
||||||
model: String,
|
|
||||||
output_format: CliOutputFormat,
|
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
|
||||||
permission_mode: PermissionMode,
|
|
||||||
) -> Result<CliAction, String> {
|
|
||||||
let raw = rest.join(" ");
|
let raw = rest.join(" ");
|
||||||
match SlashCommand::parse(&raw) {
|
match SlashCommand::parse(&raw) {
|
||||||
Some(SlashCommand::Help) => Ok(CliAction::Help),
|
Some(SlashCommand::Help) => Ok(CliAction::Help),
|
||||||
Some(SlashCommand::Agents { args }) => {
|
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
|
||||||
match classify_agents_slash_command(args.as_deref()) {
|
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
||||||
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
|
|
||||||
prompt,
|
|
||||||
model,
|
|
||||||
output_format,
|
|
||||||
allowed_tools,
|
|
||||||
permission_mode,
|
|
||||||
}),
|
|
||||||
_ => Ok(CliAction::Agents { args }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(SlashCommand::Skills { args }) => {
|
|
||||||
match classify_skills_slash_command(args.as_deref()) {
|
|
||||||
InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt {
|
|
||||||
prompt,
|
|
||||||
model,
|
|
||||||
output_format,
|
|
||||||
allowed_tools,
|
|
||||||
permission_mode,
|
|
||||||
}),
|
|
||||||
_ => Ok(CliAction::Skills { args }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(command) => Err(format_direct_slash_command_error(
|
Some(command) => Err(format_direct_slash_command_error(
|
||||||
match &command {
|
match &command {
|
||||||
SlashCommand::Unknown(name) => format!("/{name}"),
|
SlashCommand::Unknown(name) => format!("/{name}"),
|
||||||
@@ -1374,17 +1321,11 @@ impl LiveCli {
|
|||||||
self.handle_plugins_command(action.as_deref(), target.as_deref())?
|
self.handle_plugins_command(action.as_deref(), target.as_deref())?
|
||||||
}
|
}
|
||||||
SlashCommand::Agents { args } => {
|
SlashCommand::Agents { args } => {
|
||||||
match classify_agents_slash_command(args.as_deref()) {
|
Self::print_agents(args.as_deref())?;
|
||||||
InvokeCommandAction::Invoke(prompt) => self.run_turn(&prompt)?,
|
|
||||||
_ => Self::print_agents(args.as_deref())?,
|
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
SlashCommand::Skills { args } => {
|
SlashCommand::Skills { args } => {
|
||||||
match classify_skills_slash_command(args.as_deref()) {
|
Self::print_skills(args.as_deref())?;
|
||||||
InvokeCommandAction::Invoke(prompt) => self.run_turn(&prompt)?,
|
|
||||||
_ => Self::print_skills(args.as_deref())?,
|
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
SlashCommand::Branch { .. } => {
|
SlashCommand::Branch { .. } => {
|
||||||
@@ -4391,17 +4332,6 @@ mod tests {
|
|||||||
args: Some("--help".to_string())
|
args: Some("--help".to_string())
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
parse_args(&["skills".to_string(), "ralplan".to_string()])
|
|
||||||
.expect("skills invoke should parse"),
|
|
||||||
CliAction::Prompt {
|
|
||||||
prompt: "$ralplan".to_string(),
|
|
||||||
model: DEFAULT_MODEL.to_string(),
|
|
||||||
output_format: CliOutputFormat::Text,
|
|
||||||
allowed_tools: None,
|
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4415,36 +4345,10 @@ mod tests {
|
|||||||
CliAction::Skills { args: None }
|
CliAction::Skills { args: None }
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["/skills".to_string(), "help".to_string(), "overview".to_string()])
|
parse_args(&["/skills".to_string(), "help".to_string()])
|
||||||
.expect("/skills help overview should invoke"),
|
.expect("/skills help should parse"),
|
||||||
CliAction::Prompt {
|
CliAction::Skills {
|
||||||
prompt: "$help overview".to_string(),
|
args: Some("help".to_string())
|
||||||
model: DEFAULT_MODEL.to_string(),
|
|
||||||
output_format: CliOutputFormat::Text,
|
|
||||||
allowed_tools: None,
|
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_args(&["/skills".to_string(), "oh-my-claudecode:ralplan".to_string()])
|
|
||||||
.expect("/skills namespaced invoke should parse"),
|
|
||||||
CliAction::Prompt {
|
|
||||||
prompt: "$oh-my-claudecode:ralplan".to_string(),
|
|
||||||
model: DEFAULT_MODEL.to_string(),
|
|
||||||
output_format: CliOutputFormat::Text,
|
|
||||||
allowed_tools: None,
|
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_args(&["/agents".to_string(), "planner".to_string()])
|
|
||||||
.expect("/agents planner should invoke"),
|
|
||||||
CliAction::Prompt {
|
|
||||||
prompt: "/prompts:planner".to_string(),
|
|
||||||
model: DEFAULT_MODEL.to_string(),
|
|
||||||
output_format: CliOutputFormat::Text,
|
|
||||||
allowed_tools: None,
|
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let error = parse_args(&["/status".to_string()])
|
let error = parse_args(&["/status".to_string()])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,10 @@ pub struct PluginManifest {
|
|||||||
pub tools: Vec<PluginToolManifest>,
|
pub tools: Vec<PluginToolManifest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub commands: Vec<PluginCommandManifest>,
|
pub commands: Vec<PluginCommandManifest>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub agents: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
@@ -228,6 +232,10 @@ struct RawPluginManifest {
|
|||||||
pub tools: Vec<RawPluginToolManifest>,
|
pub tools: Vec<RawPluginToolManifest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub commands: Vec<PluginCommandManifest>,
|
pub commands: Vec<PluginCommandManifest>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_string_list")]
|
||||||
|
pub agents: Vec<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_string_list")]
|
||||||
|
pub skills: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -246,6 +254,24 @@ struct RawPluginToolManifest {
|
|||||||
pub required_permission: String,
|
pub required_permission: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn deserialize_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum StringList {
|
||||||
|
One(String),
|
||||||
|
Many(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(match Option::<StringList>::deserialize(deserializer)? {
|
||||||
|
Some(StringList::One(value)) => vec![value],
|
||||||
|
Some(StringList::Many(values)) => values,
|
||||||
|
None => Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct PluginTool {
|
pub struct PluginTool {
|
||||||
plugin_id: String,
|
plugin_id: String,
|
||||||
@@ -1461,6 +1487,8 @@ fn build_plugin_manifest(
|
|||||||
"lifecycle command",
|
"lifecycle command",
|
||||||
&mut errors,
|
&mut errors,
|
||||||
);
|
);
|
||||||
|
let agents = build_manifest_paths(root, raw.agents, "agent", &mut errors);
|
||||||
|
let skills = build_manifest_paths(root, raw.skills, "skill", &mut errors);
|
||||||
let tools = build_manifest_tools(root, raw.tools, &mut errors);
|
let tools = build_manifest_tools(root, raw.tools, &mut errors);
|
||||||
let commands = build_manifest_commands(root, raw.commands, &mut errors);
|
let commands = build_manifest_commands(root, raw.commands, &mut errors);
|
||||||
|
|
||||||
@@ -1478,6 +1506,8 @@ fn build_plugin_manifest(
|
|||||||
lifecycle: raw.lifecycle,
|
lifecycle: raw.lifecycle,
|
||||||
tools,
|
tools,
|
||||||
commands,
|
commands,
|
||||||
|
agents,
|
||||||
|
skills,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1593,6 +1623,47 @@ fn build_manifest_tools(
|
|||||||
validated
|
validated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_manifest_paths(
|
||||||
|
root: &Path,
|
||||||
|
paths: Vec<String>,
|
||||||
|
kind: &'static str,
|
||||||
|
errors: &mut Vec<PluginManifestValidationError>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut validated = Vec::new();
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
let trimmed = path.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
errors.push(PluginManifestValidationError::EmptyEntryField {
|
||||||
|
kind,
|
||||||
|
field: "path",
|
||||||
|
name: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = if Path::new(trimmed).is_absolute() {
|
||||||
|
PathBuf::from(trimmed)
|
||||||
|
} else {
|
||||||
|
root.join(trimmed)
|
||||||
|
};
|
||||||
|
if !resolved.exists() {
|
||||||
|
errors.push(PluginManifestValidationError::MissingPath {
|
||||||
|
kind,
|
||||||
|
path: resolved,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen.insert(trimmed.to_string()) {
|
||||||
|
validated.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validated
|
||||||
|
}
|
||||||
|
|
||||||
fn build_manifest_commands(
|
fn build_manifest_commands(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
commands: Vec<PluginCommandManifest>,
|
commands: Vec<PluginCommandManifest>,
|
||||||
@@ -2227,6 +2298,38 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_plugin_from_directory_parses_agent_and_skill_paths() {
|
||||||
|
let root = temp_dir("manifest-component-paths");
|
||||||
|
write_file(
|
||||||
|
root.join("agents").join("ops").join("triage.md").as_path(),
|
||||||
|
"---\nname: triage\ndescription: triage agent\n---\n",
|
||||||
|
);
|
||||||
|
write_file(
|
||||||
|
root.join("skills")
|
||||||
|
.join("review")
|
||||||
|
.join("SKILL.md")
|
||||||
|
.as_path(),
|
||||||
|
"---\nname: review\ndescription: review skill\n---\n",
|
||||||
|
);
|
||||||
|
write_file(
|
||||||
|
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||||
|
r#"{
|
||||||
|
"name": "component-paths",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Manifest component paths",
|
||||||
|
"agents": "./agents/ops/triage.md",
|
||||||
|
"skills": ["./skills"]
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let manifest = load_plugin_from_directory(&root).expect("manifest should load");
|
||||||
|
assert_eq!(manifest.agents, vec!["./agents/ops/triage.md"]);
|
||||||
|
assert_eq!(manifest.skills, vec!["./skills"]);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_defaults_optional_fields() {
|
fn load_plugin_from_directory_defaults_optional_fields() {
|
||||||
let root = temp_dir("manifest-defaults");
|
let root = temp_dir("manifest-defaults");
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api = { path = "../api" }
|
api = { path = "../api" }
|
||||||
commands = { path = "../commands" }
|
|
||||||
plugins = { path = "../plugins" }
|
plugins = { path = "../plugins" }
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ use api::{
|
|||||||
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
||||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
use commands::resolve_skill_path as resolve_workspace_skill_path;
|
use plugins::{
|
||||||
use plugins::PluginTool;
|
load_plugin_from_directory, PluginManager, PluginManagerConfig, PluginSummary, PluginTool,
|
||||||
|
};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
|
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
|
||||||
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
|
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
|
||||||
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
|
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
|
||||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -92,7 +93,10 @@ impl GlobalToolRegistry {
|
|||||||
Ok(Self { plugin_tools })
|
Ok(Self { plugin_tools })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn normalize_allowed_tools(&self, values: &[String]) -> Result<Option<BTreeSet<String>>, String> {
|
pub fn normalize_allowed_tools(
|
||||||
|
&self,
|
||||||
|
values: &[String],
|
||||||
|
) -> Result<Option<BTreeSet<String>>, String> {
|
||||||
if values.is_empty() {
|
if values.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -101,7 +105,11 @@ impl GlobalToolRegistry {
|
|||||||
let canonical_names = builtin_specs
|
let canonical_names = builtin_specs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|spec| spec.name.to_string())
|
.map(|spec| spec.name.to_string())
|
||||||
.chain(self.plugin_tools.iter().map(|tool| tool.definition().name.clone()))
|
.chain(
|
||||||
|
self.plugin_tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.definition().name.clone()),
|
||||||
|
)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut name_map = canonical_names
|
let mut name_map = canonical_names
|
||||||
.iter()
|
.iter()
|
||||||
@@ -152,7 +160,8 @@ impl GlobalToolRegistry {
|
|||||||
.plugin_tools
|
.plugin_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| {
|
.filter(|tool| {
|
||||||
allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
allowed_tools
|
||||||
|
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
||||||
})
|
})
|
||||||
.map(|tool| ToolDefinition {
|
.map(|tool| ToolDefinition {
|
||||||
name: tool.definition().name.clone(),
|
name: tool.definition().name.clone(),
|
||||||
@@ -175,7 +184,8 @@ impl GlobalToolRegistry {
|
|||||||
.plugin_tools
|
.plugin_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| {
|
.filter(|tool| {
|
||||||
allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
allowed_tools
|
||||||
|
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
||||||
})
|
})
|
||||||
.map(|tool| {
|
.map(|tool| {
|
||||||
(
|
(
|
||||||
@@ -1455,9 +1465,391 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
|||||||
Ok(cwd.join(".claw-todos.json"))
|
Ok(cwd.join(".claw-todos.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SkillRootKind {
|
||||||
|
Skills,
|
||||||
|
LegacyCommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct SkillCandidate {
|
||||||
|
name: String,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct SkillCandidateRoot {
|
||||||
|
path: PathBuf,
|
||||||
|
kind: SkillRootKind,
|
||||||
|
name_prefix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||||
resolve_workspace_skill_path(&cwd, skill).map_err(|error| error.to_string())
|
if requested.is_empty() {
|
||||||
|
return Err(String::from("skill must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = discover_skill_candidates().map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
|
if let Some(candidate) = candidates
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.name.eq_ignore_ascii_case(requested))
|
||||||
|
{
|
||||||
|
return Ok(candidate.path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = format!(":{requested}");
|
||||||
|
let suffix_matches = candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|candidate| candidate.name.ends_with(&suffix))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
match suffix_matches.as_slice() {
|
||||||
|
[candidate] => Ok(candidate.path.clone()),
|
||||||
|
[] => Err(format!("unknown skill: {requested}")),
|
||||||
|
matches => Err(format!(
|
||||||
|
"ambiguous skill `{requested}`; use one of: {}",
|
||||||
|
matches
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| candidate.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_skill_candidates() -> std::io::Result<Vec<SkillCandidate>> {
|
||||||
|
let cwd = std::env::current_dir()?;
|
||||||
|
let mut roots = local_skill_candidate_roots(&cwd);
|
||||||
|
extend_plugin_skill_candidate_roots(&cwd, &mut roots);
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for root in &roots {
|
||||||
|
collect_skill_candidates(root, &root.path, &mut candidates)?;
|
||||||
|
}
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_skill_candidate_roots(cwd: &Path) -> Vec<SkillCandidateRoot> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
for ancestor in cwd.ancestors() {
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
ancestor.join(".codex").join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
ancestor.join(".claw").join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
ancestor.join(".codex").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommands,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
ancestor.join(".claw").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommands,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||||
|
let codex_home = PathBuf::from(codex_home);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
codex_home.join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
codex_home.join("commands"),
|
||||||
|
SkillRootKind::LegacyCommands,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
let home = PathBuf::from(home);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
home.join(".agents").join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
home.join(".config").join("opencode").join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
home.join(".codex").join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
home.join(".claw").join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
home.join(".codex").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommands,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
home.join(".claw").join("commands"),
|
||||||
|
SkillRootKind::LegacyCommands,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
push_skill_candidate_root(
|
||||||
|
&mut roots,
|
||||||
|
PathBuf::from("/home/bellman/.codex/skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend_plugin_skill_candidate_roots(cwd: &Path, roots: &mut Vec<SkillCandidateRoot>) {
|
||||||
|
for plugin in enabled_plugins_for_cwd(cwd) {
|
||||||
|
let Some(root) = &plugin.metadata.root else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
push_skill_candidate_root(
|
||||||
|
roots,
|
||||||
|
root.join("skills"),
|
||||||
|
SkillRootKind::Skills,
|
||||||
|
Some(plugin.metadata.name.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Ok(manifest) = load_plugin_from_directory(root) {
|
||||||
|
for relative in manifest.skills {
|
||||||
|
let path = resolve_plugin_component_path(root, &relative);
|
||||||
|
let kind = if path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
|
||||||
|
{
|
||||||
|
SkillRootKind::LegacyCommands
|
||||||
|
} else {
|
||||||
|
SkillRootKind::Skills
|
||||||
|
};
|
||||||
|
push_skill_candidate_root(roots, path, kind, Some(plugin.metadata.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_skill_candidate_root(
|
||||||
|
roots: &mut Vec<SkillCandidateRoot>,
|
||||||
|
path: PathBuf,
|
||||||
|
kind: SkillRootKind,
|
||||||
|
name_prefix: Option<String>,
|
||||||
|
) {
|
||||||
|
if path.exists() && !roots.iter().any(|existing| existing.path == path) {
|
||||||
|
roots.push(SkillCandidateRoot {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
name_prefix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_skill_candidates(
|
||||||
|
root: &SkillCandidateRoot,
|
||||||
|
path: &Path,
|
||||||
|
candidates: &mut Vec<SkillCandidate>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if path.is_file() {
|
||||||
|
if let Some(candidate) = load_skill_candidate(root, path, &root.path)? {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if skill_md.is_file() {
|
||||||
|
if let Some(candidate) = load_skill_candidate(root, &skill_md, &root.path)? {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = std::fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
entries.sort_by_key(|entry| entry.file_name());
|
||||||
|
for entry in entries {
|
||||||
|
let entry_path = entry.path();
|
||||||
|
if entry_path.is_dir() {
|
||||||
|
collect_skill_candidates(root, &entry_path, candidates)?;
|
||||||
|
} else if root.kind == SkillRootKind::LegacyCommands {
|
||||||
|
if let Some(candidate) = load_skill_candidate(root, &entry_path, &root.path)? {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_skill_candidate(
|
||||||
|
root: &SkillCandidateRoot,
|
||||||
|
path: &Path,
|
||||||
|
base_root: &Path,
|
||||||
|
) -> std::io::Result<Option<SkillCandidate>> {
|
||||||
|
if !path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_skill_file = path
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case("SKILL.md"));
|
||||||
|
if root.kind == SkillRootKind::Skills && !is_skill_file {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = skill_candidate_name(root, path, base_root, is_skill_file);
|
||||||
|
Ok(Some(SkillCandidate {
|
||||||
|
name,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skill_candidate_name(
|
||||||
|
root: &SkillCandidateRoot,
|
||||||
|
path: &Path,
|
||||||
|
base_root: &Path,
|
||||||
|
is_skill_file: bool,
|
||||||
|
) -> String {
|
||||||
|
let base_name = if is_skill_file {
|
||||||
|
path.parent().and_then(Path::file_name).map_or_else(
|
||||||
|
|| fallback_file_stem(path),
|
||||||
|
|segment| segment.to_string_lossy().to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fallback_file_stem(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
prefixed_definition_name(
|
||||||
|
root.name_prefix.as_deref(),
|
||||||
|
namespace_for_file(path, base_root, is_skill_file),
|
||||||
|
&base_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn namespace_for_file(path: &Path, base_root: &Path, is_skill_file: bool) -> Option<String> {
|
||||||
|
let relative_parent = if is_skill_file {
|
||||||
|
path.parent()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.and_then(|parent| parent.strip_prefix(base_root).ok())
|
||||||
|
} else {
|
||||||
|
path.parent()
|
||||||
|
.and_then(|parent| parent.strip_prefix(base_root).ok())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let segments = relative_parent
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.to_string_lossy())
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.map(|segment| segment.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(!segments.is_empty()).then(|| segments.join(":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefixed_definition_name(
|
||||||
|
prefix: Option<&str>,
|
||||||
|
namespace: Option<String>,
|
||||||
|
base_name: &str,
|
||||||
|
) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if let Some(prefix) = prefix.filter(|prefix| !prefix.is_empty()) {
|
||||||
|
parts.push(prefix.to_string());
|
||||||
|
}
|
||||||
|
if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) {
|
||||||
|
parts.push(namespace);
|
||||||
|
}
|
||||||
|
parts.push(base_name.to_string());
|
||||||
|
parts.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_file_stem(path: &Path) -> String {
|
||||||
|
path.file_stem()
|
||||||
|
.map_or_else(String::new, |stem| stem.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled_plugins_for_cwd(cwd: &Path) -> Vec<PluginSummary> {
|
||||||
|
let Some(manager) = plugin_manager_for_cwd(cwd) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
manager
|
||||||
|
.list_installed_plugins()
|
||||||
|
.map(|plugins| {
|
||||||
|
plugins
|
||||||
|
.into_iter()
|
||||||
|
.filter(|plugin| plugin.enabled)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_manager_for_cwd(cwd: &Path) -> Option<PluginManager> {
|
||||||
|
let loader = ConfigLoader::default_for(cwd);
|
||||||
|
let runtime_config = loader.load().ok()?;
|
||||||
|
let plugin_settings = runtime_config.plugins();
|
||||||
|
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
|
||||||
|
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
|
||||||
|
plugin_config.external_dirs = plugin_settings
|
||||||
|
.external_directories()
|
||||||
|
.iter()
|
||||||
|
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
|
||||||
|
.collect();
|
||||||
|
plugin_config.install_root = plugin_settings
|
||||||
|
.install_root()
|
||||||
|
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||||
|
plugin_config.registry_path = plugin_settings
|
||||||
|
.registry_path()
|
||||||
|
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||||
|
plugin_config.bundled_root = plugin_settings
|
||||||
|
.bundled_root()
|
||||||
|
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||||
|
Some(PluginManager::new(plugin_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
|
||||||
|
let path = Path::new(value);
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else if value.starts_with('.') {
|
||||||
|
cwd.join(path)
|
||||||
|
} else {
|
||||||
|
config_home.join(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_plugin_component_path(root: &Path, value: &str) -> PathBuf {
|
||||||
|
let path = Path::new(value);
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
root.join(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||||
@@ -3054,6 +3446,27 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("claw-tools-{unique}-{name}"))
|
std::env::temp_dir().join(format!("claw-tools-{unique}-{name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_skill(root: &std::path::Path, name: &str, description: &str) {
|
||||||
|
let skill_root = root.join(name);
|
||||||
|
fs::create_dir_all(&skill_root).expect("skill root");
|
||||||
|
fs::write(
|
||||||
|
skill_root.join("SKILL.md"),
|
||||||
|
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
|
||||||
|
)
|
||||||
|
.expect("write skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_plugin_manifest(root: &std::path::Path, name: &str, extra_fields: &str) {
|
||||||
|
fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw-plugin").join("plugin.json"),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"test plugin\"{extra_fields}\n}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write plugin manifest");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exposes_mvp_tools() {
|
fn exposes_mvp_tools() {
|
||||||
let names = mvp_tool_specs()
|
let names = mvp_tool_specs()
|
||||||
@@ -3451,89 +3864,100 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_resolves_project_and_plugin_scoped_prompts() {
|
fn skill_resolves_namespaced_plugin_skill_by_unique_suffix() {
|
||||||
let _guard = env_lock()
|
let _guard = env_lock()
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let workspace = temp_path("skill-workspace");
|
let workspace = temp_path("skill-plugin-workspace");
|
||||||
let home = temp_path("skill-home");
|
let config_home = temp_path("skill-plugin-home");
|
||||||
let plugin_root = home
|
let install_root = config_home.join("plugins").join("installed");
|
||||||
.join(".claw")
|
let plugin_root = install_root.join("demo-plugin");
|
||||||
.join("plugins")
|
|
||||||
.join("installed")
|
|
||||||
.join("oh-my-claudecode-external");
|
|
||||||
let project_skill_root = workspace.join(".codex").join("skills").join("ralplan");
|
|
||||||
std::fs::create_dir_all(&project_skill_root).expect("project skill dir");
|
|
||||||
std::fs::write(
|
|
||||||
project_skill_root.join("SKILL.md"),
|
|
||||||
"---\nname: ralplan\ndescription: Project skill\n---\n",
|
|
||||||
)
|
|
||||||
.expect("project skill");
|
|
||||||
std::fs::create_dir_all(plugin_root.join(".claw-plugin")).expect("plugin manifest dir");
|
|
||||||
std::fs::write(
|
|
||||||
plugin_root.join(".claw-plugin").join("plugin.json"),
|
|
||||||
r#"{
|
|
||||||
"name": "oh-my-claudecode",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Plugin skills"
|
|
||||||
}"#,
|
|
||||||
)
|
|
||||||
.expect("plugin manifest");
|
|
||||||
std::fs::create_dir_all(home.join(".claw")).expect("config home");
|
|
||||||
std::fs::write(
|
|
||||||
home.join(".claw").join("settings.json"),
|
|
||||||
r#"{
|
|
||||||
"enabledPlugins": {
|
|
||||||
"oh-my-claudecode@external": true
|
|
||||||
}
|
|
||||||
}"#,
|
|
||||||
)
|
|
||||||
.expect("settings");
|
|
||||||
let plugin_skill_root = plugin_root.join("skills").join("ralplan");
|
|
||||||
std::fs::create_dir_all(&plugin_skill_root).expect("plugin skill dir");
|
|
||||||
std::fs::write(
|
|
||||||
plugin_skill_root.join("SKILL.md"),
|
|
||||||
"---\nname: ralplan\ndescription: Plugin skill\n---\n",
|
|
||||||
)
|
|
||||||
.expect("plugin skill");
|
|
||||||
|
|
||||||
let original_dir = std::env::current_dir().expect("cwd");
|
fs::create_dir_all(&config_home).expect("config home");
|
||||||
let old_home = std::env::var_os("HOME");
|
fs::write(
|
||||||
let old_codex_home = std::env::var_os("CODEX_HOME");
|
config_home.join("settings.json"),
|
||||||
|
r#"{"plugins":{"enabled":{"demo-plugin@external":true}}}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
write_plugin_manifest(&plugin_root, "demo-plugin", ",\n \"defaultEnabled\": true");
|
||||||
|
write_skill(
|
||||||
|
&plugin_root.join("skills").join("ops"),
|
||||||
|
"review",
|
||||||
|
"Plugin review guidance",
|
||||||
|
);
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace");
|
||||||
|
|
||||||
|
let previous_cwd = std::env::current_dir().expect("cwd");
|
||||||
|
let previous_claw_config_home = std::env::var_os("CLAW_CONFIG_HOME");
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
std::env::set_current_dir(&workspace).expect("set cwd");
|
std::env::set_current_dir(&workspace).expect("set cwd");
|
||||||
std::env::set_var("HOME", &home);
|
|
||||||
std::env::remove_var("CODEX_HOME");
|
|
||||||
|
|
||||||
let project_result = execute_tool("Skill", &json!({ "skill": "ralplan" }))
|
let result = execute_tool("Skill", &json!({ "skill": "review" }))
|
||||||
.expect("project skill should resolve");
|
.expect("plugin skill should resolve");
|
||||||
let project_output: serde_json::Value =
|
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||||
serde_json::from_str(&project_result).expect("valid json");
|
let expected_path = plugin_root
|
||||||
assert!(project_output["path"]
|
.join("skills/ops/review/SKILL.md")
|
||||||
.as_str()
|
.display()
|
||||||
.expect("path")
|
.to_string();
|
||||||
.ends_with(".codex/skills/ralplan/SKILL.md"));
|
assert_eq!(output["path"].as_str(), Some(expected_path.as_str()));
|
||||||
|
|
||||||
let plugin_result =
|
std::env::set_current_dir(previous_cwd).expect("restore cwd");
|
||||||
execute_tool("Skill", &json!({ "skill": "$oh-my-claudecode:ralplan" }))
|
if let Some(value) = previous_claw_config_home {
|
||||||
.expect("plugin skill should resolve");
|
std::env::set_var("CLAW_CONFIG_HOME", value);
|
||||||
let plugin_output: serde_json::Value =
|
} else {
|
||||||
serde_json::from_str(&plugin_result).expect("valid json");
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
assert!(plugin_output["path"]
|
|
||||||
.as_str()
|
|
||||||
.expect("path")
|
|
||||||
.ends_with("skills/ralplan/SKILL.md"));
|
|
||||||
|
|
||||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
|
||||||
match old_home {
|
|
||||||
Some(value) => std::env::set_var("HOME", value),
|
|
||||||
None => std::env::remove_var("HOME"),
|
|
||||||
}
|
}
|
||||||
match old_codex_home {
|
let _ = fs::remove_dir_all(workspace);
|
||||||
Some(value) => std::env::set_var("CODEX_HOME", value),
|
let _ = fs::remove_dir_all(config_home);
|
||||||
None => std::env::remove_var("CODEX_HOME"),
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_reports_ambiguous_bare_name_for_multiple_namespaced_matches() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let workspace = temp_path("skill-ambiguous-workspace");
|
||||||
|
let config_home = temp_path("skill-ambiguous-home");
|
||||||
|
let install_root = config_home.join("plugins").join("installed");
|
||||||
|
let plugin_root = install_root.join("demo-plugin");
|
||||||
|
|
||||||
|
fs::create_dir_all(&config_home).expect("config home");
|
||||||
|
fs::write(
|
||||||
|
config_home.join("settings.json"),
|
||||||
|
r#"{"plugins":{"enabled":{"demo-plugin@external":true}}}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
write_skill(
|
||||||
|
&workspace.join(".codex").join("skills").join("ops"),
|
||||||
|
"review",
|
||||||
|
"Local review",
|
||||||
|
);
|
||||||
|
write_plugin_manifest(&plugin_root, "demo-plugin", ",\n \"defaultEnabled\": true");
|
||||||
|
write_skill(
|
||||||
|
&plugin_root.join("skills").join("ops"),
|
||||||
|
"review",
|
||||||
|
"Plugin review guidance",
|
||||||
|
);
|
||||||
|
|
||||||
|
let previous_cwd = std::env::current_dir().expect("cwd");
|
||||||
|
let previous_claw_config_home = std::env::var_os("CLAW_CONFIG_HOME");
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
|
std::env::set_current_dir(&workspace).expect("set cwd");
|
||||||
|
|
||||||
|
let error = execute_tool("Skill", &json!({ "skill": "review" }))
|
||||||
|
.expect_err("review should be ambiguous");
|
||||||
|
assert!(error.contains("ambiguous skill `review`"));
|
||||||
|
assert!(error.contains("ops:review"));
|
||||||
|
assert!(error.contains("demo-plugin:ops:review"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(previous_cwd).expect("restore cwd");
|
||||||
|
if let Some(value) = previous_claw_config_home {
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
let _ = std::fs::remove_dir_all(home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user