mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Support plugin agent roles
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user