mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Migrate plugin commands into skills
This commit is contained in:
Generated
+1
@@ -2802,6 +2802,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -6,6 +6,9 @@ use codex_core::config::ConfigBuilder;
|
||||
use codex_core_plugins::PluginInstallError;
|
||||
use codex_core_plugins::PluginInstallRequest;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_core_plugins::command_migration::count_missing_commands;
|
||||
use codex_core_plugins::command_migration::import_commands;
|
||||
use codex_core_plugins::command_migration::missing_command_names;
|
||||
use codex_core_plugins::marketplace::MarketplaceError;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
|
||||
@@ -13,13 +16,10 @@ use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
|
||||
use codex_core_plugins::marketplace_add::add_marketplace;
|
||||
use codex_core_plugins::marketplace_add::is_local_marketplace_source;
|
||||
use codex_external_agent_migration::build_mcp_config_from_external;
|
||||
use codex_external_agent_migration::count_missing_commands;
|
||||
use codex_external_agent_migration::count_missing_subagents;
|
||||
use codex_external_agent_migration::hook_migration_event_names;
|
||||
use codex_external_agent_migration::import_commands;
|
||||
use codex_external_agent_migration::import_hooks;
|
||||
use codex_external_agent_migration::import_subagents;
|
||||
use codex_external_agent_migration::missing_command_names;
|
||||
use codex_external_agent_migration::missing_subagent_names;
|
||||
use codex_external_agent_sessions::ExternalAgentSessionMigration;
|
||||
use codex_external_agent_sessions::detect_recent_sessions;
|
||||
|
||||
@@ -526,7 +526,7 @@ async fn import_repo_migrates_mcp_hooks_commands_and_subagents() {
|
||||
.join("commands")
|
||||
.join("pr")
|
||||
.join("review.md"),
|
||||
"---\ndescription: Review PR\n---\nReview the pull request carefully.\n",
|
||||
format!("---\ndescription: Review PR with {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME}\n---\nReview the pull request with {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} carefully.\n"),
|
||||
)
|
||||
.expect("write command");
|
||||
fs::write(
|
||||
@@ -649,7 +649,7 @@ STATIC = "yes"
|
||||
.join("SKILL.md")
|
||||
)
|
||||
.expect("read command skill"),
|
||||
"---\nname: \"source-command-pr-review\"\ndescription: \"Review PR\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview the pull request carefully.\n"
|
||||
"---\nname: \"source-command-pr-review\"\ndescription: \"Review PR with Codex\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview the pull request with Codex carefully.\n"
|
||||
);
|
||||
|
||||
let agent: TomlValue = toml::from_str(
|
||||
|
||||
@@ -41,6 +41,7 @@ regex = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_yaml::Value as YamlValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
|
||||
const COMMAND_SKILL_PREFIX: &str = "source-command";
|
||||
const MAX_SKILL_NAME_LEN: usize = 64;
|
||||
const PLUGIN_COMMANDS_DIR: &str = "commands";
|
||||
const PLUGIN_METADATA_DIR: &str = ".codex-plugin";
|
||||
const MIGRATED_COMMAND_SKILLS_DIR: &str = "migrated-command-skills";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedCommand {
|
||||
description: Option<String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CommandMigrationSource {
|
||||
ExternalAgent,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl CommandMigrationSource {
|
||||
fn rewrite_content(self, content: &str) -> String {
|
||||
match self {
|
||||
Self::ExternalAgent => rewrite_external_agent_terms(content),
|
||||
Self::Plugin => content.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count_missing_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> {
|
||||
Ok(missing_command_names(source_commands, target_skills)?.len())
|
||||
}
|
||||
|
||||
pub fn missing_command_names(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
Ok(unique_supported_command_sources(source_commands)?
|
||||
.into_iter()
|
||||
.filter(|(_source_file, name)| !target_skills.join(name).exists())
|
||||
.map(|(_source_file, name)| name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<Vec<String>> {
|
||||
import_commands_from_source(
|
||||
source_commands,
|
||||
target_skills,
|
||||
CommandMigrationSource::ExternalAgent,
|
||||
)
|
||||
}
|
||||
|
||||
fn import_commands_from_source(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
migration_source: CommandMigrationSource,
|
||||
) -> io::Result<Vec<String>> {
|
||||
if !source_commands.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
fs::create_dir_all(target_skills)?;
|
||||
let mut imported = Vec::new();
|
||||
for (source_file, name) in unique_supported_command_sources(source_commands)? {
|
||||
let document = parse_command(&source_file)?;
|
||||
let target_dir = target_skills.join(&name);
|
||||
if target_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
fs::create_dir_all(&target_dir)?;
|
||||
let source_name = command_source_name(source_commands, &source_file);
|
||||
let Some(description) = document.description.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
fs::write(
|
||||
target_dir.join("SKILL.md"),
|
||||
render_command_skill(
|
||||
&document.body,
|
||||
&name,
|
||||
description,
|
||||
&source_name,
|
||||
migration_source,
|
||||
),
|
||||
)?;
|
||||
imported.push(name);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_plugin_commands(plugin_root: &Path) -> io::Result<()> {
|
||||
let target_skills = plugin_root
|
||||
.join(PLUGIN_METADATA_DIR)
|
||||
.join(MIGRATED_COMMAND_SKILLS_DIR);
|
||||
if target_skills.is_dir() {
|
||||
fs::remove_dir_all(&target_skills)?;
|
||||
} else if target_skills.exists() {
|
||||
fs::remove_file(&target_skills)?;
|
||||
}
|
||||
import_commands_from_source(
|
||||
&plugin_root.join(PLUGIN_COMMANDS_DIR),
|
||||
&target_skills,
|
||||
CommandMigrationSource::Plugin,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn migrated_command_skills_root(plugin_root: &AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
plugin_root
|
||||
.join(PLUGIN_METADATA_DIR)
|
||||
.join(MIGRATED_COMMAND_SKILLS_DIR)
|
||||
}
|
||||
|
||||
fn unique_supported_command_sources(source_commands: &Path) -> io::Result<Vec<(PathBuf, String)>> {
|
||||
let mut by_name = BTreeMap::<String, Vec<PathBuf>>::new();
|
||||
for source_file in command_source_files(source_commands)? {
|
||||
let document = parse_command(&source_file)?;
|
||||
let Some(name) = command_skill_name_if_supported(source_commands, &source_file, &document)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
by_name.entry(name).or_default().push(source_file);
|
||||
}
|
||||
|
||||
Ok(by_name
|
||||
.into_iter()
|
||||
.filter_map(|(name, source_files)| {
|
||||
let [source_file] = source_files.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
Some((source_file.clone(), name))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn command_source_files(source_commands: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
collect_markdown_files(source_commands, &mut files)?;
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_dir() {
|
||||
collect_markdown_files(&path, files)?;
|
||||
} else if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md")
|
||||
{
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_command(source_file: &Path) -> io::Result<ParsedCommand> {
|
||||
Ok(parse_command_content(&fs::read_to_string(source_file)?))
|
||||
}
|
||||
|
||||
fn parse_command_content(content: &str) -> ParsedCommand {
|
||||
let Some(rest) = content
|
||||
.strip_prefix("---\n")
|
||||
.or_else(|| content.strip_prefix("---\r\n"))
|
||||
else {
|
||||
return ParsedCommand {
|
||||
description: None,
|
||||
body: content.to_string(),
|
||||
};
|
||||
};
|
||||
let Some((end, body_start)) = frontmatter_end(rest) else {
|
||||
return ParsedCommand {
|
||||
description: None,
|
||||
body: content.to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
ParsedCommand {
|
||||
description: parse_command_description(&rest[..end]),
|
||||
body: rest[body_start..].to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn frontmatter_end(rest: &str) -> Option<(usize, usize)> {
|
||||
[
|
||||
"\r\n---\r\n",
|
||||
"\r\n---\n",
|
||||
"\n---\r\n",
|
||||
"\n---\n",
|
||||
"\r\n---",
|
||||
"\n---",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|delimiter| rest.find(delimiter).map(|end| (end, end + delimiter.len())))
|
||||
.min_by_key(|(end, _body_start)| *end)
|
||||
}
|
||||
|
||||
fn parse_command_description(raw_frontmatter: &str) -> Option<String> {
|
||||
let parsed: YamlValue = serde_yaml::from_str(raw_frontmatter).ok()?;
|
||||
let mapping = parsed.as_mapping()?;
|
||||
mapping.iter().find_map(|(key, value)| {
|
||||
if key.as_str()?.trim() == "description" {
|
||||
yaml_scalar(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn yaml_scalar(value: &YamlValue) -> Option<String> {
|
||||
match value {
|
||||
YamlValue::String(value) => Some(value.trim().to_string()),
|
||||
YamlValue::Bool(value) => Some(value.to_string()),
|
||||
YamlValue::Number(value) => Some(value.to_string()),
|
||||
YamlValue::Null | YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_skill_name(source_commands: &Path, source_file: &Path) -> String {
|
||||
slugify_name(&format!(
|
||||
"{COMMAND_SKILL_PREFIX}-{}",
|
||||
command_source_name(source_commands, source_file)
|
||||
))
|
||||
}
|
||||
|
||||
fn command_skill_name_if_supported(
|
||||
source_commands: &Path,
|
||||
source_file: &Path,
|
||||
document: &ParsedCommand,
|
||||
) -> Option<String> {
|
||||
if source_file.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
|
||||
return None;
|
||||
}
|
||||
document
|
||||
.description
|
||||
.as_deref()
|
||||
.filter(|description| !description.trim().is_empty())?;
|
||||
let name = command_skill_name(source_commands, source_file);
|
||||
if name.chars().count() > MAX_SKILL_NAME_LEN
|
||||
|| has_unsupported_command_template_features(&document.body)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
|
||||
fn command_source_name(source_commands: &Path, source_file: &Path) -> String {
|
||||
source_file
|
||||
.strip_prefix(source_commands)
|
||||
.unwrap_or(source_file)
|
||||
.with_extension("")
|
||||
.components()
|
||||
.filter_map(|component| component.as_os_str().to_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
fn render_command_skill(
|
||||
body: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
source_name: &str,
|
||||
migration_source: CommandMigrationSource,
|
||||
) -> String {
|
||||
let body = migration_source.rewrite_content(body.trim());
|
||||
let template_body = if body.is_empty() {
|
||||
"No command template body was found.".to_string()
|
||||
} else {
|
||||
body
|
||||
};
|
||||
format!(
|
||||
"---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n",
|
||||
yaml_string(name),
|
||||
yaml_string(&migration_source.rewrite_content(description)),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_unsupported_command_template_features(template: &str) -> bool {
|
||||
template.contains("$ARGUMENTS")
|
||||
|| contains_numbered_argument_placeholder(template)
|
||||
|| (template.contains("{{") && template.contains("}}"))
|
||||
|| template.contains("!`")
|
||||
|| template.contains("! `")
|
||||
|| template
|
||||
.split_whitespace()
|
||||
.any(|token| token.strip_prefix('@').is_some_and(|rest| !rest.is_empty()))
|
||||
}
|
||||
|
||||
fn contains_numbered_argument_placeholder(template: &str) -> bool {
|
||||
template
|
||||
.as_bytes()
|
||||
.windows(2)
|
||||
.any(|window| window[0] == b'$' && window[1].is_ascii_digit())
|
||||
}
|
||||
|
||||
fn yaml_string(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
fn slugify_name(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
for ch in value.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
"migrated".to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_external_agent_terms(content: &str) -> String {
|
||||
let mut rewritten = replace_case_insensitive_with_boundaries(
|
||||
content,
|
||||
&format!("{SOURCE_EXTERNAL_AGENT_NAME}.md"),
|
||||
"AGENTS.md",
|
||||
);
|
||||
for from in [
|
||||
format!("{SOURCE_EXTERNAL_AGENT_NAME} code"),
|
||||
format!("{SOURCE_EXTERNAL_AGENT_NAME}-code"),
|
||||
format!("{SOURCE_EXTERNAL_AGENT_NAME}_code"),
|
||||
format!("{SOURCE_EXTERNAL_AGENT_NAME}code"),
|
||||
SOURCE_EXTERNAL_AGENT_NAME.to_string(),
|
||||
] {
|
||||
rewritten = replace_case_insensitive_with_boundaries(&rewritten, &from, "Codex");
|
||||
}
|
||||
rewritten
|
||||
}
|
||||
|
||||
fn replace_case_insensitive_with_boundaries(
|
||||
input: &str,
|
||||
needle: &str,
|
||||
replacement: &str,
|
||||
) -> String {
|
||||
let needle_lower = needle.to_ascii_lowercase();
|
||||
if needle_lower.is_empty() {
|
||||
return input.to_string();
|
||||
}
|
||||
|
||||
let haystack_lower = input.to_ascii_lowercase();
|
||||
let bytes = input.as_bytes();
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut last_emitted = 0usize;
|
||||
let mut search_start = 0usize;
|
||||
|
||||
while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) {
|
||||
let start = search_start + relative_pos;
|
||||
let end = start + needle_lower.len();
|
||||
let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
|
||||
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
|
||||
|
||||
if boundary_before && boundary_after {
|
||||
output.push_str(&input[last_emitted..start]);
|
||||
output.push_str(replacement);
|
||||
last_emitted = end;
|
||||
}
|
||||
|
||||
search_start = start + 1;
|
||||
}
|
||||
|
||||
if last_emitted == 0 {
|
||||
return input.to_string();
|
||||
}
|
||||
|
||||
output.push_str(&input[last_emitted..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn is_word_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app_mcp_routing;
|
||||
pub mod command_migration;
|
||||
mod discoverable;
|
||||
pub mod installed_marketplaces;
|
||||
pub mod loader;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::app_mcp_routing::apps_route_available;
|
||||
use crate::command_migration::migrate_plugin_commands;
|
||||
use crate::command_migration::migrated_command_skills_root;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::manifest::PluginManifestHooks;
|
||||
@@ -797,6 +799,13 @@ async fn load_plugin(
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
if let Err(err) = migrate_plugin_commands(plugin_root.as_path()) {
|
||||
loaded_plugin.error = Some(format!(
|
||||
"failed to migrate plugin commands into skills: {err}"
|
||||
));
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
|
||||
loaded_plugin.error = Some("missing or invalid plugin.json".to_string());
|
||||
return loaded_plugin;
|
||||
@@ -967,6 +976,10 @@ fn plugin_skill_roots(
|
||||
} else {
|
||||
manifest_paths.skills.clone()
|
||||
};
|
||||
let migrated_command_skills = migrated_command_skills_root(plugin_root);
|
||||
if migrated_command_skills.is_dir() {
|
||||
paths.push(migrated_command_skills);
|
||||
}
|
||||
paths.sort_unstable();
|
||||
paths.dedup();
|
||||
paths
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::LoadedPlugin;
|
||||
use super::PluginLoadOutcome;
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::command_migration::migrate_plugin_commands;
|
||||
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::loader::PluginHookLoadOutcome;
|
||||
@@ -1678,15 +1679,42 @@ impl PluginsManager {
|
||||
self.list_marketplaces_with_policy(config, &marketplace_roots)
|
||||
}
|
||||
|
||||
fn installed_plugin_root(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let plugin_root = self.store.active_plugin_root(plugin_id).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(format!(
|
||||
"installed plugin cache entry is missing for {plugin_key}"
|
||||
))
|
||||
})?;
|
||||
migrate_plugin_commands(plugin_root.as_path()).map_err(|err| {
|
||||
MarketplaceError::InvalidPlugin(format!(
|
||||
"failed to migrate plugin commands for {plugin_key}: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(plugin_root)
|
||||
}
|
||||
|
||||
pub(crate) async fn tool_suggest_metadata_for_marketplace_plugin(
|
||||
&self,
|
||||
marketplace_name: &str,
|
||||
plugin: &ConfiguredMarketplacePlugin,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> Result<PluginCapabilitySummary, MarketplaceError> {
|
||||
let mut plugin = plugin.clone();
|
||||
if plugin.installed && matches!(&plugin.source, MarketplacePluginSource::Local { .. }) {
|
||||
let plugin_id = PluginId::parse(&plugin.id).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
})?;
|
||||
plugin.source = MarketplacePluginSource::Local {
|
||||
path: self.installed_plugin_root(&plugin_id)?,
|
||||
};
|
||||
}
|
||||
let fragment = self
|
||||
.tool_suggest_metadata_cache
|
||||
.metadata_for_plugin(marketplace_name, plugin, self.restriction_product)
|
||||
.metadata_for_plugin(marketplace_name, &plugin, self.restriction_product)
|
||||
.await?;
|
||||
Ok(fragment.project(skill_config_rules, self.auth_mode()))
|
||||
}
|
||||
@@ -1812,12 +1840,8 @@ impl PluginsManager {
|
||||
});
|
||||
}
|
||||
|
||||
let source_path = if plugin.source.is_install_materialized() && plugin.installed {
|
||||
self.store.active_plugin_root(&plugin_id).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(format!(
|
||||
"installed plugin cache entry is missing for {plugin_key}"
|
||||
))
|
||||
})?
|
||||
let source_path = if plugin.installed {
|
||||
self.installed_plugin_root(&plugin_id)?
|
||||
} else {
|
||||
let codex_home = self.codex_home.clone();
|
||||
let source = plugin.source.clone();
|
||||
|
||||
@@ -1885,6 +1885,78 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_plugins_includes_migrated_command_skills_with_explicit_skill_paths() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let source_root = codex_home.path().join("source/sample");
|
||||
|
||||
write_file(
|
||||
&source_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "sample",
|
||||
"skills": "./custom-skills/"
|
||||
}"#,
|
||||
);
|
||||
fs::create_dir_all(source_root.join("custom-skills")).unwrap();
|
||||
write_file(
|
||||
&source_root.join("commands/pr/review.md"),
|
||||
"---\ndescription: Review a Claude pull request\n---\nInspect the proposed Claude changes.\n",
|
||||
);
|
||||
write_file(
|
||||
&source_root.join(".codex-plugin/migrated-command-skills/undeclared-command/SKILL.md"),
|
||||
"---\nname: undeclared-command\ndescription: undeclared command\n---\n",
|
||||
);
|
||||
let result = PluginStore::new(codex_home.path().to_path_buf())
|
||||
.install(
|
||||
source_root.abs(),
|
||||
PluginId::parse("sample@test").expect("plugin id should parse"),
|
||||
)
|
||||
.unwrap();
|
||||
let migrated_skill = result
|
||||
.installed_path
|
||||
.join(".codex-plugin/migrated-command-skills/source-command-pr-review/SKILL.md");
|
||||
let expected_migrated_skill = "---\nname: \"source-command-pr-review\"\ndescription: \"Review a Claude pull request\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nInspect the proposed Claude changes.\n";
|
||||
assert_eq!(
|
||||
fs::read_to_string(&migrated_skill).unwrap(),
|
||||
expected_migrated_skill
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.installed_path
|
||||
.join(".codex-plugin/migrated-command-skills/undeclared-command")
|
||||
.exists()
|
||||
);
|
||||
|
||||
fs::remove_dir_all(
|
||||
result
|
||||
.installed_path
|
||||
.join(".codex-plugin/migrated-command-skills"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outcome = load_plugins_from_config(
|
||||
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
||||
codex_home.path(),
|
||||
Some(AuthMode::Chatgpt),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins()[0].skill_roots,
|
||||
vec![
|
||||
result
|
||||
.installed_path
|
||||
.join(".codex-plugin/migrated-command-skills"),
|
||||
result.installed_path.join("custom-skills"),
|
||||
]
|
||||
);
|
||||
assert!(outcome.plugins()[0].has_enabled_skills);
|
||||
assert_eq!(
|
||||
fs::read_to_string(migrated_skill).unwrap(),
|
||||
expected_migrated_skill
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_plugin_skills_dedupes_overlapping_manifest_roots() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -3562,6 +3634,94 @@ plugins = true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_local_plugin_metadata_uses_migrated_cache() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let source_root = tmp.path().join("source/sample-plugin");
|
||||
write_file(
|
||||
&source_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "sample-plugin",
|
||||
"description": "Cached sample plugin"
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&source_root.join("commands/review.md"),
|
||||
"---\ndescription: Review changes\n---\nReview the changes.\n",
|
||||
);
|
||||
let plugin_id = PluginId::parse("sample-plugin@debug").unwrap();
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(source_root.abs(), plugin_id.clone())
|
||||
.unwrap();
|
||||
fs::remove_dir_all(
|
||||
result
|
||||
.installed_path
|
||||
.join(".codex-plugin/migrated-command-skills"),
|
||||
)
|
||||
.unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
let plugin = ConfiguredMarketplacePlugin {
|
||||
id: plugin_id.as_key(),
|
||||
name: plugin_id.plugin_name.clone(),
|
||||
local_version: None,
|
||||
installed_version: Some(result.plugin_version),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: source_root.abs(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
keywords: Vec::new(),
|
||||
manifest_fallback: None,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
let detail = manager
|
||||
.read_plugin_detail_for_marketplace_plugin(&config, "debug", plugin.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
detail
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| (skill.name.as_str(), skill.description.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("sample-plugin:source-command-review", "Review changes")]
|
||||
);
|
||||
|
||||
let summary = manager
|
||||
.tool_suggest_metadata_for_marketplace_plugin(
|
||||
"debug",
|
||||
&plugin,
|
||||
&SkillConfigRules::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
summary,
|
||||
PluginCapabilitySummary {
|
||||
config_name: "sample-plugin@debug".to_string(),
|
||||
display_name: "sample-plugin".to_string(),
|
||||
description: Some("Cached sample plugin".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::command_migration::migrate_plugin_commands;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::manifest::parse_plugin_manifest;
|
||||
@@ -563,6 +564,9 @@ fn replace_plugin_root_atomically(
|
||||
fs::write(&manifest_path, contents)
|
||||
.map_err(|err| PluginStoreError::io("failed to write fallback plugin manifest", err))?;
|
||||
}
|
||||
migrate_plugin_commands(&staged_version_root).map_err(|err| {
|
||||
PluginStoreError::io("failed to migrate plugin commands into skills", err)
|
||||
})?;
|
||||
|
||||
let target_version_root = target_root.join(plugin_version);
|
||||
if target_root.exists() && !target_version_root.exists() {
|
||||
|
||||
@@ -16,8 +16,6 @@ const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
|
||||
const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks";
|
||||
const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks";
|
||||
const COMMAND_SKILL_PREFIX: &str = "source-command";
|
||||
const MAX_SKILL_NAME_LEN: usize = 64;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedDocument {
|
||||
@@ -179,49 +177,6 @@ pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Resul
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn count_missing_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> {
|
||||
Ok(missing_command_names(source_commands, target_skills)?.len())
|
||||
}
|
||||
|
||||
pub fn missing_command_names(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
) -> io::Result<Vec<String>> {
|
||||
Ok(unique_supported_command_sources(source_commands)?
|
||||
.into_iter()
|
||||
.filter(|(_source_file, name)| !target_skills.join(name).exists())
|
||||
.map(|(_source_file, name)| name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<Vec<String>> {
|
||||
if !source_commands.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
fs::create_dir_all(target_skills)?;
|
||||
let mut imported = Vec::new();
|
||||
for (source_file, name) in unique_supported_command_sources(source_commands)? {
|
||||
let document = parse_document(&source_file)?;
|
||||
let target_dir = target_skills.join(&name);
|
||||
if target_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
fs::create_dir_all(&target_dir)?;
|
||||
let source_name = command_source_name(source_commands, &source_file);
|
||||
let Some(description) = command_skill_description(&document, &source_name) else {
|
||||
continue;
|
||||
};
|
||||
fs::write(
|
||||
target_dir.join("SKILL.md"),
|
||||
render_command_skill(&document.body, &name, &description, &source_name),
|
||||
)?;
|
||||
imported.push(name);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
fn read_external_mcp_servers(
|
||||
source_root: &Path,
|
||||
external_agent_home: Option<&Path>,
|
||||
@@ -911,54 +866,6 @@ fn subagent_target_file(source_file: &Path, target_agents: &Path) -> Option<Path
|
||||
Some(target_agents.join(format!("{}.toml", source_file.file_stem()?.to_str()?)))
|
||||
}
|
||||
|
||||
fn command_source_files(source_commands: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
collect_markdown_files(source_commands, &mut files)?;
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn unique_supported_command_sources(source_commands: &Path) -> io::Result<Vec<(PathBuf, String)>> {
|
||||
let mut by_name = BTreeMap::<String, Vec<PathBuf>>::new();
|
||||
for source_file in command_source_files(source_commands)? {
|
||||
let document = parse_document(&source_file)?;
|
||||
let Some(name) = command_skill_name_if_supported(source_commands, &source_file, &document)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
by_name.entry(name).or_default().push(source_file);
|
||||
}
|
||||
|
||||
Ok(by_name
|
||||
.into_iter()
|
||||
.filter_map(|(name, source_files)| {
|
||||
let [source_file] = source_files.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
Some((source_file.clone(), name))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_dir() {
|
||||
collect_markdown_files(&path, files)?;
|
||||
} else if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md")
|
||||
{
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_document(source_file: &Path) -> io::Result<ParsedDocument> {
|
||||
let content = fs::read_to_string(source_file)?;
|
||||
Ok(parse_document_content(&content))
|
||||
@@ -1113,85 +1020,6 @@ fn render_agent_body(body: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn command_skill_name(source_commands: &Path, source_file: &Path) -> String {
|
||||
slugify_name(&format!(
|
||||
"{COMMAND_SKILL_PREFIX}-{}",
|
||||
command_source_name(source_commands, source_file)
|
||||
))
|
||||
}
|
||||
|
||||
fn command_skill_name_if_supported(
|
||||
source_commands: &Path,
|
||||
source_file: &Path,
|
||||
document: &ParsedDocument,
|
||||
) -> Option<String> {
|
||||
if source_file.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
|
||||
return None;
|
||||
}
|
||||
let source_name = command_source_name(source_commands, source_file);
|
||||
command_skill_description(document, &source_name)?;
|
||||
let name = command_skill_name(source_commands, source_file);
|
||||
if name.chars().count() > MAX_SKILL_NAME_LEN {
|
||||
return None;
|
||||
}
|
||||
if has_unsupported_command_template_features(&document.body) {
|
||||
return None;
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
|
||||
fn command_skill_description(document: &ParsedDocument, _source_name: &str) -> Option<String> {
|
||||
document
|
||||
.frontmatter
|
||||
.get("description")
|
||||
.and_then(FrontmatterValue::as_scalar)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn command_source_name(source_commands: &Path, source_file: &Path) -> String {
|
||||
source_file
|
||||
.strip_prefix(source_commands)
|
||||
.unwrap_or(source_file)
|
||||
.with_extension("")
|
||||
.components()
|
||||
.filter_map(|component| component.as_os_str().to_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
fn render_command_skill(body: &str, name: &str, description: &str, source_name: &str) -> String {
|
||||
let body = rewrite_external_agent_terms(body.trim());
|
||||
let template_body = if body.is_empty() {
|
||||
"No command template body was found.".to_string()
|
||||
} else {
|
||||
body
|
||||
};
|
||||
format!(
|
||||
"---\nname: {}\ndescription: {}\n---\n\n# {name}\n\nUse this skill when the user asks to run the migrated source command `{source_name}`.\n\n## Command Template\n\n{template_body}\n",
|
||||
yaml_string(name),
|
||||
yaml_string(&rewrite_external_agent_terms(description)),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_unsupported_command_template_features(template: &str) -> bool {
|
||||
template.contains("$ARGUMENTS")
|
||||
|| contains_numbered_argument_placeholder(template)
|
||||
|| (template.contains("{{") && template.contains("}}"))
|
||||
|| template.contains("!`")
|
||||
|| template.contains("! `")
|
||||
|| template
|
||||
.split_whitespace()
|
||||
.any(|token| token.strip_prefix('@').is_some_and(|rest| !rest.is_empty()))
|
||||
}
|
||||
|
||||
fn contains_numbered_argument_placeholder(template: &str) -> bool {
|
||||
let bytes = template.as_bytes();
|
||||
bytes
|
||||
.windows(2)
|
||||
.any(|window| window[0] == b'$' && window[1].is_ascii_digit())
|
||||
}
|
||||
|
||||
fn frontmatter_string(
|
||||
frontmatter: &BTreeMap<String, FrontmatterValue>,
|
||||
key: &str,
|
||||
@@ -1246,31 +1074,6 @@ fn json_u64(value: &JsonValue) -> Option<u64> {
|
||||
value.as_u64().or_else(|| value.as_str()?.parse().ok())
|
||||
}
|
||||
|
||||
fn yaml_string(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
fn slugify_name(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
for ch in value.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
"migrated".to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
fn as_scalar(&self) -> Option<&str> {
|
||||
match self {
|
||||
@@ -1384,8 +1187,6 @@ mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
fn source_path(relative_path: &str) -> PathBuf {
|
||||
Path::new("/repo")
|
||||
.join(external_agent_config_dir())
|
||||
@@ -1703,94 +1504,6 @@ command = "enabled-server"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_skill_names_include_nested_paths() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/pr/review.md");
|
||||
|
||||
assert_eq!(command_skill_name(&root, &file), "source-command-pr-review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_skill_names_must_fit_codex_skill_loader_limit() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/this/is/a/deeply/nested/command/with/a/very/long/name.md");
|
||||
let document = parse_document_content("---\ndescription: Review PR\n---\nReview\n");
|
||||
|
||||
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_with_overlong_descriptions_are_preserved() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/review.md");
|
||||
let description = "x".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
|
||||
let document =
|
||||
parse_document_content(&format!("---\ndescription: {description}\n---\nReview\n"));
|
||||
|
||||
assert_eq!(
|
||||
command_skill_name_if_supported(&root, &file, &document),
|
||||
Some("source-command-review".to_string())
|
||||
);
|
||||
|
||||
let rendered = render_command_skill(
|
||||
&document.body,
|
||||
"source-command-review",
|
||||
&description,
|
||||
"review",
|
||||
);
|
||||
let rendered_document = parse_document_content(&rendered);
|
||||
assert_eq!(
|
||||
rendered_document
|
||||
.frontmatter
|
||||
.get("description")
|
||||
.and_then(FrontmatterValue::as_scalar),
|
||||
Some(description.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_with_provider_runtime_expansion_are_skipped() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/deploy.md");
|
||||
let document = parse_document_content(
|
||||
"---\ndescription: Deploy\n---\nDeploy $ARGUMENTS from @release.yaml\n",
|
||||
);
|
||||
|
||||
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_without_description_are_skipped() {
|
||||
let root = source_path("commands");
|
||||
let file = source_path("commands/README.md");
|
||||
let document = parse_document_content("# Notes\n\nThis documents commands.\n");
|
||||
|
||||
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_slug_collisions_are_skipped() {
|
||||
let root = tempfile::TempDir::new().expect("tempdir");
|
||||
let commands = root.path().join("commands");
|
||||
fs::create_dir_all(&commands).expect("create commands");
|
||||
fs::write(
|
||||
commands.join("foo-bar.md"),
|
||||
"---\ndescription: First\n---\nRun the first command.\n",
|
||||
)
|
||||
.expect("write first command");
|
||||
fs::write(
|
||||
commands.join("foo_bar.md"),
|
||||
"---\ndescription: Second\n---\nRun the second command.\n",
|
||||
)
|
||||
.expect("write second command");
|
||||
|
||||
assert_eq!(
|
||||
unique_supported_command_sources(&commands).unwrap(),
|
||||
Vec::<(PathBuf, String)>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_accepts_yaml_block_lists_by_ignoring_unsupported_fields() {
|
||||
let document = parse_document_content(
|
||||
|
||||
Reference in New Issue
Block a user