diff --git a/codex-rs/utils/plugins/BUILD.bazel b/codex-rs/utils/plugins/BUILD.bazel new file mode 100644 index 0000000000..273e98fc4b --- /dev/null +++ b/codex-rs/utils/plugins/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "plugins", + crate_name = "codex_utils_plugins", +) diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml new file mode 100644 index 0000000000..4e1826c256 --- /dev/null +++ b/codex-rs/utils/plugins/Cargo.toml @@ -0,0 +1,21 @@ + +[package] +edition.workspace = true +license.workspace = true +name = "codex-utils-plugins" +version.workspace = true + +[lib] +doctest = false +name = "codex_utils_plugins" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs new file mode 100644 index 0000000000..5e990eb3cb --- /dev/null +++ b/codex-rs/utils/plugins/src/lib.rs @@ -0,0 +1,7 @@ +//! Plugin path resolution and plaintext mention sigils shared across Codex crates. + +pub mod mention_syntax; +pub mod plugin_namespace; + +pub use plugin_namespace::PLUGIN_MANIFEST_PATH; +pub use plugin_namespace::plugin_namespace_for_skill_path; diff --git a/codex-rs/utils/plugins/src/mention_syntax.rs b/codex-rs/utils/plugins/src/mention_syntax.rs new file mode 100644 index 0000000000..de5d1432b9 --- /dev/null +++ b/codex-rs/utils/plugins/src/mention_syntax.rs @@ -0,0 +1,7 @@ +//! Sigils for tool/plugin mentions in plaintext (shared across Codex crates). + +/// Default plaintext sigil for tools. +pub const TOOL_MENTION_SIGIL: char = '$'; + +/// Plugins use `@` in linked plaintext outside TUI. +pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@'; diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs new file mode 100644 index 0000000000..6688ae0469 --- /dev/null +++ b/codex-rs/utils/plugins/src/plugin_namespace.rs @@ -0,0 +1,70 @@ +//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. + +use std::fs; +use std::path::Path; + +/// Relative path from a plugin root to its manifest file. +pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifestName { + #[serde(default)] + name: String, +} + +fn plugin_manifest_name(plugin_root: &Path) -> Option { + let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH); + if !manifest_path.is_file() { + return None; + } + let contents = fs::read_to_string(&manifest_path).ok()?; + let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?; + Some( + plugin_root + .file_name() + .and_then(|entry| entry.to_str()) + .filter(|_| raw_name.trim().is_empty()) + .unwrap_or(raw_name.as_str()) + .to_string(), + ) +} + +/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid +/// plugin manifest (same `name` rules as full manifest loading in codex-core). +pub fn plugin_namespace_for_skill_path(path: &Path) -> Option { + for ancestor in path.ancestors() { + if let Some(name) = plugin_manifest_name(ancestor) { + return Some(name); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::plugin_namespace_for_skill_path; + use std::fs; + use tempfile::tempdir; + + #[test] + fn uses_manifest_name() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("plugins/sample"); + let skill_path = plugin_root.join("skills/search/SKILL.md"); + + fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write manifest"); + fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill"); + + assert_eq!( + plugin_namespace_for_skill_path(&skill_path), + Some("sample".to_string()) + ); + } +}