Support plugin agent roles

This commit is contained in:
charlesgong-openai
2026-07-07 22:54:32 -04:00
parent aaa30f79c2
commit 63d51ea592
17 changed files with 626 additions and 4 deletions
+25
View File
@@ -57,6 +57,7 @@ use tracing::instrument;
use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_AGENTS_DIR_NAME: &str = "agents";
const DEFAULT_HOOKS_CONFIG_FILE: &str = "hooks/hooks.json";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
@@ -764,6 +765,7 @@ async fn load_plugin(
manifest_description: None,
root,
enabled: plugin.enabled,
agent_roots: Vec::new(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
@@ -812,6 +814,7 @@ async fn load_plugin(
} => {
loaded_plugin.manifest_name = Some(manifest.display_name().to_string());
loaded_plugin.manifest_description = manifest.description.clone();
loaded_plugin.agent_roots = plugin_agent_roots(&plugin_root, manifest_paths);
loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths);
let resolved_skills = load_plugin_skills(
&plugin_root,
@@ -972,6 +975,28 @@ fn plugin_skill_roots(
paths
}
fn plugin_agent_roots(
plugin_root: &AbsolutePathBuf,
manifest_paths: &PluginManifestPaths,
) -> Vec<AbsolutePathBuf> {
let mut paths = default_agent_roots(plugin_root);
if let Some(path) = &manifest_paths.agents {
paths.push(path.clone());
}
paths.sort_unstable();
paths.dedup();
paths
}
fn default_agent_roots(plugin_root: &AbsolutePathBuf) -> Vec<AbsolutePathBuf> {
let agents_dir = plugin_root.join(DEFAULT_AGENTS_DIR_NAME);
if agents_dir.is_dir() {
vec![agents_dir]
} else {
Vec::new()
}
}
fn default_skill_roots(plugin_root: &AbsolutePathBuf) -> Vec<AbsolutePathBuf> {
let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME);
if skills_dir.is_dir() {
+77
View File
@@ -6,6 +6,9 @@ use codex_config::ConfigLayerSource;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_plugin::PluginId;
use codex_plugin::PluginLoadOutcome;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_plugins::PluginAgentRoot;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -66,6 +69,80 @@ fn configured_plugins_from_stack_merges_user_layers() {
);
}
#[tokio::test]
async fn load_plugins_discovers_default_and_manifest_agent_roots() {
let temp_dir = TempDir::new().expect("tempdir");
let plugin_root = temp_dir.path().join("plugins/cache/test/agented/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"agented","agents":"./extra-agents"}"#,
);
write_file(
&plugin_root.join("agents/researcher.toml"),
r#"name = "researcher"
description = "Research role"
developer_instructions = "Research carefully"
"#,
);
write_file(
&plugin_root.join("extra-agents/reviewer.toml"),
r#"name = "reviewer"
description = "Review role"
developer_instructions = "Review carefully"
"#,
);
let stack = ConfigLayerStack::new(
vec![user_layer(
user_config_path(&temp_dir, "config.toml"),
r#"
[plugins."agented@test"]
enabled = true
"#,
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack");
let store = PluginStore::new(temp_dir.path().to_path_buf());
let plugins = load_plugins_from_layer_stack(
&stack,
HashMap::new(),
&store,
Some(Product::Codex),
/*prefer_remote_curated_conflicts*/ false,
)
.await;
let plugin = plugins
.iter()
.find(|plugin| plugin.config_name == "agented@test")
.expect("agented plugin should load");
assert_eq!(
plugin.agent_roots,
vec![
plugin_root.join("agents").abs(),
plugin_root.join("extra-agents").abs(),
]
);
assert_eq!(
PluginLoadOutcome::from_plugins(plugins).effective_plugin_agent_roots(),
vec![
PluginAgentRoot {
path: plugin_root.join("agents").abs(),
plugin_id: "agented@test".to_string(),
plugin_root: plugin_root.abs(),
},
PluginAgentRoot {
path: plugin_root.join("extra-agents").abs(),
plugin_id: "agented@test".to_string(),
plugin_root: plugin_root.abs(),
},
]
);
}
#[tokio::test]
async fn hooks_only_scope_shares_plugin_resolution_without_loading_other_capabilities() {
let temp_dir = TempDir::new().expect("tempdir");
@@ -864,6 +864,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
),
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
agent_roots: Vec::new(),
skill_roots: vec![plugin_root.join("skills").abs()],
disabled_skill_paths: HashSet::new(),
has_enabled_skills: true,
@@ -2145,6 +2146,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions
manifest_description: None,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
agent_roots: Vec::new(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
@@ -2320,6 +2322,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
manifest_description: None,
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
enabled: true,
agent_roots: Vec::new(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
+4
View File
@@ -33,6 +33,8 @@ struct RawPluginManifest {
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
agents: Option<RawPluginManifestPaths>,
#[serde(default)]
skills: Option<RawPluginManifestPaths>,
#[serde(default)]
mcp_servers: Option<RawPluginManifestMcpServers>,
@@ -162,6 +164,7 @@ pub(crate) fn parse_plugin_manifest_uri(
version,
description,
keywords,
agents,
skills,
mcp_servers,
apps,
@@ -258,6 +261,7 @@ pub(crate) fn parse_plugin_manifest_uri(
description,
keywords,
paths: codex_plugin::manifest::PluginManifestPaths {
agents: resolve_manifest_paths(plugin_root, "agents", agents.as_ref()),
skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()),
mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers),
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
+77
View File
@@ -8,6 +8,8 @@ use codex_exec_server::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::PluginAgentRoot;
use codex_utils_plugins::plugin_namespace_for_skill_path;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
@@ -115,6 +117,81 @@ pub(crate) async fn load_agent_roles(
Ok(roles)
}
pub(crate) async fn load_plugin_agent_roles(
fs: &dyn ExecutorFileSystem,
plugin_agent_roots: Vec<PluginAgentRoot>,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<BTreeMap<String, AgentRoleConfig>> {
let mut roles = BTreeMap::new();
for plugin_agent_root in plugin_agent_roots {
let discovered_roles = discover_agent_roles_in_dir(
fs,
&plugin_agent_root.path,
&BTreeSet::new(),
startup_warnings,
)
.await?;
if discovered_roles.is_empty() {
continue;
}
let Some(plugin_namespace) = plugin_agent_role_namespace(fs, &plugin_agent_root).await
else {
push_agent_role_warning(
startup_warnings,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"unable to determine plugin namespace for agent roles in {}",
plugin_agent_root.path.as_path().display()
),
),
);
continue;
};
for (role_name, role) in discovered_roles {
let namespaced_role_name = format!("{plugin_namespace}:{role_name}");
if roles.contains_key(&namespaced_role_name) {
push_agent_role_warning(
startup_warnings,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"duplicate plugin agent role name `{namespaced_role_name}` discovered in {}",
plugin_agent_root.path.as_path().display()
),
),
);
continue;
}
if let Err(err) = validate_required_agent_role_description(
&namespaced_role_name,
role.description.as_deref(),
) {
push_agent_role_warning(startup_warnings, err);
continue;
}
roles.insert(namespaced_role_name, role);
}
}
Ok(roles)
}
async fn plugin_agent_role_namespace(
fs: &dyn ExecutorFileSystem,
plugin_agent_root: &PluginAgentRoot,
) -> Option<String> {
plugin_namespace_for_skill_path(fs, &plugin_agent_root.path)
.await
.or_else(|| {
plugin_agent_root
.plugin_id
.split_once('@')
.map(|(name, _)| name.to_string())
})
}
fn push_agent_role_warning(startup_warnings: &mut Vec<String>, err: std::io::Error) {
let message = format!("Ignoring malformed agent role definition: {err}");
tracing::warn!("{message}");
+57
View File
@@ -95,6 +95,7 @@ use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_plugins::PluginAgentRoot;
use serde::Deserialize;
use tempfile::tempdir;
@@ -7464,6 +7465,62 @@ nickname_candidates = ["Noether"]
Ok(())
}
#[tokio::test]
async fn plugin_agent_roles_are_namespaced_with_plugin_manifest_name() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let plugin_root = temp_dir.path().join("plugins").join("sample");
let agents_dir = plugin_root.join("agents");
let agent_path = agents_dir.join("researcher.toml");
tokio::fs::create_dir_all(plugin_root.join(".codex-plugin")).await?;
tokio::fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"sample-plugin"}"#,
)
.await?;
tokio::fs::create_dir_all(&agents_dir).await?;
tokio::fs::write(
&agent_path,
r#"
name = "researcher"
description = "Research role"
nickname_candidates = ["Hypatia"]
developer_instructions = "Research carefully"
model = "gpt-5.2"
"#,
)
.await?;
let mut startup_warnings = Vec::new();
let roles = agent_roles::load_plugin_agent_roles(
LOCAL_FS.as_ref(),
vec![PluginAgentRoot {
path: agents_dir.abs(),
plugin_id: "fallback@test".to_string(),
plugin_root: plugin_root.abs(),
}],
&mut startup_warnings,
)
.await?;
assert_eq!(startup_warnings, Vec::<String>::new());
assert_eq!(
roles.get("sample-plugin:researcher").map(|role| (
role.description.as_deref(),
role.config_file.as_ref(),
role.nickname_candidates
.as_ref()
.map(|candidates| candidates.iter().map(String::as_str).collect::<Vec<_>>())
)),
Some((
Some("Research role"),
Some(&agent_path),
Some(vec!["Hypatia"])
))
);
Ok(())
}
#[tokio::test]
async fn agent_role_file_without_developer_instructions_is_dropped_with_warning()
-> std::io::Result<()> {
+23 -1
View File
@@ -2,6 +2,7 @@ use super::*;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::shell_snapshot::ShellSnapshotFile;
use codex_core_skills::HostSkillsSnapshot;
use codex_exec_server::LOCAL_FS;
use codex_file_system::FileSystemSandboxContext;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
@@ -684,7 +685,7 @@ impl Session {
.as_ref()
.and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| session_configuration.cwd().clone());
let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone());
let mut per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone());
{
let mcp_runtime = self.services.latest_mcp_runtime();
let mcp_connection_manager = mcp_runtime.manager();
@@ -720,6 +721,27 @@ impl Session {
.plugins_manager
.plugins_for_config(&plugins_input)
.await;
let mut plugin_agent_role_warnings = Vec::new();
match crate::config::agent_roles::load_plugin_agent_roles(
LOCAL_FS.as_ref(),
plugin_outcome.effective_plugin_agent_roots(),
&mut plugin_agent_role_warnings,
)
.await
{
Ok(plugin_agent_roles) => {
for (role_name, role) in plugin_agent_roles {
per_turn_config.agent_roles.entry(role_name).or_insert(role);
}
}
Err(err) => {
plugin_agent_role_warnings
.push(format!("Failed to load plugin agent roles: {err}"));
}
}
per_turn_config
.startup_warnings
.extend(plugin_agent_role_warnings);
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let plugin_skill_snapshots = self
.services
+62
View File
@@ -29,6 +29,7 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_mcp_server;
use serde_json::Value;
use tempfile::TempDir;
use wiremock::MockServer;
@@ -39,6 +40,8 @@ const SAMPLE_PLUGIN_APP_NAMESPACE: &str = "mcp__codex_apps__google_calendar";
const SAMPLE_PLUGIN_MCP_NAMESPACE: &str = "mcp__sample";
const PLUGIN_APP_SEARCH_CALL_ID: &str = "plugin-app-search";
const PLUGIN_MCP_SEARCH_CALL_ID: &str = "plugin-mcp-search";
const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1";
const SPAWN_AGENT_TOOL_NAME: &str = "spawn_agent";
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
home.path().join("plugins/cache/test/sample/local")
@@ -76,6 +79,20 @@ fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
skill_dir.join("SKILL.md")
}
fn write_plugin_agent_plugin(home: &TempDir) {
let plugin_root = write_sample_plugin_manifest_and_config(home);
let agents_dir = plugin_root.join("agents");
std::fs::create_dir_all(&agents_dir).expect("create plugin agents dir");
std::fs::write(
agents_dir.join("researcher.toml"),
r#"name = "researcher"
description = "Research sample data"
developer_instructions = "Research the sample data carefully"
"#,
)
.expect("write plugin agent role");
}
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
let plugin_root = write_sample_plugin_manifest_and_config(home);
std::fs::write(
@@ -204,6 +221,15 @@ fn searched_plugin_tools(
)
}
fn tool_parameter_description(tool: &Value, parameter_name: &str) -> Option<String> {
tool.get("parameters")
.and_then(|parameters| parameters.get("properties"))
.and_then(|properties| properties.get(parameter_name))
.and_then(|parameter| parameter.get("description"))
.and_then(Value::as_str)
.map(str::to_string)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn capability_sections_render_in_developer_message_in_order() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -274,6 +300,42 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_agent_roles_are_available_to_spawn_agent() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex_home = Arc::new(TempDir::new()?);
write_plugin_agent_plugin(codex_home.as_ref());
let mut builder = test_codex().with_home(codex_home).with_config(|config| {
config
.features
.enable(Feature::Collab)
.expect("test config should allow feature update");
config.multi_agent_v2.hide_spawn_agent_metadata = false;
});
let test_codex = builder.build(&server).await?;
test_codex.submit_turn("hello").await?;
let body = resp_mock.single_request().body_json();
let spawn_agent = namespace_child_tool(&body, MULTI_AGENT_V1_NAMESPACE, SPAWN_AGENT_TOOL_NAME)
.expect("spawn_agent should be available");
let agent_type_description = tool_parameter_description(spawn_agent, "agent_type")
.expect("spawn_agent agent_type description");
assert!(
agent_type_description.contains("sample:researcher: {\nResearch sample data\n}"),
"expected plugin agent role in spawn_agent description: {agent_type_description:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -401,6 +401,7 @@ fn resolved_plugin(
description: None,
keywords: Vec::new(),
paths: PluginManifestPaths {
agents: Vec::new(),
skills: Vec::new(),
mcp_servers,
apps: None,
+47
View File
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::collections::HashSet;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PluginAgentRoot;
use codex_utils_plugins::PluginSkillRoot;
use crate::AppConnectorId;
@@ -21,6 +22,7 @@ pub struct LoadedPlugin<M> {
pub manifest_description: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub agent_roots: Vec<AbsolutePathBuf>,
pub skill_roots: Vec<AbsolutePathBuf>,
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
pub has_enabled_skills: bool,
@@ -123,6 +125,25 @@ impl<M: Clone> PluginLoadOutcome<M> {
skill_roots
}
pub fn effective_plugin_agent_roots(&self) -> Vec<PluginAgentRoot> {
let mut agent_roots = Vec::new();
let mut seen_paths = HashSet::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for path in &plugin.agent_roots {
if seen_paths.insert(path.clone()) {
agent_roots.push(PluginAgentRoot {
path: path.clone(),
plugin_id: plugin.config_name.clone(),
plugin_root: plugin.root.clone(),
});
}
}
}
agent_roots.sort_unstable_by(|a, b| a.path.cmp(&b.path));
agent_roots
}
pub fn effective_plugin_skill_roots(&self) -> Vec<PluginSkillRoot> {
let mut skill_roots = Vec::new();
let mut seen_paths = HashSet::new();
@@ -197,6 +218,8 @@ impl<M: Clone> PluginLoadOutcome<M> {
pub trait EffectiveSkillRoots {
fn effective_skill_roots(&self) -> Vec<AbsolutePathBuf>;
fn effective_plugin_agent_roots(&self) -> Vec<PluginAgentRoot>;
fn effective_plugin_skill_roots(&self) -> Vec<PluginSkillRoot>;
}
@@ -205,6 +228,10 @@ impl<M: Clone> EffectiveSkillRoots for PluginLoadOutcome<M> {
PluginLoadOutcome::effective_skill_roots(self)
}
fn effective_plugin_agent_roots(&self) -> Vec<PluginAgentRoot> {
PluginLoadOutcome::effective_plugin_agent_roots(self)
}
fn effective_plugin_skill_roots(&self) -> Vec<PluginSkillRoot> {
PluginLoadOutcome::effective_plugin_skill_roots(self)
}
@@ -232,6 +259,7 @@ mod tests {
manifest_description: None,
root: test_path(config_name),
enabled: true,
agent_roots: Vec::new(),
skill_roots,
disabled_skill_paths: HashSet::new(),
has_enabled_skills: true,
@@ -261,4 +289,23 @@ mod tests {
}]
);
}
#[test]
fn effective_plugin_agent_roots_preserves_first_plugin_for_shared_root() {
let shared_root = test_path("shared-agents");
let mut zeta = loaded_plugin("zeta@test", Vec::new());
zeta.agent_roots = vec![shared_root.clone()];
let mut alpha = loaded_plugin("alpha@test", Vec::new());
alpha.agent_roots = vec![shared_root.clone()];
let outcome = PluginLoadOutcome::from_plugins(vec![zeta, alpha]);
assert_eq!(
outcome.effective_plugin_agent_roots(),
vec![PluginAgentRoot {
path: shared_root,
plugin_id: "zeta@test".to_string(),
plugin_root: test_path("zeta@test"),
}]
);
}
}
+6
View File
@@ -17,6 +17,7 @@ pub struct PluginManifest<Resource> {
/// Component resources declared by a plugin manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths<Resource> {
pub agents: Vec<Resource>,
pub skills: Vec<Resource>,
pub mcp_servers: Option<PluginManifestMcpServers<Resource>>,
pub apps: Option<Resource>,
@@ -104,6 +105,7 @@ impl<Resource> PluginManifest<Resource> {
interface,
} = self;
let PluginManifestPaths {
agents,
skills,
mcp_servers,
apps,
@@ -177,6 +179,10 @@ impl<Resource> PluginManifest<Resource> {
description,
keywords,
paths: PluginManifestPaths {
agents: agents
.into_iter()
.map(&mut map)
.collect::<Result<Vec<_>, _>>()?,
skills: skills
.into_iter()
.map(&mut map)
+4
View File
@@ -30,6 +30,7 @@ fn environment_descriptor_binds_every_manifest_resource() {
let root = absolute(std::env::current_dir().expect("cwd").join("plugin-root"));
let root_uri = path_uri(&root);
let manifest_path = root.join(".codex-plugin/plugin.json");
let agents = root.join("agents");
let skills = root.join("skills");
let mcp_servers = root.join(".mcp.json");
let apps = root.join(".app.json");
@@ -43,6 +44,7 @@ fn environment_descriptor_binds_every_manifest_resource() {
description: None,
keywords: Vec::new(),
paths: PluginManifestPaths {
agents: vec![path_uri(&agents)],
skills: vec![path_uri(&skills)],
mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&mcp_servers))),
apps: Some(path_uri(&apps)),
@@ -77,6 +79,7 @@ fn environment_descriptor_binds_every_manifest_resource() {
description: None,
keywords: Vec::new(),
paths: PluginManifestPaths {
agents: vec![resource("executor-1", &agents)],
skills: vec![resource("executor-1", &skills)],
mcp_servers: Some(PluginManifestMcpServers::Path(resource(
"executor-1",
@@ -109,6 +112,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() {
description: None,
keywords: Vec::new(),
paths: PluginManifestPaths {
agents: Vec::new(),
skills: Vec::new(),
mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&outside))),
apps: None,
@@ -14,6 +14,7 @@
"repository": "https://github.com/author/plugin",
"license": "MIT",
"keywords": ["keyword1", "keyword2"],
"agents": "./agents/",
"skills": "./skills/",
"hooks": "./hooks.json",
"mcpServers": "./.mcp.json",
@@ -61,6 +62,7 @@
- `repository` (`string`): Source code URL.
- `license` (`string`): License identifier (for example `MIT`, `Apache-2.0`).
- `keywords` (`array` of `string`): Search/discovery tags.
- `agents` (`string`): Relative path to plugin-bundled agent role TOML files.
- `skills` (`string`): Relative path to skill directories/files.
- `hooks` (`string`): Hook config path.
- `mcpServers` (`string` or `object`): MCP config path, or an object whose keys are MCP server names and whose values are MCP server config objects.
@@ -114,7 +116,7 @@ Or as an object directly in `plugin.json`:
### Path conventions and defaults
- Path values should be relative and begin with `./`.
- `skills`, `hooks`, and string-valued `mcpServers` are supplemented on top of default component discovery; they do not replace defaults.
- `agents`, `skills`, `hooks`, and string-valued `mcpServers` are supplemented on top of default component discovery; they do not replace defaults.
- Custom path values must follow the plugin root convention and naming/namespacing rules.
- This repo’s scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates.
@@ -209,6 +211,8 @@ personal marketplace unless the caller explicitly requests a repo-local destinat
present.
- `composerIcon`, `logo`, `logoDark`, and `screenshots` must point to real files inside the plugin archive when
present.
- `agents` should appear in `plugin.json` only when the plugin has an `agents/` directory with
role TOML files.
- `apps` should appear in `plugin.json` only when `.app.json` actually exists.
- `mcpServers` may point to `.mcp.json` or contain the MCP server object directly in
`plugin.json`.
@@ -53,7 +53,9 @@ def display_name_from_plugin_name(plugin_name: str) -> str:
return " ".join(part.capitalize() for part in re.split(r"[-_]+", plugin_name))
def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]:
def build_plugin_json(
plugin_name: str, *, with_agents: bool, with_mcp: bool, with_apps: bool
) -> dict[str, Any]:
display_name = display_name_from_plugin_name(plugin_name)
payload: dict[str, Any] = {
"name": plugin_name,
@@ -73,6 +75,8 @@ def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> d
"defaultPrompt": f"Help me use {display_name}.",
},
}
if with_agents:
payload["agents"] = "./agents/"
if with_mcp:
payload["mcpServers"] = "./.mcp.json"
if with_apps:
@@ -204,6 +208,9 @@ def parse_args() -> argparse.Namespace:
),
)
parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory")
parser.add_argument(
"--with-agents", action="store_true", help="Create agents/ directory"
)
parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory")
parser.add_argument("--with-scripts", action="store_true", help="Create scripts/ directory")
parser.add_argument("--with-assets", action="store_true", help="Create assets/ directory")
@@ -272,11 +279,17 @@ def main() -> None:
plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json"
write_json(
plugin_json_path,
build_plugin_json(plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps),
build_plugin_json(
plugin_name,
with_agents=args.with_agents,
with_mcp=args.with_mcp,
with_apps=args.with_apps,
),
args.force,
)
optional_directories = {
"agents": args.with_agents,
"skills": args.with_skills,
"hooks": args.with_hooks,
"scripts": args.with_scripts,
@@ -97,6 +97,7 @@ def validate_manifest_shape(
"name",
"version",
"description",
"agents",
"skills",
"apps",
"mcpServers",
@@ -125,6 +126,7 @@ def validate_manifest_shape(
validate_optional_https_url(author, "url", errors, prefix="author")
validate_optional_contract_path(manifest, "skills", "skills", errors)
validate_optional_contract_path(manifest, "agents", "agents", errors)
validate_optional_contract_path(manifest, "apps", ".app.json", errors)
validate_manifest_mcp_servers(plugin_root, manifest, errors)
+7
View File
@@ -20,3 +20,10 @@ pub struct PluginSkillRoot {
pub plugin_namespace: String,
pub plugin_root: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PluginAgentRoot {
pub path: AbsolutePathBuf,
pub plugin_id: String,
pub plugin_root: AbsolutePathBuf,
}
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Reset selected Codex-side targets created by Claude Code import.
This is a developer helper for retesting the external-agent migration flow. It
never reads from or mutates Claude's source directories. By default it only
prints the changes it would make; pass --apply to mutate Codex targets.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
MCP_TABLE_HEADER = re.compile(r"^\s*\[\[?\s*mcp_servers(?:\s*\.|\s*\])")
TABLE_HEADER = re.compile(r"^\s*\[\[?.*\]\]?\s*(?:#.*)?$")
ROOT_MCP_ASSIGNMENT = re.compile(r"^\s*mcp_servers(?:\s*\.|\s*=)")
@dataclass(frozen=True)
class ResetTarget:
label: str
config_toml: Path
skills_dir: Path
hooks_json: Path
def strip_mcp_servers(text: str) -> tuple[str, bool]:
"""Remove generated [mcp_servers...] TOML tables while preserving other text."""
kept_lines: list[str] = []
skipping_mcp_table = False
before_first_table = True
removed = False
for line in text.splitlines(keepends=True):
if TABLE_HEADER.match(line):
before_first_table = False
if MCP_TABLE_HEADER.match(line):
skipping_mcp_table = True
removed = True
continue
skipping_mcp_table = False
if skipping_mcp_table:
continue
# The migration writer emits table headers, but handle an inline or
# dotted root assignment too so the reset means "all MCP servers".
if before_first_table and ROOT_MCP_ASSIGNMENT.match(line):
removed = True
continue
kept_lines.append(line)
return "".join(kept_lines), removed
def resolve_repo_root(explicit_repo_root: Path | None) -> Path | None:
if explicit_repo_root is not None:
return explicit_repo_root.expanduser().resolve()
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
return Path(result.stdout.strip()).resolve()
def backup_path(path: Path, timestamp: str) -> Path:
base = path.with_name(f"{path.name}.before-claude-import-reset-{timestamp}.bak")
if not base.exists():
return base
counter = 2
while True:
candidate = base.with_name(f"{base.name}.{counter}")
if not candidate.exists():
return candidate
counter += 1
def move_to_backup(path: Path, timestamp: str, apply: bool) -> None:
destination = backup_path(path, timestamp)
verb = "moving" if apply else "would move"
print(f" {verb} {path} -> {destination}")
if apply:
shutil.move(path, destination)
def reset_config(path: Path, timestamp: str, apply: bool) -> None:
if not path.is_file():
print(f" no config.toml at {path}")
return
original = path.read_text(encoding="utf-8")
updated, removed = strip_mcp_servers(original)
if not removed:
print(f" no MCP server tables in {path}")
return
backup = backup_path(path, timestamp)
verb = "removing" if apply else "would remove"
print(f" {verb} MCP server tables from {path}")
print(f" {'backing up' if apply else 'would back up'} {path} -> {backup}")
if apply:
shutil.copy2(path, backup)
path.write_text(updated, encoding="utf-8")
def reset_target(target: ResetTarget, timestamp: str, apply: bool) -> None:
print(f"{target.label}:")
reset_config(target.config_toml, timestamp, apply)
if target.skills_dir.exists():
if not target.skills_dir.is_dir() and not target.skills_dir.is_symlink():
raise RuntimeError(f"skills target is not a directory: {target.skills_dir}")
move_to_backup(target.skills_dir, timestamp, apply)
else:
print(f" no skills directory at {target.skills_dir}")
if target.hooks_json.exists():
if target.hooks_json.is_dir():
raise RuntimeError(f"hooks target is a directory: {target.hooks_json}")
move_to_backup(target.hooks_json, timestamp, apply)
else:
print(f" no hooks.json at {target.hooks_json}")
def home_target(codex_home: Path) -> ResetTarget:
return ResetTarget(
label="home-scoped import targets",
config_toml=codex_home / "config.toml",
skills_dir=codex_home.parent / ".agents" / "skills",
hooks_json=codex_home / "hooks.json",
)
def repo_target(repo_root: Path) -> ResetTarget:
return ResetTarget(
label="repo-scoped import targets",
config_toml=repo_root / ".codex" / "config.toml",
skills_dir=repo_root / ".agents" / "skills",
hooks_json=repo_root / ".codex" / "hooks.json",
)
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Reset Codex-side MCP servers, skills, and hooks.json created while "
"testing Claude Code import. Defaults to a dry run."
)
)
parser.add_argument(
"--apply",
action="store_true",
help="Apply the reset. Without this flag, only print planned changes.",
)
parser.add_argument(
"--scope",
choices=("all", "home", "repo"),
default="all",
help="Which import targets to reset. Default: all.",
)
parser.add_argument(
"--codex-home",
type=Path,
default=Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")),
help="Codex home for home-scoped targets. Default: $CODEX_HOME or ~/.codex.",
)
parser.add_argument(
"--repo-root",
type=Path,
help="Repo root for repo-scoped targets. Default: current git worktree root.",
)
args = parser.parse_args()
codex_home = args.codex_home.expanduser().resolve()
repo_root = resolve_repo_root(args.repo_root)
targets: list[ResetTarget] = []
if args.scope in ("all", "home"):
targets.append(home_target(codex_home))
if args.scope in ("all", "repo"):
if repo_root is None:
parser.error(
"--scope includes repo, but no git worktree or --repo-root was found"
)
targets.append(repo_target(repo_root))
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
if not args.apply:
print("dry run: pass --apply to perform these changes")
for target in targets:
reset_target(target, timestamp, args.apply)
return 0
if __name__ == "__main__":
sys.exit(main())