diff --git a/assets/icons/puzzle.svg b/assets/icons/puzzle.svg new file mode 100644 index 000000000..bc7ea8e9e --- /dev/null +++ b/assets/icons/puzzle.svg @@ -0,0 +1,13 @@ + + + diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index 2c18052d3..77652d749 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -1,6 +1,12 @@ //! Headless end-to-end probe for provider clients. //! //! Catalog mode: `probe --list-models [--binary ]`. +//! Plugin mode: `probe plugins [--home ] [--project ] [ …]` +//! lists the native plugin catalog after running the optional operation: +//! `install|update [--accept ]`, `uninstall|enable|disable `, +//! `add-marketplace ` or `remove-marketplace `. `--home` isolates +//! the provider's native state (`CODEX_HOME` for Codex; for Claude Code `HOME` +//! and `CLAUDE_CONFIG_DIR`). //! Turn mode: `probe [cwd] [approval] [acp-command args…] [flags]`. //! Flags are `--binary `, `--model `, `--mode plan`, `--effort `, //! `--resume `, `--fork`, `--leave-questions` (user-input requests @@ -16,8 +22,9 @@ use std::time::{Duration, Instant}; use agent::{ AcpAgent, AcpLaunch, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, InteractionMode, - ItemContent, McpRegistration, OptionSelection, ProviderKind, ResumeCursor, SessionCommand, - SessionOptions, TurnOptions, TurnStatus, list_models, start_session, + ItemContent, LaunchEnv, McpRegistration, OptionSelection, PluginContext, PluginOp, PluginScope, + ProviderKind, ResumeCursor, SessionCommand, SessionOptions, TurnOptions, TurnStatus, + list_models, list_plugins, run_plugin_op, start_session, }; use base64::Engine as _; @@ -41,9 +48,107 @@ fn usage() -> ! { eprintln!( " probe --list-models [--binary ]" ); + eprintln!(" probe plugins [--home ] [--project ] [ …]"); std::process::exit(2); } +fn parse_scope(arg: Option) -> PluginScope { + match arg.as_deref() { + Some("user") => PluginScope::User, + Some("project") => PluginScope::Project, + Some("local") => PluginScope::Local, + Some("managed") => PluginScope::Managed, + _ => usage(), + } +} + +fn plugins(mut args: impl Iterator) -> i32 { + let provider = parse_provider(args.next().as_deref()); + let mut home = None; + let mut project = None; + let mut accept = None; + let mut positional = Vec::new(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--home" => home = args.next().map(PathBuf::from), + "--project" => project = args.next().map(PathBuf::from), + "--accept" => accept = args.next(), + _ => positional.push(arg), + } + } + let mut env = Vec::new(); + if let (ProviderKind::ClaudeCode, Some(home)) = (provider, &home) { + env.push(( + "CLAUDE_CONFIG_DIR".to_string(), + home.join(".claude").display().to_string(), + )); + } + let context = PluginContext { + binary_path: None, + launch_env: LaunchEnv { + env, + home: home.clone(), + }, + cwd: project + .clone() + .or(home) + .unwrap_or_else(|| std::env::current_dir().unwrap()), + project: project.is_some(), + }; + let mut positional = positional.into_iter(); + let op = positional.next().map(|verb| { + let mut id = || positional.next().unwrap_or_else(|| usage()); + match verb.as_str() { + "install" => PluginOp::Install { + id: id(), + scope: parse_scope(positional.next()), + accept_command: accept.clone(), + }, + "update" => PluginOp::Update { + id: id(), + scope: parse_scope(positional.next()), + accept_command: accept.clone(), + }, + "uninstall" => PluginOp::Uninstall { + id: id(), + scope: parse_scope(positional.next()), + }, + "enable" | "disable" => PluginOp::SetEnabled { + id: id(), + scope: parse_scope(positional.next()), + enabled: verb == "enable", + }, + "add-marketplace" => PluginOp::AddMarketplace { source: id() }, + "remove-marketplace" => PluginOp::RemoveMarketplace { name: id() }, + _ => usage(), + } + }); + smol::block_on(async move { + if let Some(op) = op { + eprintln!("probe: {op:?} in {}", context.cwd.display()); + match run_plugin_op(provider, &context, &op).await { + Ok(outcome) => println!("outcome: {outcome:#?}"), + Err(error) => println!("outcome: error: {error}"), + } + } + match list_plugins(provider, &context).await { + Ok(listing) => { + let listing = serde_json::json!({ + "marketplaces": listing.marketplaces, + "marketplace_actions": listing.marketplace_actions, + "entries": listing.entries, + }); + println!("{}", serde_json::to_string_pretty(&listing).unwrap()); + 0 + } + Err(error) => { + eprintln!("list_plugins failed: {error}"); + 1 + } + } + }) +} + fn parse_provider(arg: Option<&str>) -> ProviderKind { match arg { Some("codex") => ProviderKind::Codex, @@ -99,6 +204,9 @@ fn image_attachment(path: PathBuf) -> Attachment { fn main() { env_logger::init(); let args: Vec = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("plugins") { + std::process::exit(plugins(args.into_iter().skip(1))); + } if args.first().map(String::as_str) == Some("--list-models") { let provider = parse_provider(args.get(1).map(String::as_str)); let binary = match args.get(2).map(String::as_str) { diff --git a/crates/agent/src/claude_plugins.rs b/crates/agent/src/claude_plugins.rs new file mode 100644 index 000000000..4193d34ca --- /dev/null +++ b/crates/agent/src/claude_plugins.rs @@ -0,0 +1,1130 @@ +//! Claude Code's native plugin management, driven through `claude plugin …` +//! with one bounded child process per command. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; + +use crate::{ + AgentError, CommandAcceptance, DeclaredComponents, MarketplaceAction, PluginAction, + PluginContext, PluginInstallation, PluginListing, PluginOp, PluginOpOutcome, PluginScope, + PluginSource, PluginSourceKind, ProviderKind, ProviderPluginEntry, ProviderPluginMarketplace, + Tri, native_message, +}; + +const LIST_TIMEOUT: Duration = Duration::from_secs(60); +/// Installs and marketplace adds may clone repositories. +const MUTATION_TIMEOUT: Duration = Duration::from_secs(300); +const DETAILS_CONCURRENCY: usize = 4; +const OFFICIAL_MARKETPLACE: &str = "claude-plugins-official"; +const OFFICIAL_MARKETPLACE_REPO: &str = "anthropics/claude-plugins-official"; + +/// `CLAUDECODE` and `CLAUDE_CODE_CHILD_SESSION` make the CLI ignore +/// `--accept-command` ("ignored inside a Claude Code session"), and Tcode is +/// often started from a shell that Claude Code spawned. `CLAUDE_CODE_ENTRYPOINT` +/// is stripped for the same nesting reason as sessions. +const NESTED_SESSION_VARS: [&str; 3] = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_CHILD_SESSION", +]; + +pub(crate) async fn list(context: &PluginContext) -> Result { + let (plugins, marketplaces) = smol::future::zip( + run_cli( + context, + &args(&["plugin", "list", "--json", "--available"]), + LIST_TIMEOUT, + ), + run_cli( + context, + &args(&["plugin", "marketplace", "list", "--json"]), + LIST_TIMEOUT, + ), + ) + .await; + let plugins = parse_listed(&succeeded(plugins?)?)?; + let marketplaces = parse_marketplaces(&succeeded(marketplaces?)?)?; + let in_context = InContext::new(context); + let mut listing = listing(plugins, marketplaces, &in_context); + + let installed: Vec = listing + .entries + .iter() + .filter(|entry| !entry.installations.is_empty()) + .map(|entry| entry.id.clone()) + .collect(); + let mut details = HashMap::new(); + for chunk in installed.chunks(DETAILS_CONCURRENCY) { + let tasks: Vec<_> = chunk + .iter() + .map(|id| { + let context = context.clone(); + let id = id.clone(); + smol::spawn(async move { + let output = + run_cli(&context, &args(&["plugin", "details", &id]), LIST_TIMEOUT) + .await + .and_then(succeeded); + (id, output) + }) + }) + .collect(); + for task in tasks { + let (id, output) = task.await; + details.insert(id, output); + } + } + for entry in &mut listing.entries { + match details.remove(&entry.id) { + Some(Ok(text)) => apply_details(entry, &text), + Some(Err(error)) => entry + .diagnostics + .push(("details_error".into(), native_message(&error))), + None => {} + } + } + Ok(listing) +} + +pub(crate) async fn run( + context: &PluginContext, + op: &PluginOp, +) -> Result { + let output = run_cli(context, &op_args(op)?, MUTATION_TIMEOUT).await?; + match op { + PluginOp::AddMarketplace { .. } | PluginOp::RemoveMarketplace { .. } => { + let stdout = succeeded(output)?; + Ok(PluginOpOutcome::Done { + diagnostics: vec![("message".into(), stdout.trim().to_string())], + }) + } + _ => json_outcome(&output), + } +} + +fn args(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_string()).collect() +} + +/// The argv for one mutation. `-y` is never passed: a marketplace-declared +/// command runs only with the `--accept-command` hash a person accepted. +/// Every plugin mutation names its scope, because the CLI's default +/// auto-detects one from the working directory. +fn op_args(op: &PluginOp) -> Result, AgentError> { + let args = match op { + PluginOp::Install { + id, + scope, + accept_command, + } + | PluginOp::Update { + id, + scope, + accept_command, + } => { + let verb = if matches!(op, PluginOp::Install { .. }) { + "install" + } else { + "update" + }; + let mut args = plugin_args(verb, id, *scope)?; + if let Some(sha256) = accept_command { + args.extend(["--accept-command".into(), operand(sha256)?]); + } + args + } + PluginOp::Uninstall { id, scope } => plugin_args("uninstall", id, *scope)?, + PluginOp::SetEnabled { id, scope, enabled } => { + plugin_args(if *enabled { "enable" } else { "disable" }, id, *scope)? + } + PluginOp::AddMarketplace { source } => vec![ + "plugin".into(), + "marketplace".into(), + "add".into(), + operand(source)?, + "--scope".into(), + "user".into(), + ], + PluginOp::RemoveMarketplace { name } => vec![ + "plugin".into(), + "marketplace".into(), + "remove".into(), + operand(name)?, + ], + }; + Ok(args) +} + +fn plugin_args(verb: &str, id: &str, scope: PluginScope) -> Result, AgentError> { + Ok(vec![ + "plugin".into(), + verb.into(), + operand(id)?, + "--scope".into(), + scope_arg(scope)?.into(), + "--json".into(), + ]) +} + +/// A value placed in a positional slot must not be read as an option. +fn operand(value: &str) -> Result { + if value.is_empty() || value.starts_with('-') { + return Err(AgentError::Protocol(format!( + "refusing to pass {value:?} to `claude plugin`" + ))); + } + Ok(value.to_string()) +} + +fn scope_arg(scope: PluginScope) -> Result<&'static str, AgentError> { + match scope { + PluginScope::User => Ok("user"), + PluginScope::Project => Ok("project"), + PluginScope::Local => Ok("local"), + PluginScope::Managed => Ok("managed"), + PluginScope::Session => Err(AgentError::Protocol( + "Claude Code session plugins have no management command".into(), + )), + } +} + +fn scope_from_native(scope: &str) -> Option { + match scope { + "user" => Some(PluginScope::User), + "project" => Some(PluginScope::Project), + "local" => Some(PluginScope::Local), + "managed" => Some(PluginScope::Managed), + _ => None, + } +} + +fn management_command( + context: &PluginContext, + args: &[String], +) -> Result { + let binary = crate::resolve_binary(context.binary_path.as_deref(), "claude")?; + let mut command = crate::process::command(binary); + command.args(args).current_dir(&context.cwd); + for var in NESTED_SESSION_VARS { + command.env_remove(var); + } + for (key, value) in context.launch_env.pairs(ProviderKind::ClaudeCode) { + command.env(key, value); + } + Ok(command) +} + +struct CliOutput { + success: bool, + status: String, + stdout: String, + stderr: String, +} + +async fn run_cli( + context: &PluginContext, + args: &[String], + timeout: Duration, +) -> Result { + let display = format!("claude {}", args.join(" ")); + let mut command = smol::process::Command::from(management_command(context, args)?); + // `output()` gives the child a null stdin, so a prompt can never wait on + // input, and dropping it on timeout kills the child. + command.kill_on_drop(true); + let output = smol::future::or(async { Some(command.output().await) }, async { + smol::Timer::after(timeout).await; + None + }) + .await + .ok_or_else(|| AgentError::Provider(format!("`{display}` timed out")))? + .map_err(|error| AgentError::Spawn(format!("spawning `{display}`: {error}")))?; + Ok(CliOutput { + success: output.status.success(), + status: output.status.to_string(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn succeeded(output: CliOutput) -> Result { + if output.success { + return Ok(output.stdout); + } + let message = [output.stderr.trim(), output.stdout.trim()] + .into_iter() + .find(|text| !text.is_empty()) + .map(str::to_string) + .unwrap_or(output.status); + Err(AgentError::Provider(message)) +} + +/// Mutations print one JSON result on the last stdout line; command-source +/// installs print the command and its mode as human text before it. +fn last_json_line(stdout: &str) -> Result<(Value, String), AgentError> { + let mut lines: Vec<&str> = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + let last = lines + .pop() + .ok_or_else(|| AgentError::Protocol("`claude plugin` printed no result".into()))?; + let value = serde_json::from_str::(last.trim()) + .ok() + .filter(Value::is_object) + .ok_or_else(|| { + AgentError::Protocol(format!( + "`claude plugin` did not end with a JSON result: {last}" + )) + })?; + Ok((value, lines.join("\n"))) +} + +fn json_outcome(output: &CliOutput) -> Result { + let (result, preamble) = match last_json_line(&output.stdout) { + Ok(parsed) => parsed, + Err(error) if output.success => return Err(error), + Err(_) => { + let text = [output.stderr.trim(), output.stdout.trim()] + .into_iter() + .find(|text| !text.is_empty()) + .unwrap_or(&output.status); + return Err(AgentError::Provider(text.to_string())); + } + }; + let text = |key: &str| result.get(key).and_then(Value::as_str).map(str::to_string); + let message = text("message"); + if text("outcome").as_deref() == Some("ok") { + let mut diagnostics = Vec::new(); + if result.get("command").and_then(Value::as_str) == Some("update") { + for (key, native) in [ + ("update_outcome", "updateOutcome"), + ("old_version", "oldVersion"), + ("new_version", "newVersion"), + ("message", "message"), + ] { + if let Some(value) = text(native) { + diagnostics.push((key.to_string(), value)); + } + } + } + return Ok(PluginOpOutcome::Done { diagnostics }); + } + if text("failureCode").as_deref() == Some("command_source_refused") { + let shown = result.get("shownCommand"); + let shown_text = |key: &str| { + shown + .and_then(|shown| shown.get(key)) + .and_then(Value::as_str) + }; + if let (Some(command), Some(sha256)) = (shown_text("command"), shown_text("sha256")) { + let native_text = if preamble.trim().is_empty() { + message.unwrap_or_default() + } else { + preamble + }; + return Ok(PluginOpOutcome::AcceptCommand(CommandAcceptance { + command: command.to_string(), + sha256: sha256.to_string(), + mode: shown_text("mode").map(str::to_string), + native_text, + })); + } + } + Err(AgentError::Provider( + message + .or_else(|| text("failureCode")) + .unwrap_or_else(|| result.to_string()), + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListedInstallation { + id: String, + #[serde(default)] + version: Option, + scope: String, + #[serde(default)] + enabled: Option, + #[serde(default)] + install_path: Option, + #[serde(default)] + project_path: Option, + #[serde(default)] + mcp_servers: Option>, + #[serde(default)] + project_enabled: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListedAvailable { + plugin_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + marketplace_name: Option, + #[serde(default)] + version: Option, + #[serde(default)] + source: Value, +} + +#[derive(Deserialize)] +struct Listed { + #[serde(default)] + installed: Vec, + #[serde(default)] + available: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListedMarketplace { + name: String, + #[serde(default)] + source: Option, + #[serde(default)] + repo: Option, + #[serde(default)] + url: Option, + #[serde(default)] + path: Option, + #[serde(default)] + install_location: Option, +} + +fn parse_listed(stdout: &str) -> Result { + serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!("unexpected `claude plugin list` output: {error}")) + }) +} + +fn parse_marketplaces(stdout: &str) -> Result, AgentError> { + let listed: Vec = serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!( + "unexpected `claude plugin marketplace list` output: {error}" + )) + })?; + Ok(listed + .into_iter() + .map(|marketplace| { + let native = marketplace.source.as_deref().unwrap_or_default(); + let kind = if marketplace.name == OFFICIAL_MARKETPLACE + || marketplace.repo.as_deref() == Some(OFFICIAL_MARKETPLACE_REPO) + { + PluginSourceKind::Official + } else { + match native { + "directory" | "file" => PluginSourceKind::LocalPath, + "github" | "git" => PluginSourceKind::Git, + "url" => PluginSourceKind::ThirdParty, + "npm" => PluginSourceKind::Npm, + _ => PluginSourceKind::Unknown, + } + }; + ProviderPluginMarketplace { + source: marketplace + .repo + .or(marketplace.url) + .or(marketplace.path) + .unwrap_or_else(|| native.to_string()), + name: marketplace.name, + kind, + location: marketplace.install_location, + } + }) + .collect()) +} + +/// Which installations the context directory's scopes address. `list` is +/// home-wide, so a project installation of another project is reported too, +/// but `--scope project` would act on this directory's project instead. +struct InContext { + project: Option, +} + +impl InContext { + fn new(context: &PluginContext) -> Self { + Self { + project: context.project.then(|| canonical(&context.cwd)), + } + } + + fn scopes(&self) -> &'static [PluginScope] { + if self.project.is_some() { + &[PluginScope::User, PluginScope::Project, PluginScope::Local] + } else { + &[PluginScope::User] + } + } + + fn contains(&self, scope: PluginScope, project_path: Option<&Path>) -> bool { + match scope { + PluginScope::User | PluginScope::Managed => true, + PluginScope::Project | PluginScope::Local => match (&self.project, project_path) { + (Some(project), Some(path)) => canonical(path) == *project, + _ => false, + }, + PluginScope::Session => false, + } + } +} + +fn canonical(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn listing( + listed: Listed, + marketplaces: Vec, + in_context: &InContext, +) -> PluginListing { + let marketplace_kind = |name: Option<&str>| { + name.and_then(|name| marketplaces.iter().find(|m| m.name == name)) + .map(|m| m.kind) + .unwrap_or(PluginSourceKind::Unknown) + }; + let mut entries: Vec = Vec::new(); + // Per entry: the in-context installed scopes. + let mut here: Vec> = Vec::new(); + for installed in listed.installed { + let Some(scope) = scope_from_native(&installed.scope) else { + log::warn!( + "claude plugin list: unknown scope {:?} for {}", + installed.scope, + installed.id + ); + continue; + }; + let in_this_context = in_context.contains(scope, installed.project_path.as_deref()); + let index = match entries.iter().position(|entry| entry.id == installed.id) { + Some(index) => index, + None => { + let (name, marketplace) = split_id(&installed.id); + entries.push(ProviderPluginEntry { + id: installed.id.clone(), + name, + version: None, + description: None, + // Installed entries carry no source; only an + // available listing of the same id names it. + source: PluginSource { + kind: PluginSourceKind::Unknown, + marketplace, + }, + installations: Vec::new(), + enabled: installed.enabled.map(Tri::from).unwrap_or_default(), + declared: Some(DeclaredComponents { + mcp_servers: Some(Vec::new()), + ..DeclaredComponents::default() + }), + errors: Vec::new(), + actions: Vec::new(), + diagnostics: Vec::new(), + }); + here.push(Vec::new()); + entries.len() - 1 + } + }; + let entry = &mut entries[index]; + if let (Some(servers), Some(declared)) = (&installed.mcp_servers, &mut entry.declared) { + let names = declared.mcp_servers.get_or_insert_with(Vec::new); + for name in servers.keys() { + if !names.contains(name) { + names.push(name.clone()); + } + } + } + entry.installations.push(PluginInstallation { + scope, + location: installed.install_path, + version: installed.version, + scope_enabled: (scope == PluginScope::Project && in_this_context) + .then_some(installed.project_enabled) + .flatten(), + }); + if in_this_context { + here[index].push(scope); + } + } + for entry in &mut entries { + let first = entry.installations[0].version.clone(); + if entry + .installations + .iter() + .all(|installation| installation.version == first) + { + entry.version = first; + } + } + for available in listed.available { + let kind = source_kind( + &available.source, + marketplace_kind(available.marketplace_name.as_deref()), + ); + if let Some(entry) = entries.iter_mut().find(|e| e.id == available.plugin_id) { + entry.description = available.description; + entry.source.kind = kind; + continue; + } + entries.push(ProviderPluginEntry { + id: available.plugin_id, + name: available.name, + version: available.version, + description: available.description, + source: PluginSource { + marketplace: available.marketplace_name, + kind, + }, + installations: Vec::new(), + enabled: Tri::Unknown, + declared: None, + errors: Vec::new(), + actions: Vec::new(), + diagnostics: Vec::new(), + }); + here.push(Vec::new()); + } + for (entry, here) in entries.iter_mut().zip(&here) { + entry.actions = actions(entry.enabled, here, in_context); + } + + let mut marketplace_actions = vec![MarketplaceAction::Add]; + for marketplace in &marketplaces { + marketplace_actions.push(MarketplaceAction::Remove { + marketplace: marketplace.name.clone(), + uninstalls: entries + .iter() + .filter(|entry| { + !entry.installations.is_empty() + && entry.source.marketplace.as_deref() == Some(marketplace.name.as_str()) + }) + .map(|entry| entry.id.clone()) + .collect(), + }); + } + PluginListing { + entries, + marketplaces, + marketplace_actions, + errors: Vec::new(), + } +} + +fn split_id(id: &str) -> (String, Option) { + match id.rsplit_once('@') { + Some((name, marketplace)) if !name.is_empty() => { + (name.to_string(), Some(marketplace.to_string())) + } + _ => (id.to_string(), None), + } +} + +fn source_kind(source: &Value, marketplace: PluginSourceKind) -> PluginSourceKind { + match source { + Value::String(_) => match marketplace { + PluginSourceKind::Official | PluginSourceKind::LocalPath => marketplace, + PluginSourceKind::Unknown => PluginSourceKind::Unknown, + _ => PluginSourceKind::ThirdParty, + }, + Value::Object(object) => match object.get("source").and_then(Value::as_str) { + Some("command") => PluginSourceKind::Command, + Some("github" | "git" | "git-subdir" | "url") => PluginSourceKind::Git, + Some("npm") => PluginSourceKind::Npm, + Some("directory" | "file") => PluginSourceKind::LocalPath, + _ => PluginSourceKind::Unknown, + }, + _ => PluginSourceKind::Unknown, + } +} + +/// Native operations offered for one entry in this context. Enablement is +/// reported only as the effective value, so both directions are offered +/// when it is unknown. +fn actions(enabled: Tri, here: &[PluginScope], in_context: &InContext) -> Vec { + let mut actions = Vec::new(); + for &scope in in_context.scopes() { + if !here.contains(&scope) { + actions.push(PluginAction::Install { scope }); + } + } + for &scope in here { + actions.push(PluginAction::Update { scope }); + if scope != PluginScope::Managed { + actions.push(PluginAction::Uninstall { scope }); + } + } + if !here.is_empty() { + for &scope in in_context.scopes() { + if enabled != Tri::Yes { + actions.push(PluginAction::Enable { scope }); + } + if enabled != Tri::No { + actions.push(PluginAction::Disable { scope }); + } + } + } + actions +} + +/// `plugin details` has no `--json`; its "Component inventory" section lists +/// `Label (count) name, name (note)` rows. +fn apply_details(entry: &mut ProviderPluginEntry, text: &str) { + let mut complete = true; + let mut declared = entry.declared.clone().unwrap_or_default(); + let mut in_inventory = false; + for line in text.lines() { + let trimmed = line.trim(); + if let Some(description) = trimmed.strip_prefix("Description:") { + if entry.description.is_none() { + entry.description = Some(description.trim().to_string()); + } + continue; + } + if trimmed == "Component inventory" { + in_inventory = true; + continue; + } + if !in_inventory { + continue; + } + if trimmed.is_empty() { + break; + } + let field = match trimmed.split(" (").next() { + Some("Skills") => &mut declared.skills, + Some("Agents") => &mut declared.agents, + Some("Hooks") => &mut declared.hooks, + Some("MCP servers") => &mut declared.mcp_servers, + Some("LSP servers") => &mut declared.lsp_servers, + _ => { + complete = false; + continue; + } + }; + match inventory_names(trimmed) { + Some(names) => *field = Some(names), + None => complete = false, + } + } + if !in_inventory { + complete = false; + } + entry.declared = Some(declared); + if !complete { + entry + .diagnostics + .push(("details".into(), text.trim_end().to_string())); + } +} + +fn inventory_names(row: &str) -> Option> { + let (_, rest) = row.split_once(" (")?; + let (count, rest) = rest.split_once(')')?; + let count: usize = count.parse().ok()?; + let names = rest.trim().split(" (").next().unwrap_or_default(); + let names: Vec = names + .split(", ") + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(); + (names.len() == count).then_some(names) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> String { + let path = format!( + "{}/tests/fixtures/plugins/claude/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{path}: {error}")) + } + + fn output(stdout: &str, success: bool) -> CliOutput { + CliOutput { + success, + status: if success { + "exit status: 0" + } else { + "exit status: 1" + } + .into(), + stdout: stdout.into(), + stderr: String::new(), + } + } + + fn context(cwd: &Path, project: bool) -> PluginContext { + PluginContext { + binary_path: Some(PathBuf::from("/opt/claude/bin/claude")), + launch_env: crate::LaunchEnv { + env: vec![("CLAUDE_CONFIG_DIR".into(), "/tmp/isolated/.claude".into())], + home: Some(PathBuf::from("/tmp/isolated")), + }, + cwd: cwd.to_path_buf(), + project, + } + } + + /// The probe's project was `/tmp/tcode-probe/proj`, reported as its + /// realpath; a context outside it must not act on its installations. + #[test] + fn listing_groups_installations_by_id_and_offers_scopes_of_the_context_only() { + let listed = parse_listed(&fixture("plugin-list-available-after-install.json")).unwrap(); + let marketplaces = parse_marketplaces(&fixture("marketplace-list.json")).unwrap(); + let project = InContext { + project: Some(PathBuf::from("/private/tmp/tcode-probe/proj")), + }; + let inside = listing(listed, marketplaces.clone(), &project); + let ids: Vec<&str> = inside.entries.iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + ids, + ["alpha@tcode-probe", "beta@tcode-probe", "gamma@tcode-probe"] + ); + + let alpha = &inside.entries[0]; + assert_eq!(alpha.name, "alpha"); + assert_eq!(alpha.version.as_deref(), Some("1.0.0")); + assert_eq!(alpha.enabled, Tri::Yes); + assert_eq!( + alpha.source, + PluginSource { + marketplace: Some("tcode-probe".into()), + kind: PluginSourceKind::Unknown, + } + ); + assert_eq!( + alpha.installations, + [ + PluginInstallation { + scope: PluginScope::User, + location: Some(PathBuf::from( + "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0" + )), + version: Some("1.0.0".into()), + scope_enabled: None, + }, + PluginInstallation { + scope: PluginScope::Project, + location: Some(PathBuf::from( + "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0" + )), + version: Some("1.0.0".into()), + scope_enabled: Some(true), + }, + ] + ); + assert_eq!( + alpha.declared.as_ref().unwrap().mcp_servers.as_deref(), + Some(&["alpha-dead".to_string()][..]) + ); + assert_eq!(alpha.declared.as_ref().unwrap().skills, None); + use PluginScope::*; + assert_eq!( + alpha.actions, + [ + PluginAction::Install { scope: Local }, + PluginAction::Update { scope: User }, + PluginAction::Uninstall { scope: User }, + PluginAction::Update { scope: Project }, + PluginAction::Uninstall { scope: Project }, + PluginAction::Disable { scope: User }, + PluginAction::Disable { scope: Project }, + PluginAction::Disable { scope: Local }, + ] + ); + assert_eq!( + inside.entries[1].declared.as_ref().unwrap().mcp_servers, + Some(Vec::new()) + ); + + let gamma = &inside.entries[2]; + assert!(gamma.installations.is_empty()); + assert_eq!(gamma.version, None); + assert_eq!(gamma.description.as_deref(), Some("Command-source plugin")); + assert_eq!(gamma.source.kind, PluginSourceKind::Command); + assert_eq!(gamma.enabled, Tri::Unknown); + assert_eq!(gamma.declared, None); + assert_eq!( + gamma.actions, + [ + PluginAction::Install { scope: User }, + PluginAction::Install { scope: Project }, + PluginAction::Install { scope: Local }, + ] + ); + assert_eq!( + inside.marketplace_actions, + [ + MarketplaceAction::Add, + MarketplaceAction::Remove { + marketplace: "tcode-probe".into(), + uninstalls: vec!["alpha@tcode-probe".into(), "beta@tcode-probe".into()], + }, + ] + ); + + let listed = parse_listed(&fixture("plugin-list-available-after-install.json")).unwrap(); + let outside = listing(listed, marketplaces, &InContext { project: None }); + let alpha = &outside.entries[0]; + assert_eq!(alpha.installations[1].scope_enabled, None); + assert_eq!( + alpha.actions, + [ + PluginAction::Update { scope: User }, + PluginAction::Uninstall { scope: User }, + PluginAction::Disable { scope: User }, + ] + ); + let beta = &outside.entries[1]; + assert_eq!(beta.actions, [PluginAction::Install { scope: User }]); + } + + #[test] + fn marketplaces_keep_their_native_source_and_location() { + assert_eq!( + parse_marketplaces(&fixture("real-home-marketplace-list.json")).unwrap(), + [ProviderPluginMarketplace { + name: "claude-plugins-official".into(), + source: "anthropics/claude-plugins-official".into(), + kind: PluginSourceKind::Official, + location: Some(PathBuf::from( + "/Users/tryanks/.claude/plugins/marketplaces/claude-plugins-official" + )), + }] + ); + } + + #[test] + fn details_inventory_becomes_declared_components() { + let listed = parse_listed(&fixture("plugin-list-available-after-install.json")).unwrap(); + let mut alpha = listing(listed, Vec::new(), &InContext { project: None }) + .entries + .remove(0); + alpha.description = None; + apply_details(&mut alpha, &fixture("details.txt")); + assert_eq!( + alpha.description.as_deref(), + Some("Skill, command, SessionStart hook and an unreachable HTTP MCP server") + ); + let names = |names: &[&str]| Some(names.iter().map(|n| n.to_string()).collect()); + assert_eq!( + alpha.declared, + Some(DeclaredComponents { + skills: names(&["greet", "hello"]), + agents: names(&[]), + hooks: names(&["SessionStart"]), + mcp_servers: names(&["alpha-dead"]), + untrusted_hooks: None, + lsp_servers: names(&[]), + apps: None, + }) + ); + assert!(alpha.diagnostics.is_empty()); + + apply_details(&mut alpha, "alpha 1.0.0\n Source: alpha@tcode-probe\n"); + assert_eq!( + alpha.diagnostics, + [( + "details".to_string(), + "alpha 1.0.0\n Source: alpha@tcode-probe".to_string() + )] + ); + } + + #[test] + fn command_source_refusal_becomes_a_challenge_from_the_last_json_line() { + let challenge = json_outcome(&output( + &fixture("install-command-source-challenge.txt"), + false, + )) + .unwrap(); + let PluginOpOutcome::AcceptCommand(acceptance) = challenge else { + panic!("expected a challenge, got {challenge:?}"); + }; + assert_eq!(acceptance.command, "echo /tmp/tcode-probe/gamma-src"); + assert_eq!( + acceptance.sha256, + "b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269" + ); + assert_eq!(acceptance.mode.as_deref(), Some("copy")); + assert!( + acceptance + .native_text + .starts_with("\"gamma\" is installed by running a command"), + "{}", + acceptance.native_text + ); + + // The preamble is printed on success too; the result is still the last line. + assert_eq!( + json_outcome(&output( + &fixture("install-command-source-accepted.json"), + true + )) + .unwrap(), + PluginOpOutcome::Done { + diagnostics: Vec::new() + } + ); + // A hash that no longer names the shown command is shown again. + let PluginOpOutcome::AcceptCommand(again) = json_outcome(&output( + &fixture("install-command-source-wrong-hash.json"), + false, + )) + .unwrap() else { + panic!("a mismatched hash must surface the command again"); + }; + assert!( + again + .native_text + .contains("does not name the command shown above") + ); + + // Only the last line counts: a JSON line followed by text is not a result. + let trailing = format!("{}\nNot JSON\n", fixture("install-user.json").trim()); + assert!(matches!( + json_outcome(&output(&trailing, true)), + Err(AgentError::Protocol(_)) + )); + } + + #[test] + fn mutation_results_map_native_outcomes() { + for name in [ + "install-user.json", + "enable.json", + "disable.json", + "uninstall.json", + ] { + assert_eq!( + json_outcome(&output(&fixture(name), true)).unwrap(), + PluginOpOutcome::Done { + diagnostics: Vec::new() + }, + "{name}" + ); + } + let PluginOpOutcome::Done { diagnostics } = + json_outcome(&output(&fixture("update.json"), true)).unwrap() + else { + panic!("update must succeed"); + }; + assert_eq!( + diagnostics, + [ + ("update_outcome".to_string(), "updated".to_string()), + ("old_version".to_string(), "1.0.0".to_string()), + ("new_version".to_string(), "1.0.1".to_string()), + ( + "message".to_string(), + "Plugin \"alpha\" updated from 1.0.0 to 1.0.1 for scope project (/private/tmp/tcode-probe/proj). Restart to apply changes.".to_string() + ), + ] + ); + let failed = r#"{"command":"uninstall","outcome":"failed","plugin":"beta@tcode-probe","scope":"user","message":"Plugin \"beta@tcode-probe\" not found in installed plugins","failureCode":"not_installed"}"#; + assert_eq!( + native_message(&json_outcome(&output(failed, false)).unwrap_err()), + "Plugin \"beta@tcode-probe\" not found in installed plugins" + ); + } + + #[test] + fn management_argv_names_its_scope_never_auto_accepts_and_leaves_the_nested_session() { + let sha = "b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"; + let id = "gamma@tcode-probe".to_string(); + let scope = PluginScope::Project; + let ops = [ + PluginOp::Install { + id: id.clone(), + scope, + accept_command: None, + }, + PluginOp::Install { + id: id.clone(), + scope, + accept_command: Some(sha.into()), + }, + PluginOp::Update { + id: id.clone(), + scope, + accept_command: None, + }, + PluginOp::Update { + id: id.clone(), + scope, + accept_command: Some(sha.into()), + }, + PluginOp::Uninstall { + id: id.clone(), + scope, + }, + PluginOp::SetEnabled { + id: id.clone(), + scope, + enabled: true, + }, + PluginOp::SetEnabled { + id: id.clone(), + scope, + enabled: false, + }, + PluginOp::AddMarketplace { + source: "/tmp/tcode-probe/mkt".into(), + }, + PluginOp::RemoveMarketplace { + name: "tcode-probe".into(), + }, + ]; + let cwd = std::env::temp_dir(); + for op in &ops { + let argv = op_args(op).unwrap(); + assert!( + !argv.iter().any(|arg| arg == "-y" || arg == "--yes"), + "{argv:?}" + ); + let accepted = match op { + PluginOp::Install { accept_command, .. } + | PluginOp::Update { accept_command, .. } => accept_command.as_deref(), + _ => None, + }; + let accept_at = argv.iter().position(|arg| arg == "--accept-command"); + assert_eq!( + accept_at.map(|index| argv[index + 1].as_str()), + accepted, + "{argv:?}" + ); + if !matches!(op, PluginOp::RemoveMarketplace { .. }) { + let scope_at = argv.iter().position(|arg| arg == "--scope").unwrap(); + assert!(["user", "project"].contains(&argv[scope_at + 1].as_str())); + } + + let command = management_command(&context(&cwd, true), &argv).unwrap(); + assert_eq!(command.get_current_dir(), Some(cwd.as_path())); + let envs: Vec<_> = command.get_envs().collect(); + for var in NESTED_SESSION_VARS { + assert!( + envs.contains(&(var.as_ref(), None)), + "{var} must be removed" + ); + } + assert!(envs.contains(&("HOME".as_ref(), Some("/tmp/isolated".as_ref())))); + } + assert!( + op_args(&PluginOp::AddMarketplace { + source: "--help".into() + }) + .is_err() + ); + } +} diff --git a/crates/agent/src/codex.rs b/crates/agent/src/codex.rs index d9ab03938..c9828e7b0 100644 --- a/crates/agent/src/codex.rs +++ b/crates/agent/src/codex.rs @@ -24,6 +24,7 @@ use crate::{ }; mod developer_instructions; +pub(crate) mod plugins; use developer_instructions::{DEFAULT_MODE_INSTRUCTIONS, PLAN_MODE_INSTRUCTIONS}; /// Fallback model slug for `collaborationMode.settings.model` when the session diff --git a/crates/agent/src/codex/plugins.rs b/crates/agent/src/codex/plugins.rs new file mode 100644 index 000000000..a98462e8d --- /dev/null +++ b/crates/agent/src/codex/plugins.rs @@ -0,0 +1,1140 @@ +//! Codex's native plugin management, driven through one temporary +//! `codex app-server` per listing or mutation. +//! +//! Every discovery request names the context directory in `cwds`: repo +//! marketplaces and the plugins installed from them are found only through +//! `cwds`, and `spawn_server` gives the server no working directory of its own. + +use std::io::BufWriter; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin}; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::{Value, json}; +use smol::channel::Receiver; + +use super::{ + describe_rpc_error, enrich_startup_error, initialize, send_json, settle_child_exit, + spawn_server, stop_child, +}; +use crate::process::{ChildOutput, StderrTail}; +use crate::{ + AgentError, DeclaredComponents, MarketplaceAction, PluginAction, PluginContext, + PluginInstallation, PluginListing, PluginOp, PluginOpOutcome, PluginScope, PluginSource, + PluginSourceKind, ProviderPluginEntry, ProviderPluginMarketplace, Tri, native_message, +}; + +/// Reading a remote plugin takes the server about two seconds. +const LIST_TIMEOUT: Duration = Duration::from_secs(120); +/// Installs and marketplace adds may clone repositories. +const MUTATION_TIMEOUT: Duration = Duration::from_secs(300); +/// `plugin/read` requests answered concurrently by one server. +const DETAILS_CONCURRENCY: usize = 4; +/// Marketplaces Codex itself ships or syncs from OpenAI. +const OFFICIAL_MARKETPLACES: [&str; 5] = [ + "openai-curated", + "openai-curated-remote", + "openai-api-curated", + "openai-bundled", + "openai-primary-runtime", +]; +/// An upstream race in Codex 0.159.3: servers started at the same moment on +/// a `CODEX_HOME` that was never initialized can exit with this error, as when +/// a session and a plugin listing start together on a new shadow home. It did +/// not recur on an initialized home, so one later start succeeds. +const SQLITE_RACE: &str = "failed to initialize sqlite state runtime"; +const SQLITE_RACE_RETRY_DELAY: Duration = Duration::from_millis(500); + +pub(crate) async fn list(context: &PluginContext) -> Result { + with_server(context, LIST_TIMEOUT, async |server| { + read_listing(server, &context.cwd).await + }) + .await +} + +pub(crate) async fn run( + context: &PluginContext, + op: &PluginOp, +) -> Result { + with_server(context, MUTATION_TIMEOUT, async |server| { + let offered = if matches!(op, PluginOp::Install { .. }) { + let listed = server + .request("plugin/list", json!({ "cwds": [context.cwd] })) + .await?; + parse::("plugin/list", listed)?.marketplaces + } else { + Vec::new() + }; + let (method, params) = op_request(op, &offered)?; + let result = server.request(method, params).await?; + Ok(outcome(op, &result)) + }) + .await +} + +async fn with_server( + context: &PluginContext, + timeout: Duration, + work: impl AsyncFnOnce(&mut Server) -> Result, +) -> Result { + let session = async { + let mut server = Server::start(context).await?; + let result = work(&mut server).await; + server.close(result) + }; + // Dropping the session on timeout drops the server, which kills it. + smol::future::or(async { Some(session.await) }, async { + smol::Timer::after(timeout).await; + None + }) + .await + .unwrap_or_else(|| Err(AgentError::Provider("`codex app-server` timed out".into()))) +} + +struct Server { + child: Child, + stdin: Option>, + lines: Receiver, + stderr_tail: StderrTail, + next_id: i64, +} + +impl Server { + async fn start(context: &PluginContext) -> Result { + match Self::start_once(context).await { + Err(AgentError::Protocol(message)) if message.contains(SQLITE_RACE) => { + log::warn!("codex app-server lost the first-start sqlite race; retrying once"); + smol::Timer::after(SQLITE_RACE_RETRY_DELAY).await; + Self::start_once(context).await + } + started => started, + } + } + + async fn start_once(context: &PluginContext) -> Result { + let (child, stdin, lines, stderr_tail) = + spawn_server(context.binary_path.as_deref(), &[], &context.launch_env)?; + let mut server = Self { + child, + stdin: Some(stdin), + lines, + stderr_tail, + next_id: 2, + }; + let stdin = server.stdin.as_mut().expect("the server has not stopped"); + match initialize(stdin, &server.lines).await { + Ok(()) => Ok(server), + Err(error) => server.close(Err(error)), + } + } + + async fn request(&mut self, method: &str, params: Value) -> Result { + self.requests(vec![(method, params)]) + .await? + .pop() + .expect("one reply per request") + } + + /// Sends the whole batch before reading; the server answers concurrently + /// and in any order. The outer error is the connection's, the inner ones + /// are each request's native error. + async fn requests( + &mut self, + batch: Vec<(&str, Value)>, + ) -> Result>, AgentError> { + let first = self.next_id; + let stdin = self.stdin.as_mut().expect("the server has not stopped"); + for (method, params) in &batch { + send_json( + stdin, + &json!({ "id": self.next_id, "method": method, "params": params }), + )?; + self.next_id += 1; + } + let mut replies: Vec>> = + batch.iter().map(|_| None).collect(); + let mut pending = replies.len(); + while pending > 0 { + let line = match self.lines.recv().await { + Ok(ChildOutput::Line(line)) => line, + Ok(ChildOutput::Eof) | Err(_) => { + return Err(AgentError::Protocol("codex exited before answering".into())); + } + Ok(ChildOutput::Error(error)) => return Err(AgentError::Protocol(error)), + }; + let value: Value = serde_json::from_str(&line).map_err(|error| { + AgentError::Protocol(format!("invalid JSON from codex: {error}: {line}")) + })?; + if value.get("method").is_some() { + continue; + } + let Some(slot) = value + .get("id") + .and_then(Value::as_i64) + .and_then(|id| usize::try_from(id - first).ok()) + .and_then(|index| replies.get_mut(index)) + .filter(|slot| slot.is_none()) + else { + continue; + }; + *slot = Some(match value.get("error") { + Some(error) => Err(AgentError::Provider(describe_rpc_error(error))), + None => value.get("result").cloned().ok_or_else(|| { + AgentError::Protocol(format!("response omitted result: {line}")) + }), + }); + pending -= 1; + } + Ok(replies.into_iter().flatten().collect()) + } + + /// A broken connection is explained by the process: its exit status and + /// stderr. Native errors pass through. + fn close(mut self, result: Result) -> Result { + match result { + Err(error @ (AgentError::Protocol(_) | AgentError::Io(_))) => { + let status = settle_child_exit(&mut self.child); + // The stderr tail joins its reader, so the child must be gone. + self.stop(); + Err(enrich_startup_error(error, status, &mut self.stderr_tail)) + } + other => other, + } + } + + fn stop(&mut self) { + if let Some(stdin) = self.stdin.take() { + stop_child(&mut self.child, stdin); + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop(); + } +} + +fn parse(method: &str, value: Value) -> Result { + serde_json::from_value(value) + .map_err(|error| AgentError::Protocol(format!("unexpected `{method}` result: {error}"))) +} + +fn discovery_requests(cwd: &Path) -> Vec<(&'static str, Value)> { + let cwds = json!({ "cwds": [cwd] }); + vec![ + ("plugin/list", cwds.clone()), + ("plugin/installed", cwds.clone()), + // Marketplace sources live in the user layer `marketplace/add` writes. + ("config/read", json!({})), + ("skills/list", cwds.clone()), + ("hooks/list", cwds), + ] +} + +async fn read_listing(server: &mut Server, cwd: &Path) -> Result { + let mut replies = server.requests(discovery_requests(cwd)).await?.into_iter(); + let mut next = || replies.next().expect("one reply per request"); + let listed: Catalog = parse("plugin/list", next()?)?; + let installed: Catalog = parse("plugin/installed", next()?)?; + let config = next()?; + let skills = next()?; + let hooks = next()?; + let catalog = merge(listed, installed); + let mut listing = listing(&catalog, &config, &skills, &hooks); + + let reads = detail_requests(&catalog); + for chunk in reads.chunks(DETAILS_CONCURRENCY) { + let batch = chunk + .iter() + .map(|(_, params)| ("plugin/read", params.clone())) + .collect(); + let replies = server.requests(batch).await?; + for ((id, _), reply) in chunk.iter().zip(replies) { + let Some(entry) = listing.entries.iter_mut().find(|entry| entry.id == *id) else { + continue; + }; + match reply.and_then(|result| parse::("plugin/read", result)) { + Ok(read) => apply_detail(entry, read.plugin), + Err(error) => entry + .diagnostics + .push(("details_error".into(), native_message(&error))), + } + } + } + Ok(listing) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Catalog { + marketplaces: Vec, + #[serde(default)] + marketplace_load_errors: Vec, +} + +#[derive(Deserialize)] +struct Marketplace { + name: String, + /// The marketplace manifest; remote catalogs have none. + #[serde(default)] + path: Option, + #[serde(default)] + plugins: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LoadError { + marketplace_path: String, + message: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Summary { + id: String, + name: String, + #[serde(default)] + version: Option, + /// The manifest version of a local plugin, whose `version` is null. + #[serde(default)] + local_version: Option, + #[serde(default)] + source: Value, + installed: bool, + enabled: bool, + #[serde(default)] + install_policy: Option, + #[serde(default)] + auth_policy: Option, + #[serde(default)] + availability: Option, + #[serde(default)] + disabled_reason: Option, + #[serde(default)] + interface: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Interface { + #[serde(default)] + short_description: Option, +} + +#[derive(Deserialize)] +struct Read { + plugin: Detail, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Detail { + #[serde(default)] + description: Option, + #[serde(default)] + skills: Vec, + #[serde(default)] + hooks: Vec, + #[serde(default)] + mcp_servers: Vec, + #[serde(default)] + apps: Vec, +} + +#[derive(Deserialize)] +struct Named { + name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeclaredHook { + event_name: String, +} + +/// `plugin/installed` can report plugins `plugin/list` omits (an installed +/// plugin of a local catalog the remote catalog replaces in the listing), so +/// the catalog is their union; the installed summary wins. +fn merge(listed: Catalog, installed: Catalog) -> Catalog { + let mut merged = listed; + for marketplace in installed.marketplaces { + let Some(known) = merged + .marketplaces + .iter_mut() + .find(|known| known.name == marketplace.name) + else { + merged.marketplaces.push(marketplace); + continue; + }; + if known.path.is_none() { + known.path = marketplace.path; + } + for plugin in marketplace.plugins { + match known.plugins.iter_mut().find(|known| known.id == plugin.id) { + Some(known) => *known = plugin, + None => known.plugins.push(plugin), + } + } + } + for error in installed.marketplace_load_errors { + if !merged.marketplace_load_errors.iter().any(|known| { + known.marketplace_path == error.marketplace_path && known.message == error.message + }) { + merged.marketplace_load_errors.push(error); + } + } + merged +} + +fn listing(catalog: &Catalog, config: &Value, skills: &Value, hooks: &Value) -> PluginListing { + let configured = config + .pointer("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/config/marketplaces") + .and_then(Value::as_object); + let configured_field = |name: &str, field: &str| { + configured + .and_then(|marketplaces| marketplaces.get(name)) + .and_then(|marketplace| marketplace.get(field)) + .and_then(Value::as_str) + .map(str::to_string) + }; + let mut marketplaces = Vec::new(); + let mut entries = Vec::new(); + for marketplace in &catalog.marketplaces { + let kind = marketplace_kind( + &marketplace.name, + marketplace.path.is_some(), + configured_field(&marketplace.name, "source_type").as_deref(), + ); + let location = marketplace.path.as_deref().and_then(marketplace_root); + marketplaces.push(ProviderPluginMarketplace { + name: marketplace.name.clone(), + source: configured_field(&marketplace.name, "source") + .or_else(|| location.as_ref().map(|root| root.display().to_string())) + .or_else(|| { + marketplace + .path + .as_ref() + .map(|path| path.display().to_string()) + }) + .unwrap_or_else(|| marketplace.name.clone()), + kind, + location, + }); + for plugin in &marketplace.plugins { + entries.push(entry(plugin, &marketplace.name, kind, skills, hooks)); + } + } + // A configured marketplace that failed to load is still removable. + for name in configured + .into_iter() + .flat_map(|marketplaces| marketplaces.keys()) + { + if !marketplaces + .iter() + .any(|marketplace| marketplace.name == *name) + { + marketplaces.push(ProviderPluginMarketplace { + name: name.clone(), + source: configured_field(name, "source").unwrap_or_default(), + kind: marketplace_kind( + name, + true, + configured_field(name, "source_type").as_deref(), + ), + location: None, + }); + } + } + + // `marketplace/remove` leaves the marketplace's installed plugins + // installed and active but no longer listed, so it is offered only once + // none are installed. + let mut marketplace_actions = vec![MarketplaceAction::Add]; + for marketplace in &marketplaces { + let removable = configured + .is_some_and(|configured| configured.contains_key(&marketplace.name)) + && marketplace.kind != PluginSourceKind::Official + && !entries.iter().any(|entry| { + !entry.installations.is_empty() + && entry.source.marketplace.as_deref() == Some(marketplace.name.as_str()) + }); + if removable { + marketplace_actions.push(MarketplaceAction::Remove { + marketplace: marketplace.name.clone(), + uninstalls: Vec::new(), + }); + } + } + PluginListing { + entries, + marketplaces, + marketplace_actions, + errors: catalog + .marketplace_load_errors + .iter() + .map(|error| format!("{}: {}", error.marketplace_path, error.message)) + .collect(), + } +} + +fn marketplace_kind(name: &str, local: bool, source_type: Option<&str>) -> PluginSourceKind { + if OFFICIAL_MARKETPLACES.contains(&name) { + return PluginSourceKind::Official; + } + match source_type { + Some("git") => PluginSourceKind::Git, + Some("local") => PluginSourceKind::LocalPath, + Some(_) => PluginSourceKind::Unknown, + // Unconfigured marketplaces are files found through `cwds` or HOME. + None if local => PluginSourceKind::LocalPath, + None => PluginSourceKind::Unknown, + } +} + +/// The directory holding `.agents/plugins/.json`. +fn marketplace_root(manifest: &Path) -> Option { + let plugins = manifest.parent()?; + let agents = plugins.parent()?; + (plugins.file_name()? == "plugins" && agents.file_name()? == ".agents") + .then(|| agents.parent().map(Path::to_path_buf)) + .flatten() +} + +fn entry( + plugin: &Summary, + marketplace: &str, + marketplace_kind: PluginSourceKind, + skills: &Value, + hooks: &Value, +) -> ProviderPluginEntry { + let kind = match plugin.source.get("type").and_then(Value::as_str) { + Some("git") => PluginSourceKind::Git, + Some("npm") => PluginSourceKind::Npm, + _ => marketplace_kind, + }; + let installations = if plugin.installed { + vec![PluginInstallation { + scope: PluginScope::User, + location: cache_root(plugin, marketplace, skills, hooks), + version: plugin.local_version.clone().or(plugin.version.clone()), + scope_enabled: Some(plugin.enabled), + }] + } else { + Vec::new() + }; + let mut diagnostics = Vec::new(); + for (key, value) in [ + ("install_policy", &plugin.install_policy), + ("auth_policy", &plugin.auth_policy), + ("availability", &plugin.availability), + ("disabled_reason", &plugin.disabled_reason), + ] { + if let Some(value) = value { + diagnostics.push((key.to_string(), value.clone())); + } + } + ProviderPluginEntry { + id: plugin.id.clone(), + name: plugin.name.clone(), + version: plugin.version.clone().or(plugin.local_version.clone()), + description: plugin + .interface + .as_ref() + .and_then(|interface| interface.short_description.clone()), + source: PluginSource { + marketplace: Some(marketplace.to_string()), + kind, + }, + installations, + enabled: if plugin.installed { + Tri::from(plugin.enabled) + } else { + Tri::Unknown + }, + declared: plugin.installed.then(|| DeclaredComponents { + // A disabled plugin's hooks are not listed at all. + untrusted_hooks: plugin.enabled.then(|| untrusted_hooks(&plugin.id, hooks)), + ..DeclaredComponents::default() + }), + errors: Vec::new(), + actions: actions(plugin), + diagnostics, + } +} + +fn listed<'a>(reply: &'a Value, field: &'a str) -> impl Iterator { + reply + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(move |cwd| cwd.get(field).and_then(Value::as_array)) + .flatten() +} + +fn of_plugin<'a>(item: &'a Value, id: &str) -> Option<&'a Value> { + (item.get("pluginId").and_then(Value::as_str) == Some(id)).then_some(item) +} + +/// Codex runs a plugin hook only once it is reviewed natively; `untrusted` +/// and `modified` hooks are skipped until then. +fn untrusted_hooks(id: &str, hooks: &Value) -> Vec { + let mut names: Vec = Vec::new(); + for hook in listed(hooks, "hooks").filter_map(|hook| of_plugin(hook, id)) { + let untrusted = matches!( + hook.get("trustStatus").and_then(Value::as_str), + Some("untrusted" | "modified") + ); + if let Some(event) = hook.get("eventName").and_then(Value::as_str) + && untrusted + && !names.iter().any(|name| name == event) + { + names.push(event.to_string()); + } + } + names +} + +/// The installed copy, `…/plugins/cache///`, +/// as the plugin's listed skills and hooks locate it. +fn cache_root( + plugin: &Summary, + marketplace: &str, + skills: &Value, + hooks: &Value, +) -> Option { + let paths = listed(skills, "skills") + .filter_map(|skill| of_plugin(skill, &plugin.id)?.get("path")) + .chain( + listed(hooks, "hooks") + .filter_map(|hook| of_plugin(hook, &plugin.id)?.get("sourcePath")), + ) + .filter_map(Value::as_str); + for path in paths { + let components: Vec<_> = Path::new(path).components().collect(); + let Some(at) = components.windows(4).position(|window| { + window[0].as_os_str() == "plugins" + && window[1].as_os_str() == "cache" + && window[2].as_os_str() == marketplace + && window[3].as_os_str() == plugin.name.as_str() + }) else { + continue; + }; + if components.len() > at + 4 { + return Some(components[..at + 5].iter().collect()); + } + } + None +} + +fn actions(plugin: &Summary) -> Vec { + let scope = PluginScope::User; + if !plugin.installed { + let installable = plugin.install_policy.as_deref() != Some("NOT_AVAILABLE") + && plugin.availability.as_deref().unwrap_or("AVAILABLE") == "AVAILABLE"; + return if installable { + vec![PluginAction::Install { scope }] + } else { + Vec::new() + }; + } + vec![ + PluginAction::Uninstall { scope }, + if plugin.enabled { + PluginAction::Disable { scope } + } else { + PluginAction::Enable { scope } + }, + ] +} + +/// Local catalogs are read in full so components are disclosed before an +/// install; a remote catalog only for its installed plugins. +fn detail_requests(catalog: &Catalog) -> Vec<(String, Value)> { + let mut requests = Vec::new(); + for marketplace in &catalog.marketplaces { + for plugin in &marketplace.plugins { + let params = match &marketplace.path { + Some(path) => json!({ "marketplacePath": path, "pluginName": plugin.name }), + None if plugin.installed => { + json!({ "remoteMarketplaceName": marketplace.name, "pluginName": plugin.name }) + } + None => continue, + }; + requests.push((plugin.id.clone(), params)); + } + } + requests +} + +fn apply_detail(entry: &mut ProviderPluginEntry, detail: Detail) { + if let Some(description) = detail.description.filter(|text| !text.trim().is_empty()) { + entry.description = Some(description); + } + let mut hooks: Vec = Vec::new(); + for hook in detail.hooks { + if !hooks.contains(&hook.event_name) { + hooks.push(hook.event_name); + } + } + let declared = entry + .declared + .get_or_insert_with(DeclaredComponents::default); + declared.skills = Some(detail.skills.into_iter().map(|skill| skill.name).collect()); + declared.hooks = Some(hooks); + declared.mcp_servers = Some(detail.mcp_servers); + declared.apps = Some(detail.apps.into_iter().map(|app| app.name).collect()); +} + +/// The request for one mutation. `offered` is the current `plugin/list`, +/// which names the marketplace an install reads from. Hook trust is never +/// bypassed: it stays a native review. +fn op_request(op: &PluginOp, offered: &[Marketplace]) -> Result<(&'static str, Value), AgentError> { + let request = match op { + PluginOp::Install { id, scope, .. } => { + user_scope(*scope)?; + let (marketplace, plugin) = offered + .iter() + .find_map(|marketplace| { + let plugin = marketplace.plugins.iter().find(|plugin| plugin.id == *id)?; + Some((marketplace, plugin)) + }) + .ok_or_else(|| AgentError::Provider(format!("no marketplace offers {id}")))?; + let params = match &marketplace.path { + Some(path) => json!({ "marketplacePath": path, "pluginName": plugin.name }), + None => { + json!({ "remoteMarketplaceName": marketplace.name, "pluginName": plugin.name }) + } + }; + ("plugin/install", params) + } + PluginOp::Uninstall { id, scope } => { + user_scope(*scope)?; + ("plugin/uninstall", json!({ "pluginId": id })) + } + PluginOp::SetEnabled { id, scope, enabled } => { + user_scope(*scope)?; + ( + "config/value/write", + json!({ + "keyPath": enabled_key_path(id)?, + "value": enabled, + "mergeStrategy": "upsert", + }), + ) + } + PluginOp::Update { .. } => { + return Err(AgentError::Provider( + "Codex has no per-plugin update".into(), + )); + } + PluginOp::AddMarketplace { source } => ("marketplace/add", json!({ "source": source })), + PluginOp::RemoveMarketplace { name } => { + ("marketplace/remove", json!({ "marketplaceName": name })) + } + }; + Ok(request) +} + +/// Codex installs and enables plugins only in the user's `config.toml`. +fn user_scope(scope: PluginScope) -> Result<(), AgentError> { + match scope { + PluginScope::User => Ok(()), + other => Err(AgentError::Protocol(format!( + "Codex plugins have no {other:?} scope" + ))), + } +} + +/// `config/value/write` splits the key path on dots outside quotes. +fn enabled_key_path(id: &str) -> Result { + if id.contains(['"', '\\']) { + return Err(AgentError::Protocol(format!( + "refusing to write a config key for {id:?}" + ))); + } + Ok(if id.contains('.') { + format!("plugins.\"{id}\".enabled") + } else { + format!("plugins.{id}.enabled") + }) +} + +fn outcome(op: &PluginOp, result: &Value) -> PluginOpOutcome { + let mut diagnostics = Vec::new(); + match op { + PluginOp::Install { .. } => { + let apps: Vec = result + .get("appsNeedingAuth") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|app| { + let name = app.get("name").and_then(Value::as_str)?; + Some(match app.get("installUrl").and_then(Value::as_str) { + Some(url) => format!("{name} ({url})"), + None => name.to_string(), + }) + }) + .collect(); + if !apps.is_empty() { + diagnostics.push(("apps_needing_auth".into(), apps.join(", "))); + } + } + PluginOp::AddMarketplace { .. } + if result.get("alreadyAdded").and_then(Value::as_bool) == Some(true) => + { + diagnostics.push(("already_added".into(), "true".into())); + } + _ => {} + } + PluginOpOutcome::Done { diagnostics } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The frame with JSON-RPC `id` in a recording: the request when + /// `request`, otherwise its response's `result`. + fn frame(name: &str, id: i64, request: bool) -> Value { + let path = format!( + "{}/tests/fixtures/plugins/codex/{name}", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{path}: {error}")); + let frame = text + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|frame| frame["id"] == id && frame.get("method").is_some() == request) + .unwrap_or_else(|| panic!("{name} has no frame {id}")); + if request { + frame + } else { + frame["result"].clone() + } + } + + fn result(name: &str, id: i64) -> Value { + frame(name, id, false) + } + + fn recorded(name: &str, id: i64) -> Catalog { + parse("recorded", result(name, id)).unwrap() + } + + const ALPHA: &str = "alpha@tcode-probe-mkt"; + + fn names(names: &[&str]) -> Option> { + Some(names.iter().map(|name| name.to_string()).collect()) + } + + fn policies(auth: &str) -> Vec<(String, String)> { + [ + ("install_policy", "AVAILABLE"), + ("auth_policy", auth), + ("availability", "AVAILABLE"), + ] + .map(|(key, value)| (key.to_string(), value.to_string())) + .to_vec() + } + + /// Recordings: `plugin/list {cwds:[proj]}` before the install (A#4), then + /// `plugin/installed`, `skills/list` and `hooks/list` once alpha was + /// installed and enabled (A#10–12), `plugin/read alpha` (A#5). + #[test] + fn listing_unions_installed_state_with_details_and_hook_trust() { + let catalog = merge( + recorded("plugin-list-with-cwds.jsonl", 4), + recorded("plugin-installed.jsonl", 10), + ); + let config = result("config-read.jsonl", 3); + let mut listing = listing( + &catalog, + &config, + &result("skills-list.jsonl", 11), + &result("hooks-list.jsonl", 12), + ); + let reads = detail_requests(&catalog); + assert_eq!( + reads.iter().map(|(id, _)| id.as_str()).collect::>(), + [ALPHA, "beta@tcode-probe-mkt", "gamma@tcode-probe-repo"] + ); + assert_eq!(reads[0].1, frame("plugin-read.jsonl", 5, true)["params"]); + let read: Read = parse("plugin/read", result("plugin-read.jsonl", 5)).unwrap(); + apply_detail(&mut listing.entries[0], read.plugin); + + let user = PluginScope::User; + assert_eq!( + listing.entries[0], + ProviderPluginEntry { + id: ALPHA.into(), + name: "alpha".into(), + version: Some("0.1.0".into()), + description: Some( + "Tcode probe plugin alpha: one skill, one MCP server, one hook.".into() + ), + source: PluginSource { + marketplace: Some("tcode-probe-mkt".into()), + kind: PluginSourceKind::LocalPath, + }, + installations: vec![PluginInstallation { + scope: user, + location: Some(PathBuf::from( + "/private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0" + )), + version: Some("0.1.0".into()), + scope_enabled: Some(true), + }], + enabled: Tri::Yes, + declared: Some(DeclaredComponents { + skills: names(&["alpha:alpha-hello"]), + agents: None, + hooks: names(&["sessionStart"]), + mcp_servers: names(&["alpha-docs"]), + untrusted_hooks: names(&["sessionStart"]), + lsp_servers: None, + apps: names(&[]), + }), + errors: Vec::new(), + actions: vec![ + PluginAction::Uninstall { scope: user }, + PluginAction::Disable { scope: user }, + ], + diagnostics: policies("ON_INSTALL"), + } + ); + let beta = &listing.entries[1]; + assert!(beta.installations.is_empty()); + assert_eq!(beta.version.as_deref(), Some("0.2.0")); + assert_eq!(beta.description.as_deref(), Some("Skill-only probe plugin")); + assert_eq!(beta.enabled, Tri::Unknown); + assert_eq!(beta.declared, None); + assert_eq!(beta.actions, [PluginAction::Install { scope: user }]); + assert_eq!(beta.diagnostics, policies("ON_USE")); + assert_eq!( + listing.entries[2].source, + PluginSource { + marketplace: Some("tcode-probe-repo".into()), + kind: PluginSourceKind::LocalPath, + } + ); + + assert_eq!( + listing.marketplaces, + [ + ProviderPluginMarketplace { + name: "tcode-probe-mkt".into(), + source: "/private/tmp/tcode-probe/codex-mkt".into(), + kind: PluginSourceKind::LocalPath, + location: Some(PathBuf::from("/private/tmp/tcode-probe/codex-mkt")), + }, + ProviderPluginMarketplace { + name: "tcode-probe-repo".into(), + source: "/tmp/tcode-probe/codex-proj".into(), + kind: PluginSourceKind::LocalPath, + location: Some(PathBuf::from("/tmp/tcode-probe/codex-proj")), + }, + ] + ); + // alpha is installed, and the repo marketplace is not configured. + assert_eq!(listing.marketplace_actions, [MarketplaceAction::Add]); + assert!(listing.errors.is_empty()); + + // After `plugin/uninstall` (A#25) nothing from the marketplace is installed. + let uninstalled = merge( + recorded("plugin-list-with-cwds.jsonl", 4), + recorded("plugin-installed.jsonl", 25), + ); + let after = super::listing( + &uninstalled, + &config, + &result("skills-list.jsonl", 16), + &result("hooks-list.jsonl", 18), + ); + assert_eq!( + after.marketplace_actions, + [ + MarketplaceAction::Add, + MarketplaceAction::Remove { + marketplace: "tcode-probe-mkt".into(), + uninstalls: Vec::new(), + }, + ] + ); + } + + /// Recordings after `config/value/write enabled=false`: `plugin/installed` + /// (A#14), `skills/list` (A#16) and `hooks/list` (A#18), from which the + /// disabled plugin is absent. + #[test] + fn disabled_plugin_offers_enable_and_claims_no_hook_trust() { + let catalog = merge( + recorded("plugin-list-with-cwds.jsonl", 4), + recorded("plugin-installed-after-disable.jsonl", 14), + ); + let listing = listing( + &catalog, + &result("config-read.jsonl", 3), + &result("skills-list.jsonl", 16), + &result("hooks-list.jsonl", 18), + ); + let alpha = &listing.entries[0]; + assert_eq!(alpha.enabled, Tri::No); + assert_eq!( + alpha.installations, + [PluginInstallation { + scope: PluginScope::User, + location: None, + version: Some("0.1.0".into()), + scope_enabled: Some(false), + }] + ); + assert_eq!(alpha.declared.as_ref().unwrap().untrusted_hooks, None); + assert_eq!( + alpha.actions, + [ + PluginAction::Uninstall { + scope: PluginScope::User + }, + PluginAction::Enable { + scope: PluginScope::User + }, + ] + ); + } + + #[test] + fn requests_match_the_recorded_frames_name_the_context_and_never_bypass_trust() { + let cwd = Path::new("/tmp/tcode-probe/codex-proj"); + for (method, params) in discovery_requests(cwd) { + if method != "config/read" { + assert_eq!(params["cwds"], json!([cwd]), "{method}"); + } + } + + let offered = recorded("plugin-list-with-cwds.jsonl", 4).marketplaces; + let user = PluginScope::User; + let cases = [ + ( + PluginOp::Install { + id: ALPHA.into(), + scope: user, + accept_command: Some("not a Codex concept".into()), + }, + "plugin-install.jsonl", + 8, + ), + ( + PluginOp::SetEnabled { + id: ALPHA.into(), + scope: user, + enabled: false, + }, + "config-value-write-enabled.jsonl", + 13, + ), + ( + PluginOp::Uninstall { + id: ALPHA.into(), + scope: user, + }, + "plugin-uninstall.jsonl", + 23, + ), + ( + PluginOp::AddMarketplace { + source: "/tmp/tcode-probe/codex-mkt".into(), + }, + "marketplace-add.jsonl", + 2, + ), + ( + PluginOp::RemoveMarketplace { + name: "tcode-probe-mkt".into(), + }, + "marketplace-remove.jsonl", + 28, + ), + ]; + for (op, name, id) in cases { + let (method, params) = op_request(&op, &offered).unwrap(); + let recorded = frame(name, id, true); + assert_eq!(method, recorded["method"], "{op:?}"); + assert_eq!(params, recorded["params"], "{op:?}"); + let wire = params.to_string(); + assert!( + !wire.contains("dangerously") && !wire.contains("bypass"), + "{wire}" + ); + } + // A dotted id stays one key segment (written live as `[plugins."a.b@m"]`). + assert_eq!( + enabled_key_path("a.b@m").unwrap(), + "plugins.\"a.b@m\".enabled" + ); + } + + /// A `codex` that records each start and dies at startup with `$FAILURE` + /// on stderr, as 0.159.3 does when it loses the first-start sqlite race. + #[cfg(unix)] + #[test] + fn only_the_first_start_sqlite_race_is_retried_once() { + use std::os::unix::fs::PermissionsExt; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("agent-codex-plugins-{nonce}")); + std::fs::create_dir_all(&dir).unwrap(); + let starts = dir.join("starts"); + let bin = dir.join("codex"); + std::fs::write( + &bin, + format!( + "#!/bin/sh\necho start >> '{}'\necho \"Error: $FAILURE\" >&2\nexit 1\n", + starts.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let attempt = |failure: &str| { + let context = PluginContext { + binary_path: Some(bin.clone()), + launch_env: crate::LaunchEnv { + env: vec![("FAILURE".into(), failure.into())], + home: None, + }, + cwd: dir.clone(), + project: false, + }; + // Concurrently forked test children can hold the fresh script + // open, so exec fails with ETXTBSY until they exit. + for _ in 0..20 { + let _ = std::fs::remove_file(&starts); + let message = smol::block_on(list(&context)).unwrap_err().to_string(); + if !message.contains("Text file busy") { + let count = std::fs::read_to_string(&starts).unwrap().lines().count(); + return (message, count); + } + std::thread::sleep(Duration::from_millis(25)); + } + panic!("the fake codex never started"); + }; + + let (message, starts_made) = + attempt("failed to initialize sqlite state runtime under /tmp/fresh-home"); + assert_eq!(starts_made, 2); + assert!(message.contains(SQLITE_RACE), "{message}"); + assert!(message.contains("exit status: 1"), "{message}"); + + let (message, starts_made) = attempt("Cannot find module './dist/cli.js'"); + assert_eq!(starts_made, 1); + assert!(message.contains("Cannot find module"), "{message}"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 78fda2c2d..2f91bd889 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -16,6 +16,8 @@ mod actor; pub mod claude; mod claude_context; mod claude_manifest; +#[cfg(feature = "process")] +mod claude_plugins; #[cfg(not(feature = "process"))] pub mod claude { pub use crate::claude_context::*; @@ -69,11 +71,15 @@ pub struct Caps { /// Whether this adapter can attach tcode's HTTP MCP servers to a session. /// Preview attachment rides the same capability as every other MCP server. pub mcp_servers: bool, + /// The harness drives the desktop itself, so tcode's computer-use MCP + /// server is not attached to its sessions. + pub native_computer_use: bool, pub launch_args: bool, pub downgrade_approval_without_native_approvals: bool, pub option_descriptors: OptionDescriptors, pub home_path: bool, pub trust_project_extensions: bool, + pub plugin_management: PluginManagement, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -126,11 +132,23 @@ impl ProviderKind { live_approval_mode_switch: true, live_option_push: LiveOptionPush::None, mcp_servers: true, + native_computer_use: false, launch_args: true, downgrade_approval_without_native_approvals: false, option_descriptors: OptionDescriptors::Catalog, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement { + actions: &[ + PluginActionKind::Install, + PluginActionKind::Uninstall, + PluginActionKind::Enable, + PluginActionKind::Disable, + PluginActionKind::Update, + ], + marketplaces: true, + apply: ApplyNote::ReloadOrRestart, + }, }, ProviderKind::Codex => Caps { supports_steering: true, @@ -141,11 +159,23 @@ impl ProviderKind { live_approval_mode_switch: false, live_option_push: LiveOptionPush::None, mcp_servers: true, + // `codex features list` reports `computer_use stable` (0.159.3). + native_computer_use: true, launch_args: false, downgrade_approval_without_native_approvals: false, option_descriptors: OptionDescriptors::Catalog, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement { + actions: &[ + PluginActionKind::Install, + PluginActionKind::Uninstall, + PluginActionKind::Enable, + PluginActionKind::Disable, + ], + marketplaces: true, + apply: ApplyNote::NextSession, + }, }, ProviderKind::Acp => Caps { supports_steering: false, @@ -158,11 +188,13 @@ impl ProviderKind { // ACP support is negotiated per agent; capable agents receive // every tcode HTTP MCP registration in session/new or load. mcp_servers: true, + native_computer_use: false, launch_args: true, downgrade_approval_without_native_approvals: false, option_descriptors: OptionDescriptors::Wire, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement::NONE, }, ProviderKind::Pi => Caps { supports_steering: true, @@ -173,11 +205,13 @@ impl ProviderKind { live_approval_mode_switch: false, live_option_push: LiveOptionPush::Only(&["reasoningEffort"]), mcp_servers: false, + native_computer_use: false, launch_args: true, downgrade_approval_without_native_approvals: true, option_descriptors: OptionDescriptors::Catalog, home_path: true, trust_project_extensions: true, + plugin_management: PluginManagement::NONE, }, ProviderKind::OpenCode => Caps { supports_steering: false, @@ -188,11 +222,13 @@ impl ProviderKind { live_approval_mode_switch: false, live_option_push: LiveOptionPush::None, mcp_servers: true, + native_computer_use: false, launch_args: true, downgrade_approval_without_native_approvals: false, option_descriptors: OptionDescriptors::Catalog, home_path: false, trust_project_extensions: false, + plugin_management: PluginManagement::NONE, }, ProviderKind::Cursor => Caps { supports_steering: false, @@ -203,11 +239,13 @@ impl ProviderKind { live_approval_mode_switch: false, live_option_push: LiveOptionPush::All, mcp_servers: true, + native_computer_use: false, launch_args: true, downgrade_approval_without_native_approvals: false, option_descriptors: OptionDescriptors::Wire, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement::NONE, }, ProviderKind::Grok => Caps { supports_steering: true, @@ -218,11 +256,13 @@ impl ProviderKind { live_approval_mode_switch: false, live_option_push: LiveOptionPush::All, mcp_servers: true, + native_computer_use: false, launch_args: true, downgrade_approval_without_native_approvals: false, option_descriptors: OptionDescriptors::Wire, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement::NONE, }, } } @@ -240,6 +280,349 @@ impl ProviderKind { } } +/// Which native plugin operations an adapter drives. Whether one is offered +/// for a given entry is computed per catalog ([`ProviderPluginEntry::actions`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PluginManagement { + pub actions: &'static [PluginActionKind], + pub marketplaces: bool, + pub apply: ApplyNote, +} + +impl PluginManagement { + pub const NONE: Self = Self { + actions: &[], + marketplaces: false, + apply: ApplyNote::Unverified, + }; + + pub fn supports(&self, action: PluginActionKind) -> bool { + self.actions.contains(&action) + } +} + +/// When a plugin change made through the native CLI reaches the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyNote { + /// Skills, commands and hooks apply after `/reload-plugins` in a running + /// session or in the next session; MCP servers only in the next session; + /// updates need a restart. + ReloadOrRestart, + /// Sessions started afterwards see the change; what a running session + /// picks up is not established. + NextSession, + /// No evidence yet of what a running session picks up. + Unverified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginActionKind { + Install, + Uninstall, + Enable, + Disable, + Update, +} + +/// A native settings scope a plugin is installed or enabled in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginScope { + User, + Project, + Local, + Managed, + Session, +} + +/// A yes/no fact the native CLI may not report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Tri { + Yes, + No, + #[default] + Unknown, +} + +impl From for Tri { + fn from(value: bool) -> Self { + if value { Tri::Yes } else { Tri::No } + } +} + +/// One native operation the host computed as available for an entry in the +/// catalog's context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PluginAction { + Install { scope: PluginScope }, + Uninstall { scope: PluginScope }, + Enable { scope: PluginScope }, + Disable { scope: PluginScope }, + Update { scope: PluginScope }, +} + +impl PluginAction { + pub fn kind(self) -> PluginActionKind { + match self { + Self::Install { .. } => PluginActionKind::Install, + Self::Uninstall { .. } => PluginActionKind::Uninstall, + Self::Enable { .. } => PluginActionKind::Enable, + Self::Disable { .. } => PluginActionKind::Disable, + Self::Update { .. } => PluginActionKind::Update, + } + } + + pub fn scope(self) -> PluginScope { + match self { + Self::Install { scope } + | Self::Uninstall { scope } + | Self::Enable { scope } + | Self::Disable { scope } + | Self::Update { scope } => scope, + } + } +} + +/// A marketplace-level native operation available in the catalog's context. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MarketplaceAction { + Add, + /// Removing the marketplace also uninstalls these installed plugins in + /// every scope, so the host asks before running it. + Remove { + marketplace: String, + uninstalls: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginSourceKind { + Official, + ThirdParty, + LocalPath, + Git, + Npm, + Command, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginSource { + #[serde(default)] + pub marketplace: Option, + pub kind: PluginSourceKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginInstallation { + pub scope: PluginScope, + /// Where the installed copy lives on the host. + #[serde(default)] + pub location: Option, + #[serde(default)] + pub version: Option, + /// The scope's own enablement where the native CLI reports it; the + /// effective value is [`ProviderPluginEntry::enabled`]. + #[serde(default)] + pub scope_enabled: Option, +} + +/// Components a plugin declares, by name. A `None` field was not enumerated, +/// which is not the same as declaring none. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeclaredComponents { + #[serde(default)] + pub skills: Option>, + #[serde(default)] + pub agents: Option>, + /// Hook event names. + #[serde(default)] + pub hooks: Option>, + #[serde(default)] + pub mcp_servers: Option>, + /// Hook event names the native CLI reports as not yet trusted (Codex + /// skips such hooks until they are reviewed natively). `None` when the + /// provider has no hook-trust state. + #[serde(default)] + pub untrusted_hooks: Option>, + #[serde(default)] + pub lsp_servers: Option>, + #[serde(default)] + pub apps: Option>, +} + +/// One plugin in a provider's native catalog: installed in any scope, offered +/// by a marketplace, or both. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderPluginEntry { + /// The native id the CLI accepts, `name@marketplace`. + pub id: String, + pub name: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub description: Option, + pub source: PluginSource, + #[serde(default)] + pub installations: Vec, + /// Effective enablement for the catalog's context directory. + #[serde(default)] + pub enabled: Tri, + #[serde(default)] + pub declared: Option, + #[serde(default)] + pub errors: Vec, + #[serde(default)] + pub actions: Vec, + /// Read-only `(key, native text)` facts, keyed by stable identifiers the + /// client localizes. + #[serde(default)] + pub diagnostics: Vec<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderPluginMarketplace { + pub name: String, + /// The repository, URL or path the marketplace was added from. + pub source: String, + pub kind: PluginSourceKind, + #[serde(default)] + pub location: Option, +} + +/// A provider's native plugin catalog as seen from one directory. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginListing { + pub entries: Vec, + pub marketplaces: Vec, + pub marketplace_actions: Vec, + /// Native errors for parts of the catalog that could not be read, such + /// as a marketplace that failed to load. + pub errors: Vec, +} + +/// Where and as whom a plugin-management command runs: the profile's CLI and +/// environment, and the directory whose project scopes it addresses. +#[derive(Debug, Clone)] +pub struct PluginContext { + pub binary_path: Option, + pub launch_env: LaunchEnv, + pub cwd: PathBuf, + /// False when `cwd` is only a neutral directory, so project and local + /// scopes are not offered. + pub project: bool, +} + +/// One native plugin mutation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginOp { + Install { + id: String, + scope: PluginScope, + /// The `sha256` of a command the user accepted from an earlier + /// [`PluginOpOutcome::AcceptCommand`]. + accept_command: Option, + }, + Uninstall { + id: String, + scope: PluginScope, + }, + SetEnabled { + id: String, + scope: PluginScope, + enabled: bool, + }, + Update { + id: String, + scope: PluginScope, + accept_command: Option, + }, + AddMarketplace { + source: String, + }, + RemoveMarketplace { + name: String, + }, +} + +/// What a native plugin mutation reported. Native failures are errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginOpOutcome { + Done { + diagnostics: Vec<(String, String)>, + }, + /// The CLI refused to run a marketplace-declared command until a person + /// accepts exactly this command. + AcceptCommand(CommandAcceptance), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandAcceptance { + pub command: String, + pub sha256: String, + #[serde(default)] + pub mode: Option, + /// The CLI's own explanation. + pub native_text: String, +} + +/// List a provider's native plugins and marketplaces as seen from `context`. +#[cfg(feature = "process")] +pub async fn list_plugins( + provider: ProviderKind, + context: &PluginContext, +) -> Result { + match provider { + ProviderKind::ClaudeCode => claude_plugins::list(context).await, + ProviderKind::Codex => codex::plugins::list(context).await, + ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok + | ProviderKind::Acp => Err(no_plugin_management(provider)), + } +} + +/// Run one native plugin mutation in `context`. +#[cfg(feature = "process")] +pub async fn run_plugin_op( + provider: ProviderKind, + context: &PluginContext, + op: &PluginOp, +) -> Result { + match provider { + ProviderKind::ClaudeCode => claude_plugins::run(context, op).await, + ProviderKind::Codex => codex::plugins::run(context, op).await, + ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok + | ProviderKind::Acp => Err(no_plugin_management(provider)), + } +} + +/// A native failure in the CLI's own words. +#[cfg(feature = "process")] +fn native_message(error: &AgentError) -> String { + match error { + AgentError::Provider(message) => message.clone(), + other => other.to_string(), + } +} + +#[cfg(feature = "process")] +fn no_plugin_management(provider: ProviderKind) -> AgentError { + AgentError::Provider(format!( + "{} has no plugin management", + provider.display_name() + )) +} + /// Registry agents never offered for new sessions because they duplicate a /// native integration. pub const HIDDEN_ACP_AGENT_IDS: [&str; 6] = [ diff --git a/crates/agent/tests/fixtures/plugins/claude/COMMANDS.md b/crates/agent/tests/fixtures/plugins/claude/COMMANDS.md new file mode 100644 index 000000000..4e8766791 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/COMMANDS.md @@ -0,0 +1,613 @@ +# Claude Code plugin probe — commands + +- CLI: `2.1.285 (Claude Code)` (`claude --version`), binary `/Users/tryanks/.local/share/mise/installs/claude/latest/claude` → `2.1.285/claude`. Date 2026-10-03, macOS (Darwin 27.0.0, arm64). +- Driver: `/tmp/tcode-claude-run/probe.py` + `probe_lib.py` (not in the repo). Every command runs with stdin = `/dev/null`, stdout and stderr captured separately. +- **Env** (all runs): a clean base of `PATH USER LOGNAME SHELL TMPDIR LANG` only. The probe itself runs inside a Tcode-launched Claude Code session; its `CLAUDECODE`/`CLAUDE_CODE_*` variables are deliberately *not* inherited. Adding them back one at a time to an otherwise accepted `--accept-command` install showed that `CLAUDECODE` and `CLAUDE_CODE_CHILD_SESSION` each make the CLI refuse it ("--accept-command is ignored inside a Claude Code session"); `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_PID`, `CLAUDE_CODE_EXECPATH`, `AI_AGENT`, `CLAUDE_CODE_SESSION_ATTENDED` and `CLAUDE_CODE_MESSAGING_SOCKET` do not. Those recordings are not kept. + - `iso` = base + `HOME=/tmp/tcode-probe/claude-home` + `CLAUDE_CONFIG_DIR=/tmp/tcode-probe/claude-home/.claude` (CLAUDE_CONFIG_DIR is not in `claude --help`; it is honoured — all state landed under it). + - `real` = base + `HOME=/Users/tryanks` (read-only commands and the one `--plugin-dir` session only). +- Paths: marketplace `/tmp/tcode-probe/mkt` (the `manifests/mkt` files below), command-source output dir `/tmp/tcode-probe/gamma-src` (the `manifests/gamma-src` files below), project `/tmp/tcode-probe/proj` (macOS realpath `/private/tmp/tcode-probe/proj`), non-project cwd `/tmp/tcode-probe/outside`. + +## Authored manifests + +### `manifests/gamma-src/.claude-plugin/plugin.json` + +```json +{ + "name": "gamma", + "version": "0.0.1", + "description": "Plugin directory produced by the gamma command source" +} +``` + +### `manifests/gamma-src/skills/gamma-note/SKILL.md` + +```markdown +--- +name: gamma-note +description: Writes a short note from gamma. Use when the user asks for a gamma note. +--- + +Reply with "gamma note". +``` + +### `manifests/mkt/.claude-plugin/marketplace.json` + +```json +{ + "name": "tcode-probe", + "description": "Throwaway marketplace for the Tcode plugin probe", + "owner": { "name": "Tcode probe" }, + "plugins": [ + { + "name": "alpha", + "source": "./plugins/alpha", + "description": "Skill, command, SessionStart hook and an unreachable HTTP MCP server", + "version": "1.0.0" + }, + { + "name": "beta", + "source": "./plugins/beta", + "description": "Skill only", + "version": "0.1.0" + }, + { + "name": "gamma", + "source": { + "source": "command", + "command": "echo /tmp/tcode-probe/gamma-src", + "timeout": 30 + }, + "description": "Command-source plugin" + } + ] +} +``` + +### `manifests/mkt/plugins/alpha/.claude-plugin/plugin.json` + +```json +{ + "name": "alpha", + "version": "1.0.0", + "description": "Skill, command, SessionStart hook and an unreachable HTTP MCP server" +} +``` + +### `manifests/mkt/plugins/alpha/.mcp.json` + +```json +{ + "mcpServers": { + "alpha-dead": { + "type": "http", + "url": "http://127.0.0.1:9/mcp" + } + } +} +``` + +### `manifests/mkt/plugins/alpha/commands/hello.md` + +```markdown +--- +description: Print a hello line from the alpha plugin +--- + +Reply with exactly: hello from alpha command +``` + +### `manifests/mkt/plugins/alpha/hooks/hooks.json` + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "echo alpha-session-start" } + ] + } + ] + } +} +``` + +### `manifests/mkt/plugins/alpha/skills/greet/SKILL.md` + +```markdown +--- +name: greet +description: Greets the user by name. Use when the user asks to be greeted. +--- + +Say "Hello from alpha" followed by the user's name. +``` + +### `manifests/mkt/plugins/beta/.claude-plugin/plugin.json` + +```json +{ + "name": "beta", + "version": "0.1.0", + "description": "Skill only" +} +``` + +### `manifests/mkt/plugins/beta/skills/beta-note/SKILL.md` + +```markdown +--- +name: beta-note +description: Writes a short note from beta. Use when the user asks for a beta note. +--- + +Reply with "beta note". +``` + +Before step `16-update` the driver rewrote `"version": "1.0.0"` → `"1.0.1"` in `mkt/.claude-plugin/marketplace.json` and `mkt/plugins/alpha/.claude-plugin/plugin.json`. + +## Commands, in execution order + +Only the runs whose output is kept here are listed; the numbering is the probe's. + +| # | step | argv (after the binary) | cwd | env | exit | stdout | stderr | +|---|---|---|---|---|---|---|---| +| 2 | `real-marketplace-list` | `plugin marketplace list --json` | `/tmp/tcode-probe/outside` | real | 0 | `real-home-marketplace-list.json` | (empty) | +| 9 | `04-marketplace-list` | `plugin marketplace list --json` | `/tmp/tcode-probe/proj` | iso | 0 | `marketplace-list.json` | (empty) | +| 11 | `06-install-alpha-user` | `plugin install alpha@tcode-probe --scope user --json` | `/tmp/tcode-probe/proj` | iso | 0 | `install-user.json` | (empty) | +| 17 | `09c-plugin-list-available-after-install` | `plugin list --json --available` | `/tmp/tcode-probe/proj` | iso | 0 | `plugin-list-available-after-install.json` | (empty) | +| 19 | `10-details` | `plugin details alpha` | `/tmp/tcode-probe/proj` | iso | 0 | `details.txt` | (empty) | +| 21 | `11-disable` | `plugin disable alpha --json` | `/tmp/tcode-probe/proj` | iso | 0 | `disable.json` | (empty) | +| 23 | `12-enable` | `plugin enable alpha --json` | `/tmp/tcode-probe/proj` | iso | 0 | `enable.json` | (empty) | +| 24 | `13-disable-user-scope` | `plugin disable alpha --scope user --json` | `/tmp/tcode-probe/proj` | iso | 0 | `disable-scope-user.json` | (empty) | +| 32 | `16-update` | `plugin update alpha --json` | `/tmp/tcode-probe/proj` | iso | 0 | `update.json` | (empty) | +| 35 | `17-uninstall-beta-project` | `plugin uninstall beta --scope project --json` | `/tmp/tcode-probe/proj` | iso | 0 | `uninstall.json` | (empty) | +| 36 | `18-install-gamma-challenge` | `plugin install gamma@tcode-probe --json` | `/tmp/tcode-probe/proj` | iso | 1 | `install-command-source-challenge.txt` | (not kept) | +| 38 | `19-install-gamma-wrong-hash` | `plugin install gamma@tcode-probe --json --accept-command 0000000000000000000000000000000000000000000000000000000000000000` | `/tmp/tcode-probe/proj` | iso | 1 | `install-command-source-wrong-hash.json` | (not kept) | +| 56 | `20-install-gamma-accepted` | `plugin install gamma@tcode-probe --json --accept-command b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269` | `/tmp/tcode-probe/proj` | iso | 0 | `install-command-source-accepted.json` | (empty) | + +Notes: + +- 2 `real-marketplace-list`: REAL home, read-only. +- 21 `11-disable`: No --scope: auto-detect. +- 23 `12-enable`: No --scope: auto-detect. +- 32 `16-update`: Before this run alpha version was bumped 1.0.0 -> 1.0.1 in marketplace.json and plugin.json. No --scope. +- 56 `20-install-gamma-accepted`: --accept-command = shownCommand.sha256 from step 18 (b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269); clean env. + +## `claude plugin --help` (exit 0) + +```text +Usage: claude plugin|plugins [options] [command] + +Manage Claude Code plugins + +Options: + -h, --help Display help for command + +Commands: + configure [options] Show a plugin's options and which are + unset, or save values from stdin with + --values-stdin + details [options] Show a plugin's component inventory and + projected token cost + disable [options] [plugin] Disable an enabled plugin + enable [options] Enable a disabled plugin + eval [options] [target] Run eval cases (/**/case.yaml + or prompt.md + graders/*.md; the eval dir + is evals/ unless --eval-dir or the + manifest says otherwise) against a plugin + and report scored results. Target is a + path, a plugin name, or a + `plugin@marketplace` id — installed and + skills-dir plugins both resolve (and add + a no-plugin baseline arm). It loads the + plugin and runs its eval suite (prompts, + graders; scaffold scripts and real MCP + servers only when you opt in) on your + machine, as you: only evaluate plugins + you trust — the run's sandboxing limits + what a malicious plugin can reach but is + not a guarantee, and a bundled suite + passing is not a security vetting. The + first run in an untrusted plugin + directory asks you to confirm + (--trust-plugin answers for CI) + help [command] display help for command + init|new [options] Scaffold a new plugin at + ~/.claude/skills// (auto-loads next + session as @skills-dir) + install|i [options] Install a plugin from available + marketplaces (use plugin@marketplace for + specific marketplace) + list [options] List installed plugins + marketplace Manage Claude Code marketplaces + prune|autoremove [options] Remove auto-installed dependencies that + are no longer needed + tag [options] [path] Create a {name}--v{version} git tag for a + plugin release, validating that + plugin.json and any enclosing marketplace + entry agree + uninstall|remove [options] Uninstall an installed plugin + update [options] Update a plugin to the latest version + (restart required to apply) + validate [options] Validate a plugin or marketplace + manifest, or the skills, agents, and + commands in a directory +``` + +## Every subcommand `--help` (`bash -c 'claude plugin $s --help'`, exit codes inline) + +```text +=== claude plugin list --help +Usage: claude plugin list [options] + +List installed plugins + +Options: + --available Include available plugins from marketplaces (requires + --json) + --data-size [plugin] Measure each installed plugin's saved data directory, or + only the named plugin's (requires --json) + -h, --help Display help for command + --json Output as JSON +[exit 0] + +=== claude plugin install --help +Usage: claude plugin install|i [options] + +Install a plugin from available marketplaces (use plugin@marketplace for +specific marketplace) + +Options: + --accept-command Accept the marketplace-declared command (a + command-source install, or the headersHelper that + fetches the archive) whose sha256 a previous --json + run reported as shownCommand.sha256; counts as -y + for exactly that command, for that plugin and + marketplace catalog, and nothing else. If either + changed (a refresh that moved the catalog counts), + the run refuses and reports the command again, to + be shown to a person again + --config Set a userConfig option declared in the plugin's + manifest, or a bundled .mcpb server's own + user_config field as .= (a bare + key works when only one bundled server declares + it). Repeatable. Values are validated against the + schema and stored via the same path as the + interactive /plugin configure flow. + -h, --help Display help for command + --json Print one machine-readable result line on stdout + instead of the human message (same exit codes; a + marketplace-declared command is still shown and + must be confirmed — pass -y when not interactive) + --registry For a @npm install: resolve and download + from this npm registry instead of the one your npm + configuration selects + -s, --scope Installation scope: user, project, or local + (default: "user") + -y, --yes Accept the displayed marketplace-declared command + without the confirmation prompt — a plugin + installed by running a command, or one whose + archive is fetched through a headersHelper command + (required when stdin or stdout is not a TTY) +[exit 0] + +=== claude plugin uninstall --help +Usage: claude plugin uninstall|remove [options] + +Uninstall an installed plugin + +Options: + -h, --help Display help for command + --json Print one machine-readable result line on stdout instead + of the human message (same exit codes; not with --prune) + --keep-data Preserve the plugin's persistent data directory + (~/.claude/plugins/data/{id}/) + --prune Also remove auto-installed dependencies that are no + longer needed (requires -y in non-interactive contexts) + -s, --scope Uninstall from scope: user, project, or local (default: + "user") + -y, --yes Skip the --prune confirmation prompt (required when stdin + or stdout is not a TTY) +[exit 0] + +=== claude plugin enable --help +Usage: claude plugin enable [options] + +Enable a disabled plugin + +Options: + -h, --help Display help for command + --json Print one machine-readable result line on stdout instead + of the human message (same exit codes) + -s, --scope Installation scope: user, project, local (default: + auto-detect) +[exit 0] + +=== claude plugin disable --help +Usage: claude plugin disable [options] [plugin] + +Disable an enabled plugin + +Options: + -a, --all Disable all enabled plugins + -h, --help Display help for command + --json Print one machine-readable result line on stdout instead + of the human message (same exit codes) + -s, --scope Installation scope: user, project, local (default: + auto-detect) +[exit 0] + +=== claude plugin update --help +Usage: claude plugin update [options] + +Update a plugin to the latest version (restart required to apply) + +Options: + --accept-command Accept the marketplace-declared command (a + command-source install, or the headersHelper that + fetches the archive) whose sha256 a previous --json + run reported as shownCommand.sha256; counts as -y + for exactly that command, for that plugin and + marketplace catalog, and nothing else. If either + changed (a refresh that moved the catalog counts), + the run refuses and reports the command again, to + be shown to a person again + -h, --help Display help for command + --json Print one machine-readable result line on stdout + instead of the human message (same exit codes; a + marketplace-declared command is still shown and + must be confirmed — pass -y when not interactive) + -s, --scope Installation scope: user, project, local, managed + (default: auto-detect) + -y, --yes Accept the displayed marketplace-declared command + without the confirmation prompt — a changed install + command, or the headersHelper command that fetches + its archive (required when stdin or stdout is not a + TTY) +[exit 0] + +=== claude plugin details --help +Usage: claude plugin details [options] + +Show a plugin's component inventory and projected token cost + +Options: + -h, --help Display help for command +[exit 0] + +=== claude plugin validate --help +Usage: claude plugin validate [options] + +Validate a plugin or marketplace manifest, or the skills, agents, and commands +in a directory + +Options: + -h, --help Display help for command + --json Output the validation report as JSON (same exit codes) + --strict Treat warnings as errors (exit 1). Use in CI to fail on + unrecognized fields, missing metadata, and other issues that the + runtime tolerates. +[exit 0] + +=== claude plugin init --help +Usage: claude plugin init|new [options] + +Scaffold a new plugin at ~/.claude/skills// (auto-loads next session as +@skills-dir) + +Options: + --author Author name (default: git config user.name) + --author-email Author email (default: git config user.email) + --description Manifest description + -f, --force Overwrite an existing .claude-plugin/ at the target + -h, --help Display help for command + --with Also scaffold: skills, agents, hooks, mcp, lsp, + output-style, channel +[exit 0] + +=== claude plugin configure --help +Usage: claude plugin configure [options] + +Show a plugin's options and which are unset, or save values from stdin with +--values-stdin + +Options: + -h, --help Display help for command + --json Output as JSON + --values-stdin Read option values from stdin as a JSON object of single-line + strings; options left out keep their values +[exit 0] + +=== claude plugin prune --help +Usage: claude plugin prune|autoremove [options] + +Remove auto-installed dependencies that are no longer needed + +Options: + --dry-run List what would be removed without removing + -h, --help Display help for command + -s, --scope Prune at scope: user, project, or local (default: "user") + -y, --yes Skip the confirmation prompt (required when stdin or + stdout is not a TTY) +[exit 0] + +=== claude plugin tag --help +Usage: claude plugin tag [options] [path] + +Create a {name}--v{version} git tag for a plugin release, validating that +plugin.json and any enclosing marketplace entry agree + +Options: + --dry-run Print what would be tagged without creating it + -f, --force Skip the dirty-working-tree and tag-already-exists checks + -h, --help Display help for command + -m, --message Tag annotation message (use %s for the version) + --push Push the tag to --remote after creating it + --remote Remote to push to with --push (default: "origin") +[exit 0] + +=== claude plugin test --help +claude plugin test: hooks modules are not turned on in this build yet (early access); set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 to run a plugin's tests +[exit 1] + +=== claude plugin eval --help +Usage: claude plugin eval [options] [command] [target] + +Run eval cases (/**/case.yaml or prompt.md + graders/*.md; the eval +dir is evals/ unless --eval-dir or the manifest says otherwise) against a plugin +and report scored results. Target is a path, a plugin name, or a +`plugin@marketplace` id — installed and skills-dir plugins both resolve (and add +a no-plugin baseline arm). It loads the plugin and runs its eval suite (prompts, +graders; scaffold scripts and real MCP servers only when you opt in) on your +machine, as you: only evaluate plugins you trust — the run's sandboxing limits +what a malicious plugin can reach but is not a guarantee, and a bundled suite +passing is not a security vetting. The first run in an untrusted plugin +directory asks you to confirm (--trust-plugin answers for CI) + +Options: + --ablation Run a no-plugin baseline arm and report the score + delta (none | with-without; default: with-without + whenever a plugin resolves — by name, or from the + target path — and none when nothing does; under + with-without, graders marked with-only, incl. + `tool_used: Skill`, are a plugin-fired indicator + rather than part of the score) + --allow-real-servers With --mocks record: also start the plugin's REAL + MCP server processes for servers that have no mock + (they run as you, outside the OS sandbox that + confines shell tools; use only on plugins you trust) + --allow-tools Operator grant for gated tools (Bash, Write, Edit, + WebFetch, mcp__*). Supports Tool(pattern:*) syntax + --case Filter cases by name glob + -j, --concurrency Run up to agent runs at once (1-8; default 1). + Each run is a full claude child on your own + credential, so they share one rate limit; results + and the report keep case order + --eval-dir Directory name (below the plugin) that holds the + eval cases; results go to //results/ — + for an installed-plugin target, .//results/ + with this flag, else ./evals/results/ (default dir: + the manifest's experimental.evals value, else + evals/) + -h, --help Display help for command + --json [path] Print the full run result (prompts, graders, per-run + scores) as JSON to stdout, or write it to this .json + file + --judge-model Override LLM-grader model (default: haiku) + --keep-temp Preserve scaffold dirs for debugging + --max-cost-usd Optional hard cost ceiling; abort and report partial + results if hit (exit 2). The ceiling is checked + before each run launches, so overrun is bounded to + the runs in flight (one, or up to --concurrency) — + when a run breaches, paid graders (llm/baseline) are + skipped while free graders still score it. Runs are + already bounded by max_turns and timeout_seconds — + only set this when you need a strict budget + --mocks Mock stand-ins for MCP servers, from /mocks/ (record | off; default: record). record: + a plugin server with no mock is NOT started (see + --allow-real-servers); off: no stand-ins, every real + server starts (as you, outside the OS sandbox), its + tools gated by --allow-tools + --model Override model for all cases + --no-publish Keep the HTML report local only; skip publishing it + to claude.ai + --no-scaffold Explicitly skip scaffold_script + --output-dir Directory for aggregate-result.json (default: + .//results//) + --publish-report Also require publishing the report to claude.ai + (already the default when your account supports it); + explains why if unavailable + --report Write the self-contained HTML report (scores, + prompts, grader verdicts) to instead of the + results dir + --runs Override per-case runs (default: case.runs ?? 3) + --scaffold Run each case's scaffold_script (runs + author-supplied bash as you; off by default — only + use on case files you authored) + --tag Filter cases by tag (repeatable) + --threshold <0..1> Exit 1 if any case score is below this threshold + (default: 1.0) + --trust-plugin Assert that you trust this plugin's code and eval + suite, and skip the first-run trust prompt (for CI; + like --dangerously-skip-permissions, only pass it + for plugins you would run yourself). Does not imply + --scaffold, --allow-tools or --mocks off + --verbose Log per-message trace events to the debug log (use + --debug-file to read them) + +Commands: + init [options] [name] Author an eval suite under the eval dir (evals/ + unless --eval-dir or the manifest says otherwise) + via an interview that sources inputs and designs + graders. Use --bare for a blank single-case + template. +[exit 0] + +=== claude plugin marketplace --help +Usage: claude plugin marketplace [options] [command] + +Manage Claude Code marketplaces + +Options: + -h, --help Display help for command + +Commands: + add [options] Add a marketplace from a URL, path, or GitHub repo + help [command] display help for command + list [options] List all configured marketplaces + remove|rm [options] Remove a configured marketplace + update [options] [name] Update marketplace(s) from their source - updates + all if no name specified +[exit 0] + +=== claude plugin marketplace add --help +Usage: claude plugin marketplace add [options] + +Add a marketplace from a URL, path, or GitHub repo + +Options: + --claudeai Add the marketplace of this name that claude.ai hosts for + you, by its listed name or its local name (see: claude + plugin marketplace list) + -h, --help Display help for command + --scope Where to declare the marketplace: user (default), + project, or local + --sparse Limit checkout to specific directories via git + sparse-checkout (for monorepos). Example: --sparse + .claude-plugin plugins +[exit 0] + +=== claude plugin marketplace list --help +Usage: claude plugin marketplace list [options] + +List all configured marketplaces + +Options: + -h, --help Display help for command + --json Output as JSON +[exit 0] + +=== claude plugin marketplace remove --help +Usage: claude plugin marketplace remove|rm [options] + +Remove a configured marketplace + +Options: + -h, --help Display help for command + --scope Remove the marketplace declaration from a specific settings + scope: user, project, or local. Omit to remove it from every + scope. +[exit 0] + +=== claude plugin marketplace update --help +Usage: claude plugin marketplace update [options] [name] + +Update marketplace(s) from their source - updates all if no name specified + +Options: + -h, --help Display help for command +[exit 0] +``` + diff --git a/crates/agent/tests/fixtures/plugins/claude/details.txt b/crates/agent/tests/fixtures/plugins/claude/details.txt new file mode 100644 index 000000000..3f08e5470 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/details.txt @@ -0,0 +1,21 @@ +alpha 1.0.0 + Description: Skill, command, SessionStart hook and an unreachable HTTP MCP server + Source: alpha@tcode-probe + +Component inventory + Skills (2) greet, hello + Agents (0) + Hooks (1) SessionStart (harness-only — no model context cost) + MCP servers (1) alpha-dead (tool schemas resolved at runtime; not counted) + LSP servers (0) + +Projected token cost + Always-on: ~32 tok added to every session + +Per-component (rounded) + component always-on on-invoke + greet < 20 < 20 + hello < 20 < 20 + + On-invoke cost is paid each time a skill or agent fires. + Token counts are estimates and may differ from actual usage. diff --git a/crates/agent/tests/fixtures/plugins/claude/disable-scope-user.json b/crates/agent/tests/fixtures/plugins/claude/disable-scope-user.json new file mode 100644 index 000000000..12c2473fb --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/disable-scope-user.json @@ -0,0 +1 @@ +{"command":"disable","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"user","message":"Successfully disabled plugin: alpha (scope: user)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/disable.json b/crates/agent/tests/fixtures/plugins/claude/disable.json new file mode 100644 index 000000000..85145a0c6 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/disable.json @@ -0,0 +1 @@ +{"command":"disable","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"project","message":"Successfully disabled plugin: alpha (scope: project)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/enable.json b/crates/agent/tests/fixtures/plugins/claude/enable.json new file mode 100644 index 000000000..bf7d18c24 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/enable.json @@ -0,0 +1 @@ +{"command":"enable","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"project","message":"Successfully enabled plugin: alpha (scope: project)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-command-source-accepted.json b/crates/agent/tests/fixtures/plugins/claude/install-command-source-accepted.json new file mode 100644 index 000000000..93b86b79a --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-command-source-accepted.json @@ -0,0 +1,4 @@ +"gamma" is installed by running a command from marketplace "tcode-probe" on this machine: + echo /tmp/tcode-probe/gamma-src + (mode "copy": its output directory is copied into the plugin cache) +{"command":"install","outcome":"ok","plugin":"gamma@tcode-probe","pluginId":"gamma@tcode-probe","scope":"user","message":"Successfully installed plugin: gamma@tcode-probe (scope: user)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-command-source-challenge.txt b/crates/agent/tests/fixtures/plugins/claude/install-command-source-challenge.txt new file mode 100644 index 000000000..a079a23c6 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-command-source-challenge.txt @@ -0,0 +1,5 @@ +"gamma" is installed by running a command from marketplace "tcode-probe" on this machine: + echo /tmp/tcode-probe/gamma-src + (mode "copy": its output directory is copied into the plugin cache) +Not an interactive terminal, so the command was only displayed, not accepted. Re-run in a terminal to confirm it, or pass -y/--yes to accept the command shown above. +{"command":"install","outcome":"failed","plugin":"gamma@tcode-probe","scope":"user","message":"gamma@tcode-probe is installed by running a command on this machine (`echo /tmp/tcode-probe/gamma-src`) that has not been reviewed yet, so it was not run. Review and accept it from its /plugin details pane, or in a terminal: run `claude plugin install gamma@tcode-probe`.","failureCode":"command_source_refused","shownCommand":{"kind":"command_source","pluginId":"gamma@tcode-probe","command":"echo /tmp/tcode-probe/gamma-src","mode":"copy","catalogRevision":"sha256:cab213ed98e09aae33c15cf3ff6a1617518d74aa0543466d5987b04050bcb5bb","sha256":"b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"}} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-command-source-wrong-hash.json b/crates/agent/tests/fixtures/plugins/claude/install-command-source-wrong-hash.json new file mode 100644 index 000000000..5f33073de --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-command-source-wrong-hash.json @@ -0,0 +1,5 @@ +"gamma" is installed by running a command from marketplace "tcode-probe" on this machine: + echo /tmp/tcode-probe/gamma-src + (mode "copy": its output directory is copied into the plugin cache) +--accept-command does not name the command shown above (it may have changed since it was shown), so it was not run. Show it to the person again before accepting it. +{"command":"install","outcome":"failed","plugin":"gamma@tcode-probe","scope":"user","message":"gamma@tcode-probe is installed by running a command on this machine (`echo /tmp/tcode-probe/gamma-src`) that has not been reviewed yet, so it was not run. Review and accept it from its /plugin details pane, or in a terminal: run `claude plugin install gamma@tcode-probe`.","failureCode":"command_source_refused","shownCommand":{"kind":"command_source","pluginId":"gamma@tcode-probe","command":"echo /tmp/tcode-probe/gamma-src","mode":"copy","catalogRevision":"sha256:cab213ed98e09aae33c15cf3ff6a1617518d74aa0543466d5987b04050bcb5bb","acceptCommandMatched":false,"sha256":"b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"}} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-user.json b/crates/agent/tests/fixtures/plugins/claude/install-user.json new file mode 100644 index 000000000..5898e35ed --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-user.json @@ -0,0 +1 @@ +{"command":"install","outcome":"ok","plugin":"alpha@tcode-probe","pluginId":"alpha@tcode-probe","scope":"user","message":"Successfully installed plugin: alpha@tcode-probe (scope: user)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/marketplace-list.json b/crates/agent/tests/fixtures/plugins/claude/marketplace-list.json new file mode 100644 index 000000000..f3efbfed3 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/marketplace-list.json @@ -0,0 +1,8 @@ +[ + { + "name": "tcode-probe", + "source": "directory", + "path": "/tmp/tcode-probe/mkt", + "installLocation": "/tmp/tcode-probe/mkt" + } +] diff --git a/crates/agent/tests/fixtures/plugins/claude/plugin-list-available-after-install.json b/crates/agent/tests/fixtures/plugins/claude/plugin-list-available-after-install.json new file mode 100644 index 000000000..4ec638ae8 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/plugin-list-available-after-install.json @@ -0,0 +1,61 @@ +{ + "installed": [ + { + "id": "alpha@tcode-probe", + "version": "1.0.0", + "scope": "user", + "enabled": true, + "installPath": "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0", + "installedAt": "2026-10-03T02:13:48.097Z", + "lastUpdated": "2026-10-03T02:13:48.097Z", + "mcpServers": { + "alpha-dead": { + "type": "http", + "url": "http://127.0.0.1:9/mcp" + } + }, + "projectEnabled": true + }, + { + "id": "alpha@tcode-probe", + "version": "1.0.0", + "scope": "project", + "enabled": true, + "installPath": "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0", + "installedAt": "2026-10-03T02:13:50.068Z", + "lastUpdated": "2026-10-03T02:13:50.068Z", + "projectPath": "/private/tmp/tcode-probe/proj", + "mcpServers": { + "alpha-dead": { + "type": "http", + "url": "http://127.0.0.1:9/mcp" + } + }, + "projectEnabled": true + }, + { + "id": "beta@tcode-probe", + "version": "0.1.0", + "scope": "project", + "enabled": true, + "installPath": "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/beta/0.1.0", + "installedAt": "2026-10-03T02:13:49.458Z", + "lastUpdated": "2026-10-03T02:13:49.458Z", + "projectPath": "/private/tmp/tcode-probe/proj", + "projectEnabled": true + } + ], + "available": [ + { + "pluginId": "gamma@tcode-probe", + "name": "gamma", + "description": "Command-source plugin", + "marketplaceName": "tcode-probe", + "source": { + "source": "command", + "command": "echo /tmp/tcode-probe/gamma-src", + "timeout": 30 + } + } + ] +} diff --git a/crates/agent/tests/fixtures/plugins/claude/real-home-marketplace-list.json b/crates/agent/tests/fixtures/plugins/claude/real-home-marketplace-list.json new file mode 100644 index 000000000..7f345b028 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/real-home-marketplace-list.json @@ -0,0 +1,8 @@ +[ + { + "name": "claude-plugins-official", + "source": "github", + "repo": "anthropics/claude-plugins-official", + "installLocation": "/Users/tryanks/.claude/plugins/marketplaces/claude-plugins-official" + } +] diff --git a/crates/agent/tests/fixtures/plugins/claude/uninstall.json b/crates/agent/tests/fixtures/plugins/claude/uninstall.json new file mode 100644 index 000000000..939ba53d5 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/uninstall.json @@ -0,0 +1 @@ +{"command":"uninstall","outcome":"ok","plugin":"beta","pluginId":"beta@tcode-probe","scope":"project","keptData":false,"message":"Successfully uninstalled plugin: beta (scope: project)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/update.json b/crates/agent/tests/fixtures/plugins/claude/update.json new file mode 100644 index 000000000..b0928ebd3 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/update.json @@ -0,0 +1 @@ +{"command":"update","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"project","message":"Plugin \"alpha\" updated from 1.0.0 to 1.0.1 for scope project (/private/tmp/tcode-probe/proj). Restart to apply changes.","updateOutcome":"updated","oldVersion":"1.0.0","newVersion":"1.0.1"} diff --git a/crates/agent/tests/fixtures/plugins/codex/COMMANDS.md b/crates/agent/tests/fixtures/plugins/codex/COMMANDS.md new file mode 100644 index 000000000..ca1f62e39 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/COMMANDS.md @@ -0,0 +1,427 @@ +# Codex plugin probe: commands and state + +Recorded 2026-10-03 against `codex-cli 0.159.3` (`codex --version`; npm +package `@openai/codex` installed through mise at +`~/.local/share/mise/installs/node/26.8.1/lib/node_modules/@openai/codex`), +macOS arm64. Findings that cite these files: `tmp/probe-codex-plugins.md`. + +## Isolation + +- `CODEX_HOME=/tmp/tcode-probe/codex-home` for every recorded run. It started + empty. No `auth.json` was copied: no request needed authentication (see the + findings, question 6). +- `HOME=/tmp/tcode-probe/codex-fakehome` (empty) for every recorded run. + `CODEX_HOME` does not isolate `$HOME/.agents/skills`: an exploratory run + without this override listed the user's own skills + (`/Users//.agents/skills/*`, scope `user`) in `skills/list`. +- Project directory: `/tmp/tcode-probe/codex-proj` (a git repo with one + commit), not `/tmp/tcode-probe/proj`. A concurrent Claude-plugin probe uses + `/tmp/tcode-probe/proj` and wiped it in the middle of an exploratory run, so + this probe moved to `codex-`-prefixed directories: + `codex-proj`, `codex-elsewhere` (empty), `codex-nongit` (copy of + `codex-proj` without `.git`, marketplace renamed `tcode-probe-nongit`), + `codex-fakehome`. +- The no-network run used a separate fresh home + `/tmp/tcode-probe/codex-home-nonet` so the startup sync started from + nothing. +- `~/.codex` was never read or written. + +## app-server invocation + +Every server is `codex app-server` (argv exactly that, nothing else) with the +environment above plus `RUST_LOG=info` (stderr only; stdout frames are not +affected), stdin/stdout piped, and the process cwd stated per run. Framing is +the one in `crates/agent/src/codex.rs`: one JSON object per line, no +`"jsonrpc"` member, `initialize` with id 1 and + +```json +{"clientInfo":{"name":"tcode","title":"Tcode","version":"0.1.0"},"capabilities":{"experimentalApi":true}} +``` + +then the `{"method":"initialized"}` notification. Requests are sent one at a +time; each waits for its response. Teardown is closing stdin; every server +exited 0. + +The driver and scenario scripts stay outside the repository in +`/tmp/tcode-probe/` (`driver.py`, `scenario_main.py`, `scenario_cwd.py`, +`scenario_forms.py`, `scenario_nonet.py`, `scenario_reconcile.py`, +`race2.py`, `race3.py`, `package.py`); stderr logs are in +`/tmp/tcode-probe/logs/`. + +## File formats + +- Per-method files (`marketplace-add.jsonl` … `marketplace-remove.jsonl`): + every line is a literal frame of server A in the main run, request then + response. Only the frames the Codex adapter's tests read are kept; the + id → step mapping below marks the others "(not kept)". The observer, + notification, cwd-discovery, manifest-form, no-network and + external-reconcile runs are described for provenance; their recordings, + the CLI outputs, the generated schemas and the authored marketplaces are + not kept. +- `config-read.jsonl`: recorded later (2026-10-03, same binary) with the same + driver and framing against a fresh `CODEX_HOME=/tmp/tcode-plugins-live/rec-home`, + process cwd `/tmp`: `marketplace/add {"source":"/tmp/tcode-probe/codex-mkt"}` + (id 2, not kept), then `config/read {}` (id 3). Its `marketplaces` table is + the one the main run's `config.toml` snapshot shows after id 2. + +## Marketplace layout + +`/tmp/tcode-probe/codex-mkt` (marketplace `tcode-probe-mkt`, added by local +path): + +``` +.agents/plugins/marketplace.json name tcode-probe-mkt; alpha AVAILABLE/ON_INSTALL, beta AVAILABLE/ON_USE +plugins/alpha/.codex-plugin/plugin.json name, version 0.1.0, description, keywords, skills "./skills/", mcpServers "./mcp.json", interface +plugins/alpha/mcp.json mcpServers.alpha-docs: streamable-http http://127.0.0.1:9/mcp (nothing listens) +plugins/alpha/hooks/hooks.json SessionStart command "echo tcode-probe-alpha-hook" (found by default discovery) +plugins/alpha/skills/alpha-hello/SKILL.md +plugins/beta/.codex-plugin/plugin.json name, version 0.2.0, description, skills, interface +plugins/beta/skills/beta-hello/SKILL.md +``` + +alpha uses the `.codex-plugin/plugin.json` form because 0.159.3 loads plugin +hooks only from that form (manifest-form run). The first draft of alpha +used a root portable `plugin.json`; its hook never appeared. + +`/tmp/tcode-probe/codex-proj` (repo marketplace `tcode-probe-repo`): +`.agents/plugins/marketplace.json` with one +plugin `gamma` (`.codex-plugin/plugin.json`, one skill). + +Manifest-form matrix (`/tmp/tcode-probe/codex-formtest`, `codex-hooktest`, +`codex-mcptest`; the recording is not kept): + +| plugin | layout | +|---|---| +| rootonly | root `plugin.json` without `$schema` | +| compatonly | `.codex-plugin/plugin.json` only | +| both | root `plugin.json` without `$schema` + `.codex-plugin/plugin.json` | +| none | no manifest, `skills/` only | +| rootschema | root `plugin.json` with Agent Plugins `$schema` | +| hcompatfield | compat manifest, `"hooks": "./hooks/hooks.json"` | +| hcompatdefault | compat manifest, no `hooks` field, `hooks/hooks.json` present | +| hcompatinline | compat manifest, inline `hooks` object | +| hrootdefault | root `$schema` manifest, `hooks/hooks.json` present | +| hrootext | root `$schema` manifest, `extensions."com.openai".hooks` path | +| mcompatmcpjson | compat manifest, portable `mcp.json`, no `mcpServers` field | +| mcompatdotmcp | compat manifest, `"mcpServers": "./.mcp.json"` | +| mcompatptr | compat manifest, `"mcpServers": "./mcp.json"` (portable file) | +| aboth | root `$schema` manifest + compat overlay, `hooks/hooks.json`, `mcp.json` | +| aboth2 | as aboth, overlay with explicit `"hooks": "./hooks/hooks.json"` | +| aroothookext | root `$schema` manifest with `extensions."com.openai"` hooks + interface, no overlay | + +## Main run (`scenario_main.py`) + +Process cwd `/tmp/tcode-probe/codex-proj` for all five servers. + +- **A**: the management server; all per-method files. +- **B1-passive**: starts after A initialized; `initialize` only. +- **B2-reader**: after every A mutation, sends `plugin/installed {}`, + `skills/list {cwds:[proj]}` and `hooks/list {cwds:[proj]}` itself. +- **B3-session**: `thread/start {"cwd":proj,"approvalPolicy":"untrusted","sandbox":"read-only"}` + (the shape `codex.rs` sends), then idle. +- **B4-session-after-install**: same `thread/start`, started after A's + install, then idle. + +The observer frames were recorded but are not kept. B1, B3 and B4 start only +after A has initialized the home: starting several servers at the same moment +on a fresh `CODEX_HOME` makes some exit before replying with +`Error: failed to initialize sqlite state runtime under ` (12 of 36 +starts in `race2.py`, 3 servers × 12 fresh homes; 0 of 36 on an already +initialized home in `race3.py`). + +Server A, in order (`t` = seconds since start): + +| id | request | file | +|---|---|---| +| 1 | `initialize`, then `initialized` | (not kept) | +| 2 | `marketplace/add {"source":"/tmp/tcode-probe/codex-mkt"}` | marketplace-add.jsonl | +| 3 | `plugin/list {}` | (not kept) | +| 4 | `plugin/list {"cwds":[proj]}` | plugin-list-with-cwds.jsonl | +| 5 | `plugin/read {"pluginName":"alpha","marketplacePath":}` | plugin-read.jsonl | +| 6 | `skills/list {"cwds":[proj]}` before install | (not kept) | +| 7 | `hooks/list {"cwds":[proj]}` before install | (not kept) | +| 8 | `plugin/install {"pluginName":"alpha","marketplacePath":…}` | plugin-install.jsonl | +| 9 | `plugin/installed {}` | (not kept) | +| 10 | `plugin/installed {"cwds":[proj]}` | plugin-installed.jsonl | +| 11 | `skills/list` (enabled) | skills-list.jsonl | +| 12 | `hooks/list` (enabled) | hooks-list.jsonl | +| — | CLI `codex plugin list --json`, `… --json --available`, `codex plugin marketplace list --json` | (not kept) | +| 13 | `config/value/write {"keyPath":"plugins.alpha@tcode-probe-mkt.enabled","value":false,"mergeStrategy":"upsert"}` | config-value-write-enabled.jsonl | +| 14 | `plugin/installed {}` (disabled) | plugin-installed-after-disable.jsonl | +| 15 | `plugin/reconcile {"reason":…}` | (not kept) | +| 16 | `skills/list` (disabled) | skills-list.jsonl | +| 17 | `skills/list {"cwds":[proj],"forceReload":true}` (disabled) | (not kept) | +| 18 | `hooks/list` (disabled) | hooks-list.jsonl | +| — | CLI `codex plugin list --json` (disabled) | (not kept) | +| 19 | `config/value/write … "value":true …` | (not kept) | +| 20 | `plugin/installed {}` (re-enabled) | (not kept) | +| 21 | `skills/list` (re-enabled) | (not kept) | +| 22 | `hooks/list` (re-enabled) | (not kept) | +| 23 | `plugin/uninstall {"pluginId":"alpha@tcode-probe-mkt"}` | plugin-uninstall.jsonl | +| 24 | `plugin/reconcile` (after uninstall) | (not kept) | +| 25 | `plugin/installed {}` (after uninstall) | plugin-installed.jsonl | +| 26 | `skills/list` (after uninstall) | (not kept) | +| 27 | `hooks/list` (after uninstall) | (not kept) | +| 28 | `marketplace/remove {"marketplaceName":"tcode-probe-mkt"}` | marketplace-remove.jsonl | +| 29 | `plugin/list {"cwds":[proj]}` (after remove; curated catalog has synced by now) | (not kept) | + +After the lifecycle, the watcher control wrote +`$HOME/.agents/skills/probe-control/SKILL.md`, then +`/.agents/skills/probe-repo-control/SKILL.md`, then removed both, with a +4 s quiet window after each step (recording not kept). + +CLI commands (cwd `codex-proj`, same environment, no `RUST_LOG`): + +``` +codex plugin list --json +codex plugin list --json --available +codex plugin marketplace list --json +codex plugin list --json # after id 13 +``` + +## Other runs + +- `scenario_cwd.py` (cwd discovery). Server `D-cwd-proj` (cwd + `codex-proj`): `plugin/list` with `{}`, `cwds:[proj]`, `cwds:[proj/plugins]` + and `cwds:[codex-nongit]`; `skills/list {}` and `hooks/list {}`; + `plugin/install gamma` from the repo marketplace; `plugin/installed` with + `{}` and with `cwds:[proj]`; `skills/list` with `cwds:[codex-elsewhere]` and + with `cwds:[proj]`. Server `D-cwd-elsewhere` (cwd `codex-elsewhere`): + `plugin/list` and `plugin/installed`, each with `{}` and with `cwds:[proj]`; + `skills/list` with `{}` and with `cwds:[proj]`; + `plugin/uninstall gamma@tcode-probe-repo`; `plugin/installed cwds:[proj]`. +- `scenario_forms.py` (manifest forms). cwd `codex-proj`; for each of + the three test marketplaces: `marketplace/add`, `plugin/read` for every + plugin, then `marketplace/remove`. +- `scenario_nonet.py` (no network). `sandbox-exec -f nonet.sb codex + app-server`, where `nonet.sb` is + `(version 1) (allow default) (deny network-outbound (remote ip))`, with + `CODEX_HOME=/tmp/tcode-probe/codex-home-nonet` (fresh), cwd `codex-proj`: + the whole local lifecycle, plus `marketplace/add {"source":"openai/plugins"}` + and `plugin/list {"forceRefetch":true}`. +- `scenario_reconcile.py` (external reconcile). Server + `C-reconciler`: `marketplace/add`, then `plugin/reconcile` as a baseline, + then again after each change made outside it: CLI + `codex plugin add alpha@tcode-probe-mkt` (stdout + ``Added plugin `alpha` from marketplace `tcode-probe-mkt`.`` and + `Installed plugin root: /private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0`), + a second server `D-writer` sending `config/value/write enabled=false`, and + CLI `codex plugin remove alpha@tcode-probe-mkt` (stdout + ``Removed plugin `alpha` from marketplace `tcode-probe-mkt`.``). Ends with + `marketplace/remove`. + +## `CODEX_HOME` state after each mutating step (main run) + +Besides `config.toml` and `plugins/`, the first server start created the +state databases (`state_5.sqlite`, `logs_2.sqlite`, …), `installation_id`, +`skills/.system/` (five bundled system skills) and `.tmp/plugins/`. That last +directory is a checkout of the curated marketplace (`plugins.sha` +`5fd93af4cd0c623e020d0cc7e9ce178b4ac1f70f`); it shows up as marketplace +`openai-api-curated` once the background sync finishes, as in id 29 but not +yet at id 3. + +#### before any request + +`config.toml`: + +```toml + +``` + +`plugins/`: + +``` + +``` + +#### after marketplace/add + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" +``` + +`plugins/`: + +``` + +``` + +#### after plugin/install alpha + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = true +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after config/value/write enabled=false + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = false +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after plugin/reconcile (after disable) + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = false +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after config/value/write enabled=true + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = true +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after plugin/uninstall alpha + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +``` + +#### after plugin/reconcile (after uninstall) + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +``` + +#### after marketplace/remove + +`config.toml`: + +```toml +(empty file) +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +``` diff --git a/crates/agent/tests/fixtures/plugins/codex/config-read.jsonl b/crates/agent/tests/fixtures/plugins/codex/config-read.jsonl new file mode 100644 index 000000000..3c4358bdf --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/config-read.jsonl @@ -0,0 +1,2 @@ +{"id":3,"method":"config/read","params":{}} +{"id":3,"result":{"config":{"model":null,"review_model":null,"model_context_window":null,"model_auto_compact_token_limit":null,"model_auto_compact_token_limit_scope":null,"model_provider":null,"approval_policy":null,"approvals_reviewer":null,"sandbox_mode":null,"sandbox_workspace_write":null,"forced_chatgpt_workspace_id":null,"forced_login_method":null,"web_search":null,"tools":null,"instructions":null,"developer_instructions":null,"compact_prompt":null,"model_reasoning_effort":null,"model_reasoning_summary":null,"model_verbosity":null,"service_tier":null,"analytics":null,"apps":null,"browser_use":null,"computer_use":null,"desktop":null,"project_doc_fallback_filenames":[],"tui":null,"sqlite_home":null,"experimental_realtime_ws_startup_context":null,"include_environment_context":true,"permissions":null,"features":{"api_key_model_discovery":false,"auth_elicitation":true,"background_paginated_rollout_migration":false,"codex_apps_mcp_2026_07_28":false,"mcp_2026_07_28":false,"memories":false,"mentions_v2":true,"remote_control":false,"remote_plugin":true,"tool_suggest":true,"windows_sandbox_service":false},"experimental_realtime_start_instructions":null,"experimental_realtime_ws_model":null,"responses_api_metadata":null,"hide_agent_reasoning":false,"plugins":{},"include_permissions_instructions":true,"suppress_unstable_features_warning":null,"model_post_turn_compact_threshold_percent":null,"memories":null,"feedback":null,"personality":null,"experimental_thread_store":null,"agents":null,"skills":null,"project_root_markers":[".git"],"js_repl_node_path":null,"file_opener":"vscode","check_for_update_on_startup":null,"experimental_compact_prompt_file":null,"show_raw_agent_reasoning":null,"mcp_oauth_callback_url":null,"orchestrator":null,"mcp_optional_startup_grace_ms":null,"mcp_oauth_callback_port":null,"default_permissions":null,"js_repl_node_module_dirs":null,"experimental_use_unified_exec_tool":null,"include_apps_instructions":true,"model_catalog_json":null,"tool_output_token_limit":null,"cli_auth_credentials_store":"file","model_instructions_file":null,"mcp_servers":{},"experimental_realtime_ws_base_url":null,"mcp_oauth_credentials_store":"auto","history":{"persistence":"save-all","max_bytes":null},"auto_review":null,"allow_login_shell":true,"plan_mode_reasoning_effort":null,"ghost_snapshot":null,"profiles":{},"include_collaboration_mode_instructions":true,"experimental_thread_store_endpoint":null,"notify":null,"hooks":null,"notice":null,"marketplaces":{"tcode-probe-mkt":{"last_updated":null,"last_revision":null,"source_type":"local","source":"/private/tmp/tcode-probe/codex-mkt","ref":null,"sparse_paths":null}},"project_doc_max_bytes":32768,"thread_unload_delay_secs":null,"profile":null,"otel":null,"mcp_enterprise_managed_auth":null,"apps_mcp_product_sku":null,"allow_symlinked_codex_home":null,"audio":null,"model_providers":{},"windows":null,"background_terminal_max_timeout":300000,"log_dir":null,"shell_environment_policy":{"inherit":null,"ignore_default_excludes":null,"exclude":null,"set":null,"include_only":null,"filters":null,"experimental_use_profile":null},"realtime":null,"experimental_realtime_ws_backend_prompt":null,"goals":null,"experimental_realtime_webrtc_call_base_url":null,"tool_suggest":null,"cloud":null,"projects":null,"disable_paste_burst":null,"oss_provider":null,"chatgpt_base_url":"https://chatgpt.com/backend-api/","openai_base_url":null},"origins":{"marketplaces.tcode-probe-mkt.source":{"name":{"type":"user","file":"/private/tmp/tcode-plugins-live/rec-home/config.toml","profile":null},"version":"sha256:537636f0796f5eb8ed3546ae0ab7d7b0858dbe69f8c0555ed1c2f9043ff0dd7c"},"marketplaces.tcode-probe-mkt.source_type":{"name":{"type":"user","file":"/private/tmp/tcode-plugins-live/rec-home/config.toml","profile":null},"version":"sha256:537636f0796f5eb8ed3546ae0ab7d7b0858dbe69f8c0555ed1c2f9043ff0dd7c"}}}} diff --git a/crates/agent/tests/fixtures/plugins/codex/config-value-write-enabled.jsonl b/crates/agent/tests/fixtures/plugins/codex/config-value-write-enabled.jsonl new file mode 100644 index 000000000..b53d4e946 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/config-value-write-enabled.jsonl @@ -0,0 +1,2 @@ +{"id":13,"method":"config/value/write","params":{"keyPath":"plugins.alpha@tcode-probe-mkt.enabled","value":false,"mergeStrategy":"upsert"}} +{"id":13,"result":{"status":"ok","version":"sha256:f2be58cf32b904ebdd370e6f824091b0f16b18977506d313e63350b26151f846","filePath":"/private/tmp/tcode-probe/codex-home/config.toml","overriddenMetadata":null}} diff --git a/crates/agent/tests/fixtures/plugins/codex/hooks-list.jsonl b/crates/agent/tests/fixtures/plugins/codex/hooks-list.jsonl new file mode 100644 index 000000000..c63f5eee9 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/hooks-list.jsonl @@ -0,0 +1,4 @@ +{"id":12,"method":"hooks/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":12,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","hooks":[{"key":"alpha@tcode-probe-mkt:hooks/hooks.json:session_start:0:0","eventName":"sessionStart","handlerType":"command","command":"echo tcode-probe-alpha-hook","async":false,"matcher":null,"timeoutSec":600,"statusMessage":"tcode probe alpha hook","additionalContextLimit":null,"sourcePath":"/private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json","source":"plugin","pluginId":"alpha@tcode-probe-mkt","displayOrder":0,"enabled":true,"isManaged":false,"currentHash":"sha256:d628fcaca98205d5b4dc811ab48aea01b3e8fd22a09c76192e72228d94939dc8","trustStatus":"untrusted"}],"warnings":[],"errors":[]}]}} +{"id":18,"method":"hooks/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":18,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","hooks":[],"warnings":[],"errors":[]}]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/marketplace-add.jsonl b/crates/agent/tests/fixtures/plugins/codex/marketplace-add.jsonl new file mode 100644 index 000000000..7328163b5 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/marketplace-add.jsonl @@ -0,0 +1,2 @@ +{"id":2,"method":"marketplace/add","params":{"source":"/tmp/tcode-probe/codex-mkt"}} +{"id":2,"result":{"marketplaceName":"tcode-probe-mkt","installedRoot":"/private/tmp/tcode-probe/codex-mkt","alreadyAdded":false}} diff --git a/crates/agent/tests/fixtures/plugins/codex/marketplace-remove.jsonl b/crates/agent/tests/fixtures/plugins/codex/marketplace-remove.jsonl new file mode 100644 index 000000000..859a6afa7 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/marketplace-remove.jsonl @@ -0,0 +1,2 @@ +{"id":28,"method":"marketplace/remove","params":{"marketplaceName":"tcode-probe-mkt"}} +{"id":28,"result":{"marketplaceName":"tcode-probe-mkt","installedRoot":null}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-install.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-install.jsonl new file mode 100644 index 000000000..d4640e5f2 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-install.jsonl @@ -0,0 +1,2 @@ +{"id":8,"method":"plugin/install","params":{"pluginName":"alpha","marketplacePath":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json"}} +{"id":8,"result":{"authPolicy":"ON_INSTALL","appsNeedingAuth":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-installed-after-disable.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-installed-after-disable.jsonl new file mode 100644 index 000000000..1f148b3c7 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-installed-after-disable.jsonl @@ -0,0 +1,2 @@ +{"id":14,"method":"plugin/installed","params":{}} +{"id":14,"result":{"marketplaces":[{"name":"tcode-probe-mkt","path":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","interface":{"displayName":"Tcode Probe Marketplace"},"plugins":[{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":true,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]}]}],"marketplaceLoadErrors":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-installed.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-installed.jsonl new file mode 100644 index 000000000..02d9b80b1 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-installed.jsonl @@ -0,0 +1,4 @@ +{"id":10,"method":"plugin/installed","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":10,"result":{"marketplaces":[{"name":"tcode-probe-mkt","path":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","interface":{"displayName":"Tcode Probe Marketplace"},"plugins":[{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":true,"installedAt":null,"enabled":true,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]}]}],"marketplaceLoadErrors":[]}} +{"id":25,"method":"plugin/installed","params":{}} +{"id":25,"result":{"marketplaces":[],"marketplaceLoadErrors":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-list-with-cwds.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-list-with-cwds.jsonl new file mode 100644 index 000000000..b3c4e4041 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-list-with-cwds.jsonl @@ -0,0 +1,2 @@ +{"id":4,"method":"plugin/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":4,"result":{"marketplaces":[{"name":"tcode-probe-mkt","path":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","interface":{"displayName":"Tcode Probe Marketplace"},"plugins":[{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]},{"id":"beta@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.2.0","name":"beta","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/beta"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_USE","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Beta Probe","shortDescription":"Skill-only probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":[]}]},{"name":"tcode-probe-repo","path":"/tmp/tcode-probe/codex-proj/.agents/plugins/marketplace.json","interface":null,"plugins":[{"id":"gamma@tcode-probe-repo","remotePluginId":null,"version":null,"localVersion":"0.3.0","name":"gamma","shareContext":null,"source":{"type":"local","path":"/tmp/tcode-probe/codex-proj/plugins/gamma"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":null,"shortDescription":null,"longDescription":null,"developerName":null,"category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":[]}]}],"marketplaceLoadErrors":[],"featuredPluginIds":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-read.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-read.jsonl new file mode 100644 index 000000000..6dc694f97 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-read.jsonl @@ -0,0 +1,2 @@ +{"id":5,"method":"plugin/read","params":{"pluginName":"alpha","marketplacePath":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json"}} +{"id":5,"result":{"plugin":{"marketplaceName":"tcode-probe-mkt","marketplacePath":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","summary":{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]},"shareUrl":null,"description":"Tcode probe plugin alpha: one skill, one MCP server, one hook.","skills":[{"name":"alpha:alpha-hello","description":"Tcode probe skill from plugin alpha. Replies with the word alpha.","shortDescription":null,"interface":null,"path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha/skills/alpha-hello/SKILL.md","enabled":true}],"onboardingSkill":null,"hooks":[{"key":"alpha@tcode-probe-mkt:hooks/hooks.json:session_start:0:0","eventName":"sessionStart"}],"apps":[],"appTemplates":[],"mcpServers":["alpha-docs"],"scheduledTasks":null}}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-uninstall.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-uninstall.jsonl new file mode 100644 index 000000000..50e6b0c9f --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-uninstall.jsonl @@ -0,0 +1,2 @@ +{"id":23,"method":"plugin/uninstall","params":{"pluginId":"alpha@tcode-probe-mkt"}} +{"id":23,"result":{}} diff --git a/crates/agent/tests/fixtures/plugins/codex/skills-list.jsonl b/crates/agent/tests/fixtures/plugins/codex/skills-list.jsonl new file mode 100644 index 000000000..8b56da7bc --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/skills-list.jsonl @@ -0,0 +1,4 @@ +{"id":11,"method":"skills/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":11,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","skills":[{"name":"alpha:alpha-hello","description":"Tcode probe skill from plugin alpha. Replies with the word alpha.","path":"/private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md","scope":"user","enabled":true,"pluginId":"alpha@tcode-probe-mkt"},{"name":"imagegen","description":"Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas.","interface":{"displayName":"Image Gen","shortDescription":"Generate or edit images for websites, games, and more","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $imagegen to make or edit an image for this project."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"openai-docs","description":"Use for Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, customization, automations, and self-knowledge—including 'you,' 'your,' 'this app,' or 'this coding agent' when they refer to Codex—and for OpenAI APIs/products and ChatGPT Work. Also use for model choice/migration, prompting, SDKs, Responses, Realtime, agents, evals, and Chat/Work/Codex comparisons. Do not use for generic app/software tasks that merely mention Codex.","shortDescription":"Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, and self-knowledge; OpenAI APIs and ChatGPT Work. 'You'/'this app' means Codex only.","interface":{"displayName":"OpenAI Docs","shortDescription":"OpenAI and Codex docs for models, skills, tasks, and setup","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use OpenAI Docs for official docs lookup, questions about Codex itself or Codex surfaces, model selection, model migration, and prompt-upgrade work."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"review-agent","description":"Perform a read-only, defect-first review of a specified code change and return every actionable finding. Use when another agent delegates review of uncommitted changes, a base-branch diff, a commit, or custom review instructions.","interface":{"displayName":"Review Agent","shortDescription":"Find actionable bugs in code changes","iconSmall":null,"iconLarge":null,"iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $review-agent to review the requested code changes and return actionable findings."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/review-agent/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-creator","description":"Create or update a Codex skill with appropriately scoped instructions and any needed supporting resources.","shortDescription":"Create or update a skill","interface":{"displayName":"Skill Creator","shortDescription":"Create or update a skill","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-installer","description":"Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos).","shortDescription":"Install curated skills from openai/skills or other repos","interface":{"displayName":"Skill Installer","shortDescription":"Install curated skills from openai/skills or other repos","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/SKILL.md","scope":"system","enabled":true,"pluginId":null}],"errors":[]}]}} +{"id":16,"method":"skills/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":16,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","skills":[{"name":"imagegen","description":"Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas.","interface":{"displayName":"Image Gen","shortDescription":"Generate or edit images for websites, games, and more","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $imagegen to make or edit an image for this project."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"openai-docs","description":"Use for Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, customization, automations, and self-knowledge—including 'you,' 'your,' 'this app,' or 'this coding agent' when they refer to Codex—and for OpenAI APIs/products and ChatGPT Work. Also use for model choice/migration, prompting, SDKs, Responses, Realtime, agents, evals, and Chat/Work/Codex comparisons. Do not use for generic app/software tasks that merely mention Codex.","shortDescription":"Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, and self-knowledge; OpenAI APIs and ChatGPT Work. 'You'/'this app' means Codex only.","interface":{"displayName":"OpenAI Docs","shortDescription":"OpenAI and Codex docs for models, skills, tasks, and setup","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use OpenAI Docs for official docs lookup, questions about Codex itself or Codex surfaces, model selection, model migration, and prompt-upgrade work."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"review-agent","description":"Perform a read-only, defect-first review of a specified code change and return every actionable finding. Use when another agent delegates review of uncommitted changes, a base-branch diff, a commit, or custom review instructions.","interface":{"displayName":"Review Agent","shortDescription":"Find actionable bugs in code changes","iconSmall":null,"iconLarge":null,"iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $review-agent to review the requested code changes and return actionable findings."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/review-agent/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-creator","description":"Create or update a Codex skill with appropriately scoped instructions and any needed supporting resources.","shortDescription":"Create or update a skill","interface":{"displayName":"Skill Creator","shortDescription":"Create or update a skill","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-installer","description":"Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos).","shortDescription":"Install curated skills from openai/skills or other repos","interface":{"displayName":"Skill Installer","shortDescription":"Install curated skills from openai/skills or other repos","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/SKILL.md","scope":"system","enabled":true,"pluginId":null}],"errors":[]}]}} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index e04fa3f02..5bd961c0d 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -683,6 +683,52 @@ pub struct BrowserSettings { pub allow_evaluate: bool, } +/// Which providers' native plugins Settings → Plugins manages. Off, Tcode +/// never runs that CLI's plugin commands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginManagementSettings { + #[serde(default = "default_true")] + pub enabled: bool, + /// Per-provider switches keyed by [`provider_key`]; an absent key takes + /// [`Self::provider_default`]. Keyed by string rather than + /// [`ProviderKind`] so a provider this build does not know survives a + /// load and save instead of failing the whole file. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub providers: BTreeMap, +} + +impl Default for PluginManagementSettings { + fn default() -> Self { + Self { + enabled: true, + providers: BTreeMap::new(), + } + } +} + +impl PluginManagementSettings { + pub fn provider_default(kind: ProviderKind) -> bool { + kind == ProviderKind::Codex + } + + /// The provider's own switch, regardless of the master switch. + pub fn provider_switch(&self, kind: ProviderKind) -> bool { + self.providers + .get(provider_key(kind)) + .copied() + .unwrap_or_else(|| Self::provider_default(kind)) + } + + /// Whether Tcode may touch this provider's plugin state at all. + pub fn provider_enabled(&self, kind: ProviderKind) -> bool { + self.enabled && self.provider_switch(kind) + } + + fn is_default(&self) -> bool { + self == &Self::default() + } +} + /// A field-scoped mutation of persisted application settings. /// /// Keeping nested settings mutations field-scoped prevents a writer holding a @@ -719,6 +765,11 @@ pub enum SettingsPatch { BrowserEnabled(bool), BrowserHomeUrl(Option), BrowserAllowEvaluate(bool), + PluginManagementEnabled(bool), + PluginManagementProvider { + provider: ProviderKind, + enabled: bool, + }, TitleGenerationProvider(ProviderKind), TitleGenerationModel(String), TitleGenerationProfileId(Option), @@ -858,6 +909,8 @@ pub struct Settings { /// `orchestrate`), so legacy files stay clean and load with the defaults. #[serde(default, skip_serializing_if = "BrowserSettings::is_default")] pub browser: BrowserSettings, + #[serde(default, skip_serializing_if = "PluginManagementSettings::is_default")] + pub plugins: PluginManagementSettings, /// Provider/model used to generate a concise title for new threads. #[serde(default, skip_serializing_if = "TitleGenerationSettings::is_default")] pub title_generation: TitleGenerationSettings, @@ -959,6 +1012,7 @@ impl Default for Settings { orchestrate: OrchestrateSettings::default(), computer_use: ComputerUseSettings::default(), browser: BrowserSettings::default(), + plugins: PluginManagementSettings::default(), title_generation: TitleGenerationSettings::default(), fallback_review: FallbackReviewSettings::default(), collapsed_projects: Vec::new(), @@ -1048,6 +1102,12 @@ impl Settings { SettingsPatch::BrowserEnabled(value) => self.browser.enabled = value, SettingsPatch::BrowserHomeUrl(value) => self.browser.home_url = value, SettingsPatch::BrowserAllowEvaluate(value) => self.browser.allow_evaluate = value, + SettingsPatch::PluginManagementEnabled(value) => self.plugins.enabled = value, + SettingsPatch::PluginManagementProvider { provider, enabled } => { + self.plugins + .providers + .insert(provider_key(provider).to_string(), enabled); + } SettingsPatch::TitleGenerationProvider(value) => { self.title_generation.provider = value; } @@ -1285,12 +1345,26 @@ mod tests { assert!(legacy.browser.enabled); assert!(legacy.browser.allow_evaluate); assert_eq!(legacy.browser.home_url, None); + for settings in [&legacy, &Settings::default()] { + assert!(settings.plugins.provider_enabled(ProviderKind::Codex)); + for kind in [ + ProviderKind::ClaudeCode, + ProviderKind::Pi, + ProviderKind::OpenCode, + ProviderKind::Cursor, + ProviderKind::Grok, + ProviderKind::Acp, + ] { + assert!(!settings.plugins.provider_enabled(kind), "{kind:?}"); + } + } let partial: Settings = serde_json::from_str( r#"{ "computer_use":{"enabled":true}, "browser":{"enabled":false}, "orchestrate":{}, + "plugins":{"providers":{"claude":true,"kiro":true}}, "title_generation":{"provider":"codex","model":"m"} }"#, ) @@ -1308,6 +1382,17 @@ mod tests { ); assert!(!partial.orchestrate.child_worktrees); assert_eq!(partial.title_generation.profile_id, None); + assert!(partial.plugins.provider_enabled(ProviderKind::ClaudeCode)); + assert!(partial.plugins.provider_enabled(ProviderKind::Codex)); + let master_off: Settings = + serde_json::from_str(r#"{"plugins":{"enabled":false,"providers":{"claude":true}}}"#) + .unwrap(); + assert!(!master_off.plugins.provider_enabled(ProviderKind::Codex)); + assert!( + !master_off + .plugins + .provider_enabled(ProviderKind::ClaudeCode) + ); let child: OrchestrateChildModel = serde_json::from_str(r#"{"provider":"codex","model":"m","enabled":true}"#).unwrap(); diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index 179afabe5..02dc0a2d0 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; -use agent::{ApprovalDecision, ApprovalMode, InteractionMode, ProviderKind, RewindMode}; +use agent::{ + ApprovalDecision, ApprovalMode, InteractionMode, PluginScope, ProviderKind, RewindMode, +}; use serde::{Deserialize, Serialize}; use tcode_core::{ acp::AcpAgentPatch, @@ -12,7 +14,7 @@ use tcode_core::{ pub use tcode_core::settings::SettingsPatch; -use crate::ExternalThread; +use crate::{ExternalThread, RuntimeOperationId}; /// Fallback cell metrics matching the host emulator's own defaults, used when /// a client resizes without knowing its physical cell size. @@ -144,6 +146,58 @@ pub enum Command { session_id: String, id: String, }, + /// List a profile's native plugins as seen from `cwd` (a project + /// directory), or from outside any project when `cwd` is `None`. + RefreshProviderPlugins { + profile_id: String, + #[serde(default)] + cwd: Option, + }, + InstallProviderPlugin { + profile_id: String, + entry_id: String, + scope: PluginScope, + #[serde(default)] + cwd: Option, + }, + UninstallProviderPlugin { + profile_id: String, + entry_id: String, + scope: PluginScope, + #[serde(default)] + cwd: Option, + }, + SetProviderPluginEnabled { + profile_id: String, + entry_id: String, + scope: PluginScope, + enabled: bool, + #[serde(default)] + cwd: Option, + }, + UpdateProviderPlugin { + profile_id: String, + entry_id: String, + scope: PluginScope, + #[serde(default)] + cwd: Option, + }, + AddProviderMarketplace { + profile_id: String, + source: String, + #[serde(default)] + cwd: Option, + }, + RemoveProviderMarketplace { + profile_id: String, + name: String, + #[serde(default)] + cwd: Option, + }, + ResolvePluginChallenge { + op_id: RuntimeOperationId, + accept: bool, + }, ResetSettings, WriteRelaunchMarker { session_id: String, @@ -476,6 +530,7 @@ impl Command { | Self::RefreshProviderUsage | Self::CheckProviderVersions | Self::RefreshAcpRegistry + | Self::RefreshProviderPlugins { .. } | Self::LoadBranches { .. } | Self::CopyPlan { .. } | Self::DownloadPlan { .. } diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 80fb782a6..2d368e58f 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -190,6 +190,105 @@ pub struct ProvidersStatus { /// Environment-variable names present for each profile. Values never cross /// the protocol boundary. pub secret_names: HashMap>, + /// Native plugin catalogs of the profiles a client asked about, for + /// providers whose plugin management is switched on. + #[serde(default)] + pub plugins: Vec, +} + +/// One provider profile's native plugin catalog. The native CLI's own files +/// are the truth; the host only keeps the last listing in memory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderPluginCatalog { + pub profile_id: String, + /// The project directory the catalog was listed from; `None` lists from + /// outside any project, so only user scope is offered. + #[serde(default)] + pub context_cwd: Option, + #[serde(default)] + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_actions: Vec, + #[serde(default)] + pub entries: Vec, + /// Native errors for parts of the catalog that could not be read; the + /// rest of the listing still stands. + #[serde(default)] + pub errors: Vec, + pub state: PluginCatalogState, + #[serde(default)] + pub loading: bool, + /// Plugin ids or marketplace names with an operation in flight. + #[serde(default)] + pub pending: Vec, + #[serde(default)] + pub challenges: Vec, +} + +/// How far the entries can be trusted. A failed listing keeps the last +/// known entries rather than clearing them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum PluginCatalogState { + Fresh, + Stale { reason: PluginStaleReason }, + Error { message: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginStaleReason { + NotLoaded, + /// Listed for another directory or profile configuration. + ContextChanged, + /// A native change went through and has not been listed yet. + Changed, +} + +/// A native confirmation an operation is waiting on. It is answered with +/// [`crate::Command::ResolvePluginChallenge`]; a command the CLI shows again +/// arrives as a new challenge with a new `op_id`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginChallenge { + pub op_id: RuntimeOperationId, + pub profile_id: String, + /// The plugin id or marketplace name the operation targets. + pub entry_id: String, + pub kind: PluginChallengeKind, + #[serde(default)] + pub native_text: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum PluginChallengeKind { + /// Run this marketplace-declared command. + AcceptCommand { + command: String, + sha256: String, + #[serde(default)] + mode: Option, + }, + /// The native operation has no confirmation of its own but removes more + /// than its target: these installed plugins, in every scope. + ConfirmDestructive { uninstalls: Vec }, +} + +/// The native operation a plugin toast reports on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum PluginOperationTarget { + Plugin { + action: agent::PluginActionKind, + scope: agent::PluginScope, + plugin: String, + }, + AddMarketplace { + source: String, + }, + RemoveMarketplace { + name: String, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -435,6 +534,22 @@ pub enum RuntimeToast { name: String, detail: String, }, + PluginOperationStarted { + operation: RuntimeOperationId, + profile_id: String, + target: PluginOperationTarget, + }, + PluginOperationSucceeded { + operation: RuntimeOperationId, + profile_id: String, + target: PluginOperationTarget, + }, + PluginOperationFailed { + operation: RuntimeOperationId, + profile_id: String, + target: PluginOperationTarget, + detail: String, + }, } #[non_exhaustive] diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index cbc281754..282fd5455 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -16,10 +16,11 @@ pub use command::{Command, CommandResponse, SettingsPatch, TerminalSelection, Th pub use event::{ AcpMarketplaceItem, EventEnvelope, ExternalImportState, ExternalImportStatus, GitActionRequest, GitStatusStatus, IndexSnapshot, IndexSummary, MergeWorktreeFailure, NoticeSeverity, - ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError, - RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent, - SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus, - TerminalSplitStatus, TerminalStatus, Topic, + PluginCatalogState, PluginChallenge, PluginChallengeKind, PluginOperationTarget, + PluginStaleReason, ProviderPluginCatalog, ProviderVersionStatus, ProvidersStatus, + QueuedMessageStatus, RuntimeEffect, RuntimeError, RuntimeNotice, RuntimeNotification, + RuntimeOperationId, RuntimeToast, ServerEvent, SessionEventRecord, SessionStatus, + TcodeUpdateStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic, }; pub use query::{ ExternalThread, GitDiffResult, GitDiffScope, GitFileText, HostedDevice, HostingAction, @@ -48,7 +49,7 @@ pub use wire::{ // `ProviderKind` has no unknown fallback; version 9 sends Orchestrate rows that // leave bundled guidance out, which an older peer would read as no guidance. // -// Unreleased: (none) +// Unreleased: provider plugin catalogs, their commands, challenges and switches. pub const PROTOCOL_VERSION: u32 = 9; #[cfg(test)] diff --git a/crates/protocol/src/tests.rs b/crates/protocol/src/tests.rs index d93e37552..5f78080c0 100644 --- a/crates/protocol/src/tests.rs +++ b/crates/protocol/src/tests.rs @@ -1033,3 +1033,133 @@ fn path_info_kinds_read_older_peers_and_spell_the_flags() { ); assert!(!relayed.probing_direct); } + +/// A provider-plugin catalog and a plugin command are read by clients that +/// render the actions the host computed, so their literal shapes are the +/// contract; an older providers snapshot still decodes with no catalogs. +#[test] +fn provider_plugin_catalog_and_commands_use_their_documented_wire_shapes() { + let older: ProvidersStatus = serde_json::from_value(json!({ + "model_catalogs": {}, "models_loading": {}, "provider_versions": {}, + "tcode_update": {"current": "1.0.0", "latest": null, "release_url": null, + "update_available": false, "checking": false}, + "provider_snapshots": {}, "acp_marketplace_items": [], "acp_registry_loading": false, + "acp_registry_error": null, "acp_installing": [], "providers_checked_at": null, + "providers_checking": false, "secret_names": {} + })) + .unwrap(); + assert!(older.plugins.is_empty()); + + let catalog: ProviderPluginCatalog = serde_json::from_value(json!({ + "profile_id": "claude", + "context_cwd": "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/work/proj", + "marketplaces": [{"name": "tcode-probe", "source": "/tmp/tcode-probe/mkt", + "kind": "local_path", "location": "/tmp/tcode-probe/mkt"}], + "marketplace_actions": [ + {"type": "add"}, + {"type": "remove", "marketplace": "tcode-probe", "uninstalls": ["alpha@tcode-probe"]} + ], + "entries": [{ + "id": "alpha@tcode-probe", + "name": "alpha", + "version": "1.0.0", + "source": {"marketplace": "tcode-probe", "kind": "local_path"}, + "installations": [{"scope": "project", "location": "/cache/alpha/1.0.0", + "version": "1.0.0", "scope_enabled": true}], + "enabled": "yes", + "declared": {"skills": ["greet", "hello"], "hooks": ["SessionStart"], + "mcp_servers": ["alpha-dead"]}, + "actions": [{"type": "disable", "scope": "project"}, + {"type": "update", "scope": "project"}], + "diagnostics": [["message", "Restart to apply changes."]] + }], + "errors": ["failed to load marketplace /work/proj/.agents/plugins/marketplace.json"], + "state": {"type": "stale", "content": {"reason": "changed"}}, + "loading": true, + "pending": ["gamma@tcode-probe"], + "challenges": [{ + "op_id": 12, + "profile_id": "claude", + "entry_id": "gamma@tcode-probe", + "kind": {"type": "accept_command", "content": { + "command": "echo /tmp/tcode-probe/gamma-src", "sha256": "b9c02c85", "mode": "copy"}}, + "native_text": "\"gamma\" is installed by running a command" + }] + })) + .unwrap(); + assert_eq!( + catalog.errors, + ["failed to load marketplace /work/proj/.agents/plugins/marketplace.json"] + ); + let alpha = &catalog.entries[0]; + assert_eq!(alpha.enabled, agent::Tri::Yes); + assert_eq!(alpha.description, None); + assert!(alpha.errors.is_empty()); + assert_eq!( + alpha.installations[0], + agent::PluginInstallation { + scope: agent::PluginScope::Project, + location: Some(PathBuf::from("/cache/alpha/1.0.0")), + version: Some("1.0.0".into()), + scope_enabled: Some(true), + } + ); + let declared = alpha.declared.as_ref().unwrap(); + assert_eq!(declared.agents, None); + assert_eq!( + declared.mcp_servers.as_deref(), + Some(&["alpha-dead".to_string()][..]) + ); + assert_eq!( + alpha.actions, + [ + agent::PluginAction::Disable { + scope: agent::PluginScope::Project + }, + agent::PluginAction::Update { + scope: agent::PluginScope::Project + }, + ] + ); + assert_eq!( + catalog.marketplace_actions[1], + agent::MarketplaceAction::Remove { + marketplace: "tcode-probe".into(), + uninstalls: vec!["alpha@tcode-probe".into()], + } + ); + assert_eq!( + catalog.state, + PluginCatalogState::Stale { + reason: PluginStaleReason::Changed + } + ); + assert_eq!( + catalog.challenges[0].kind, + PluginChallengeKind::AcceptCommand { + command: "echo /tmp/tcode-probe/gamma-src".into(), + sha256: "b9c02c85".into(), + mode: Some("copy".into()), + } + ); + assert_eq!(catalog.challenges[0].op_id, RuntimeOperationId(12)); + + let command = decode_client_line(r#"{"id":4,"payload":{"type":"command","content":{"type":"install_provider_plugin","content":{"profile_id":"claude","entry_id":"gamma@tcode-probe","scope":"project","cwd":"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/work/proj"}}}}"#).unwrap(); + assert_eq!( + command.payload, + ClientPayload::Command(Command::InstallProviderPlugin { + profile_id: "claude".into(), + entry_id: "gamma@tcode-probe".into(), + scope: agent::PluginScope::Project, + cwd: Some(PathBuf::from("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/work/proj")), + }) + ); + let resolve = decode_client_line(r#"{"id":5,"payload":{"type":"command","content":{"type":"resolve_plugin_challenge","content":{"op_id":12,"accept":true}}}}"#).unwrap(); + assert_eq!( + resolve.payload, + ClientPayload::Command(Command::ResolvePluginChallenge { + op_id: RuntimeOperationId(12), + accept: true, + }) + ); +} diff --git a/crates/runtime/src/app/acp.rs b/crates/runtime/src/app/acp.rs index 0fabf7346..4197ecae2 100644 --- a/crates/runtime/src/app/acp.rs +++ b/crates/runtime/src/app/acp.rs @@ -200,7 +200,7 @@ impl AppState { /// provider rail). ACP agents have no model catalog: the agent publishes its /// models over the wire once the session starts. pub fn set_active_acp_agent(&mut self, target_id: &str, id: &str, cx: &mut HostCx) { - let provider_commands = self.cached_provider_commands(ProviderKind::Acp, Some(id)); + let provider_commands = self.cached_provider_commands(ProviderKind::Acp, None, Some(id)); let Some(active) = self.resident_mut(target_id) else { return; }; diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 7fad0bcee..2be2f726c 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -11,6 +11,7 @@ impl AppState { code: code.into(), message: message.into(), }; + self.validate_plugin_command(command)?; match command { Command::DeleteProfile { profile_id } if Settings::is_builtin_profile_id(profile_id) => diff --git a/crates/runtime/src/app/events.rs b/crates/runtime/src/app/events.rs index 73bfbac99..3da674add 100644 --- a/crates/runtime/src/app/events.rs +++ b/crates/runtime/src/app/events.rs @@ -98,15 +98,25 @@ impl AppState { // into the timeline or the persisted event log. Parked sessions still // receive provider updates, so update/cache those too. AgentEvent::ProviderCommands { commands } => { - let cache_key = self.resident_mut(session_id).map(|resident| { + let owner = self.resident_mut(session_id).map(|resident| { resident.provider_commands.clone_from(commands); - (resident.meta.provider, resident.meta.acp_agent_id.clone()) + ( + resident.meta.provider, + resident.meta.profile_id.clone(), + resident.meta.acp_agent_id.clone(), + ) + }); + let key = owner.and_then(|(provider, profile_id, acp_agent_id)| { + self.commands_cache_key( + provider, + profile_id.as_deref(), + acp_agent_id.as_deref(), + ) }); - if let Some((provider, acp_agent_id)) = cache_key { + if let Some(key) = key { self.enqueue_store_write( StoreWrite::SaveCommands { - provider, - acp_agent_id, + key, commands: commands.clone(), }, cx, diff --git a/crates/runtime/src/app/lifecycle.rs b/crates/runtime/src/app/lifecycle.rs index 51a1cd982..5ed840c3c 100644 --- a/crates/runtime/src/app/lifecycle.rs +++ b/crates/runtime/src/app/lifecycle.rs @@ -115,7 +115,11 @@ impl AppState { }; let orchestrate_registration = self.orchestrate_registration_for(&meta); let orchestrate_report_registration = self.orchestrate_child_registration_for(&meta); - let computer_use_registration = self.computer_use_registration_for(&meta); + let computer_use_registration = if computer_use_attaches(meta.provider, &self.settings) { + self.computer_use_registration_for(&meta) + } else { + None + }; let provider_launcher = self.provider_launcher.clone(); let session_id = meta.id.clone(); if let Some(cursor) = &meta.resume_cursor { diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index 62cbf4d20..5584fac4a 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -71,7 +71,7 @@ use tcode_services::import::{ use tcode_services::provider_probe::{default_program, probe_provider, run_capture_env}; use tcode_services::session_search::SessionSearch; use tcode_services::settings::SettingsStore; -use tcode_services::store::{SessionStore, now_millis, now_secs}; +use tcode_services::store::{CommandsCacheKey, SessionStore, now_millis, now_secs}; use tcode_services::user_files; use tcode_services::version_check::provider_updates::{ self, CheckInput as ProviderCheckInput, Installation, @@ -234,6 +234,7 @@ use history::SessionLog; mod lifecycle; mod options; mod orchestrate; +mod plugins; mod providers; mod send; mod sessions; @@ -255,8 +256,8 @@ use active_session::{ use orchestrate::McpWiring; pub use providers::ProviderCatalog; use providers::{ - effort_selection, normalized_selections, provider_secret_names, session_launch_env, - session_options, + computer_use_attaches, effort_selection, launch_env_for_profile, normalized_selections, + provider_secret_names, session_launch_env, session_options, }; pub use sessions::ResidentSessions; pub(crate) use snapshots::DomainDiff; @@ -364,6 +365,8 @@ pub struct AppState { pub acp_registry_error: Option, /// Registry ids currently downloading (their marketplace row shows a spinner). pub acp_installing: std::collections::HashSet, + /// Native plugin catalogs by profile id, listed on demand. + plugin_catalogs: HashMap, mcp: McpWiring, callback_last_turn: HashMap, callback_approval_requests: HashSet<(String, String)>, @@ -531,6 +534,7 @@ impl AppState { acp_registry_loading: false, acp_registry_error: None, acp_installing: std::collections::HashSet::new(), + plugin_catalogs: HashMap::new(), mcp: McpWiring::default(), callback_last_turn: HashMap::new(), callback_approval_requests: HashSet::new(), diff --git a/crates/runtime/src/app/options.rs b/crates/runtime/src/app/options.rs index fb251a6e3..d38305221 100644 --- a/crates/runtime/src/app/options.rs +++ b/crates/runtime/src/app/options.rs @@ -20,7 +20,8 @@ impl AppState { ) { let profile_id = profile_id.filter(|id| !Settings::is_builtin_profile_id(id)); let remembered_effort = self.remembered_effort(provider, model.as_deref()); - let store = self.store.clone(); + let provider_commands = + self.cached_provider_commands(provider, profile_id.as_deref(), None); let Some(active) = self.resident_mut(target_id) else { return; }; @@ -42,7 +43,7 @@ impl AppState { // selections, then restore the effort last used with this model. active.meta.option_selections.clear(); active.meta.option_selections.extend(remembered_effort); - active.provider_commands = store.load_commands(active.meta.provider, None); + active.provider_commands = provider_commands; active.pending_ultrathink = false; return; } @@ -72,7 +73,7 @@ impl AppState { active.meta.model = model; active.meta.option_selections.clear(); active.meta.option_selections.extend(remembered_effort); - active.provider_commands = store.load_commands(provider, None); + active.provider_commands = provider_commands; active.provider_options.clear(); active.pending_ultrathink = false; if active.pending_relay.is_some() { @@ -283,8 +284,7 @@ impl AppState { self.upsert_session_in_memory(meta.clone()); let session_id = meta.id.clone(); let cwd = meta.cwd.clone(); - let provider_commands = - self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let provider_commands = self.cached_provider_commands_for(&meta); self.residents.live.insert( session_id.clone(), ActiveSession::new(meta, false, provider_commands), diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index db631f61e..f4d69d75c 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -393,8 +393,7 @@ impl AppState { ); self.upsert_session_in_memory(meta.clone()); let id = meta.id.clone(); - let provider_commands = - self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let provider_commands = self.cached_provider_commands_for(&meta); let mut child = Self::build_draft_session( meta.project_id.clone().unwrap_or_default(), meta.cwd.clone(), @@ -823,7 +822,7 @@ impl AppState { pub(super) fn load_background_session(&mut self, meta: SessionMeta, cx: &mut HostCx) { let thread_id = meta.id.clone(); - let commands = self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let commands = self.cached_provider_commands_for(&meta); let mut child = Self::build_draft_session( meta.project_id.clone().unwrap_or_default(), meta.cwd.clone(), diff --git a/crates/runtime/src/app/plugins.rs b/crates/runtime/src/app/plugins.rs new file mode 100644 index 000000000..6a59dac52 --- /dev/null +++ b/crates/runtime/src/app/plugins.rs @@ -0,0 +1,1091 @@ +use super::*; + +use agent::{ + MarketplaceAction, PluginAction, PluginActionKind, PluginContext, PluginListing, PluginOp, + PluginOpOutcome, PluginScope, list_plugins, run_plugin_op, +}; +use tcode_protocol::{ + Command, PluginCatalogState, PluginChallenge, PluginChallengeKind, PluginOperationTarget, + PluginStaleReason, ProviderPluginCatalog, +}; + +/// One profile's last native plugin listing. Nothing here is persisted: the +/// CLI's own files are the truth and are listed again on demand. +pub(super) struct PluginCatalog { + context_cwd: Option, + launch: PluginLaunch, + listing: PluginListing, + state: PluginCatalogState, + loading: bool, + /// Bumped when the context or the profile's CLI/home changes; listings + /// and challenges from an older generation are discarded. + generation: u64, + /// Only the newest listing request may land. + list_request: u64, + /// The one mutation of this profile in flight or waiting on its user. + operation: Option, + /// Native facts a finished operation reported, shown on its entry until + /// the entry is changed again or the context changes. + notes: HashMap>, +} + +/// The native installation a profile's plugin commands address. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PluginLaunch { + provider: ProviderKind, + binary_path: Option, + home: Option, +} + +struct PluginOperation { + id: RuntimeOperationId, + subject: String, + target: PluginOperationTarget, + op: PluginOp, + cwd: Option, + generation: u64, + started: bool, + challenge: Option, +} + +fn error(code: &str, message: &str) -> ProtocolError { + ProtocolError { + code: code.into(), + message: message.into(), + } +} + +fn management_disabled() -> ProtocolError { + error( + "plugin_management_disabled", + "Plugin management for this provider is turned off in Settings → Plugins.", + ) +} + +fn native_message(error: AgentError) -> String { + match error { + AgentError::Provider(message) => message, + other => other.to_string(), + } +} + +impl AppState { + fn plugin_launch(&self, profile_id: &str) -> Option { + let profile = self.settings.resolved_profile(profile_id)?; + Some(PluginLaunch { + provider: profile.kind, + binary_path: profile.settings.binary_path, + home: profile.settings.home_path, + }) + } + + pub fn refresh_provider_plugins( + &mut self, + profile_id: &str, + cwd: Option, + cx: &mut HostCx, + ) { + let Some(launch) = self + .plugin_launch(profile_id) + .filter(|launch| self.settings.plugins.provider_enabled(launch.provider)) + else { + return; + }; + let catalog = self + .plugin_catalogs + .entry(profile_id.to_string()) + .or_insert_with(|| PluginCatalog { + context_cwd: cwd.clone(), + launch: launch.clone(), + listing: PluginListing::default(), + state: PluginCatalogState::Stale { + reason: PluginStaleReason::NotLoaded, + }, + loading: false, + generation: 0, + list_request: 0, + operation: None, + notes: HashMap::new(), + }); + if catalog.context_cwd != cwd || catalog.launch != launch { + catalog.context_cwd = cwd; + catalog.launch = launch; + catalog.generation += 1; + catalog.notes.clear(); + if matches!(catalog.state, PluginCatalogState::Fresh) { + catalog.state = PluginCatalogState::Stale { + reason: PluginStaleReason::ContextChanged, + }; + } + if let Some(operation) = catalog + .operation + .take_if(|operation| operation.challenge.is_some()) + { + Self::abandon_challenge(profile_id, operation, cx); + } + } + self.list_plugins(profile_id, cx); + } + + /// A challenge that can no longer be answered ends its operation with + /// the CLI's own refusal, which is what happened natively. + fn abandon_challenge(profile_id: &str, operation: PluginOperation, cx: &mut HostCx) { + if !operation.started { + return; + } + let detail = operation + .challenge + .and_then(|challenge| challenge.native_text) + .unwrap_or_default(); + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::PluginOperationFailed { + operation: operation.id, + profile_id: profile_id.to_string(), + target: operation.target, + detail, + }), + ); + } + + /// A provider switched off keeps nothing a client could act on: its + /// listing is forgotten, a listing in flight is ignored and a pending + /// challenge is abandoned. A change already running finishes and + /// reports; a command challenge it then raises is no longer asked. + pub(super) fn forget_disabled_plugin_catalogs(&mut self, cx: &mut HostCx) { + let plugins = &self.settings.plugins; + for (profile_id, catalog) in self + .plugin_catalogs + .iter_mut() + .filter(|(_, catalog)| !plugins.provider_enabled(catalog.launch.provider)) + { + if let Some(operation) = catalog + .operation + .take_if(|operation| operation.challenge.is_some()) + { + Self::abandon_challenge(profile_id, operation, cx); + } + catalog.listing = PluginListing::default(); + catalog.state = PluginCatalogState::Stale { + reason: PluginStaleReason::NotLoaded, + }; + catalog.loading = false; + catalog.list_request += 1; + catalog.generation += 1; + catalog.notes.clear(); + } + } + + fn list_plugins(&mut self, profile_id: &str, cx: &mut HostCx) { + let settings = self.settings.clone(); + let settings_store = self.settings_store.clone(); + let Some(catalog) = self + .plugin_catalogs + .get_mut(profile_id) + .filter(|catalog| settings.plugins.provider_enabled(catalog.launch.provider)) + else { + return; + }; + catalog.list_request += 1; + catalog.loading = true; + let request = catalog.list_request; + let provider = catalog.launch.provider; + let cwd = catalog.context_cwd.clone(); + let profile_id = profile_id.to_string(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let context = + plugin_context(&host_cx, settings, settings_store, &profile_id, cwd).await; + let result = list_plugins(provider, &context).await; + host_cx.enqueue(move |state, _cx| { + let Some(catalog) = state.plugin_catalogs.get_mut(&profile_id) else { + return; + }; + if catalog.list_request != request { + return; + } + catalog.loading = false; + match result { + Ok(listing) => { + catalog.listing = listing; + catalog.state = PluginCatalogState::Fresh; + } + Err(error) => { + catalog.state = PluginCatalogState::Error { + message: native_message(error), + } + } + } + }); + }); + } + + /// Check a plugin command against the catalog it was offered from, so a + /// client acting on an old snapshot cannot run what the host no longer + /// offers. + pub(super) fn validate_plugin_command(&self, command: &Command) -> Result<(), ProtocolError> { + let (profile_id, cwd) = match command { + Command::RefreshProviderPlugins { profile_id, .. } => (profile_id, None), + Command::InstallProviderPlugin { + profile_id, cwd, .. + } + | Command::UninstallProviderPlugin { + profile_id, cwd, .. + } + | Command::SetProviderPluginEnabled { + profile_id, cwd, .. + } + | Command::UpdateProviderPlugin { + profile_id, cwd, .. + } + | Command::AddProviderMarketplace { + profile_id, cwd, .. + } + | Command::RemoveProviderMarketplace { + profile_id, cwd, .. + } => (profile_id, Some(cwd)), + Command::ResolvePluginChallenge { op_id, .. } => { + let Some(catalog) = self.plugin_catalogs.values().find(|catalog| { + catalog + .operation + .as_ref() + .and_then(|operation| operation.challenge.as_ref()) + .is_some_and(|challenge| challenge.op_id == *op_id) + }) else { + return Err(error( + "unknown_plugin_challenge", + "This confirmation is no longer pending.", + )); + }; + return if self + .settings + .plugins + .provider_enabled(catalog.launch.provider) + { + Ok(()) + } else { + Err(management_disabled()) + }; + } + _ => return Ok(()), + }; + let Some(launch) = self.plugin_launch(profile_id) else { + return Err(error( + "unknown_profile", + "This provider profile is no longer available.", + )); + }; + let management = launch.provider.caps().plugin_management; + if management.actions.is_empty() && !management.marketplaces { + return Err(error( + "plugin_management_unsupported", + "This provider's plugins cannot be managed from Tcode.", + )); + } + // Listing a provider that is switched off does nothing. + let Some(cwd) = cwd else { + return Ok(()); + }; + if !self.settings.plugins.provider_enabled(launch.provider) { + return Err(management_disabled()); + } + let catalog = self + .plugin_catalogs + .get(profile_id) + .filter(|catalog| catalog.context_cwd == *cwd && catalog.launch == launch) + .ok_or_else(|| { + error( + "plugin_catalog_stale", + "List this provider's plugins again before changing them.", + ) + })?; + if catalog.operation.is_some() { + return Err(error( + "plugin_operation_in_flight", + "Another plugin change for this provider is still running.", + )); + } + let offered = |entry_id: &str, action: PluginAction| { + management.supports(action.kind()) + && catalog + .listing + .entries + .iter() + .any(|entry| entry.id == entry_id && entry.actions.contains(&action)) + }; + let available = match command { + Command::InstallProviderPlugin { + entry_id, scope, .. + } => offered(entry_id, PluginAction::Install { scope: *scope }), + Command::UninstallProviderPlugin { + entry_id, scope, .. + } => offered(entry_id, PluginAction::Uninstall { scope: *scope }), + Command::SetProviderPluginEnabled { + entry_id, + scope, + enabled: true, + .. + } => offered(entry_id, PluginAction::Enable { scope: *scope }), + Command::SetProviderPluginEnabled { + entry_id, + scope, + enabled: false, + .. + } => offered(entry_id, PluginAction::Disable { scope: *scope }), + Command::UpdateProviderPlugin { + entry_id, scope, .. + } => offered(entry_id, PluginAction::Update { scope: *scope }), + Command::AddProviderMarketplace { source, .. } => { + management.marketplaces + && !source.trim().is_empty() + && catalog + .listing + .marketplace_actions + .contains(&MarketplaceAction::Add) + } + Command::RemoveProviderMarketplace { name, .. } => { + management.marketplaces && removal(&catalog.listing, name).is_some() + } + _ => true, + }; + if available { + Ok(()) + } else { + Err(error( + "plugin_action_unavailable", + "This plugin action is not available here.", + )) + } + } + + pub fn start_plugin_operation( + &mut self, + profile_id: &str, + cwd: Option, + op: PluginOp, + cx: &mut HostCx, + ) { + let (subject, target) = match &op { + PluginOp::Install { id, scope, .. } => { + plugin_target(id, *scope, PluginActionKind::Install) + } + PluginOp::Uninstall { id, scope } => { + plugin_target(id, *scope, PluginActionKind::Uninstall) + } + PluginOp::SetEnabled { id, scope, enabled } => plugin_target( + id, + *scope, + if *enabled { + PluginActionKind::Enable + } else { + PluginActionKind::Disable + }, + ), + PluginOp::Update { id, scope, .. } => { + plugin_target(id, *scope, PluginActionKind::Update) + } + PluginOp::AddMarketplace { source } => ( + source.clone(), + PluginOperationTarget::AddMarketplace { + source: source.clone(), + }, + ), + PluginOp::RemoveMarketplace { name } => ( + name.clone(), + PluginOperationTarget::RemoveMarketplace { name: name.clone() }, + ), + }; + let id = self.next_operation_id(); + // Native removal has no confirmation of its own, so ask first. + let challenge = match &op { + PluginOp::RemoveMarketplace { name } => { + let uninstalls = self + .plugin_catalogs + .get(profile_id) + .and_then(|catalog| removal(&catalog.listing, name)) + .unwrap_or_default(); + Some(PluginChallenge { + op_id: self.next_operation_id(), + profile_id: profile_id.to_string(), + entry_id: name.clone(), + kind: PluginChallengeKind::ConfirmDestructive { uninstalls }, + native_text: None, + }) + } + _ => None, + }; + let Some(catalog) = self.plugin_catalogs.get_mut(profile_id) else { + return; + }; + let wait = challenge.is_some(); + catalog.operation = Some(PluginOperation { + id, + subject, + target, + op, + cwd, + generation: catalog.generation, + started: false, + challenge, + }); + if !wait { + self.execute_plugin_operation(profile_id, cx); + } + } + + fn execute_plugin_operation(&mut self, profile_id: &str, cx: &mut HostCx) { + let settings = self.settings.clone(); + let settings_store = self.settings_store.clone(); + let Some(catalog) = self.plugin_catalogs.get_mut(profile_id) else { + return; + }; + let provider = catalog.launch.provider; + let Some(operation) = catalog.operation.as_mut() else { + return; + }; + if !operation.started { + operation.started = true; + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::PluginOperationStarted { + operation: operation.id, + profile_id: profile_id.to_string(), + target: operation.target.clone(), + }), + ); + } + let id = operation.id; + let op = operation.op.clone(); + let cwd = operation.cwd.clone(); + let profile_id = profile_id.to_string(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let context = + plugin_context(&host_cx, settings, settings_store, &profile_id, cwd).await; + let result = run_plugin_op(provider, &context, &op).await; + host_cx.enqueue(move |state, cx| { + state.finish_plugin_operation(&profile_id, id, result, cx); + }); + }); + } + + fn finish_plugin_operation( + &mut self, + profile_id: &str, + id: RuntimeOperationId, + result: Result, + cx: &mut HostCx, + ) { + let challenge_id = self.next_operation_id(); + let Some(catalog) = self.plugin_catalogs.get_mut(profile_id) else { + return; + }; + let Some(mut operation) = catalog + .operation + .take_if(|operation| operation.id == id && operation.challenge.is_none()) + else { + return; + }; + let target = operation.target.clone(); + let toast = match result { + Ok(PluginOpOutcome::AcceptCommand(acceptance)) + if operation.generation == catalog.generation => + { + operation.challenge = Some(PluginChallenge { + op_id: challenge_id, + profile_id: profile_id.to_string(), + entry_id: operation.subject.clone(), + kind: PluginChallengeKind::AcceptCommand { + command: acceptance.command, + sha256: acceptance.sha256, + mode: acceptance.mode, + }, + native_text: Some(acceptance.native_text), + }); + catalog.operation = Some(operation); + return; + } + Ok(PluginOpOutcome::AcceptCommand(acceptance)) => RuntimeToast::PluginOperationFailed { + operation: id, + profile_id: profile_id.to_string(), + target, + detail: acceptance.native_text, + }, + Ok(PluginOpOutcome::Done { diagnostics }) => { + if diagnostics.is_empty() { + catalog.notes.remove(&operation.subject); + } else { + catalog.notes.insert(operation.subject.clone(), diagnostics); + } + RuntimeToast::PluginOperationSucceeded { + operation: id, + profile_id: profile_id.to_string(), + target, + } + } + Err(error) => { + catalog.notes.remove(&operation.subject); + RuntimeToast::PluginOperationFailed { + operation: id, + profile_id: profile_id.to_string(), + target, + detail: native_message(error), + } + } + }; + emit_runtime(cx, RuntimeEvent::Toast(toast)); + self.after_plugin_change(profile_id, cx); + } + + /// A finished native change: menus cached for the same home no longer + /// describe it, and every catalog listing that home is out of date. + fn after_plugin_change(&mut self, profile_id: &str, cx: &mut HostCx) { + let Some(launch) = self + .plugin_catalogs + .get(profile_id) + .map(|catalog| catalog.launch.clone()) + else { + return; + }; + self.enqueue_store_write( + StoreWrite::InvalidateCommands(CommandsCacheKey::Native { + provider: launch.provider, + home: launch.home.clone(), + }), + cx, + ); + let affected: Vec = self + .plugin_catalogs + .iter_mut() + .filter(|(_, catalog)| { + catalog.launch.provider == launch.provider && catalog.launch.home == launch.home + }) + .map(|(id, catalog)| { + if !matches!(catalog.state, PluginCatalogState::Error { .. }) { + catalog.state = PluginCatalogState::Stale { + reason: PluginStaleReason::Changed, + }; + } + id.clone() + }) + .collect(); + for id in affected { + self.list_plugins(&id, cx); + } + } + + /// Answer a pending challenge. Acceptance applies only to the command or + /// removal this challenge showed; the hash never comes from the client. + pub fn resolve_plugin_challenge( + &mut self, + op_id: RuntimeOperationId, + accept: bool, + cx: &mut HostCx, + ) -> Result<(), ProtocolError> { + let unknown = || { + error( + "unknown_plugin_challenge", + "This confirmation is no longer pending.", + ) + }; + let profile_id = self + .plugin_catalogs + .iter() + .find(|(_, catalog)| { + catalog + .operation + .as_ref() + .and_then(|operation| operation.challenge.as_ref()) + .is_some_and(|challenge| challenge.op_id == op_id) + }) + .map(|(id, _)| id.clone()) + .ok_or_else(unknown)?; + let current = self.plugin_launch(&profile_id); + let next_challenge_id = self.next_operation_id(); + let catalog = self + .plugin_catalogs + .get_mut(&profile_id) + .ok_or_else(unknown)?; + let mut operation = catalog.operation.take().ok_or_else(unknown)?; + if operation.generation != catalog.generation || current.as_ref() != Some(&catalog.launch) { + Self::abandon_challenge(&profile_id, operation, cx); + return Err(unknown()); + } + if !accept { + Self::abandon_challenge(&profile_id, operation, cx); + return Ok(()); + } + let challenge = operation.challenge.take().ok_or_else(unknown)?; + match challenge.kind { + PluginChallengeKind::AcceptCommand { sha256, .. } => match &mut operation.op { + PluginOp::Install { accept_command, .. } + | PluginOp::Update { accept_command, .. } => *accept_command = Some(sha256), + _ => return Err(unknown()), + }, + PluginChallengeKind::ConfirmDestructive { uninstalls } => { + let current = removal(&catalog.listing, &operation.subject).unwrap_or_default(); + if current != uninstalls { + operation.challenge = Some(PluginChallenge { + op_id: next_challenge_id, + kind: PluginChallengeKind::ConfirmDestructive { + uninstalls: current, + }, + ..challenge + }); + catalog.operation = Some(operation); + return Err(error( + "plugin_challenge_changed", + "What this removes has changed; confirm it again.", + )); + } + } + } + catalog.operation = Some(operation); + self.execute_plugin_operation(&profile_id, cx); + Ok(()) + } + + pub(crate) fn provider_plugin_catalogs(&self) -> Vec { + let mut catalogs: Vec = self + .plugin_catalogs + .iter() + .filter(|(profile_id, _)| { + self.settings + .resolved_profile(profile_id) + .is_some_and(|profile| self.settings.plugins.provider_enabled(profile.kind)) + }) + .map(|(profile_id, catalog)| ProviderPluginCatalog { + profile_id: profile_id.clone(), + context_cwd: catalog.context_cwd.clone(), + marketplaces: catalog.listing.marketplaces.clone(), + marketplace_actions: catalog.listing.marketplace_actions.clone(), + errors: catalog.listing.errors.clone(), + entries: catalog + .listing + .entries + .iter() + .map(|entry| { + let mut entry = entry.clone(); + if let Some(notes) = catalog.notes.get(&entry.id) { + entry.diagnostics.extend(notes.iter().cloned()); + } + entry + }) + .collect(), + state: if self.plugin_launch(profile_id).as_ref() == Some(&catalog.launch) { + catalog.state.clone() + } else { + PluginCatalogState::Stale { + reason: PluginStaleReason::ContextChanged, + } + }, + loading: catalog.loading, + pending: catalog + .operation + .iter() + .map(|operation| operation.subject.clone()) + .collect(), + challenges: catalog + .operation + .iter() + .filter_map(|operation| operation.challenge.clone()) + .collect(), + }) + .collect(); + catalogs.sort_by(|a, b| a.profile_id.cmp(&b.profile_id)); + catalogs + } +} + +fn plugin_target( + id: &str, + scope: PluginScope, + action: PluginActionKind, +) -> (String, PluginOperationTarget) { + ( + id.to_string(), + PluginOperationTarget::Plugin { + action, + scope, + plugin: id.to_string(), + }, + ) +} + +/// What removing `name` uninstalls, when the listing offers its removal. +fn removal(listing: &PluginListing, name: &str) -> Option> { + listing + .marketplace_actions + .iter() + .find_map(|action| match action { + MarketplaceAction::Remove { + marketplace, + uninstalls, + } if marketplace == name => Some(uninstalls.clone()), + _ => None, + }) +} + +/// The profile's CLI and environment, as a session launch resolves them but +/// without session arguments or Tcode's MCP servers. Without a project the +/// command runs from the provider's home, outside any project. +async fn plugin_context( + host_cx: &HostCx, + settings: Settings, + settings_store: SettingsStore, + profile_id: &str, + cwd: Option, +) -> PluginContext { + let binary_path = settings + .resolved_profile(profile_id) + .and_then(|profile| profile.settings.binary_path); + let id = profile_id.to_string(); + let launch_env = host_cx + .unblock(move || { + let secrets = settings_store.profile_secrets(&id); + launch_env_for_profile(&settings, &id, secrets) + }) + .await; + let project = cwd.is_some(); + let cwd = cwd + .or_else(|| launch_env.home.clone()) + .or_else(std::env::home_dir) + .unwrap_or_else(std::env::temp_dir); + PluginContext { + binary_path, + launch_env, + cwd, + project, + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::app::test_support::*; + use tcode_protocol::{CommandResponse, HostMessage, SettingsPatch}; + + const GAMMA_SHA: &str = "b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"; + + /// A `claude` that answers the recorded probe outputs and logs each + /// invocation as ` `. + struct FakeClaude { + dir: PathBuf, + binary: PathBuf, + log: PathBuf, + home: PathBuf, + project: PathBuf, + } + + impl FakeClaude { + fn new() -> Self { + use std::os::unix::fs::PermissionsExt as _; + let dir = + std::env::temp_dir().join(format!("tcode-fake-claude-{}", uuid::Uuid::new_v4())); + let home = dir.join("home"); + let project = dir.join("project"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&project).unwrap(); + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../agent/tests/fixtures/plugins/claude"); + let log = dir.join("argv.log"); + let binary = dir.join("claude"); + fs::write( + &binary, + format!( + "#!/bin/sh\n\ + F='{fixtures}'\n\ + printf '%s %s\\n' \"$PWD\" \"$*\" >> '{log}'\n\ + case \"$*\" in\n\ + 'plugin list --json --available') cat \"$F/plugin-list-available-after-install.json\" ;;\n\ + 'plugin marketplace list --json') cat \"$F/marketplace-list.json\" ;;\n\ + 'plugin details '*) cat \"$F/details.txt\" ;;\n\ + *'--accept-command {GAMMA_SHA}') cat \"$F/install-command-source-accepted.json\" ;;\n\ + 'plugin install gamma@tcode-probe '*) cat \"$F/install-command-source-challenge.txt\"; exit 1 ;;\n\ + 'plugin disable alpha@tcode-probe --scope user --json') cat \"$F/disable-scope-user.json\" ;;\n\ + *) echo \"unexpected: $*\" >&2; exit 2 ;;\n\ + esac\n", + fixtures = fixtures.display(), + log = log.display(), + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap(); + Self { + dir, + binary, + log, + home, + project, + } + } + + fn calls(&self, verb: &str) -> Vec { + fs::read_to_string(&self.log) + .unwrap_or_default() + .lines() + .filter(|line| line.contains(verb)) + .map(str::to_string) + .collect() + } + } + + impl Drop for FakeClaude { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + /// A host whose Claude profile runs `fake`, with the default switches. + fn unmanaged_host(fake: &FakeClaude, store: &TestStore) -> TestClientState { + let mut state = TestClientState::new((**store).clone()); + let claude = state.settings.provider_mut(ProviderKind::ClaudeCode); + claude.binary_path = Some(fake.binary.clone()); + claude.home_path = Some(fake.home.clone()); + state + } + + fn host_with(fake: &FakeClaude, store: &TestStore) -> TestClientState { + let mut state = unmanaged_host(fake, store); + state.settings.apply(claude_switch(true)); + state + } + + fn claude_switch(enabled: bool) -> SettingsPatch { + SettingsPatch::PluginManagementProvider { + provider: ProviderKind::ClaudeCode, + enabled, + } + } + + fn catalog(state: &TestClientState) -> Option { + state + .provider_plugin_catalogs() + .into_iter() + .find(|catalog| catalog.profile_id == "claude") + } + + fn settled(state: &TestClientState) -> bool { + catalog(state).is_some_and(|catalog| { + !catalog.loading + && catalog.pending.is_empty() + && matches!(catalog.state, PluginCatalogState::Fresh) + }) + } + + fn challenged(state: &TestClientState) -> bool { + catalog(state).is_some_and(|catalog| !catalog.challenges.is_empty()) + } + + fn ack(cx: &mut TestAppContext, id: u64) -> Result { + cx.drain_outgoing() + .into_iter() + .find_map(|message| match message { + HostMessage::Ack { id: acked, result } if acked == id => Some(result), + _ => None, + }) + .expect("command acknowledged") + } + + #[test] + fn accepting_a_command_challenge_reruns_with_its_hash_and_a_stale_one_is_refused() { + let fake = FakeClaude::new(); + let store = TestStore::new("tcode-plugin-challenge"); + let cx = &mut TestAppContext::default(); + let state = cx.new_entity(host_with(&fake, &store)); + let project = Some(fake.project.clone()); + let refresh = |cwd: Option| Command::RefreshProviderPlugins { + profile_id: "claude".into(), + cwd, + }; + let install = Command::InstallProviderPlugin { + profile_id: "claude".into(), + entry_id: "gamma@tcode-probe".into(), + scope: PluginScope::User, + cwd: project.clone(), + }; + + state.dispatch_command(cx, 1, refresh(project.clone())); + cx.run_until(settled); + state.dispatch_command(cx, 2, install.clone()); + cx.run_until(challenged); + let challenge = state.read(|state| catalog(state).unwrap().challenges.remove(0)); + assert_eq!( + challenge.kind, + PluginChallengeKind::AcceptCommand { + command: "echo /tmp/tcode-probe/gamma-src".into(), + sha256: GAMMA_SHA.into(), + mode: Some("copy".into()), + } + ); + + // Listing from another directory abandons the challenge it showed. + state.dispatch_command(cx, 3, refresh(None)); + cx.run_until(settled); + state.dispatch_command( + cx, + 4, + Command::ResolvePluginChallenge { + op_id: challenge.op_id, + accept: true, + }, + ); + cx.run_until_parked(); + assert_eq!(ack(cx, 4).unwrap_err().code, "unknown_plugin_challenge"); + assert_eq!(fake.calls("plugin install").len(), 1); + + state.dispatch_command(cx, 5, refresh(project.clone())); + cx.run_until(settled); + state.dispatch_command(cx, 6, install); + cx.run_until(challenged); + let challenge = state.read(|state| catalog(state).unwrap().challenges.remove(0)); + state.dispatch_command( + cx, + 7, + Command::ResolvePluginChallenge { + op_id: challenge.op_id, + accept: true, + }, + ); + cx.run_until(|state| settled(state) && fake.calls("--accept-command").len() == 1); + assert_eq!(ack(cx, 7).unwrap(), CommandResponse::Unit); + let installs = fake.calls("plugin install"); + let accepted = installs.last().unwrap(); + assert!( + accepted.ends_with(&format!( + "plugin install gamma@tcode-probe --scope user --json --accept-command {GAMMA_SHA}" + )), + "{installs:?}" + ); + let project = fs::canonicalize(&fake.project).unwrap(); + assert!( + accepted.starts_with(&format!("{} ", project.display())), + "{accepted}" + ); + } + + /// Off, Tcode never runs the provider's CLI and refuses its plugin + /// commands; switching it off abandons what was waiting on the person. + #[test] + fn a_switched_off_provider_is_never_run_and_its_commands_are_refused() { + let fake = FakeClaude::new(); + let store = TestStore::new("tcode-plugin-switch"); + let cx = &mut TestAppContext::default(); + let state = cx.new_entity(unmanaged_host(&fake, &store)); + let project = Some(fake.project.clone()); + let refresh = Command::RefreshProviderPlugins { + profile_id: "claude".into(), + cwd: project.clone(), + }; + let install = Command::InstallProviderPlugin { + profile_id: "claude".into(), + entry_id: "gamma@tcode-probe".into(), + scope: PluginScope::User, + cwd: project.clone(), + }; + + state.dispatch_command(cx, 1, refresh.clone()); + cx.run_until_parked(); + assert_eq!(ack(cx, 1).unwrap(), CommandResponse::Unit); + state.dispatch_command(cx, 2, install.clone()); + cx.run_until_parked(); + assert_eq!(ack(cx, 2).unwrap_err().code, "plugin_management_disabled"); + assert!(state.read(|state| catalog(state).is_none())); + assert_eq!(fake.calls(""), Vec::::new()); + + state.dispatch_command( + cx, + 3, + Command::PatchSettings { + patch: claude_switch(true), + }, + ); + state.dispatch_command(cx, 4, refresh); + cx.run_until(settled); + assert!(!fake.calls("plugin list").is_empty()); + state.dispatch_command(cx, 5, install); + cx.run_until(challenged); + let challenge = state.read(|state| catalog(state).unwrap().challenges.remove(0)); + + state.dispatch_command( + cx, + 6, + Command::PatchSettings { + patch: claude_switch(false), + }, + ); + cx.run_until_parked(); + assert!(state.read(|state| catalog(state).is_none())); + let calls = fake.calls(""); + state.dispatch_command( + cx, + 7, + Command::ResolvePluginChallenge { + op_id: challenge.op_id, + accept: true, + }, + ); + cx.run_until_parked(); + assert_eq!(ack(cx, 7).unwrap_err().code, "unknown_plugin_challenge"); + assert_eq!(fake.calls(""), calls); + } + + #[test] + fn a_plugin_change_invalidates_only_the_commands_cached_for_its_home() { + let fake = FakeClaude::new(); + let store = TestStore::new("tcode-plugin-cache"); + let commands = vec![ProviderCommand { + name: "alpha:greet".into(), + description: None, + kind: agent::ProviderCommandKind::Skill, + }]; + let key = |home: Option| CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home, + }; + let other_home = key(Some(fake.dir.join("other-home"))); + for home in [key(Some(fake.home.clone())), key(None), other_home.clone()] { + store.save_commands(&home, &commands).unwrap(); + } + let cx = &mut TestAppContext::default(); + let state = cx.new_entity(host_with(&fake, &store)); + assert_eq!( + state.read(|state| state.cached_provider_commands( + ProviderKind::ClaudeCode, + None, + None + )), + commands + ); + + state.dispatch_command( + cx, + 1, + Command::RefreshProviderPlugins { + profile_id: "claude".into(), + cwd: None, + }, + ); + cx.run_until(settled); + state.dispatch_command( + cx, + 2, + Command::SetProviderPluginEnabled { + profile_id: "claude".into(), + entry_id: "alpha@tcode-probe".into(), + scope: PluginScope::User, + enabled: false, + cwd: None, + }, + ); + cx.run_until(|state| settled(state) && fake.calls("plugin disable").len() == 1); + + assert!( + state + .read(|state| state.cached_provider_commands(ProviderKind::ClaudeCode, None, None)) + .is_empty() + ); + assert_eq!(store.load_commands(&key(None)), commands); + assert_eq!(store.load_commands(&other_home), commands); + } +} diff --git a/crates/runtime/src/app/providers.rs b/crates/runtime/src/app/providers.rs index f65d7f8d8..f317ccd34 100644 --- a/crates/runtime/src/app/providers.rs +++ b/crates/runtime/src/app/providers.rs @@ -93,6 +93,7 @@ impl ProviderCatalog { acp_registry_loading: bool, acp_registry_error: Option, acp_installing: HashSet, + plugins: Vec, ) -> ProvidersStatus { ProvidersStatus { model_catalogs: self.model_catalogs.clone(), @@ -150,6 +151,7 @@ impl ProviderCatalog { .any(|status| status.checking) || self.tcode_update.checking, secret_names: self.provider_secret_names.clone(), + plugins, } } } @@ -721,12 +723,48 @@ impl AppState { }); } + /// The installation a session's command menus come from: a native + /// profile's home, or one ACP agent. + pub(super) fn commands_cache_key( + &self, + provider: ProviderKind, + profile_id: Option<&str>, + acp_agent_id: Option<&str>, + ) -> Option { + let home = match provider { + ProviderKind::Acp => None, + ProviderKind::Codex + | ProviderKind::ClaudeCode + | ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok => self + .settings + .resolved_profile( + profile_id.unwrap_or_else(|| Settings::builtin_profile_id(provider)), + ) + .and_then(|profile| profile.settings.home_path), + }; + CommandsCacheKey::new(provider, home, acp_agent_id) + } + pub(super) fn cached_provider_commands( &self, provider: ProviderKind, + profile_id: Option<&str>, acp_agent_id: Option<&str>, ) -> Vec { - self.store.load_commands(provider, acp_agent_id) + self.commands_cache_key(provider, profile_id, acp_agent_id) + .map(|key| self.store.load_commands(&key)) + .unwrap_or_default() + } + + pub(super) fn cached_provider_commands_for(&self, meta: &SessionMeta) -> Vec { + self.cached_provider_commands( + meta.provider, + meta.profile_id.as_deref(), + meta.acp_agent_id.as_deref(), + ) } /// The cached model catalog for `provider` (empty when never fetched). @@ -848,6 +886,12 @@ pub(super) fn session_launch_env( LaunchEnv { env, home: None } } +/// Whether tcode's computer-use server is offered to a session of `provider`. +/// A harness with its own computer use keeps it; tcode's is not added beside it. +pub(super) fn computer_use_attaches(provider: ProviderKind, settings: &Settings) -> bool { + settings.computer_use.enabled && !provider.caps().native_computer_use +} + pub(super) fn session_options( meta: &SessionMeta, settings: &Settings, @@ -909,9 +953,7 @@ pub(super) fn session_options( .is_some() .then_some(orchestrate_report_server) .flatten(), - settings - .computer_use - .enabled + computer_use_attaches(meta.provider, settings) .then_some(computer_use_server) .flatten(), ] diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 6dfc96db9..ac3d6bbca 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -627,6 +627,7 @@ impl AppState { self.providers.invalidate_usage(&id); } self.settings = settings; + self.forget_disabled_plugin_catalogs(cx); self.providers.provider_secret_names = provider_secret_names(&self.settings, &self.settings_store); // Keep the live computer-use MCP config in step with the persisted @@ -1359,7 +1360,8 @@ impl AppState { } let (provider, model, acp_agent_id, profile_id, reasoning_effort) = self.draft_defaults(&project_id); - let provider_commands = self.cached_provider_commands(provider, acp_agent_id.as_deref()); + let provider_commands = + self.cached_provider_commands(provider, profile_id.as_deref(), acp_agent_id.as_deref()); let mut draft = Self::build_draft_session( project_id, cwd, @@ -1600,8 +1602,7 @@ impl AppState { meta.resume_cursor.is_some() ); let session_id = meta.id.clone(); - let provider_commands = - self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let provider_commands = self.cached_provider_commands_for(&meta); let mut active = ActiveSession::new(meta, false, provider_commands); let terminal_preferences = self.terminal_preferences_for(&active); let restored_terminal = self.restore_terminal_workspace(&mut active); diff --git a/crates/runtime/src/app/snapshots.rs b/crates/runtime/src/app/snapshots.rs index ed23b7503..e0b7dc438 100644 --- a/crates/runtime/src/app/snapshots.rs +++ b/crates/runtime/src/app/snapshots.rs @@ -234,6 +234,7 @@ impl AppState { self.acp_registry_loading, self.acp_registry_error.clone(), self.acp_installing.clone(), + self.provider_plugin_catalogs(), ) } diff --git a/crates/runtime/src/app/store_write.rs b/crates/runtime/src/app/store_write.rs index a6dbfc112..c61148a72 100644 --- a/crates/runtime/src/app/store_write.rs +++ b/crates/runtime/src/app/store_write.rs @@ -28,10 +28,10 @@ pub(super) enum StoreWrite { completion: smol::channel::Sender>, }, SaveCommands { - provider: ProviderKind, - acp_agent_id: Option, + key: CommandsCacheKey, commands: Vec, }, + InvalidateCommands(CommandsCacheKey), WriteTerminalUi(Vec), WriteSettings(Vec), SetProfileSecret { @@ -113,6 +113,7 @@ impl StoreWrite { Some(completion), ), StoreWrite::SaveCommands { .. } + | StoreWrite::InvalidateCommands(_) | StoreWrite::WriteTerminalUi(_) | StoreWrite::WriteSettings(_) | StoreWrite::SetProfileSecret { .. } @@ -261,19 +262,20 @@ impl StoreWriter { }) }; match write { - StoreWrite::SaveCommands { - provider, - acp_agent_id, - commands, - } => self - .store - .save_commands(provider, acp_agent_id.as_deref(), &commands) - .err() - .map(|err| { + StoreWrite::SaveCommands { key, commands } => { + self.store.save_commands(&key, &commands).err().map(|err| { + StoreWriteFailure::Warning(format!( + "failed to persist {key:?} command cache: {err}" + )) + }) + } + StoreWrite::InvalidateCommands(key) => { + self.store.invalidate_commands(&key).err().map(|err| { StoreWriteFailure::Warning(format!( - "failed to persist {provider:?} command cache: {err}" + "failed to invalidate {key:?} command cache: {err}" )) - }), + }) + } StoreWrite::WriteTerminalUi(bytes) => { atomic_write(self.terminal_preferences_path.clone(), bytes) .err() diff --git a/crates/runtime/src/app/subagents.rs b/crates/runtime/src/app/subagents.rs index dbc852a83..841aaab68 100644 --- a/crates/runtime/src/app/subagents.rs +++ b/crates/runtime/src/app/subagents.rs @@ -296,7 +296,7 @@ impl AppState { self.upsert_session_in_memory(meta.clone()); let id = meta.id.clone(); - let commands = self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let commands = self.cached_provider_commands_for(&meta); let mut mirror = Self::build_draft_session( meta.project_id.clone().unwrap_or_default(), meta.cwd.clone(), diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index f9e587ea8..b7aa76527 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -3020,11 +3020,17 @@ fn reopened_command_cache_seeds_a_draft_before_provider_start() { kind: agent::ProviderCommandKind::Command, }]; store - .save_commands(ProviderKind::ClaudeCode, None, &commands) + .save_commands( + &CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home: None, + }, + &commands, + ) .unwrap(); let state = TestClientState::new(SessionStore::open_at(root.clone()).unwrap()); - let seeded = state.cached_provider_commands(ProviderKind::ClaudeCode, None); + let seeded = state.cached_provider_commands(ProviderKind::ClaudeCode, None, None); let draft = AppState::build_draft_session( "project".into(), PathBuf::from("/tmp/project"), @@ -3264,10 +3270,11 @@ fn session_launch_preserves_approval_policy_and_scopes_mcp_registrations() { let orchestrate = registration(agent::McpRegistration::SERVER_NAME_ORCHESTRATE, 8); let report = registration("child-report", 9); let computer = registration(agent::McpRegistration::SERVER_NAME_COMPUTER_USE, 10); - for (provider, preview_supported) in [ - (ProviderKind::ClaudeCode, true), - (ProviderKind::Codex, true), - (ProviderKind::Pi, false), + // Codex ships its own computer use, so tcode's server is withheld from it. + for (provider, preview_supported, computer_supported) in [ + (ProviderKind::ClaudeCode, true, true), + (ProviderKind::Codex, true, false), + (ProviderKind::Pi, false, true), ] { for native_approvals in [false, true] { let mut settings = Settings::default(); @@ -3320,7 +3327,7 @@ fn session_launch_preserves_approval_policy_and_scopes_mcp_registrations() { (preview_supported, &preview), (lead, &orchestrate), (child, &report), - (computer_enabled, &computer), + (computer_enabled && computer_supported, &computer), ] { if enabled { expected.push((®.name, ®.url, ®.bearer_token)); diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 49a37a1aa..f05681ff0 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -395,6 +395,97 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co Command::SetActiveAcpAgent { session_id, id } => { app.set_active_acp_agent(&session_id, &id, cx) } + Command::RefreshProviderPlugins { profile_id, cwd } => { + app.refresh_provider_plugins(&profile_id, cwd, cx) + } + Command::InstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::Install { + id: entry_id, + scope, + accept_command: None, + }, + cx, + ), + Command::UninstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::Uninstall { + id: entry_id, + scope, + }, + cx, + ), + Command::SetProviderPluginEnabled { + profile_id, + entry_id, + scope, + enabled, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::SetEnabled { + id: entry_id, + scope, + enabled, + }, + cx, + ), + Command::UpdateProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::Update { + id: entry_id, + scope, + accept_command: None, + }, + cx, + ), + Command::AddProviderMarketplace { + profile_id, + source, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::AddMarketplace { + source: source.trim().to_string(), + }, + cx, + ), + Command::RemoveProviderMarketplace { + profile_id, + name, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::RemoveMarketplace { name }, + cx, + ), + Command::ResolvePluginChallenge { op_id, accept } => { + return CommandOutcome::Immediate( + app.resolve_plugin_challenge(op_id, accept, cx) + .map(|()| CommandResponse::Unit), + ); + } Command::ResetSettings => app.reset_settings(cx), Command::WriteRelaunchMarker { session_id, diff --git a/crates/services/src/store/mod.rs b/crates/services/src/store/mod.rs index e0f7b9553..b05879f60 100644 --- a/crates/services/src/store/mod.rs +++ b/crates/services/src/store/mod.rs @@ -62,6 +62,44 @@ struct EventEnvelopeRef<'a> { event: &'a AgentEvent, } +/// Which installation a cached command list belongs to. Native commands +/// depend on the provider's home (its plugins, skills and settings), so two +/// profiles with different homes never seed each other's menus. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandsCacheKey { + /// `home` is the profile's home override; `None` is the CLI's default. + Native { + provider: ProviderKind, + home: Option, + }, + Acp { + agent_id: String, + }, +} + +impl CommandsCacheKey { + /// `None` for an ACP session without an agent id. + pub fn new( + provider: ProviderKind, + home: Option, + acp_agent_id: Option<&str>, + ) -> Option { + match provider { + ProviderKind::Acp => acp_agent_id.map(|agent_id| Self::Acp { + agent_id: agent_id.to_string(), + }), + provider => Some(Self::Native { provider, home }), + } + } +} + +/// 64-bit FNV-1a: a short, stable file-name segment for a home path. +fn fnv1a(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + /// Cheap, cloneable handle to the data directory. Every handle for the same /// directory in this process shares one database, writer and lifecycle. #[derive(Clone)] @@ -562,20 +600,29 @@ impl SessionStore { self.root.join(format!("models-{name}.json")) } - fn commands_path(&self, provider: ProviderKind, acp_agent_id: Option<&str>) -> Option { - let name = match provider { - ProviderKind::Codex => "codex".to_string(), - ProviderKind::ClaudeCode => "claude".to_string(), - ProviderKind::Pi => "pi".to_string(), - ProviderKind::OpenCode => "opencode".to_string(), - ProviderKind::Cursor => "cursor".to_string(), - ProviderKind::Grok => "grok".to_string(), - ProviderKind::Acp => { - let id = acp_agent_id?; + fn commands_path(&self, key: &CommandsCacheKey) -> PathBuf { + let name = match key { + CommandsCacheKey::Native { provider, home } => { + let provider = match provider { + ProviderKind::Codex => "codex", + ProviderKind::ClaudeCode => "claude", + ProviderKind::Pi => "pi", + ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", + ProviderKind::Acp => "acp", + }; + let home = home.as_deref().map_or_else( + || "default".to_string(), + |home| format!("{:016x}", fnv1a(home.as_os_str().as_encoded_bytes())), + ); + format!("{provider}-{home}") + } + CommandsCacheKey::Acp { agent_id } => { // Registry ids are external input and may contain path separators. // Hex keeps the filename reversible and collision-free without // allowing an id to escape the data directory. - let encoded = id + let encoded = agent_id .as_bytes() .iter() .map(|byte| format!("{byte:02x}")) @@ -583,7 +630,7 @@ impl SessionStore { format!("acp-{encoded}") } }; - Some(self.root.join(format!("commands-{name}.json"))) + self.root.join(format!("commands-{name}.json")) } /// Load the last-fetched model catalog for `provider` so the picker is @@ -604,44 +651,38 @@ impl SessionStore { fs::rename(&tmp, path) } - /// Load the most recently reported command/skill list for a native provider - /// or one specific ACP agent. Empty when missing, unreadable, or when an ACP - /// agent id was not supplied. - pub fn load_commands( - &self, - provider: ProviderKind, - acp_agent_id: Option<&str>, - ) -> Vec { - let Some(path) = self.commands_path(provider, acp_agent_id) else { - return Vec::new(); - }; - let Ok(bytes) = fs::read(path) else { + /// Load the most recently reported command/skill list for one native + /// installation or ACP agent. Empty when missing or unreadable. + pub fn load_commands(&self, key: &CommandsCacheKey) -> Vec { + let Ok(bytes) = fs::read(self.commands_path(key)) else { return Vec::new(); }; serde_json::from_slice(&bytes).unwrap_or_default() } - /// Atomically persist the complete command/skill list reported by a native - /// provider or one specific ACP agent. Empty lists are meaningful: they + /// Atomically persist the complete command/skill list reported by one + /// native installation or ACP agent. Empty lists are meaningful: they /// replace a stale non-empty cache. pub fn save_commands( &self, - provider: ProviderKind, - acp_agent_id: Option<&str>, + key: &CommandsCacheKey, commands: &[ProviderCommand], ) -> io::Result<()> { - let path = self.commands_path(provider, acp_agent_id).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "ACP command cache requires an agent id", - ) - })?; + let path = self.commands_path(key); let tmp = path.with_extension("json.tmp"); let data = serde_json::to_vec_pretty(commands).map_err(invalid_data)?; fs::write(&tmp, data)?; fs::rename(&tmp, path) } + /// Forget a cached command list after the installation changed under it. + pub fn invalidate_commands(&self, key: &CommandsCacheKey) -> io::Result<()> { + match fs::remove_file(self.commands_path(key)) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } + } + /// Load every project and session, in the order they were first stored. /// A row this build cannot decode is skipped with a warning and left in /// the database untouched. diff --git a/crates/services/src/store/tests.rs b/crates/services/src/store/tests.rs index 19db868a1..9ca72bfa3 100644 --- a/crates/services/src/store/tests.rs +++ b/crates/services/src/store/tests.rs @@ -672,7 +672,7 @@ fn removing_project_cleans_only_managed_icons() { } #[test] -fn command_cache_roundtrips_per_provider_and_acp_agent() { +fn command_cache_is_kept_per_native_home_and_acp_agent() { let dir = DataDir::new(); let root = dir.path().to_path_buf(); let store = dir.store(); @@ -686,33 +686,42 @@ fn command_cache_roundtrips_per_provider_and_acp_agent() { description: None, kind: ProviderCommandKind::Skill, }]; - store - .save_commands(ProviderKind::ClaudeCode, None, &native) - .unwrap(); - store - .save_commands(ProviderKind::Acp, Some("vendor/agent"), &acp) - .unwrap(); + let default_home = CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home: None, + }; + let shadow_home = CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home: Some(PathBuf::from("/tmp/claude-shadow")), + }; + let agent = CommandsCacheKey::Acp { + agent_id: "vendor/agent".into(), + }; + store.save_commands(&default_home, &native).unwrap(); + store.save_commands(&agent, &acp).unwrap(); // Reopen the store to prove the values come from disk, not memory. let reopened = SessionStore::open_at(root.clone()).unwrap(); - assert_eq!( - reopened.load_commands(ProviderKind::ClaudeCode, None), - native - ); - assert_eq!( - reopened.load_commands(ProviderKind::Acp, Some("vendor/agent")), - acp - ); + assert_eq!(reopened.load_commands(&default_home), native); + assert!(reopened.load_commands(&shadow_home).is_empty()); + assert_eq!(reopened.load_commands(&agent), acp); assert!( reopened - .load_commands(ProviderKind::Acp, Some("different-agent")) + .load_commands(&CommandsCacheKey::Acp { + agent_id: "different-agent".into() + }) .is_empty() ); - assert!(root.join("commands-claude.json").is_file()); assert!( root.join("commands-acp-76656e646f722f6167656e74.json") .is_file() ); + + reopened.save_commands(&shadow_home, &native).unwrap(); + reopened.invalidate_commands(&default_home).unwrap(); + reopened.invalidate_commands(&default_home).unwrap(); + assert!(reopened.load_commands(&default_home).is_empty()); + assert_eq!(reopened.load_commands(&shadow_home), native); } /// This test binary, re-run as another process that opens `root` and holds diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs index e4c571581..977cbaebe 100644 --- a/crates/ui/src/assets.rs +++ b/crates/ui/src/assets.rs @@ -49,6 +49,10 @@ const EXTRA_ICONS: &[(&str, &[u8])] = &[ "icons/box.svg", include_bytes!("../../../assets/icons/box.svg"), ), + ( + "icons/puzzle.svg", + include_bytes!("../../../assets/icons/puzzle.svg"), + ), ( "icons/ruler.svg", include_bytes!("../../../assets/icons/ruler.svg"), diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index c648f0589..f9d8c3b30 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -1139,6 +1139,12 @@ fn render_model_row( .text_color(muted) .child(crate::tr!("providers.mcp_unavailable")), ) + }) + .when(row.provider.caps().native_computer_use, |this| { + this.child(div().text_size(px(11.)).text_color(muted).child(crate::tr!( + "providers.native_computer_use", + name = provider_label(row.provider) + ))) }), ) .when(index < 9 && !compact, |this| { diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index cf04c7968..832ba9b08 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -36,6 +36,7 @@ pub mod pairing; pub mod palette; mod pasteboard; mod plan_panel; +mod plugins_settings; mod preview_panel; mod project_icon; pub(crate) mod provider_card; diff --git a/crates/ui/src/plugins_settings.rs b/crates/ui/src/plugins_settings.rs new file mode 100644 index 000000000..5f1b71b63 --- /dev/null +++ b/crates/ui/src/plugins_settings.rs @@ -0,0 +1,2011 @@ +//! Settings → Plugins: one section per enabled provider profile, showing the +//! native plugin catalog the host listed for it. Every action, marketplace +//! operation and confirmation comes from the host's catalog; a control the +//! host did not offer is never drawn. A provider whose management is switched +//! off shows only its switch. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::rc::Rc; + +use agent::{ + ApplyNote, DeclaredComponents, MarketplaceAction, PluginAction, PluginActionKind, + PluginManagement, PluginScope, PluginSourceKind, ProviderPluginEntry, Tri, +}; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, Hsla, InteractiveElement as _, IntoElement, + ListAlignment, ListState, ParentElement as _, Render, ScrollHandle, SharedString, Styled as _, + Subscription, Window, div, list, point, prelude::FluentBuilder as _, px, +}; +use gpui_base::{Collapsible, Scrollbar, StyledExt as _, h_flex, v_flex}; +use tcode_core::settings::{PluginManagementSettings, ResolvedProfile}; +use tcode_protocol::{ + PluginCatalogState, PluginChallenge, PluginChallengeKind, PluginStaleReason, + ProviderPluginCatalog, RuntimeOperationId, +}; + +use crate::icon::{Icon, IconName}; +use crate::material; +use crate::overlay::{DialogActions, OverlayExt as _}; +use crate::scroll::ScrollableElement as _; +use crate::sizing::Sizable as _; +use crate::store::{StoreChange, TopicKind, WorkspaceStore, observe_store_topics}; +use crate::theme::ActiveTheme as _; +use crate::widgets::Spinner; +use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::input::{Input, InputState}; +use crate::widgets::switch::Switch; +use crate::window_state::WindowState; + +const SCOPES: [PluginScope; 5] = [ + PluginScope::User, + PluginScope::Project, + PluginScope::Local, + PluginScope::Managed, + PluginScope::Session, +]; + +pub struct PluginsSettingsPanel { + store: Entity, + window_state: Entity, + /// The settings page's content scroll, which a focused section is + /// brought into view in. + page_scroll: ScrollHandle, + /// Whether the page is on screen. Entering it lists every managed + /// profile again, and challenges are only presented while it shows. + shown: bool, + /// `(profile id, entry id)` rows whose details disclosure is open. + expanded: HashSet<(String, String)>, + /// Profiles whose marketplace disclosure is open. + marketplaces_open: HashSet, + /// A profile section to bring into view once it is laid out. + focus: Option, + /// The challenge whose dialog is open. + challenge: Option, + /// Challenges this client already answered; the replica keeps showing + /// them until the host's answer arrives. + answered: HashSet, + /// The switches the shown page last listed for; a provider switched on + /// since is listed then. + switches: PluginManagementSettings, + _subscriptions: Vec, +} + +impl PluginsSettingsPanel { + pub fn new( + store: Entity, + window_state: Entity, + page_scroll: ScrollHandle, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let subscriptions = vec![ + cx.subscribe_in( + &store, + window, + |this, _, change: &StoreChange, window, cx| match change.topic { + TopicKind::Providers | TopicKind::Settings => { + if this.shown { + this.list_switched_on(cx); + this.sync_challenge(window, cx); + } + cx.notify(); + } + TopicKind::ActiveSession | TopicKind::SessionStatus => cx.notify(), + _ => {} + }, + ), + cx.observe(&window_state, |_, _, cx| cx.notify()), + ]; + Self { + store, + window_state, + page_scroll, + shown: false, + expanded: HashSet::new(), + marketplaces_open: HashSet::new(), + focus: None, + challenge: None, + answered: HashSet::new(), + switches: PluginManagementSettings::default(), + _subscriptions: subscriptions, + } + } + + /// The page left the screen; entering it again lists the catalogs anew. + pub fn hide(&mut self) { + self.shown = false; + } + + /// Bring `profile_id`'s section into view once it is laid out. + pub fn focus_profile(&mut self, profile_id: String, cx: &mut Context) { + self.focus = Some(profile_id); + cx.notify(); + } + + /// Profiles Tcode lists plugins for under `switches`. + fn managed_profiles(&self, switches: &PluginManagementSettings, cx: &App) -> Vec { + self.store + .read(cx) + .enabled_profiles() + .into_iter() + .filter(|profile| { + manages(profile.kind.caps().plugin_management) + && switches.provider_enabled(profile.kind) + }) + .map(|profile| profile.id) + .collect() + } + + fn list_switched_on(&mut self, cx: &mut App) { + let switches = self.store.read(cx).plugin_management().clone(); + let before = self.managed_profiles(&self.switches, cx); + for profile_id in self.managed_profiles(&switches, cx) { + if !before.contains(&profile_id) { + self.refresh(&profile_id, cx); + } + } + self.switches = switches; + } + + /// List a profile's catalog from the open thread's directory, or from + /// outside any project when no thread is open. + fn refresh(&self, profile_id: &str, cx: &mut App) { + let profile_id = profile_id.to_string(); + self.store.update(cx, |store, _| { + let cwd = store.active_session_cwd(); + store.refresh_provider_plugins(profile_id, cwd); + }); + } + + fn pending_challenges(&self, cx: &App) -> Vec { + let store = self.store.read(cx); + store + .enabled_profiles() + .iter() + .filter_map(|profile| store.provider_plugin_catalog(&profile.id)) + .flat_map(|catalog| catalog.challenges.iter().cloned()) + .collect() + } + + /// Keep one dialog open for the first unanswered challenge in the + /// replica, and close it once the host no longer waits on it. + fn sync_challenge(&mut self, window: &mut Window, cx: &mut Context) { + let challenges = self.pending_challenges(cx); + if let Some(open) = self.challenge { + if challenges.iter().any(|challenge| challenge.op_id == open) { + return; + } + self.challenge = None; + window.close_dialog(cx); + } + if !self.shown { + return; + } + let Some(next) = challenges + .into_iter() + .find(|challenge| !self.answered.contains(&challenge.op_id)) + else { + return; + }; + self.challenge = Some(next.op_id); + self.open_challenge(next, window, cx); + } + + fn open_challenge( + &self, + challenge: PluginChallenge, + window: &mut Window, + cx: &mut Context, + ) { + let store = self.store.clone(); + let panel = cx.entity().downgrade(); + let op_id = challenge.op_id; + let answer = Rc::new(move |accept: bool, cx: &mut App| { + store.update(cx, |store, _| store.resolve_plugin_challenge(op_id, accept)); + _ = panel.update(cx, |panel: &mut Self, _| { + panel.answered.insert(op_id); + if panel.challenge == Some(op_id) { + panel.challenge = None; + } + }); + }); + window.open_alert_dialog(cx, move |alert, _, cx| { + let (title, accept_label, decline_label, danger) = match &challenge.kind { + PluginChallengeKind::AcceptCommand { .. } => ( + crate::tr!( + "providers.plugins.challenge.accept_command_title", + name = challenge.entry_id + ), + crate::tr!("providers.plugins.challenge.accept"), + crate::tr!("providers.plugins.challenge.decline"), + false, + ), + PluginChallengeKind::ConfirmDestructive { .. } => ( + crate::tr!( + "providers.plugins.challenge.destructive_title", + name = challenge.entry_id + ), + crate::tr!("providers.plugins.challenge.remove"), + crate::tr!("providers.plugins.challenge.cancel"), + true, + ), + }; + let decline = answer.clone(); + let accept = answer.clone(); + let escape = answer.clone(); + let accept_button = Button::new("plugin-challenge-accept") + .debug_selector(|| "plugin-challenge-accept".into()) + .label(accept_label.into_owned()) + .on_click(move |_, window, cx| { + accept(true, cx); + window.close_dialog(cx); + }); + alert + .bg(cx.theme().popover) + .width(px(560.)) + .title(SharedString::from(title.into_owned())) + .description(challenge_body(&challenge, cx)) + .on_cancel(move |_, _, cx| { + escape(false, cx); + true + }) + .footer( + DialogActions::new() + .flex_wrap() + .child( + Button::new("plugin-challenge-decline") + .debug_selector(|| "plugin-challenge-decline".into()) + .label(decline_label.into_owned()) + .on_click(move |_, window, cx| { + decline(false, cx); + window.close_dialog(cx); + }), + ) + .child(if danger { + accept_button.danger() + } else { + accept_button.primary() + }), + ) + }); + } + + fn open_add_plugin(&self, profile_id: String, window: &mut Window, cx: &mut Context) { + let title = crate::tr!( + "providers.plugins.add_plugin_title", + name = self + .store + .read(cx) + .provider_profile_display_name(&profile_id) + ) + .into_owned(); + let store = self.store.clone(); + let picker = cx.new(|cx| AddPluginPicker::new(store, profile_id, window, cx)); + window.open_dialog(cx, move |dialog, window, cx| { + let picker = picker.clone(); + let height = crate::sizing::fit_viewport(456., window.viewport_size().height * 0.7); + dialog + .w(px(620.)) + .bg(cx.theme().popover) + .shadow_xl() + .title(title.clone()) + .content(move |content, _, _| { + content.h(height).child( + div() + .debug_selector(|| "add-plugin-body".into()) + .size_full() + .child(picker.clone()), + ) + }) + }); + } + + fn open_add_marketplace( + &self, + profile_id: String, + cwd: Option, + window: &mut Window, + cx: &mut Context, + ) { + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder(crate::tr!( + "providers.plugins.marketplace_source_placeholder" + )) + }); + let store = self.store.clone(); + window.open_dialog(cx, move |dialog, _, cx| { + let field = input.clone(); + let submit_input = input.clone(); + let store = store.clone(); + let profile_id = profile_id.clone(); + let cwd = cwd.clone(); + dialog + .w(px(520.)) + .bg(cx.theme().popover) + .shadow_xl() + .title(crate::tr!("providers.plugins.add_marketplace_title").into_owned()) + .content(move |content, _, cx| { + content.child( + v_flex() + .w_full() + .gap_1p5() + .child( + div() + .text_size(px(13.)) + .font_medium() + .child(crate::tr!("providers.plugins.marketplace_source")), + ) + .child(Input::new(&field).small()) + .child( + div() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("providers.plugins.marketplace_source_help")), + ), + ) + }) + .footer( + DialogActions::new() + .child( + Button::new("plugin-marketplace-cancel") + .ghost() + .label(crate::tr!("settings.cancel").into_owned()) + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("plugin-marketplace-submit") + .debug_selector(|| "plugin-marketplace-submit".into()) + .primary() + .label(crate::tr!("providers.plugins.add").into_owned()) + .on_click(move |_, window, cx| { + let source = submit_input.read(cx).value().trim().to_string(); + if source.is_empty() { + return; + } + let (profile_id, cwd) = (profile_id.clone(), cwd.clone()); + store.update(cx, |store, _| { + store.add_provider_marketplace(profile_id, source, cwd) + }); + window.close_dialog(cx); + }), + ), + ) + }); + } + + fn render_profile(&self, profile: &ResolvedProfile, cx: &mut Context) -> AnyElement { + let management = profile.kind.caps().plugin_management; + let store = self.store.read(cx); + let name = store.provider_profile_display_name(&profile.id); + let catalog = store.provider_plugin_catalog(&profile.id).cloned(); + let requested_cwd = store.active_session_cwd(); + let switches = store.plugin_management(); + let (master, switch_on) = (switches.enabled, switches.provider_switch(profile.kind)); + let muted = cx.theme().muted_foreground; + let profile_id = profile.id.clone(); + let kind = profile.kind; + let switch_id = format!("plugins-switch-{profile_id}"); + let switch = div().debug_selector(move || switch_id.clone()).child( + Switch::new(SharedString::from(format!("plugins-switch-{profile_id}"))) + .checked(switch_on) + .disabled(!master) + .tooltip(crate::tr!("providers.plugins.provider_switch", name = name).into_owned()) + .on_click(cx.listener(move |this, checked: &bool, _, cx| { + let checked = *checked; + this.store.update(cx, |store, _| { + store.set_provider_plugin_management(kind, checked) + }); + })), + ); + + let mut header = self + .row(cx) + .flex_row() + .items_center() + .gap_2() + .child( + div() + .flex_none() + .size(px(16.)) + .child(crate::provider_card::provider_glyph(profile.kind).small()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(13.)) + .font_medium() + .child(name.clone()), + ); + let section_id = SharedString::from(format!("plugins-section-{profile_id}")); + let mut section = div().id(section_id.clone()).w_full().debug_selector({ + let section_id = section_id.clone(); + move || section_id.to_string() + }); + if self.focus.as_ref() == Some(&profile_id) { + section = section.child(self.scroll_into_view()); + } + + let off = if !master { + Some(crate::tr!("providers.plugins.management_off")) + } else if !switch_on { + Some(crate::tr!("providers.plugins.provider_off", name = name)) + } else if !manages(management) { + Some(crate::tr!("providers.plugins.unmanaged")) + } else { + None + }; + if let Some(note) = off { + let rows = vec![ + header.child(switch).into_any_element(), + self.note_row(note.into_owned(), muted, cx), + ]; + return section + .child(material::grouped(rows, cx)) + .into_any_element(); + } + + let loading = catalog.as_ref().is_none_or(|catalog| catalog.loading); + let refresh_id = format!("plugins-refresh-{profile_id}"); + header = header + .when(loading, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }) + .child( + Button::new(SharedString::from(refresh_id.clone())) + .debug_selector(move || refresh_id.clone()) + .ghost() + .small() + .text_size(px(12.)) + .icon(Icon::empty().path("icons/rotate-ccw.svg")) + .tooltip(crate::tr!("providers.plugins.refresh", name = name)) + .on_click(cx.listener({ + let profile_id = profile_id.clone(); + move |this, _, _, cx| this.refresh(&profile_id, cx) + })), + ) + .child(switch); + + let context_cwd = catalog + .as_ref() + .map_or(requested_cwd, |catalog| catalog.context_cwd.clone()); + let context = match &context_cwd { + Some(path) => crate::tr!("providers.plugins.project_context", path = path.display()), + None => crate::tr!("providers.plugins.no_project_context"), + }; + let mut rows = vec![ + header.into_any_element(), + self.row(cx) + .text_size(px(12.)) + .text_color(muted) + .child(context.into_owned()) + .into_any_element(), + ]; + rows.extend(self.status_rows(catalog.as_ref(), cx)); + + let mut after_card: Vec = Vec::new(); + if let Some(catalog) = &catalog { + let busy = !catalog.pending.is_empty(); + let installed: Vec<&ProviderPluginEntry> = catalog + .entries + .iter() + .filter(|entry| !entry.installations.is_empty()) + .collect(); + if installed.is_empty() { + if !matches!( + catalog.state, + PluginCatalogState::Error { .. } + | PluginCatalogState::Stale { + reason: PluginStaleReason::NotLoaded + } + ) { + rows.push(self.note_row( + crate::tr!("providers.plugins.no_installed").into_owned(), + muted, + cx, + )); + } + } else { + for entry in installed { + rows.push(self.installed_row(&profile_id, catalog, entry, busy, cx)); + } + } + after_card.push(self.section_actions(&profile_id, catalog, management, cx)); + if management.marketplaces { + after_card.push(self.marketplaces(&profile_id, catalog, busy, cx)); + } + } + after_card.push( + div() + .pl_3() + .text_size(px(12.)) + .text_color(muted) + .child(apply_note(management.apply)) + .into_any_element(), + ); + + section + .child( + v_flex() + .w_full() + .gap_2() + .child(material::grouped(rows, cx)) + .children(after_card), + ) + .into_any_element() + } + + fn status_rows( + &self, + catalog: Option<&ProviderPluginCatalog>, + cx: &mut Context, + ) -> Vec { + let muted = cx.theme().muted_foreground; + let danger = cx.theme().danger; + let warning = cx.theme().warning; + let Some(catalog) = catalog else { + return vec![self.note_row( + crate::tr!("providers.plugins.listing").into_owned(), + muted, + cx, + )]; + }; + let mut rows = Vec::new(); + if catalog.loading { + rows.push(self.note_row( + crate::tr!("providers.plugins.listing").into_owned(), + muted, + cx, + )); + } + match &catalog.state { + PluginCatalogState::Fresh => {} + PluginCatalogState::Stale { reason } => { + let text = match reason { + PluginStaleReason::NotLoaded => crate::tr!("providers.plugins.not_listed"), + PluginStaleReason::ContextChanged => { + crate::tr!("providers.plugins.stale_context") + } + PluginStaleReason::Changed => crate::tr!("providers.plugins.stale_changed"), + }; + if !catalog.loading { + rows.push(self.note_row(text.into_owned(), muted, cx)); + } + } + PluginCatalogState::Error { message } => rows.push( + self.row(cx) + .debug_selector(|| "plugins-list-error".into()) + .gap_1() + .text_size(px(12.)) + .text_color(danger) + .child(crate::tr!("providers.plugins.list_failed").into_owned()) + .child(verbatim(message.clone(), cx)) + .into_any_element(), + ), + } + for error in &catalog.errors { + rows.push( + self.row(cx) + .text_size(px(12.)) + .text_color(warning) + .child(verbatim(error.clone(), cx)) + .into_any_element(), + ); + } + rows + } + + fn installed_row( + &self, + profile_id: &str, + catalog: &ProviderPluginCatalog, + entry: &ProviderPluginEntry, + busy: bool, + cx: &mut Context, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + let pending = catalog.pending.contains(&entry.id); + let key = (profile_id.to_string(), entry.id.clone()); + let expanded = self.expanded.contains(&key); + let has_details = entry.declared.is_some() || !entry.diagnostics.is_empty(); + let (enabled_label, enabled_bg, enabled_fg) = match entry.enabled { + Tri::Yes => ( + crate::tr!("providers.plugins.effective_enabled"), + cx.theme().success.opacity(0.12), + cx.theme().success_foreground, + ), + Tri::No => ( + crate::tr!("providers.plugins.effective_disabled"), + cx.theme().muted, + muted, + ), + Tri::Unknown => ( + crate::tr!("providers.plugins.effective_unknown"), + cx.theme().muted, + muted, + ), + }; + let row_id = format!("plugin-row-{profile_id}-{}", entry.id); + let toggle_id = format!("plugin-details-{profile_id}-{}", entry.id); + let title = h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .text_size(px(13.)) + .font_medium() + .child(entry.name.clone()), + ) + .when_some(entry.version.clone(), |row, version| { + row.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(12.)) + .text_color(muted) + .child(version), + ) + }) + .child(material::semantic_chip( + enabled_label.into_owned(), + enabled_bg, + enabled_fg, + cx, + )) + .when(pending, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }) + .child(div().flex_1()) + .when(has_details, |row| { + row.child( + Button::new(SharedString::from(toggle_id.clone())) + .debug_selector(move || toggle_id.clone()) + .ghost() + .small() + .text_size(px(12.)) + .icon(if expanded { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .label(crate::tr!("providers.plugins.details").into_owned()) + .tooltip(crate::tr!( + "providers.plugins.toggle_details", + name = entry.name + )) + .on_click(cx.listener(move |this, _, _, cx| { + if !this.expanded.remove(&key) { + this.expanded.insert(key.clone()); + } + cx.notify(); + })), + ) + }); + + let scopes = SCOPES.into_iter().filter(|scope| { + entry + .installations + .iter() + .any(|installation| installation.scope == *scope) + || entry.actions.iter().any(|action| action.scope() == *scope) + }); + let scope_rows: Vec = scopes + .map(|scope| { + let installation = entry + .installations + .iter() + .find(|installation| installation.scope == scope); + let facts = h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_x_2() + .text_size(px(12.)) + .child(div().font_medium().child(scope_label(scope))) + .map(|row| match installation { + None => row.child( + div() + .text_color(muted) + .child(crate::tr!("providers.plugins.not_installed_in_scope")), + ), + Some(installation) => row + .when_some(installation.version.clone(), |row, version| { + row.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_color(muted) + .child(version), + ) + }) + .when_some(installation.scope_enabled, |row, enabled| { + row.child(div().text_color(muted).child(if enabled { + crate::tr!("providers.plugins.scope_enabled") + } else { + crate::tr!("providers.plugins.scope_disabled") + })) + }), + }); + let location = installation + .and_then(|installation| installation.location.as_ref()) + .map(|path| { + div() + .w_full() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.)) + .text_color(muted) + .child(path.display().to_string()) + }); + let actions: Vec = entry + .actions + .iter() + .copied() + .filter(|action| action.scope() == scope) + .collect(); + v_flex() + .w_full() + .pl_3() + .py_1() + .gap_1() + .border_l_1() + .border_color(cx.theme().border) + .child(facts) + .children(location) + .when(!actions.is_empty(), |col| { + col.child(h_flex().w_full().flex_wrap().gap_1().children( + ordered(actions).into_iter().map(|action| { + action_button( + &self.store, + profile_id, + &entry.id, + action, + catalog.context_cwd.clone(), + busy, + ) + .into_any_element() + }), + )) + }) + .into_any_element() + }) + .collect(); + + let source = source_line(entry, cx); + self.row(cx) + .id(SharedString::from(row_id.clone())) + .debug_selector(move || row_id.clone()) + .gap_1p5() + .child(title) + .when_some(entry.description.clone(), |col, description| { + col.child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(description), + ) + }) + .child(source) + .children(scope_rows) + .children(entry.errors.iter().map(|error| { + div() + .text_size(px(12.)) + .text_color(cx.theme().danger) + .child(verbatim(error.clone(), cx)) + })) + .child( + Collapsible::new() + .w_full() + .open(expanded) + .content(details(entry, cx)), + ) + .into_any_element() + } + + fn section_actions( + &self, + profile_id: &str, + catalog: &ProviderPluginCatalog, + management: PluginManagement, + cx: &mut Context, + ) -> AnyElement { + let add_id = format!("plugins-add-{profile_id}"); + let marketplaces_id = format!("plugins-marketplaces-{profile_id}"); + let open = self.marketplaces_open.contains(profile_id); + h_flex() + .w_full() + .flex_wrap() + .gap_2() + .when(management.supports(PluginActionKind::Install), |row| { + row.child( + Button::new(SharedString::from(add_id.clone())) + .debug_selector(move || add_id.clone()) + .outline() + .small() + .text_size(px(12.)) + .icon(IconName::Plus) + .label(crate::tr!("providers.plugins.add_plugin").into_owned()) + .on_click(cx.listener({ + let profile_id = profile_id.to_string(); + move |this, _, window, cx| { + this.open_add_plugin(profile_id.clone(), window, cx) + } + })), + ) + }) + .when(management.marketplaces, |row| { + row.child( + Button::new(SharedString::from(marketplaces_id.clone())) + .debug_selector(move || marketplaces_id.clone()) + .ghost() + .small() + .text_size(px(12.)) + .icon(if open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .label( + crate::tr!( + "providers.plugins.marketplaces", + count = catalog.marketplaces.len() + ) + .into_owned(), + ) + .on_click(cx.listener({ + let profile_id = profile_id.to_string(); + move |this, _, _, cx| { + if !this.marketplaces_open.remove(&profile_id) { + this.marketplaces_open.insert(profile_id.clone()); + } + cx.notify(); + } + })), + ) + }) + .into_any_element() + } + + fn marketplaces( + &self, + profile_id: &str, + catalog: &ProviderPluginCatalog, + busy: bool, + cx: &mut Context, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + let mut rows: Vec = catalog + .marketplaces + .iter() + .map(|marketplace| { + let removable = catalog.marketplace_actions.iter().any(|action| { + matches!(action, MarketplaceAction::Remove { marketplace: name, .. } if *name == marketplace.name) + }); + let pending = catalog.pending.contains(&marketplace.name); + let remove_id = format!("plugin-marketplace-remove-{profile_id}-{}", marketplace.name); + self.row(cx) + .flex_row() + .items_start() + .gap_3() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + h_flex() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .text_size(px(13.)) + .font_medium() + .child(marketplace.name.clone()), + ) + .child(material::semantic_chip( + source_kind_label(marketplace.kind), + cx.theme().muted, + muted, + cx, + )) + .when(pending, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }), + ) + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.)) + .text_color(muted) + .child(marketplace.source.clone()), + ) + .when_some(marketplace.location.as_ref(), |col, location| { + col.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.)) + .text_color(muted) + .child(location.display().to_string()), + ) + }), + ) + .when(removable, |row| { + let name = marketplace.name.clone(); + let profile_id = profile_id.to_string(); + let cwd = catalog.context_cwd.clone(); + let store = self.store.clone(); + row.child( + Button::new(SharedString::from(remove_id.clone())) + .debug_selector(move || remove_id.clone()) + .outline() + .danger() + .small() + .text_size(px(12.)) + .disabled(busy) + .label(crate::tr!("providers.plugins.remove").into_owned()) + .on_click(move |_, _, cx| { + let (profile_id, name, cwd) = + (profile_id.clone(), name.clone(), cwd.clone()); + store.update(cx, |store, _| { + store.remove_provider_marketplace(profile_id, name, cwd) + }); + }), + ) + }) + .into_any_element() + }) + .collect(); + if rows.is_empty() { + rows.push(self.note_row( + crate::tr!("providers.plugins.no_marketplaces").into_owned(), + muted, + cx, + )); + } + let can_add = catalog + .marketplace_actions + .contains(&MarketplaceAction::Add); + let add_id = format!("plugin-marketplace-add-{profile_id}"); + Collapsible::new() + .w_full() + .open(self.marketplaces_open.contains(profile_id)) + .content( + v_flex() + .w_full() + .gap_2() + .child(material::grouped(rows, cx)) + .when(can_add, |col| { + col.child( + h_flex().child( + Button::new(SharedString::from(add_id.clone())) + .debug_selector(move || add_id.clone()) + .outline() + .small() + .text_size(px(12.)) + .icon(IconName::Plus) + .disabled(busy) + .label( + crate::tr!("providers.plugins.add_marketplace") + .into_owned(), + ) + .on_click(cx.listener({ + let profile_id = profile_id.to_string(); + let cwd = catalog.context_cwd.clone(); + move |this, _, window, cx| { + this.open_add_marketplace( + profile_id.clone(), + cwd.clone(), + window, + cx, + ) + } + })), + ), + ) + }), + ) + .into_any_element() + } + + fn master_switch(&self, cx: &mut Context) -> AnyElement { + let enabled = self.store.read(cx).plugin_management().enabled; + material::group(cx) + .child( + h_flex() + .w_full() + .min_h(px(56.)) + .px_3() + .py_2() + .gap_3() + .items_center() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .text_size(px(13.)) + .font_medium() + .child(crate::tr!("providers.plugins.management")), + ) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("providers.plugins.management_description")), + ), + ) + .child( + div() + .debug_selector(|| "plugins-management-switch".into()) + .child( + Switch::new("plugins-management-switch") + .checked(enabled) + .on_click(cx.listener(|this, checked: &bool, _, cx| { + let checked = *checked; + this.store.update(cx, |store, _| { + store.set_plugin_management_enabled(checked) + }); + })), + ), + ), + ) + .into_any_element() + } + + /// Scroll the settings page so the element this is placed in starts at + /// the top of the viewport, once this frame has placed it. + fn scroll_into_view(&self) -> impl IntoElement { + let handle = self.page_scroll.clone(); + gpui::canvas( + move |bounds, window, _| { + window.on_next_frame(move |window, _| { + let viewport = handle.bounds(); + let offset = handle.offset(); + let top = (offset.y + viewport.origin.y - bounds.origin.y) + .clamp(-handle.max_offset().y, px(0.)); + handle.set_offset(point(offset.x, top)); + window.refresh(); + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_0() + } + + /// One row of a grouped card. + fn row(&self, _cx: &Context) -> gpui::Div { + v_flex() + .w_full() + .min_h(px(44.)) + .px_3() + .py_2p5() + .justify_center() + } + + fn note_row(&self, text: String, color: Hsla, cx: &Context) -> AnyElement { + self.row(cx) + .text_size(px(12.)) + .text_color(color) + .child(text) + .into_any_element() + } +} + +impl Render for PluginsSettingsPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.shown && self.store.read(cx).settings_hydrated() { + self.shown = true; + self.switches = self.store.read(cx).plugin_management().clone(); + for profile_id in self.managed_profiles(&self.switches, cx) { + self.refresh(&profile_id, cx); + } + cx.defer_in(window, |this, window, cx| this.sync_challenge(window, cx)); + } + let profiles = self.store.read(cx).enabled_profiles(); + let compact = self.window_state.read(cx).compact; + let page = v_flex() + .w_full() + .gap(if compact { px(16.) } else { px(24.) }) + .child( + div() + .pl_3() + .text_size(px(11.)) + .font_medium() + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("settings.plugins_section")), + ) + .child(self.master_switch(cx)) + .children( + profiles + .iter() + .map(|profile| self.render_profile(profile, cx)), + ); + self.focus = None; + page + } +} + +/// The searchable list of plugins the catalog offers but has not installed. +struct AddPluginPicker { + store: Entity, + profile_id: String, + search: Entity, + list: ListState, + ids: Vec, + _subscriptions: Vec, +} + +impl AddPluginPicker { + fn new( + store: Entity, + profile_id: String, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search = cx.new(|cx| { + InputState::new(window, cx).placeholder(crate::tr!("providers.plugins.search")) + }); + let subscriptions = vec![ + observe_store_topics(&store, &[TopicKind::Providers], cx), + cx.observe(&search, |_, _, cx| cx.notify()), + ]; + Self { + store, + profile_id, + search, + list: ListState::new(0, ListAlignment::Top, px(120.)).measure_all(), + ids: Vec::new(), + _subscriptions: subscriptions, + } + } + + fn render_row( + &self, + catalog: &ProviderPluginCatalog, + entry: &ProviderPluginEntry, + cx: &mut Context, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + let busy = !catalog.pending.is_empty(); + let pending = catalog.pending.contains(&entry.id); + let installs: Vec = entry + .actions + .iter() + .copied() + .filter(|action| action.kind() == PluginActionKind::Install) + .collect(); + let row_id = format!("plugin-available-{}-{}", self.profile_id, entry.id); + let install = match installs.as_slice() { + [] => None, + [only] => Some( + h_flex() + .child(action_button( + &self.store, + &self.profile_id, + &entry.id, + *only, + catalog.context_cwd.clone(), + busy, + )) + .into_any_element(), + ), + several => Some( + h_flex() + .flex_wrap() + .items_center() + .gap_1() + .child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(crate::tr!("providers.plugins.install_to")), + ) + .children(several.iter().map(|action| { + action_button( + &self.store, + &self.profile_id, + &entry.id, + *action, + catalog.context_cwd.clone(), + busy, + ) + .label(scope_label(action.scope())) + })) + .into_any_element(), + ), + }; + v_flex() + .id(SharedString::from(row_id.clone())) + .debug_selector(move || row_id.clone()) + .w_full() + .p_3() + .gap_1() + .hover(|row| row.bg(cx.theme().list_hover)) + .child( + h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .text_size(px(14.)) + .font_medium() + .child(entry.name.clone()), + ) + .when_some(entry.version.clone(), |row, version| { + row.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(12.)) + .text_color(muted) + .child(version), + ) + }) + .when(pending, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }), + ) + .when_some(entry.description.clone(), |col, description| { + col.child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(description), + ) + }) + .child(source_line(entry, cx)) + .when(entry.source.kind == PluginSourceKind::Command, |col| { + col.child( + div() + .text_size(px(12.)) + .text_color(cx.theme().warning) + .child(crate::tr!("providers.plugins.command_source")), + ) + }) + .when_some(entry.declared.as_ref(), |col, declared| { + col.child(declared_chips(declared, cx)) + }) + .children(entry.errors.iter().map(|error| { + div() + .text_size(px(12.)) + .text_color(cx.theme().danger) + .child(verbatim(error.clone(), cx)) + })) + .children(install) + .into_any_element() + } +} + +impl Render for AddPluginPicker { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let query = self.search.read(cx).value().trim().to_lowercase(); + let catalog = self + .store + .read(cx) + .provider_plugin_catalog(&self.profile_id) + .cloned(); + let available: Vec = catalog + .iter() + .flat_map(|catalog| catalog.entries.iter()) + .filter(|entry| entry.installations.is_empty()) + .cloned() + .collect(); + let none_available = available.is_empty(); + let matches: Rc> = Rc::new( + available + .into_iter() + .filter(|entry| { + query.is_empty() + || entry.name.to_lowercase().contains(&query) + || entry + .description + .as_deref() + .is_some_and(|text| text.to_lowercase().contains(&query)) + }) + .collect(), + ); + if !matches.iter().map(|entry| &entry.id).eq(&self.ids) { + self.ids = matches.iter().map(|entry| entry.id.clone()).collect(); + self.list.reset(matches.len()); + } + let muted = cx.theme().muted_foreground; + let body = match catalog { + Some(catalog) if !matches.is_empty() => { + let catalog = Rc::new(catalog); + crate::scroll::page_viewport( + "add-plugin-bounce", + crate::wheel_easing::Handle::List(self.list.clone()), + list( + self.list.clone(), + cx.processor(move |this, ix: usize, _, cx| { + this.render_row(&catalog, &matches[ix], cx) + }), + ) + .size_full(), + ) + .into_any_element() + } + catalog => div() + .p_3() + .text_size(px(13.)) + .text_color(muted) + .child( + if catalog.is_none_or(|catalog| catalog.loading) && none_available { + crate::tr!("providers.plugins.listing") + } else if none_available { + crate::tr!("providers.plugins.no_available") + } else { + crate::tr!("providers.plugins.no_matches") + }, + ) + .into_any_element(), + }; + v_flex() + .size_full() + .gap_3() + .child(Input::new(&self.search).small()) + .child( + v_flex() + .w_full() + .flex_1() + .min_h_0() + .relative() + .rounded(material::radius_card(cx)) + .bg(cx.theme().muted) + .child(body) + .when(!window.is_inspector_picking(cx), |col| { + col.child(Scrollbar::vertical(&self.list).id("add-plugin-scrollbar")) + }), + ) + } +} + +fn manages(management: PluginManagement) -> bool { + !management.actions.is_empty() || management.marketplaces +} + +/// Enable and Disable side by side first, then Update, then Uninstall. +fn ordered(mut actions: Vec) -> Vec { + actions.sort_by_key(|action| match action.kind() { + PluginActionKind::Install => 0, + PluginActionKind::Enable => 1, + PluginActionKind::Disable => 2, + PluginActionKind::Update => 3, + PluginActionKind::Uninstall => 4, + }); + actions +} + +fn action_button( + store: &Entity, + profile_id: &str, + entry_id: &str, + action: PluginAction, + cwd: Option, + busy: bool, +) -> Button { + let (verb, label) = match action.kind() { + PluginActionKind::Install => ("install", crate::tr!("providers.plugins.action.install")), + PluginActionKind::Uninstall => ( + "uninstall", + crate::tr!("providers.plugins.action.uninstall"), + ), + PluginActionKind::Enable => ("enable", crate::tr!("providers.plugins.action.enable")), + PluginActionKind::Disable => ("disable", crate::tr!("providers.plugins.action.disable")), + PluginActionKind::Update => ("update", crate::tr!("providers.plugins.action.update")), + }; + let id = format!( + "plugin-{verb}-{profile_id}-{entry_id}-{}", + scope_key(action.scope()) + ); + let store = store.clone(); + let (profile_id, entry_id) = (profile_id.to_string(), entry_id.to_string()); + let button = Button::new(SharedString::from(id.clone())) + .debug_selector(move || id.clone()) + .outline() + .small() + .text_size(px(12.)) + .disabled(busy) + .label(label.into_owned()) + .on_click(move |_, _, cx| { + let (profile_id, entry_id, cwd) = (profile_id.clone(), entry_id.clone(), cwd.clone()); + store.update(cx, |store, _| { + store.run_provider_plugin_action(profile_id, entry_id, action, cwd) + }); + }); + if action.kind() == PluginActionKind::Uninstall { + button.danger() + } else { + button + } +} + +fn source_line(entry: &ProviderPluginEntry, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_2() + .text_size(px(11.)) + .text_color(muted) + .when_some(entry.source.marketplace.as_ref(), |row, marketplace| { + row.child( + crate::tr!( + "providers.plugins.from_marketplace", + marketplace = marketplace + ) + .into_owned(), + ) + }) + .child(material::semantic_chip( + source_kind_label(entry.source.kind), + cx.theme().muted, + muted, + cx, + )) + .into_any_element() +} + +/// The declared components and the host's read-only facts for an entry. +fn details(entry: &ProviderPluginEntry, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + v_flex() + .w_full() + .pt_1() + .gap_2() + .when_some(entry.declared.as_ref(), |col, declared| { + col.child(declared_chips(declared, cx)) + }) + .children(entry.diagnostics.iter().map(|(key, text)| { + v_flex() + .w_full() + .gap_0p5() + .child( + div() + .text_size(px(12.)) + .font_medium() + .text_color(muted) + .child(diagnostic_label(key)), + ) + .child( + div() + .w_full() + .p_2() + .rounded(material::radius_input(cx)) + .bg(cx.theme().muted) + .text_size(px(12.)) + .child(verbatim(text.clone(), cx)), + ) + })) + .into_any_element() +} + +fn declared_chips(declared: &DeclaredComponents, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + let groups = [ + ("skills", &declared.skills, false), + ("agents", &declared.agents, false), + ("hooks", &declared.hooks, false), + ("untrusted_hooks", &declared.untrusted_hooks, true), + ("mcp_servers", &declared.mcp_servers, false), + ("lsp_servers", &declared.lsp_servers, false), + ("apps", &declared.apps, false), + ]; + v_flex() + .w_full() + .gap_1() + .children(groups.into_iter().filter_map(|(key, names, warn)| { + let names = names.as_ref()?; + let (bg, fg) = if warn { + ( + cx.theme().warning.opacity(0.14), + cx.theme().warning_foreground, + ) + } else { + (cx.theme().muted, muted) + }; + Some( + h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_1() + .child(div().text_size(px(12.)).font_medium().child(format!( + "{} · {}", + crate::tr!(format!("providers.plugins.declared.{key}")), + names.len() + ))) + .when(names.is_empty(), |row| { + row.child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(crate::tr!("providers.plugins.none_declared")), + ) + }) + .children( + names + .iter() + .map(|name| material::semantic_chip(name.clone(), bg, fg, cx)), + ), + ) + })) + .into_any_element() +} + +fn challenge_body(challenge: &PluginChallenge, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + let field = |label: SharedString, value: String| { + v_flex() + .w_full() + .gap_0p5() + .child( + div() + .text_size(px(12.)) + .font_medium() + .text_color(muted) + .child(label), + ) + .child(div().w_full().text_size(px(12.)).child(verbatim(value, cx))) + }; + let body = match &challenge.kind { + PluginChallengeKind::AcceptCommand { + command, + sha256, + mode, + } => v_flex() + .w_full() + .gap_3() + .when_some(challenge.native_text.clone(), |col, text| { + col.child( + div() + .w_full() + .p_2() + .rounded(material::radius_input(cx)) + .bg(cx.theme().muted) + .text_size(px(12.)) + .child(verbatim(text, cx)), + ) + }) + .child(field( + crate::tr!("providers.plugins.challenge.command") + .into_owned() + .into(), + command.clone(), + )) + .child(field( + crate::tr!("providers.plugins.challenge.sha256") + .into_owned() + .into(), + sha256.clone(), + )) + .when_some(mode.clone(), |col, mode| { + col.child(field( + crate::tr!("providers.plugins.challenge.mode") + .into_owned() + .into(), + mode, + )) + }), + PluginChallengeKind::ConfirmDestructive { uninstalls } => v_flex() + .w_full() + .gap_2() + .text_size(px(13.)) + .child(if uninstalls.is_empty() { + crate::tr!("providers.plugins.challenge.destructive_none") + } else { + crate::tr!("providers.plugins.challenge.destructive_body") + }) + .children(uninstalls.iter().map(|id| { + let selector = format!("plugin-challenge-uninstall-{id}"); + div() + .debug_selector(move || selector.clone()) + .pl_3() + .child(verbatim(id.clone(), cx)) + })) + .when_some(challenge.native_text.clone(), |col, text| { + col.child(div().text_color(muted).child(verbatim(text, cx))) + }), + }; + div() + .id("plugin-challenge-body") + .w_full() + .max_h(px(360.)) + .overflow_y_scroll_area() + .child(body) + .into_any_element() +} + +/// Native text exactly as the CLI printed it, in the monospace face. +fn verbatim(text: String, cx: &App) -> AnyElement { + div() + .w_full() + .font_family(cx.theme().mono_font_family.clone()) + .child(text) + .into_any_element() +} + +fn apply_note(apply: ApplyNote) -> String { + match apply { + ApplyNote::ReloadOrRestart => crate::tr!("providers.plugins.apply_reload_or_restart"), + ApplyNote::NextSession => crate::tr!("providers.plugins.apply_next_session"), + ApplyNote::Unverified => crate::tr!("providers.plugins.apply_unverified"), + } + .into_owned() +} + +fn scope_key(scope: PluginScope) -> &'static str { + match scope { + PluginScope::User => "user", + PluginScope::Project => "project", + PluginScope::Local => "local", + PluginScope::Managed => "managed", + PluginScope::Session => "session", + } +} + +fn scope_label(scope: PluginScope) -> String { + crate::tr!(format!("providers.plugins.scope.{}", scope_key(scope))).into_owned() +} + +fn source_kind_label(kind: PluginSourceKind) -> String { + match kind { + PluginSourceKind::Official => crate::tr!("providers.plugins.source.official"), + PluginSourceKind::ThirdParty => crate::tr!("providers.plugins.source.third_party"), + PluginSourceKind::LocalPath => crate::tr!("providers.plugins.source.local_path"), + PluginSourceKind::Git => crate::tr!("providers.plugins.source.git"), + PluginSourceKind::Npm => crate::tr!("providers.plugins.source.npm"), + PluginSourceKind::Command => crate::tr!("providers.plugins.source.command"), + PluginSourceKind::Unknown => crate::tr!("providers.plugins.source.unknown"), + } + .into_owned() +} + +/// Diagnostics are keyed by stable identifiers; one this client does not +/// know yet is shown as its key. +fn diagnostic_label(key: &str) -> String { + match key { + "details" | "details_error" | "message" | "update_outcome" | "old_version" + | "new_version" => crate::tr!(format!("providers.plugins.diagnostic.{key}")).into_owned(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use agent::{PluginInstallation, PluginSource}; + use gpui::{TestAppContext, VisualTestContext, size}; + use tcode_core::settings::Settings; + use tcode_protocol::{ + ClientPayload, Command, CommandResponse, EventEnvelope, HostMessage, ProvidersStatus, + ServerEvent, SettingsPatch, Topic, decode_client_line, encode_line, + }; + + use super::*; + use crate::settings_page::SettingsPage; + use crate::store::WorkspaceAttachment; + + struct Host { + outgoing: async_channel::Receiver, + incoming: async_channel::Sender, + store: Entity, + _pump: gpui::Task<()>, + } + + impl Host { + fn replicate(&self, catalog: ProviderPluginCatalog, cx: &mut VisualTestContext) { + send_providers(&self.incoming, catalog); + self.settle(cx); + } + + fn replicate_settings(&self, settings: Settings, cx: &mut VisualTestContext) { + send_settings(&self.incoming, settings); + self.settle(cx); + } + + fn settle(&self, cx: &mut VisualTestContext) { + cx.run_until_parked(); + self.store + .update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + } + + /// Play the host: acknowledge every command and return them. + fn commands(&self, cx: &mut VisualTestContext) -> Vec { + let mut commands = Vec::new(); + loop { + cx.run_until_parked(); + let Ok(line) = self.outgoing.try_recv() else { + break; + }; + let Ok(message) = decode_client_line(&line) else { + continue; + }; + if let ClientPayload::Command(command) = message.payload { + commands.push(command); + self.incoming + .try_send( + encode_line(&HostMessage::Ack { + id: message.id, + result: Ok(CommandResponse::Unit), + }) + .unwrap(), + ) + .unwrap(); + } + } + commands + } + } + + fn catalog( + entries: Vec, + challenges: Vec, + ) -> ProviderPluginCatalog { + ProviderPluginCatalog { + profile_id: "claude".into(), + context_cwd: Some("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/work/project".into()), + marketplaces: Vec::new(), + marketplace_actions: Vec::new(), + entries, + errors: Vec::new(), + state: PluginCatalogState::Fresh, + loading: false, + pending: Vec::new(), + challenges, + } + } + + /// Settings with Claude Code's plugin management switched on. + fn claude_managed() -> Settings { + let mut settings = Settings::default(); + settings.apply(SettingsPatch::PluginManagementProvider { + provider: agent::ProviderKind::ClaudeCode, + enabled: true, + }); + settings + } + + /// Open Settings → Plugins the way navigation does, against a host + /// whose replicated state holds `settings` and `catalog`. + fn open_plugins( + settings: Settings, + catalog: ProviderPluginCatalog, + cx: &mut TestAppContext, + ) -> (Host, &mut VisualTestContext) { + cx.update(crate::theme::init); + let (to_host, outgoing) = async_channel::unbounded(); + let (incoming, from_host) = async_channel::unbounded(); + send_settings(&incoming, settings); + send_providers(&incoming, catalog); + let link = tcode_client::HostLink::new(to_host, from_host); + let pump_link = link.clone(); + let executor = cx.background_executor.clone(); + let pump = cx.background_executor.spawn(async move { + pump_link + .pump_with_timer(|| executor.timer(std::time::Duration::from_millis(25))) + .await; + }); + let store = cx.new(|cx| { + WorkspaceStore::new_attached(link, WorkspaceAttachment::Local, None, None, false, cx) + }); + let window_state = cx.new(|_| { + let mut state = WindowState::new(false); + state.pending_settings_section = Some("plugins".into()); + state + }); + let page_store = store.clone(); + let (_, cx) = cx.add_window_view(|window, cx| { + let page = cx.new(|cx| SettingsPage::new(page_store, window_state, window, cx)); + gpui_base::Root::new(page, window, cx) + }); + cx.simulate_resize(size(px(1000.), px(1400.))); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + ( + Host { + outgoing, + incoming, + store, + _pump: pump, + }, + cx, + ) + } + + fn send_settings(incoming: &async_channel::Sender, settings: Settings) { + incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic: Topic::Settings, + event: ServerEvent::SettingsSnapshot(settings), + })) + .unwrap(), + ) + .unwrap(); + } + + fn send_providers(incoming: &async_channel::Sender, catalog: ProviderPluginCatalog) { + incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic: Topic::Providers, + event: ServerEvent::ProvidersReplaced(ProvidersStatus { + plugins: vec![catalog], + ..Default::default() + }), + })) + .unwrap(), + ) + .unwrap(); + } + + fn draw(cx: &mut VisualTestContext) { + for _ in 0..2 { + cx.run_until_parked(); + cx.update(|window, cx| { + window.refresh(); + _ = window.draw(cx); + }); + } + } + + fn alpha() -> ProviderPluginEntry { + ProviderPluginEntry { + id: "alpha@mkt".into(), + name: "alpha".into(), + version: Some("1.0.0".into()), + description: Some("Alpha plugin".into()), + source: PluginSource { + marketplace: Some("mkt".into()), + kind: PluginSourceKind::LocalPath, + }, + installations: vec![PluginInstallation { + scope: PluginScope::Project, + location: Some("/work/project/.claude/plugins/alpha".into()), + version: Some("1.0.0".into()), + scope_enabled: Some(true), + }], + enabled: Tri::Yes, + declared: None, + errors: Vec::new(), + actions: vec![ + PluginAction::Install { + scope: PluginScope::User, + }, + PluginAction::Disable { + scope: PluginScope::Project, + }, + ], + diagnostics: Vec::new(), + } + } + + /// The host decides which actions an entry offers in its context; the + /// page draws exactly those and no control the host did not compute. + #[gpui::test] + fn only_host_computed_plugin_actions_are_rendered(cx: &mut TestAppContext) { + let (host, cx) = open_plugins(claude_managed(), catalog(vec![alpha()], Vec::new()), cx); + assert!(cx.debug_bounds("plugin-row-claude-alpha@mkt").is_some()); + + let offered = [ + "plugin-install-claude-alpha@mkt-user", + "plugin-disable-claude-alpha@mkt-project", + ]; + for verb in ["install", "uninstall", "enable", "disable", "update"] { + for scope in SCOPES { + // Debug selectors are looked up by `'static` name. + let id: &'static str = + format!("plugin-{verb}-claude-alpha@mkt-{}", scope_key(scope)).leak(); + assert_eq!(cx.debug_bounds(id).is_some(), offered.contains(&id), "{id}"); + } + } + drop(host); + } + + /// A marketplace removal waits on the person: the dialog lists what it + /// uninstalls, and Remove answers exactly that challenge, once. + #[gpui::test] + fn destructive_challenge_lists_uninstalls_and_accept_answers_it(cx: &mut TestAppContext) { + let challenge = PluginChallenge { + op_id: RuntimeOperationId(7), + profile_id: "claude".into(), + entry_id: "mkt".into(), + kind: PluginChallengeKind::ConfirmDestructive { + uninstalls: vec!["alpha@mkt".into(), "beta@mkt".into()], + }, + native_text: None, + }; + let pending = catalog(Vec::new(), vec![challenge]); + let (host, cx) = open_plugins(claude_managed(), pending.clone(), cx); + assert!( + cx.debug_bounds("plugin-challenge-uninstall-alpha@mkt") + .is_some() + ); + assert!( + cx.debug_bounds("plugin-challenge-uninstall-beta@mkt") + .is_some() + ); + host.commands(cx); + + let accept = cx.debug_bounds("plugin-challenge-accept").unwrap(); + cx.simulate_click(accept.center(), gpui::Modifiers::default()); + draw(cx); + let answers: Vec = host + .commands(cx) + .into_iter() + .filter(|command| matches!(command, Command::ResolvePluginChallenge { .. })) + .collect(); + assert!( + matches!( + answers.as_slice(), + [Command::ResolvePluginChallenge { + op_id: RuntimeOperationId(7), + accept: true + }] + ), + "{answers:?}" + ); + // The replica still shows the challenge until the host has acted on + // the answer; the person is not asked again meanwhile. + host.replicate(pending, cx); + assert!(cx.debug_bounds("plugin-challenge-accept").is_none()); + } + + /// Off, a provider's section is its switch: a catalog still in the + /// replica draws no controls and nothing is listed until the switch is + /// turned on, which lists it. + #[gpui::test] + fn a_switched_off_provider_shows_only_its_switch_until_switched_on(cx: &mut TestAppContext) { + let (host, cx) = open_plugins(Settings::default(), catalog(vec![alpha()], Vec::new()), cx); + assert!(cx.debug_bounds("plugins-switch-claude").is_some()); + for id in [ + "plugin-row-claude-alpha@mkt", + "plugin-install-claude-alpha@mkt-user", + "plugin-disable-claude-alpha@mkt-project", + "plugins-add-claude", + "plugins-refresh-claude", + ] { + assert!(cx.debug_bounds(id).is_none(), "{id}"); + } + let refreshed = |commands: &[Command]| -> Vec { + commands + .iter() + .filter_map(|command| match command { + Command::RefreshProviderPlugins { profile_id, .. } => Some(profile_id.clone()), + _ => None, + }) + .collect() + }; + assert_eq!(refreshed(&host.commands(cx)), ["codex"]); + + let switch = cx.debug_bounds("plugins-switch-claude").unwrap(); + cx.simulate_click(switch.center(), gpui::Modifiers::default()); + draw(cx); + let commands = host.commands(cx); + assert!( + commands.iter().any(|command| matches!( + command, + Command::PatchSettings { + patch: SettingsPatch::PluginManagementProvider { + provider: agent::ProviderKind::ClaudeCode, + enabled: true, + } + } + )), + "{commands:?}" + ); + host.replicate_settings(claude_managed(), cx); + assert_eq!(refreshed(&host.commands(cx)), ["claude"]); + assert!(cx.debug_bounds("plugins-add-claude").is_some()); + } +} diff --git a/crates/ui/src/provider_card.rs b/crates/ui/src/provider_card.rs index 0ce97fb51..f7f5f661b 100644 --- a/crates/ui/src/provider_card.rs +++ b/crates/ui/src/provider_card.rs @@ -31,6 +31,9 @@ use gpui_base::{StyledExt as _, h_flex, v_flex}; use agent::ProviderKind; use tcode_core::settings::builtin_provider_color; +/// The card's dialog asked to manage this profile's plugins. +pub struct ManagePlugins(pub String); + pub struct ProviderCard { store: Entity, /// The protocol this card's profile drives (glyph, shared model catalog / @@ -44,6 +47,8 @@ pub struct ProviderCard { _subscription: Subscription, } +impl gpui::EventEmitter for ProviderCard {} + impl ProviderCard { pub fn new( store: Entity, @@ -71,8 +76,21 @@ impl ProviderCard { .store .read(cx) .provider_profile_display_name(&profile_id); - let dialog = cx - .new(|cx| ProviderDialog::new(store.clone(), provider, profile_id.clone(), window, cx)); + let card = cx.entity().downgrade(); + let manage_id = profile_id.clone(); + let manage_plugins: std::rc::Rc = std::rc::Rc::new(move |cx| { + _ = card.update(cx, |_, cx| cx.emit(ManagePlugins(manage_id.clone()))); + }); + let dialog = cx.new(|cx| { + ProviderDialog::new( + store.clone(), + provider, + profile_id.clone(), + manage_plugins, + window, + cx, + ) + }); window.open_dialog(cx, move |dlg, window, cx| { let content = dialog.clone(); dlg.title(title.clone()) @@ -156,6 +174,12 @@ impl ProviderCard { .text_color(muted) .child(crate::tr!("providers.mcp_unavailable")), ) + }) + .when(provider.caps().native_computer_use, |this| { + this.child(div().text_size(px(11.)).text_color(muted).child(crate::tr!( + "providers.native_computer_use", + name = name.clone() + ))) }), ) .tooltip({ diff --git a/crates/ui/src/provider_dialog.rs b/crates/ui/src/provider_dialog.rs index 8cd5125a5..601befd4b 100644 --- a/crates/ui/src/provider_dialog.rs +++ b/crates/ui/src/provider_dialog.rs @@ -8,6 +8,7 @@ use crate::scroll::ScrollableElement as _; use std::collections::HashSet; +use std::rc::Rc; use crate::overlay::{DialogButtons, OverlayExt as _}; use crate::theme::ActiveTheme as _; @@ -72,6 +73,8 @@ pub struct ProviderDialog { original_secret_names: Vec, custom_models: Vec, hidden_models: Vec, + /// Leaves the dialog for this profile's section of Settings → Plugins. + manage_plugins: Rc, _subscriptions: Vec, } @@ -80,6 +83,7 @@ impl ProviderDialog { store: Entity, provider: ProviderKind, profile_id: String, + manage_plugins: Rc, window: &mut Window, cx: &mut Context, ) -> Self { @@ -190,6 +194,7 @@ impl ProviderDialog { original_secret_names, custom_models: settings.custom_models.clone(), hidden_models: settings.hidden_models.clone(), + manage_plugins, _subscriptions: subscriptions, }; dialog.rebuild_env_rows(&seeds, window, cx); @@ -905,13 +910,28 @@ pub fn render_footer( _window: &mut Window, cx: &mut App, ) -> AnyElement { - let (is_user, profile_id, store) = { + let (is_user, profile_id, store, manage_plugins) = { let d = dialog.read(cx); - (d.is_user_profile(), d.profile_id.clone(), d.store.clone()) + ( + d.is_user_profile(), + d.profile_id.clone(), + d.store.clone(), + d.manage_plugins.clone(), + ) }; let save_dialog = dialog.clone(); - let mut left = div().flex_1(); + let mut left = h_flex().flex_1().flex_wrap().gap_2().child( + Button::new("provider-manage-plugins") + .debug_selector(|| "provider-manage-plugins".into()) + .ghost() + .icon(IconName::Puzzle) + .label(crate::tr!("providers.plugins.manage").into_owned()) + .on_click(move |_, window, cx| { + window.close_dialog(cx); + manage_plugins(cx); + }), + ); if is_user { let delete_id = profile_id.clone(); let delete_store = store.clone(); diff --git a/crates/ui/src/runtime_event.rs b/crates/ui/src/runtime_event.rs index 386343b99..5d0b43cfc 100644 --- a/crates/ui/src/runtime_event.rs +++ b/crates/ui/src/runtime_event.rs @@ -1,8 +1,9 @@ use agent::RewindMode; use tcode_core::git::GitAction; use tcode_protocol::{ - GitActionRequest, MergeWorktreeFailure, NoticeSeverity, RuntimeEffect, RuntimeError, - RuntimeNotice, RuntimeNotification as RuntimeEvent, RuntimeOperationId, RuntimeToast, + GitActionRequest, MergeWorktreeFailure, NoticeSeverity, PluginOperationTarget, RuntimeEffect, + RuntimeError, RuntimeNotice, RuntimeNotification as RuntimeEvent, RuntimeOperationId, + RuntimeToast, }; use crate::toast::ToastKind; @@ -251,6 +252,60 @@ fn git_action_toast_titles(action: GitAction) -> (String, String) { } } +/// Started, succeeded and failed titles for a native plugin operation. +fn plugin_operation_toast_titles(target: &PluginOperationTarget) -> [String; 3] { + use agent::PluginActionKind; + let (name, keys) = match target { + PluginOperationTarget::Plugin { action, plugin, .. } => ( + plugin.as_str(), + match action { + PluginActionKind::Install => [ + "providers.plugins.toast.installing", + "providers.plugins.toast.installed", + "providers.plugins.toast.install_failed", + ], + PluginActionKind::Uninstall => [ + "providers.plugins.toast.uninstalling", + "providers.plugins.toast.uninstalled", + "providers.plugins.toast.uninstall_failed", + ], + PluginActionKind::Enable => [ + "providers.plugins.toast.enabling", + "providers.plugins.toast.enabled", + "providers.plugins.toast.enable_failed", + ], + PluginActionKind::Disable => [ + "providers.plugins.toast.disabling", + "providers.plugins.toast.disabled", + "providers.plugins.toast.disable_failed", + ], + PluginActionKind::Update => [ + "providers.plugins.toast.updating", + "providers.plugins.toast.updated", + "providers.plugins.toast.update_failed", + ], + }, + ), + PluginOperationTarget::AddMarketplace { source } => ( + source.as_str(), + [ + "providers.plugins.toast.adding_marketplace", + "providers.plugins.toast.added_marketplace", + "providers.plugins.toast.add_marketplace_failed", + ], + ), + PluginOperationTarget::RemoveMarketplace { name } => ( + name.as_str(), + [ + "providers.plugins.toast.removing_marketplace", + "providers.plugins.toast.removed_marketplace", + "providers.plugins.toast.remove_marketplace_failed", + ], + ), + }; + keys.map(|key| crate::tr!(key, name = name).into_owned()) +} + pub(super) fn present_runtime_toast(toast: &RuntimeToast) -> PresentedRuntimeToast { let (disposition, kind, title, detail, retry) = match toast { RuntimeToast::GitBusy => ( @@ -324,6 +379,45 @@ pub(super) fn present_runtime_toast(toast: &RuntimeToast) -> PresentedRuntimeToa Some(detail.clone()), None, ), + RuntimeToast::PluginOperationStarted { + operation, target, .. + } => { + let [title, _, _] = plugin_operation_toast_titles(target); + ( + RuntimeToastDisposition::Start(*operation), + ToastKind::Loading, + title, + None, + None, + ) + } + RuntimeToast::PluginOperationSucceeded { + operation, target, .. + } => { + let [_, title, _] = plugin_operation_toast_titles(target); + ( + RuntimeToastDisposition::Finish(*operation), + ToastKind::Success, + title, + None, + None, + ) + } + RuntimeToast::PluginOperationFailed { + operation, + target, + detail, + .. + } => { + let [_, _, title] = plugin_operation_toast_titles(target); + ( + RuntimeToastDisposition::Finish(*operation), + ToastKind::Error, + title, + Some(detail.clone()), + None, + ) + } _ => ( RuntimeToastDisposition::Push, ToastKind::Warning, @@ -368,6 +462,11 @@ mod tests { #[test] fn toast_lifecycle_and_raw_diagnostics_survive_localization() { let _locale_guard = crate::settings::TestLocaleGuard::acquire(); + let plugin_target = PluginOperationTarget::Plugin { + action: agent::PluginActionKind::Install, + scope: agent::PluginScope::User, + plugin: "gamma@tcode-probe".into(), + }; let retry = GitActionRequest { session_id: "session-1".into(), action: GitAction::CommitPush, @@ -409,6 +508,22 @@ mod tests { name: "Agent".into(), detail: "acp raw\0detail".into(), }, + RuntimeToast::PluginOperationStarted { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + }, + RuntimeToast::PluginOperationSucceeded { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + }, + RuntimeToast::PluginOperationFailed { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + detail: "plugin raw\0detail".into(), + }, ]; for locale in [crate::LANGUAGE_ENGLISH, crate::LANGUAGE_SIMPLIFIED_CHINESE] { @@ -417,7 +532,8 @@ mod tests { let presented = present_runtime_toast(toast); match toast { RuntimeToast::GitStarted { operation, .. } - | RuntimeToast::AcpInstallStarted { operation, .. } => { + | RuntimeToast::AcpInstallStarted { operation, .. } + | RuntimeToast::PluginOperationStarted { operation, .. } => { assert_eq!( presented.disposition, RuntimeToastDisposition::Start(*operation) @@ -425,7 +541,8 @@ mod tests { assert_eq!(presented.kind, ToastKind::Loading); } RuntimeToast::GitSucceeded { operation, .. } - | RuntimeToast::AcpInstallSucceeded { operation, .. } => { + | RuntimeToast::AcpInstallSucceeded { operation, .. } + | RuntimeToast::PluginOperationSucceeded { operation, .. } => { assert_eq!( presented.disposition, RuntimeToastDisposition::Finish(*operation) @@ -433,7 +550,8 @@ mod tests { assert_eq!(presented.kind, ToastKind::Success); } RuntimeToast::GitFailed { operation, .. } - | RuntimeToast::AcpInstallFailed { operation, .. } => { + | RuntimeToast::AcpInstallFailed { operation, .. } + | RuntimeToast::PluginOperationFailed { operation, .. } => { assert_eq!( presented.disposition, RuntimeToastDisposition::Finish(*operation) @@ -535,6 +653,14 @@ mod tests { .as_deref(), Some("acp raw\0detail") ); + let plugin_failed = present_runtime_toast(&RuntimeToast::PluginOperationFailed { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + detail: "plugin raw\0detail".into(), + }); + assert_eq!(plugin_failed.detail.as_deref(), Some("plugin raw\0detail")); + assert!(plugin_failed.title.contains("gamma@tcode-probe")); for (event, severity, raw_message) in [ ( diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index feae075ef..922a8327d 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -21,7 +21,8 @@ use gpui_base::{InteractiveElementExt as _, StyledExt as _, v_flex}; use crate::acp_panel::{AcpAgentCard, AcpPanel}; use crate::orchestrate_settings::OrchestrateSettingsPanel; -use crate::provider_card::ProviderCard; +use crate::plugins_settings::PluginsSettingsPanel; +use crate::provider_card::{ManagePlugins, ProviderCard}; use crate::provider_model_picker::ProviderModelPicker; use crate::settings::{ImageMode, LANGUAGE_ENGLISH, LANGUAGE_SIMPLIFIED_CHINESE, ThemeMode}; use crate::store::WorkspaceStore; @@ -68,6 +69,7 @@ enum Section { Browser, ComputerUse, Orchestrate, + Plugins, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Remote, Archived, @@ -79,22 +81,24 @@ enum Section { /// headless listener in a browser. Choosing a machine, and the invitation other /// devices pair with, live in `crate::remote`. #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] -const SECTIONS: [Section; 8] = [ +const SECTIONS: [Section; 9] = [ Section::General, Section::Remote, Section::Providers, Section::Usage, Section::Orchestrate, + Section::Plugins, Section::ComputerUse, Section::Browser, Section::Archived, ]; #[cfg(not(any(feature = "remote-hosting", target_family = "wasm")))] -const SECTIONS: [Section; 7] = [ +const SECTIONS: [Section; 8] = [ Section::General, Section::Providers, Section::Usage, Section::Orchestrate, + Section::Plugins, Section::ComputerUse, Section::Browser, Section::Archived, @@ -157,6 +161,7 @@ impl Section { | Self::Browser | Self::ComputerUse | Self::Orchestrate + | Self::Plugins | Self::Archived => SectionGroup::Machine, } } @@ -180,6 +185,7 @@ impl Section { | Self::Usage | Self::ComputerUse | Self::Orchestrate + | Self::Plugins | Self::Archived => true, } } @@ -192,6 +198,7 @@ impl Section { Self::Browser => "settings-nav-browser", Self::ComputerUse => "settings-nav-computer-use", Self::Orchestrate => "settings-nav-orchestrate", + Self::Plugins => "settings-nav-plugins", #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => "settings-nav-remote", Self::Archived => "settings-nav-archived", @@ -206,6 +213,7 @@ impl Section { Self::Browser => IconName::Globe, Self::ComputerUse => IconName::LayoutDashboard, Self::Orchestrate => IconName::Map, + Self::Plugins => IconName::Puzzle, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => IconName::Network, Self::Archived => IconName::Inbox, @@ -220,6 +228,7 @@ impl Section { Self::Browser => crate::tr!("settings.browser"), Self::ComputerUse => crate::tr!("settings.computer_use"), Self::Orchestrate => crate::tr!("settings.orchestrate"), + Self::Plugins => crate::tr!("settings.plugins"), #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => crate::tr!("settings.remote"), Self::Archived => crate::tr!("settings.archived"), @@ -295,10 +304,13 @@ pub struct SettingsPage { window_state: Entity, /// One card per native profile, keyed by profile id (built-in + user). provider_cards: Vec<(String, Entity)>, + provider_card_subscriptions: Vec, /// Long-lived state for the modal ACP marketplace and custom form. acp_panel: Entity, /// Editable main-model identities and child-model routing matrix. orchestrate_panel: Entity, + /// Each enabled profile's native plugin catalog. + plugins_panel: Entity, /// Hosting this machine. Absent where the client cannot listen at all. #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] #[cfg(not(target_family = "wasm"))] @@ -361,6 +373,7 @@ impl SettingsPage { "browser" => Section::Browser, "computer_use" => Section::ComputerUse, "orchestrate" => Section::Orchestrate, + "plugins" => Section::Plugins, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] "remote" => Section::Remote, "archived" => Section::Archived, @@ -480,6 +493,16 @@ impl SettingsPage { let acp_panel = cx.new(|cx| AcpPanel::new(store.clone(), window, cx)); let orchestrate_panel = cx.new(|cx| OrchestrateSettingsPanel::new(store.clone(), window, cx)); + let content_scroll = ScrollHandle::new(); + let plugins_panel = cx.new(|cx| { + PluginsSettingsPanel::new( + store.clone(), + window_state.clone(), + content_scroll.clone(), + window, + cx, + ) + }); #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] #[cfg(not(target_family = "wasm"))] let hosting_panel = cx.new(|cx| crate::remote::HostingPanel::new(window, cx)); @@ -511,8 +534,10 @@ impl SettingsPage { store, window_state, provider_cards: Vec::new(), + provider_card_subscriptions: Vec::new(), acp_panel, orchestrate_panel, + plugins_panel, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] hosting_panel, title_model_picker, @@ -542,7 +567,7 @@ impl SettingsPage { host_permissions: None, toggle_focus: HashMap::new(), section_scroll: ScrollHandle::new(), - content_scroll: ScrollHandle::new(), + content_scroll, _subscriptions: subscriptions, }; page._subscriptions @@ -723,6 +748,18 @@ impl SettingsPage { (profile.id, card) }) .collect(); + self.provider_card_subscriptions = self + .provider_cards + .iter() + .map(|(_, card)| { + cx.subscribe(card, |this, _, ManagePlugins(profile_id), cx| { + this.select_section(Section::Plugins, cx); + let profile_id = profile_id.clone(); + this.plugins_panel + .update(cx, |panel, cx| panel.focus_profile(profile_id, cx)); + }) + }) + .collect(); } /// Reconcile card entities by installed id, preserving editors for agents @@ -1247,6 +1284,9 @@ impl SettingsPage { if self.section != Section::ComputerUse { self.host_permissions = None; } + if self.section != Section::Plugins { + self.plugins_panel.update(cx, |panel, _| panel.hide()); + } let column = match self.section { Section::General => self.render_general(cx), Section::Providers => self.render_providers(window, cx), @@ -1254,6 +1294,7 @@ impl SettingsPage { Section::Browser => self.render_browser(cx), Section::ComputerUse => self.render_computer_use(cx), Section::Orchestrate => v_flex().child(self.orchestrate_panel.clone()), + Section::Plugins => v_flex().child(self.plugins_panel.clone()), #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Section::Remote => v_flex().child(self.hosting_panel.clone()), Section::Archived => self.render_archived(cx), @@ -2413,7 +2454,24 @@ impl SettingsPage { .child( v_flex() .child(self.section_label(crate::tr!("computer_use.section"), cx)) - .child(self.grouped_plain(rows, cx)), + .child(self.grouped_plain(rows, cx)) + .children( + agent::ProviderKind::NATIVE + .into_iter() + .chain([agent::ProviderKind::Acp]) + .filter(|kind| kind.caps().native_computer_use) + .map(|kind| { + div() + .pt_2() + .pl_3() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!( + "providers.native_computer_use", + name = kind.display_name() + )) + }), + ), ) .child(self.permissions_group(cx)) } diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index 53f18cdc8..cb9de4928 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; -use agent::{ApprovalDecision, ApprovalMode, InteractionMode, ProviderKind, RewindMode}; +use agent::{ + ApprovalDecision, ApprovalMode, InteractionMode, PluginAction, ProviderKind, RewindMode, +}; use gpui::{App, Context, Task}; use tcode_core::{ acp::AcpAgentPatch, @@ -11,7 +13,7 @@ use tcode_core::{ }, ui::{TerminalSplitDirection, WorkspaceMode}, }; -use tcode_protocol::{Command, CommandResponse, ProtocolError, SettingsPatch}; +use tcode_protocol::{Command, CommandResponse, ProtocolError, RuntimeOperationId, SettingsPatch}; use super::{StoreChange, TopicKind, WorkspaceStore}; @@ -772,6 +774,85 @@ impl WorkspaceStore { } } +impl WorkspaceStore { + pub fn set_plugin_management_enabled(&mut self, enabled: bool) { + self.patch_settings(SettingsPatch::PluginManagementEnabled(enabled)); + } + pub fn set_provider_plugin_management(&mut self, provider: ProviderKind, enabled: bool) { + self.patch_settings(SettingsPatch::PluginManagementProvider { provider, enabled }); + } + pub fn refresh_provider_plugins(&mut self, profile_id: String, cwd: Option) { + self.dispatch(Command::RefreshProviderPlugins { profile_id, cwd }); + } + /// Run one action the host offered for `entry_id` in the catalog listed + /// from `cwd`. + pub fn run_provider_plugin_action( + &mut self, + profile_id: String, + entry_id: String, + action: PluginAction, + cwd: Option, + ) { + let scope = action.scope(); + self.dispatch(match action { + PluginAction::Install { .. } => Command::InstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + }, + PluginAction::Uninstall { .. } => Command::UninstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + }, + PluginAction::Enable { .. } | PluginAction::Disable { .. } => { + Command::SetProviderPluginEnabled { + profile_id, + entry_id, + scope, + enabled: matches!(action, PluginAction::Enable { .. }), + cwd, + } + } + PluginAction::Update { .. } => Command::UpdateProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + }, + }); + } + pub fn add_provider_marketplace( + &mut self, + profile_id: String, + source: String, + cwd: Option, + ) { + self.dispatch(Command::AddProviderMarketplace { + profile_id, + source, + cwd, + }); + } + pub fn remove_provider_marketplace( + &mut self, + profile_id: String, + name: String, + cwd: Option, + ) { + self.dispatch(Command::RemoveProviderMarketplace { + profile_id, + name, + cwd, + }); + } + pub fn resolve_plugin_challenge(&mut self, op_id: RuntimeOperationId, accept: bool) { + self.dispatch(Command::ResolvePluginChallenge { op_id, accept }); + } +} + impl WorkspaceStore { pub fn set_interaction_mode(&mut self, mode: InteractionMode) { self.dispatch(Command::SetInteractionMode { diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 2bdf5d4c2..127baa784 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -2017,6 +2017,10 @@ impl WorkspaceStore { self.settings_replica.browser.clone() } + pub fn plugin_management(&self) -> &tcode_core::settings::PluginManagementSettings { + &self.settings_replica.plugins + } + pub fn provider_profile_kind(&self, profile_id: &str) -> agent::ProviderKind { self.settings_replica .resolved_profile(profile_id) @@ -2127,6 +2131,23 @@ impl WorkspaceStore { items } + pub fn provider_plugin_catalog( + &self, + profile_id: &str, + ) -> Option<&tcode_protocol::ProviderPluginCatalog> { + self.providers_replica + .plugins + .iter() + .find(|catalog| catalog.profile_id == profile_id) + } + + /// The working directory of the thread or draft the window has open. + pub fn active_session_cwd(&self) -> Option { + self.session_status_replica + .as_ref() + .map(|status| status.cwd.clone()) + } + pub fn acp_registry_loading(&self) -> bool { self.providers_replica.acp_registry_loading } diff --git a/locales/en.yml b/locales/en.yml index 40b20c107..7252be142 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -242,6 +242,8 @@ settings: browser: "Browser" computer_use: "Computer Use" orchestrate: "Orchestrate" + plugins: "Plugins" + plugins_section: "PLUGINS" remote: "Remote" archived: "Archived Threads" archived_section: "ARCHIVED THREADS" @@ -454,6 +456,7 @@ providers: reveal_email: "Click to reveal email" hide_email: "Click to hide email" mcp_unavailable: "MCP tools (computer use / preview / orchestrate) unavailable" + native_computer_use: "%{name} has its own computer use; Tcode's computer-use tools are not attached." # Status dot + summary copy (T3 §2) status: checking: "Checking provider status" @@ -606,6 +609,123 @@ providers: args_hint: "e.g. --debug" env: "Environment variables" env_hint: "KEY=value KEY2=value2" + # Native provider plugins (claude plugin …), managed on the host. + plugins: + management: "Plugin management" + management_description: "Tcode manages each provider's native plugins through that provider's own CLI or protocol. Off, Tcode never touches them." + management_off: "Plugin management is off; Tcode does not touch this provider's plugins." + provider_off: "Off: Tcode does not touch %{name} plugins." + provider_switch: "Manage %{name} plugins from Tcode" + unmanaged: "Plugin management for this provider is not available in Tcode yet; use the provider's own interface." + project_context: "Project: %{path}" + no_project_context: "No project context: user scope only" + refresh: "List %{name} plugins again" + listing: "Listing plugins…" + not_listed: "Not listed yet." + stale_context: "Listed for another project or configuration; listing again shows the current one." + stale_changed: "Plugins changed since the last listing." + list_failed: "Could not list plugins:" + no_installed: "No plugins installed." + installed_section: "Installed" + add_plugin: "Add plugin…" + add_plugin_title: "Add a %{name} plugin" + search: "Search plugins" + no_available: "No plugins available from the added marketplaces." + no_matches: "No plugins match the search." + install_to: "Install to" + command_source: "Installing runs a command declared by the marketplace; you will be asked to accept it first." + from_marketplace: "From %{marketplace}" + effective_enabled: "Enabled" + effective_disabled: "Disabled" + effective_unknown: "Enablement unknown" + scope_enabled: "Enabled in this scope" + scope_disabled: "Disabled in this scope" + not_installed_in_scope: "Not installed" + details: "Details" + toggle_details: "Show or hide details of %{name}" + none_declared: "none" + action: + install: "Install" + uninstall: "Uninstall" + enable: "Enable" + disable: "Disable" + update: "Update" + scope: + user: "User" + project: "Project" + local: "Local" + managed: "Managed" + session: "Session" + source: + official: "Official" + third_party: "Third party" + local_path: "Local path" + git: "Git" + npm: "npm" + command: "Command" + unknown: "Unknown source" + declared: + skills: "Skills" + agents: "Agents" + hooks: "Hooks" + mcp_servers: "MCP servers" + untrusted_hooks: "Untrusted hooks" + lsp_servers: "LSP servers" + apps: "Apps" + diagnostic: + details: "Native details" + details_error: "Could not read details" + message: "Message" + update_outcome: "Update outcome" + old_version: "Previous version" + new_version: "New version" + marketplaces: "Marketplaces (%{count})" + no_marketplaces: "No marketplaces added." + add_marketplace: "Add marketplace…" + add_marketplace_title: "Add a marketplace" + marketplace_source: "Source" + marketplace_source_help: "A local path, a GitHub repository (owner/repo) or a URL." + marketplace_source_placeholder: "owner/repo, https://… or /path/to/marketplace" + add: "Add" + remove: "Remove" + apply_reload_or_restart: "Skills, commands and hooks apply after /reload-plugins in a running session or in the next session; MCP servers apply in the next session; updates need a restart." + apply_next_session: "Changes apply to sessions started afterwards; whether a running session picks them up is not verified." + apply_unverified: "Whether a running session picks up changes is not verified; start a new session." + manage: "Manage plugins" + challenge: + accept_command_title: "Accept the command for %{name}?" + command: "Command" + sha256: "SHA-256" + mode: "Mode" + accept: "Accept" + decline: "Decline" + destructive_title: "Remove marketplace %{name}?" + destructive_body: "Removing the marketplace also uninstalls these plugins in every scope:" + destructive_none: "No installed plugins come from this marketplace." + remove: "Remove" + cancel: "Cancel" + toast: + installing: "Installing %{name}…" + installed: "%{name} installed" + install_failed: "Could not install %{name}" + uninstalling: "Uninstalling %{name}…" + uninstalled: "%{name} uninstalled" + uninstall_failed: "Could not uninstall %{name}" + enabling: "Enabling %{name}…" + enabled: "%{name} enabled" + enable_failed: "Could not enable %{name}" + disabling: "Disabling %{name}…" + disabled: "%{name} disabled" + disable_failed: "Could not disable %{name}" + updating: "Updating %{name}…" + updated: "%{name} updated" + update_failed: "Could not update %{name}" + adding_marketplace: "Adding marketplace %{name}…" + added_marketplace: "Marketplace %{name} added" + add_marketplace_failed: "Could not add marketplace %{name}" + removing_marketplace: "Removing marketplace %{name}…" + removed_marketplace: "Marketplace %{name} removed" + remove_marketplace_failed: "Could not remove marketplace %{name}" palette: placeholder: "Search commands, projects, and threads…" new_thread: "New thread in %{project}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index b6828f15c..66121e294 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -242,6 +242,8 @@ settings: browser: "浏览器" computer_use: "电脑操作" orchestrate: "Orchestrate" + plugins: "插件" + plugins_section: "插件" remote: "远程连接" archived: "已归档对话" archived_section: "已归档对话" @@ -453,6 +455,7 @@ providers: reveal_email: "点击显示邮箱" hide_email: "点击隐藏邮箱" mcp_unavailable: "MCP 工具(电脑操作 / 预览 / 编排)不可用" + native_computer_use: "%{name} 自带电脑操作;不会附加 Tcode 的电脑操作工具。" status: checking: "正在检查提供方状态" checking_detail: "正在等待服务返回安装与认证详情。" @@ -600,6 +603,122 @@ providers: args_hint: "例如 --debug" env: "环境变量" env_hint: "KEY=value KEY2=value2" + plugins: + management: "插件管理" + management_description: "Tcode 通过各提供商自己的 CLI 或协议管理其原生插件。关闭后,Tcode 不会触碰这些插件。" + management_off: "插件管理已关闭;Tcode 不会触碰此提供商的插件。" + provider_off: "已关闭:Tcode 不会触碰 %{name} 的插件。" + provider_switch: "在 Tcode 中管理 %{name} 插件" + unmanaged: "Tcode 暂不支持管理此提供商的插件,请使用提供商自己的界面。" + project_context: "项目:%{path}" + no_project_context: "无项目上下文:仅用户作用域" + refresh: "重新列出 %{name} 插件" + listing: "正在列出插件…" + not_listed: "尚未列出。" + stale_context: "列表来自其他项目或配置;重新列出可查看当前状态。" + stale_changed: "上次列出后插件已有变更。" + list_failed: "无法列出插件:" + no_installed: "未安装插件。" + installed_section: "已安装" + add_plugin: "添加插件…" + add_plugin_title: "添加 %{name} 插件" + search: "搜索插件" + no_available: "已添加的插件市场中没有可安装的插件。" + no_matches: "没有匹配的插件。" + install_to: "安装到" + command_source: "安装会运行插件市场声明的命令;运行前会请你确认。" + from_marketplace: "来自 %{marketplace}" + effective_enabled: "已启用" + effective_disabled: "已停用" + effective_unknown: "启用状态未知" + scope_enabled: "在此作用域启用" + scope_disabled: "在此作用域停用" + not_installed_in_scope: "未安装" + details: "详情" + toggle_details: "显示或隐藏 %{name} 的详情" + none_declared: "无" + action: + install: "安装" + uninstall: "卸载" + enable: "启用" + disable: "停用" + update: "更新" + scope: + user: "用户" + project: "项目" + local: "本地" + managed: "托管" + session: "会话" + source: + official: "官方" + third_party: "第三方" + local_path: "本地路径" + git: "Git" + npm: "npm" + command: "命令" + unknown: "来源未知" + declared: + skills: "技能" + agents: "代理" + hooks: "钩子" + mcp_servers: "MCP 服务器" + untrusted_hooks: "未受信任的钩子" + lsp_servers: "LSP 服务器" + apps: "应用" + diagnostic: + details: "原生详情" + details_error: "无法读取详情" + message: "消息" + update_outcome: "更新结果" + old_version: "原版本" + new_version: "新版本" + marketplaces: "插件市场(%{count})" + no_marketplaces: "尚未添加插件市场。" + add_marketplace: "添加插件市场…" + add_marketplace_title: "添加插件市场" + marketplace_source: "来源" + marketplace_source_help: "本地路径、GitHub 仓库(owner/repo)或 URL。" + marketplace_source_placeholder: "owner/repo、https://… 或 /path/to/marketplace" + add: "添加" + remove: "移除" + apply_reload_or_restart: "技能、命令和钩子在运行中的会话执行 /reload-plugins 后或在下一个会话中生效;MCP 服务器在下一个会话中生效;更新需要重启。" + apply_next_session: "变更对之后开始的会话生效;尚未验证运行中的会话是否会应用。" + apply_unverified: "尚未验证运行中的会话是否会应用变更;请开始新会话。" + manage: "管理插件" + challenge: + accept_command_title: "接受 %{name} 的命令?" + command: "命令" + sha256: "SHA-256" + mode: "模式" + accept: "接受" + decline: "拒绝" + destructive_title: "移除插件市场 %{name}?" + destructive_body: "移除插件市场会在所有作用域中一并卸载以下插件:" + destructive_none: "没有已安装的插件来自此插件市场。" + remove: "移除" + cancel: "取消" + toast: + installing: "正在安装 %{name}…" + installed: "%{name} 安装完成" + install_failed: "无法安装 %{name}" + uninstalling: "正在卸载 %{name}…" + uninstalled: "%{name} 已卸载" + uninstall_failed: "无法卸载 %{name}" + enabling: "正在启用 %{name}…" + enabled: "%{name} 已启用" + enable_failed: "无法启用 %{name}" + disabling: "正在停用 %{name}…" + disabled: "%{name} 已停用" + disable_failed: "无法停用 %{name}" + updating: "正在更新 %{name}…" + updated: "%{name} 已更新" + update_failed: "无法更新 %{name}" + adding_marketplace: "正在添加插件市场 %{name}…" + added_marketplace: "插件市场 %{name} 已添加" + add_marketplace_failed: "无法添加插件市场 %{name}" + removing_marketplace: "正在移除插件市场 %{name}…" + removed_marketplace: "插件市场 %{name} 已移除" + remove_marketplace_failed: "无法移除插件市场 %{name}" palette: placeholder: "搜索命令、项目和对话…" new_thread: "在 %{project} 中新建对话"