mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Migrate plugin commands into skills on install (#33411)
## What changed - Convert supported plugin command Markdown into generated skills when a plugin is installed. - Read command sources from the manifest's `commands` field, falling back to the plugin's `commands/` directory. - Load generated command skills alongside native plugin skills while preferring native skills with the same name. - Skip commands with unsupported templates, missing descriptions, name collisions, or generated skills larger than 4 KB, and keep migration failures from blocking plugin installation. ## Testing - Cover default and manifest-configured command paths, stale generated skill cleanup, size limits, native-skill precedence, invalid manifest fields, and non-fatal migration errors. GitOrigin-RevId: 67d910048544b160b191066569bca69b2b951ebc
This commit is contained in:
1 parent
38b064c31b
commit
2cd6ed7509
14 files changed
+984
-427
No files matched your search
@@ -21,8 +21,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;
|
||||
|
||||
/// Describes source-specific terms that should be rewritten in migrated artifacts.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -62,34 +60,6 @@ impl RewriteProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls how migrated commands obtain the description required by a Codex skill.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandDescriptionMode {
|
||||
/// Skip source commands that do not declare a non-empty frontmatter description.
|
||||
RequireFrontmatter,
|
||||
/// Derive a stable description from the source command name when frontmatter is absent.
|
||||
UseSourceNameFallback,
|
||||
}
|
||||
|
||||
/// Describes source-specific command migration behavior.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CommandMigrationProfile {
|
||||
rewrite_profile: RewriteProfile,
|
||||
description_mode: CommandDescriptionMode,
|
||||
}
|
||||
|
||||
impl CommandMigrationProfile {
|
||||
pub const fn new(
|
||||
rewrite_profile: RewriteProfile,
|
||||
description_mode: CommandDescriptionMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
rewrite_profile,
|
||||
description_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedDocument {
|
||||
frontmatter: BTreeMap<String, FrontmatterValue>,
|
||||
@@ -228,70 +198,6 @@ pub fn import_subagents_with_rewrite_profile(
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn count_missing_commands_with_profile(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
profile: CommandMigrationProfile,
|
||||
) -> io::Result<usize> {
|
||||
Ok(missing_command_names_with_profile(source_commands, target_skills, profile)?.len())
|
||||
}
|
||||
|
||||
pub fn missing_command_names_with_profile(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
profile: CommandMigrationProfile,
|
||||
) -> io::Result<Vec<String>> {
|
||||
Ok(
|
||||
unique_supported_command_sources(source_commands, profile.description_mode)?
|
||||
.into_iter()
|
||||
.filter(|(_source_file, name)| !target_skills.join(name).exists())
|
||||
.map(|(_source_file, name)| name)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn import_commands_with_profile(
|
||||
source_commands: &Path,
|
||||
target_skills: &Path,
|
||||
profile: CommandMigrationProfile,
|
||||
) -> 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, profile.description_mode)?
|
||||
{
|
||||
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, profile.description_mode)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
fs::write(
|
||||
target_dir.join("SKILL.md"),
|
||||
render_command_skill(
|
||||
&document.body,
|
||||
&name,
|
||||
&description,
|
||||
&source_name,
|
||||
profile.rewrite_profile,
|
||||
),
|
||||
)?;
|
||||
imported.push(name);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
fn read_external_mcp_servers(
|
||||
source_root: &Path,
|
||||
external_agent_home: Option<&Path>,
|
||||
@@ -851,61 +757,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,
|
||||
description_mode: CommandDescriptionMode,
|
||||
) -> 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,
|
||||
description_mode,
|
||||
) 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))
|
||||
@@ -1067,102 +918,6 @@ fn render_agent_body(body: &str, rewrite_profile: RewriteProfile) -> 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,
|
||||
description_mode: CommandDescriptionMode,
|
||||
) -> 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, description_mode)?;
|
||||
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,
|
||||
description_mode: CommandDescriptionMode,
|
||||
) -> Option<String> {
|
||||
let frontmatter_description = document
|
||||
.frontmatter
|
||||
.get("description")
|
||||
.and_then(FrontmatterValue::as_scalar)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
frontmatter_description.or_else(|| match description_mode {
|
||||
CommandDescriptionMode::RequireFrontmatter => None,
|
||||
CommandDescriptionMode::UseSourceNameFallback => {
|
||||
Some(format!("Migrated source command `{source_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,
|
||||
rewrite_profile: RewriteProfile,
|
||||
) -> String {
|
||||
let body = rewrite_external_agent_terms(body.trim(), rewrite_profile);
|
||||
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, rewrite_profile)),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1217,31 +972,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 {
|
||||
|
||||
Reference in new issue
Block a user