Migrate plugin commands into skills

This commit is contained in:
charlesgong-openai
2026-07-10 03:59:44 +00:00
parent 1f0566d3f5
commit 390c58bd45
11 changed files with 610 additions and 299 deletions
+1
View File
@@ -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(
+1
View File
@@ -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
View File
@@ -1,4 +1,5 @@
mod app_mcp_routing;
pub mod command_migration;
mod discoverable;
pub mod installed_marketplaces;
pub mod loader;
+13
View File
@@ -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
+31 -7
View File
@@ -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();
+160
View File
@@ -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();
+4
View File
@@ -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(