feat: structured plugin parsing (#13711)

#### What

Add structured `@plugin` parsing and TUI support for plugin mentions.

- Core: switch from plain-text `@display_name` parsing to structured
`plugin://...` mentions via `UserInput::Mention` and
`[$...](plugin://...)` links in text, same pattern as apps/skills.
- TUI: add plugin mention popup, autocomplete, and chips when typing
`$`. Load plugin capability summaries and feed them into the composer;
plugin mentions appear alongside skills and apps.
- Generalize mention parsing to a sigil parameter, still defaults to `$`

<img width="797" height="119" alt="image"
src="https://github.com/user-attachments/assets/f0fe2658-d908-4927-9139-73f850805ceb"
/>

Builds on #13510. Currently clients have to build their own `id` via
`plugin@marketplace` and filter plugins to show by `enabled`, but we
will add `id` and `available` as fields returned from `plugin/list`
soon.

####Tests

Added tests, verified locally.
This commit is contained in:
sayan-oai
2026-03-06 11:08:36 -08:00
committed by GitHub
parent 0e41a5c4a8
commit 8a54d3caaa
18 changed files with 468 additions and 181 deletions

View File

@@ -66,6 +66,7 @@ pub struct ConfiguredMarketplacePluginSummary {
pub struct LoadedPlugin {
pub config_name: String,
pub manifest_name: Option<String>,
pub manifest_description: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub skill_roots: Vec<PathBuf>,
@@ -84,6 +85,7 @@ impl LoadedPlugin {
pub struct PluginCapabilitySummary {
pub config_name: String,
pub display_name: String,
pub description: Option<String>,
pub has_skills: bool,
pub mcp_server_names: Vec<String>,
pub app_connector_ids: Vec<AppConnectorId>,
@@ -104,6 +106,7 @@ impl PluginCapabilitySummary {
.manifest_name
.clone()
.unwrap_or_else(|| plugin.config_name.clone()),
description: plugin.manifest_description.clone(),
has_skills: !plugin.skill_roots.is_empty(),
mcp_server_names,
app_connector_ids: plugin.apps.clone(),
@@ -476,6 +479,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
let mut loaded_plugin = LoadedPlugin {
config_name,
manifest_name: None,
manifest_description: None,
root,
enabled: plugin.enabled,
skill_roots: Vec::new(),
@@ -507,6 +511,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
};
loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path()));
loaded_plugin.manifest_description = manifest.description;
loaded_plugin.skill_roots = default_skill_roots(plugin_root.as_path());
let mut mcp_servers = HashMap::new();
for mcp_config_path in default_mcp_config_paths(plugin_root.as_path()) {
@@ -752,7 +757,10 @@ mod tests {
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
r#"{
"name": "sample",
"description": "Plugin that includes the sample MCP server and Skills"
}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
@@ -792,6 +800,9 @@ mod tests {
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: Some("sample".to_string()),
manifest_description: Some(
"Plugin that includes the sample MCP server and Skills".to_string(),
),
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
skill_roots: vec![plugin_root.join("skills")],
@@ -819,6 +830,19 @@ mod tests {
error: None,
}]
);
assert_eq!(
outcome.capability_summaries(),
&[PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
description: Some(
"Plugin that includes the sample MCP server and Skills".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
}]
);
assert_eq!(
outcome.effective_skill_roots(),
vec![plugin_root.join("skills")]
@@ -862,6 +886,7 @@ mod tests {
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: None,
manifest_description: None,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
skill_roots: Vec::new(),
@@ -972,6 +997,7 @@ mod tests {
let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin {
config_name: config_name.to_string(),
manifest_name: Some(manifest_name.to_string()),
manifest_description: None,
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
enabled: true,
skill_roots: Vec::new(),
@@ -982,6 +1008,7 @@ mod tests {
let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary {
config_name: config_name.to_string(),
display_name: display_name.to_string(),
description: None,
..PluginCapabilitySummary::default()
};
let outcome = PluginLoadOutcome::from_plugins(vec![