feat(cli): add plugin reload command

This commit is contained in:
Casey Chow
2026-05-19 12:53:21 -04:00
parent c280fcc7ed
commit 7f078f3a84
9 changed files with 131 additions and 2 deletions
+1
View File
@@ -2223,6 +2223,7 @@ dependencies = [
"clap_complete",
"codex-api",
"codex-app-server",
"codex-app-server-client",
"codex-app-server-daemon",
"codex-app-server-protocol",
"codex-app-server-test-client",
+1
View File
@@ -22,6 +22,7 @@ anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
clap_complete = { workspace = true }
codex-app-server = { workspace = true }
codex-app-server-client = { workspace = true }
codex-app-server-daemon = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-app-server-test-client = { workspace = true }
+21
View File
@@ -957,6 +957,12 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides);
marketplace_cli.run().await?;
}
PluginSubcommand::Reload(args) => {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
plugin_cmd::run_plugin_reload(overrides, args).await?;
}
PluginSubcommand::Remove(args) => {
let overrides = config_overrides
.parse_overrides()
@@ -2540,6 +2546,21 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_reload_parses_under_plugin() {
let cli = MultitoolCli::try_parse_from(["codex", "plugin", "reload"]).expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugins_still_parses_as_prompt_text() {
let cli = MultitoolCli::try_parse_from(["codex", "plugins"]).expect("parse");
assert!(cli.subcommand.is_none());
assert_eq!(cli.interactive.prompt.as_deref(), Some("plugins"));
}
#[test]
fn update_parses_as_update_subcommand() {
let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse");
+34 -1
View File
@@ -1,6 +1,5 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
@@ -12,6 +11,7 @@ use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace::MarketplaceListError;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_core_skills::SkillsManager;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
@@ -43,6 +43,9 @@ pub enum PluginSubcommand {
/// Add, list, upgrade, or remove configured plugin marketplaces.
Marketplace(MarketplaceCli),
/// Reload Codex's local installed-plugin state from disk/cache and refresh plugin-backed runtime state.
Reload(ReloadPluginsArgs),
/// Remove an installed plugin from local config and cache.
///
/// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with
@@ -91,6 +94,14 @@ pub struct RemovePluginArgs {
marketplace_name: Option<String>,
}
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin reload",
about = "Reload Codex's local installed-plugin state from marketplaces",
after_help = "What this does:\n Clears Codex's local plugin and skill caches so the next local load re-scans installed plugin state from disk/cache.\n\nWhat happens next:\n Updated plugin context is picked up in new CLI sessions and other fresh loads.\n Existing app-server-backed threads are not refreshed.\n Plugin instructions and skills already loaded into an existing thread are not reread."
)]
pub struct ReloadPluginsArgs;
pub async fn run_plugin_add(
overrides: Vec<(String, toml::Value)>,
args: AddPluginArgs,
@@ -203,6 +214,28 @@ pub async fn run_plugin_remove(
Ok(())
}
pub async fn run_plugin_reload(
overrides: Vec<(String, toml::Value)>,
_: ReloadPluginsArgs,
) -> Result<()> {
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let plugins_manager = PluginsManager::new(codex_home.to_path_buf());
let skills_manager = SkillsManager::new(codex_home, config.bundled_skills_enabled());
plugins_manager.clear_cache();
skills_manager.clear_cache();
println!("Cleared Codex's local plugin and skill caches.");
println!("Updated plugin context will be picked up in new CLI sessions and other fresh loads.");
println!("Existing app-server-backed threads are not refreshed.");
println!("Plugin instructions and skills already loaded into an existing thread stay as-is.");
Ok(())
}
struct PluginCommandContext {
codex_home: PathBuf,
plugins_input: PluginsConfigInput,
+26 -1
View File
@@ -2,7 +2,6 @@ use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
@@ -293,6 +292,32 @@ async fn plugin_remove_works_after_marketplace_is_removed() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_reload_command_help_uses_singular_plugin_name() -> Result<()> {
let codex_home = TempDir::new()?;
codex_command(codex_home.path())?
.args(["plugin", "reload", "--help"])
.assert()
.success()
.stdout(contains("Usage: codex plugin reload"))
.stdout(contains(
"Reload Codex's local installed-plugin state from marketplaces",
))
.stdout(contains("Clears Codex's local plugin and skill caches"))
.stdout(contains(
"Updated plugin context is picked up in new CLI sessions and other fresh loads",
))
.stdout(contains(
"Existing app-server-backed threads are not refreshed",
))
.stdout(contains(
"Plugin instructions and skills already loaded into an existing thread are not reread",
));
Ok(())
}
#[tokio::test]
async fn plugin_add_rejects_cached_plugins_without_authorizing_marketplace_snapshot() -> Result<()>
{
+4
View File
@@ -414,6 +414,10 @@ impl Session {
self.services.agent_control.session_id()
}
pub(crate) fn plugins_manager(&self) -> Arc<PluginsManager> {
Arc::clone(&self.services.plugins_manager)
}
#[instrument(name = "session_init", level = "info", skip_all)]
#[allow(clippy::too_many_arguments)]
#[expect(
+3
View File
@@ -15,6 +15,8 @@ pub(crate) mod multi_agents_spec;
pub(crate) mod multi_agents_v2;
mod plan;
pub(crate) mod plan_spec;
mod reload_plugins;
pub(crate) mod reload_plugins_spec;
mod request_permissions;
mod request_plugin_install;
pub(crate) mod request_plugin_install_spec;
@@ -59,6 +61,7 @@ pub use mcp_resource::ListMcpResourceTemplatesHandler;
pub use mcp_resource::ListMcpResourcesHandler;
pub use mcp_resource::ReadMcpResourceHandler;
pub use plan::PlanHandler;
pub use reload_plugins::ReloadPluginsHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
+10
View File
@@ -16,6 +16,7 @@ use crate::tools::handlers::ListMcpResourcesHandler;
use crate::tools::handlers::McpHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadMcpResourceHandler;
use crate::tools::handlers::ReloadPluginsHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
@@ -45,6 +46,7 @@ use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHand
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::tools::handlers::reload_plugins_spec::RELOAD_PLUGINS_TOOL_NAME;
use crate::tools::handlers::view_image_spec::ViewImageToolOptions;
use crate::tools::hosted_spec::WebSearchToolOptions;
use crate::tools::hosted_spec::create_image_generation_tool;
@@ -435,6 +437,14 @@ fn collect_tool_executors(
executors.push(Arc::new(TestSyncHandler));
}
if config
.experimental_supported_tools
.iter()
.any(|tool| tool == RELOAD_PLUGINS_TOOL_NAME)
{
executors.push(Arc::new(ReloadPluginsHandler));
}
if config.environment_mode.has_environment() {
let include_environment_id =
matches!(config.environment_mode, ToolEnvironmentMode::Multiple);
@@ -14,6 +14,7 @@ use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1;
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2;
use crate::tools::handlers::plan_spec::create_update_plan_tool;
use crate::tools::handlers::reload_plugins_spec::RELOAD_PLUGINS_TOOL_NAME;
use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME;
use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool;
use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description;
@@ -1392,6 +1393,36 @@ fn test_test_model_info_includes_sync_tool() {
assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool"));
}
#[test]
fn test_model_info_includes_reload_plugins_tool() {
let mut model_info = model_info();
model_info.experimental_supported_tools = vec![RELOAD_PLUGINS_TOOL_NAME.to_string()];
let features = Features::with_defaults();
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[],
);
assert!(
tools
.iter()
.any(|tool| tool.name() == RELOAD_PLUGINS_TOOL_NAME)
);
}
#[test]
fn test_build_specs_mcp_tools_converted() {
let model_info = model_info();