Compare commits

...
1 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 d692be3473 feat(cli): codewhale plugins install, uninstall, update and show (CW-8161)
`codewhale plugins` gains the rest of the lifecycle so the shell no longer
needs the TUI or the Runtime API to remove or update a plugin:

- install <spec> [--json]: the same installer as `codewhale install`
  (install_from_cli, split so --json can read the receipt); the plain
  command is unchanged.
- uninstall <name> [--yes] [--json]: disables an enabled user plugin first,
  then PluginMutationRequest::Uninstall. Asks "Remove <name> <version>?" in
  a terminal; with no terminal, or with --json, it refuses without --yes.
- update <name> [--json]: PluginMutationRequest::Update; reports "updated to
  <version>" or "already current"; a network-approval outcome is reported as
  install reports it (check_network_outcome, shared).
- show <name> [--json]: the capability review trust prints, via a shared
  show() that trust now calls too; never prompts, never trusts, and counts
  as a read-only command for telemetry.

Docs: docs/PLUGINS.md and docs/zh_hans/PLUGINS.md gain a Shell CLI section;
web/data/agent-docs.generated.json is regenerated by preflight for the doc
change. Tests: CLI parse test for the four subcommands and headless tests for
show, update (current, new version, network approval) and uninstall (refusals,
declined prompt, enabled plugin disabled first and removed).

Worker: agy 1.5.7 with gemini-4-hydrogen-high; reviewed and landed by the Codewhale coordinator.
Draft by agy 1.5.7 (gemini-4-hydrogen-high), completed and verified by Claude Sonnet 5.5.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:48:20 -08:00
10 changed files with 727 additions and 28 deletions

No files matched your search

+2
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Plugin lifecycle from the shell:** `codewhale plugins` now has `install <spec>`, `show <name>`, `update <name>` and `uninstall <name>` (each with `--json`) alongside `status`, `restage`, `trust`, `enable`, `disable` and `revoke`. `plugins install` uses the same reviewed installer as `codewhale install`, `show` prints the capability review without prompting or trusting, `update` refreshes from the recorded source and reports either the new version or `already current`, and `uninstall` disables an enabled bundle first and asks in a terminal unless `--yes` is passed (non-interactive and `--json` runs require `--yes`). (CW-8161)
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
+2 -2
View File
@@ -234,10 +234,10 @@ enum Commands {
#[command(about = "Install a plugin bundle without trusting or enabling it")]
Install(TuiPassthroughArgs),
#[command(
about = "Inspect, review, trust, enable, disable or revoke plugin bundles",
about = "Install, inspect, review, trust, enable, disable, update, uninstall or revoke plugin bundles",
alias = "plugin",
override_usage = "codewhale plugins <COMMAND>",
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
after_help = "Commands:\n install <SPEC> [--json] Install a plugin bundle without trusting or enabling it\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n show <NAME> [--json] Show the capability review without prompting or trusting\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n update <NAME> [--json] Update an installed plugin bundle from its recorded source\n uninstall <NAME> [--yes] [--json] Disable if needed and remove an installed plugin bundle\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
)]
Plugins(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
+22 -6
View File
@@ -95,11 +95,21 @@ pub async fn execute(
}
}
pub async fn install_from_cli(
pub fn check_network_outcome(outcome: &PluginMutationOutcome) -> Result<()> {
match outcome {
PluginMutationOutcome::NeedsApproval(host) => {
bail!("Network approval required for {host}; use /network allow {host}, then retry")
}
PluginMutationOutcome::NetworkDenied(host) => bail!("Network access denied for {host}"),
_ => Ok(()),
}
}
pub async fn install_from_cli_receipt(
source: String,
config: codewhale_runtime::config::Config,
mut registry: PluginRegistry,
) -> Result<()> {
) -> Result<PluginMutationReceipt> {
let runtime = tokio::runtime::Handle::current();
let policy = crate::activation::extension_host_policy_enabled();
let receipt = tokio::task::spawn_blocking(move || {
@@ -119,6 +129,16 @@ pub async fn install_from_cli(
))
})
.await??;
check_network_outcome(&receipt.outcome)?;
Ok(receipt)
}
pub async fn install_from_cli(
source: String,
config: codewhale_runtime::config::Config,
registry: PluginRegistry,
) -> Result<()> {
let receipt = install_from_cli_receipt(source, config, registry).await?;
match receipt.outcome {
PluginMutationOutcome::Installed => {
println!("Installed {} (disabled, untrusted).", receipt.name);
@@ -128,10 +148,6 @@ pub async fn install_from_cli(
);
Ok(())
}
PluginMutationOutcome::NeedsApproval(host) => {
bail!("Network approval required for {host}; use /network allow {host}, then retry")
}
PluginMutationOutcome::NetworkDenied(host) => bail!("Network access denied for {host}"),
_ => bail!("Unexpected plugin install outcome"),
}
}
+2
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Plugin lifecycle from the shell:** `codewhale plugins` now has `install <spec>`, `show <name>`, `update <name>` and `uninstall <name>` (each with `--json`) alongside `status`, `restage`, `trust`, `enable`, `disable` and `revoke`. `plugins install` uses the same reviewed installer as `codewhale install`, `show` prints the capability review without prompting or trusting, `update` refreshes from the recorded source and reports either the new version or `already current`, and `uninstall` disables an enabled bundle first and asks in a terminal unless `--yes` is passed (non-interactive and `--json` runs require `--yes`). (CW-8161)
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
@@ -278,6 +278,42 @@ fn report(
Ok(())
}
fn block_on_mutation<F: std::future::Future>(future: F) -> Result<F::Output> {
match tokio::runtime::Handle::try_current() {
Ok(handle) => Ok(handle.block_on(future)),
Err(_) => Ok(tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(future)),
}
}
fn manifest_version_at(path: &Path) -> Option<String> {
let manifest_path = codewhale_plugins::agent_plugin::resolve_manifest_path(path)?;
codewhale_plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
.ok()
.map(|validated| validated.manifest.plugin.version)
}
pub(crate) fn show(
registry: &PluginRegistry,
locale: Locale,
name: &str,
json: bool,
console: &mut dyn Console,
) -> Result<()> {
let marked = console.agent_marked();
let plugin = find(registry, locale, name)?;
if json {
console.print(&serde_json::to_string_pretty(&review_json(
registry, plugin, locale, marked,
))?);
} else {
console.print(&review_text(registry, plugin, locale, marked));
}
Ok(())
}
pub(crate) struct TrustRequest {
pub name: String,
pub review_token: Option<String>,
@@ -315,13 +351,10 @@ pub(crate) fn trust(
"`--yes` only skips the confirmation prompt. Trust also needs --review-token, the bundle review token that names the exact content to trust; to see it, {review_again}"
);
}
show(registry, locale, &name, json, console)?;
if json {
console.print(&serde_json::to_string_pretty(&review_json(
registry, plugin, locale, marked,
))?);
return Ok(());
}
console.print(&review_text(registry, plugin, locale, marked));
if !console.interactive() {
console.print(&format!(
"\nNot trusted. The bundle review token ties trust to exactly the content shown above; it does not record that anyone read this review. A trust made with it is recorded as {}. To trust, run:\n {}",
@@ -358,7 +391,7 @@ pub(crate) fn trust(
"Trust needs confirmation: pass --yes with the bundle review token, or run in a terminal to be shown the review and asked"
);
}
console.print(&review_text(registry, plugin, locale, marked));
show(registry, locale, &name, false, console)?;
if !console.confirm("\nTrust this bundle with exactly the capabilities above?") {
console.print("Not trusted.");
return Ok(());
@@ -370,6 +403,150 @@ pub(crate) fn trust(
report(registry, locale, &name, "trusted", json, console)
}
pub(crate) fn update(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
name: &str,
network: &codewhale_net::network_policy::NetworkPolicy,
json: bool,
console: &mut dyn Console,
) -> Result<()> {
use codewhale_plugins::mutation::{
PluginMutationContext, PluginMutationOutcome, PluginMutationRequest, check_network_outcome,
execute,
};
let before = find(registry, locale, name)?.clone();
let selector = before.name().to_string();
let ctx = PluginMutationContext {
network,
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
};
let receipt = block_on_mutation(execute(
PluginMutationRequest::Update { selector },
&ctx,
Arc::make_mut(registry),
))?
.map_err(|error| failed(locale, &format!("{error:#}")))?;
check_network_outcome(&receipt.outcome)?;
match receipt.outcome {
PluginMutationOutcome::Updated => {
*registry = registry.rediscover_for_workspace(registry.workspace());
let version = registry
.get(&receipt.name)
.map(|plugin| plugin.manifest.plugin.version.clone())
.or_else(|| receipt.path.as_deref().and_then(manifest_version_at))
.unwrap_or_default();
if json {
let mut record = registry
.get(&receipt.name)
.map(crate::plugin_cli_record)
.unwrap_or_else(|| {
serde_json::json!({
"name": text(&receipt.name),
"version": text(&version),
"path": receipt.path.as_ref().map(|p| text(&p.to_string_lossy())),
"content_hash": receipt.installed_content_hash.or(receipt.content_hash),
})
});
if let Some(object) = record.as_object_mut() {
object.insert("action".to_string(), "updated".into());
}
console.print(&serde_json::to_string_pretty(&record)?);
} else {
let action = if version.is_empty() {
"updated".to_string()
} else {
format!("updated to {}", text(&version))
};
console.print(
&tr(locale, MessageId::CmdPluginBundleMutationSuccess)
.replace("{name}", &text(&receipt.name))
.replace("{action}", &action),
);
}
Ok(())
}
PluginMutationOutcome::NoChange => {
if json {
let mut record = crate::plugin_cli_record(&before);
if let Some(object) = record.as_object_mut() {
object.insert("action".to_string(), "already_current".into());
}
console.print(&serde_json::to_string_pretty(&record)?);
} else {
console.print(
&tr(locale, MessageId::CmdPluginBundleMutationSuccess)
.replace("{name}", &text(&receipt.name))
.replace("{action}", "already current"),
);
}
Ok(())
}
_ => bail!("Unexpected plugin update outcome"),
}
}
pub(crate) fn uninstall(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
name: &str,
yes: bool,
json: bool,
console: &mut dyn Console,
) -> Result<()> {
use codewhale_plugins::mutation::{PluginMutationContext, PluginMutationRequest, execute};
use codewhale_plugins::types::PluginScope;
let plugin = find(registry, locale, name)?.clone();
let plugin_name = plugin.name().to_string();
let version = plugin.manifest.plugin.version.clone();
if !yes {
if json || !console.interactive() {
bail!("Uninstall needs confirmation: pass --yes, or run in a terminal to be asked");
}
let prompt = if version.is_empty() {
format!("Remove {}?", text(&plugin_name))
} else {
format!("Remove {} {}?", text(&plugin_name), text(&version))
};
if !console.confirm(&prompt) {
console.print("Not removed.");
return Ok(());
}
}
if plugin.scope == PluginScope::User && plugin.enabled {
Arc::make_mut(registry)
.disable(&plugin_name)
.map_err(|error| failed(locale, &error))?;
}
let network = codewhale_net::network_policy::NetworkPolicy::default();
let ctx = PluginMutationContext {
network: &network,
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
};
let receipt = block_on_mutation(execute(
PluginMutationRequest::Uninstall {
selector: plugin_name,
},
&ctx,
Arc::make_mut(registry),
))?
.map_err(|error| failed(locale, &format!("{error:#}")))?;
if json {
console.print(&serde_json::to_string_pretty(&serde_json::json!({
"name": text(&receipt.name),
"version": text(&version),
"action": "uninstalled",
}))?);
} else {
console.print(
&tr(locale, MessageId::CmdPluginBundleMutationSuccess)
.replace("{name}", &text(&receipt.name))
.replace("{action}", "uninstalled"),
);
}
Ok(())
}
pub(crate) fn enable(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
@@ -1507,3 +1507,258 @@ fn headless_marked_trust_needs_an_install_from_a_folder_in_the_project() {
Some(PluginApprover::command_line(Some(registry.workspace())))
);
}
fn install_temp_home_plugin(
root: &Path,
name: &str,
version: &str,
) -> (
std::path::PathBuf,
std::sync::Arc<codewhale_plugins::PluginRegistry>,
) {
let source = root.join("src").join(name);
fs::create_dir_all(source.join("skills/hello")).unwrap();
fs::write(
source.join("plugin.toml"),
format!(
"schema_version = 1\n[plugin]\nname = {name:?}\nversion = {version:?}\n[skills]\npath = \"skills\"\n"
),
)
.unwrap();
fs::write(
source.join("skills/hello/SKILL.md"),
"---\nname: hello\ndescription: hi\n---\nbody\n",
)
.unwrap();
let (app, _temp) = create_test_app(root);
let mut registry = app.plugin_registry.clone();
let network = codewhale_net::network_policy::NetworkPolicy::default();
let ctx = codewhale_plugins::mutation::PluginMutationContext {
network: &network,
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
};
let parsed = codewhale_plugins::install::PluginInstallSource::parse(
source.to_str().expect("utf-8 path"),
)
.unwrap();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(codewhale_plugins::mutation::execute(
codewhale_plugins::mutation::PluginMutationRequest::Install { source: parsed },
&ctx,
std::sync::Arc::make_mut(&mut registry),
))
.unwrap();
let registry = registry.rediscover_for_workspace(registry.workspace());
(source, registry)
}
#[test]
fn headless_show_prints_review_without_prompting_or_trusting() {
let _lock = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
write_mcp_review_bundle(root.path());
let (mut app, _temp) = create_test_app(root.path());
let Some(AppAction::OpenCommandReview { content, .. }) =
plugins_with_kimi_home_override(&mut app, Some("trust review-mcp"), None).action
else {
panic!("trust opens a confirmation control");
};
let registry = app.plugin_registry.clone();
let token = registry.get("review-mcp").unwrap().review_token();
let mut interactive = ScriptedConsole {
interactive: true,
answer: true,
..ScriptedConsole::default()
};
headless::show(&registry, Locale::En, "review-mcp", false, &mut interactive).unwrap();
assert!(interactive.prompts.is_empty());
assert!(interactive.printed[0].starts_with(&content));
assert!(interactive.printed[0].ends_with(&format!("Bundle review token: {token}")));
assert!(!registry.get("review-mcp").unwrap().trusted());
let mut json_console = ScriptedConsole::default();
headless::show(&registry, Locale::En, "review-mcp", true, &mut json_console).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_console.printed[0]).unwrap();
assert_eq!(parsed["action"], "review");
assert_eq!(parsed["trusted_by_this_command"], false);
assert_eq!(parsed["bundle_review_token"], token);
}
#[test]
fn headless_update_reports_current_or_new_version_and_network_approval() {
let _lock = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let home = root.path().join("home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
let (source, mut registry) = install_temp_home_plugin(root.path(), "demo", "1.0.0");
let network = codewhale_net::network_policy::NetworkPolicy::default();
let mut console = ScriptedConsole::default();
headless::update(
&mut registry,
Locale::En,
"demo",
&network,
false,
&mut console,
)
.unwrap();
assert!(
console.printed.last().unwrap().contains("already current"),
"{:?}",
console.printed
);
fs::write(
source.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.1.0\"\n[skills]\npath = \"skills\"\n",
)
.unwrap();
headless::update(
&mut registry,
Locale::En,
"demo",
&network,
false,
&mut console,
)
.unwrap();
assert!(
console.printed.last().unwrap().contains("updated to 1.1.0"),
"{:?}",
console.printed
);
assert_eq!(
registry.get("demo").unwrap().manifest.plugin.version,
"1.1.0"
);
fs::write(
home.join("plugins/demo/.installed-from"),
serde_json::json!({
"version": 2,
"spec": "https://example.com/demo.tar.gz",
"source_checksum": "",
"content_hash": "",
"plugin_name": "demo"
})
.to_string(),
)
.unwrap();
let err = headless::update(
&mut registry,
Locale::En,
"demo",
&network,
false,
&mut console,
)
.unwrap_err()
.to_string();
assert!(
err.contains(
"Network approval required for example.com; use /network allow example.com, then retry"
),
"{err}"
);
}
#[test]
fn headless_uninstall_disables_enabled_plugin_first_and_removes_it() {
let _lock = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let home = root.path().join("home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
let (_source, mut registry) = install_temp_home_plugin(root.path(), "demo", "1.0.0");
let token = registry.get("demo").unwrap().review_token();
let mut console = ScriptedConsole::default();
headless_trust_named(
&mut registry,
"demo",
Some(&token),
true,
false,
&mut console,
)
.unwrap();
headless::enable(&mut registry, Locale::En, "demo", false, &mut console).unwrap();
assert!(registry.get("demo").unwrap().enabled);
let installed_dir = home.join("plugins/demo");
assert!(installed_dir.exists());
let piped_err = headless::uninstall(
&mut registry,
Locale::En,
"demo",
false,
false,
&mut console,
)
.unwrap_err()
.to_string();
assert!(piped_err.contains("--yes"), "{piped_err}");
assert!(installed_dir.exists());
let mut json_interactive = ScriptedConsole {
interactive: true,
answer: true,
..ScriptedConsole::default()
};
let json_err = headless::uninstall(
&mut registry,
Locale::En,
"demo",
false,
true,
&mut json_interactive,
)
.unwrap_err()
.to_string();
assert!(json_err.contains("--yes"), "{json_err}");
assert!(json_interactive.prompts.is_empty());
assert!(installed_dir.exists());
let mut declined = ScriptedConsole {
interactive: true,
answer: false,
..ScriptedConsole::default()
};
headless::uninstall(
&mut registry,
Locale::En,
"demo",
false,
false,
&mut declined,
)
.unwrap();
assert_eq!(declined.prompts.len(), 1);
assert_eq!(declined.prompts[0].0, "Remove demo 1.0.0?");
assert!(installed_dir.exists());
assert!(registry.get("demo").unwrap().enabled);
let mut accepted = ScriptedConsole {
interactive: true,
answer: true,
..ScriptedConsole::default()
};
headless::uninstall(
&mut registry,
Locale::En,
"demo",
false,
false,
&mut accepted,
)
.unwrap();
assert_eq!(accepted.prompts.len(), 1);
assert_eq!(accepted.prompts[0].0, "Remove demo 1.0.0?");
assert!(!installed_dir.exists());
let rediscovered = registry.rediscover_for_workspace(registry.workspace());
assert!(rediscovered.get("demo").is_none());
}
+206 -11
View File
@@ -147,7 +147,9 @@ enum Commands {
SessionDiagnostics(SessionDiagnosticsArgs),
#[command(about = "Install a local, GitHub or version-pinned npm plugin bundle")]
Install { source: String },
#[command(about = "Inspect, review, trust, enable, disable or revoke plugin bundles")]
#[command(
about = "Install, inspect, review, trust, enable, disable, update, uninstall or revoke plugin bundles"
)]
Plugins {
#[command(subcommand)]
command: PluginsCommand,
@@ -278,6 +280,40 @@ enum PluginsCommand {
#[arg(long)]
json: bool,
},
#[command(about = "Show a plugin's capability review without prompting or trusting")]
Show {
#[arg(value_name = "NAME", help = "Plugin bundle name")]
name: String,
#[arg(long, help = "Print the review as JSON")]
json: bool,
},
#[command(
about = "Install a local, GitHub or version-pinned npm plugin bundle without trusting or enabling it"
)]
Install {
#[arg(value_name = "SPEC")]
spec: String,
#[arg(long, help = "Print the result as JSON")]
json: bool,
},
#[command(about = "Update an installed user-scope plugin bundle from its recorded source")]
Update {
#[arg(value_name = "NAME", help = "Plugin bundle name")]
name: String,
#[arg(long, help = "Print the result as JSON")]
json: bool,
},
#[command(
about = "Uninstall an installed user-scope plugin bundle (disables it first if enabled)"
)]
Uninstall {
#[arg(value_name = "NAME", help = "Plugin bundle name")]
name: String,
#[arg(long, short = 'y', help = "Skip the confirmation prompt")]
yes: bool,
#[arg(long, help = "Print the result as JSON; requires --yes")]
json: bool,
},
#[command(about = "Restore an eligible reviewed plugin snapshot")]
Restage {
#[arg(value_name = "NAME")]
@@ -2468,7 +2504,7 @@ fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
|| matches!(
command,
Some(Commands::Plugins {
command: PluginsCommand::Status { .. }
command: PluginsCommand::Status { .. } | PluginsCommand::Show { .. }
})
)
}
@@ -2803,15 +2839,37 @@ async fn run_async_main_dispatch(
)
.await
}
Commands::Plugins { command } => tokio::task::spawn_blocking(move || {
let _config = load_structural_config_from_cli(&cli)?;
let workspace = crate::commands::groups::plugins::headless::agent_project()
.unwrap_or_else(|| resolve_workspace(&cli));
let registry = plugin_discovery.inspect_registry_for_workspace(&workspace);
run_plugins_cli(command, registry)
})
.await
.context("Plugin command failed to finish")?,
Commands::Plugins { command } => match command {
PluginsCommand::Install { spec, json } => {
let config = load_config_from_cli(&cli)?;
let default_workspace = resolve_workspace(&cli);
let workspace = crate::commands::groups::plugins::headless::agent_project()
.unwrap_or_else(|| default_workspace.clone());
let registry = if workspace == default_workspace {
(*plugin_registry).clone()
} else {
(*plugin_discovery.inspect_registry_for_workspace(&workspace)).clone()
};
run_plugins_install_cli(
spec,
json,
config,
registry,
&plugin_discovery,
&workspace,
)
.await
}
command => tokio::task::spawn_blocking(move || {
let config = load_structural_config_from_cli(&cli)?;
let workspace = crate::commands::groups::plugins::headless::agent_project()
.unwrap_or_else(|| resolve_workspace(&cli));
let registry = plugin_discovery.inspect_registry_for_workspace(&workspace);
run_plugins_cli(command, registry, &config)
})
.await
.context("Plugin command failed to finish")?,
},
Commands::Setup(args) => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
@@ -9433,9 +9491,47 @@ fn print_plugin_cli_diagnostic(diagnostic: &codewhale_plugins::types::PluginDiag
);
}
async fn run_plugins_install_cli(
spec: String,
json: bool,
config: Config,
registry: codewhale_plugins::PluginRegistry,
plugin_discovery: &Arc<codewhale_plugins::PluginDiscoveryContext>,
workspace: &Path,
) -> Result<()> {
if !json {
return codewhale_plugins::mutation::install_from_cli(spec, config, registry).await;
}
let receipt =
codewhale_plugins::mutation::install_from_cli_receipt(spec, config, registry).await?;
let refreshed = plugin_discovery.inspect_registry_for_workspace(workspace);
let record = refreshed
.get(&receipt.name)
.map(|plugin| {
let mut value = plugin_cli_record(plugin);
if let Some(object) = value.as_object_mut() {
object.insert("action".to_string(), "installed".into());
}
value
})
.unwrap_or_else(|| {
serde_json::json!({
"name": plugin_cli_text(&receipt.name),
"action": "installed",
"path": receipt.path.as_ref().map(|p| plugin_cli_text(&p.to_string_lossy())),
"content_hash": receipt.installed_content_hash.or(receipt.content_hash),
"trust": "untrusted",
"enabled": false,
})
});
println!("{}", serde_json::to_string_pretty(&record)?);
Ok(())
}
fn run_plugins_cli(
command: PluginsCommand,
mut registry: Arc<codewhale_plugins::PluginRegistry>,
config: &Config,
) -> Result<()> {
use crate::commands::groups::plugins::headless;
use codewhale_localization::{MessageId, resolve_locale, tr};
@@ -9525,6 +9621,39 @@ fn run_plugins_cli(
}
}
}
PluginsCommand::Show { name, json } => headless::show(
&registry,
locale,
&name,
json,
&mut headless::ProcessConsole,
)?,
PluginsCommand::Install { .. } => {
unreachable!("plugins install is dispatched before spawn_blocking")
}
PluginsCommand::Update { name, json } => {
let network = config
.network
.clone()
.map(codewhale_net::network_policy::NetworkPolicy::from)
.unwrap_or_default();
headless::update(
&mut registry,
locale,
&name,
&network,
json,
&mut headless::ProcessConsole,
)?;
}
PluginsCommand::Uninstall { name, yes, json } => headless::uninstall(
&mut registry,
locale,
&name,
yes,
json,
&mut headless::ProcessConsole,
)?,
PluginsCommand::Trust {
name,
review_token,
@@ -21017,6 +21146,72 @@ mod mcp_add_arg_tests {
#[cfg(test)]
mod config_stay_tests;
#[cfg(test)]
mod plugins_cli_tests {
use super::*;
use clap::Parser;
#[test]
fn plugins_cli_parses_install_uninstall_update_and_show() {
let install = Cli::try_parse_from([
"codewhale",
"plugins",
"install",
"github:example/plugin",
"--json",
])
.expect("parse plugins install");
assert!(matches!(
install.command,
Some(Commands::Plugins {
command: PluginsCommand::Install {
ref spec,
json: true,
}
}) if spec == "github:example/plugin"
));
let uninstall =
Cli::try_parse_from(["codewhale", "plugins", "uninstall", "demo", "-y", "--json"])
.expect("parse plugins uninstall");
assert!(matches!(
uninstall.command,
Some(Commands::Plugins {
command: PluginsCommand::Uninstall {
ref name,
yes: true,
json: true,
}
}) if name == "demo"
));
let update = Cli::try_parse_from(["codewhale", "plugins", "update", "demo", "--json"])
.expect("parse plugins update");
assert!(matches!(
update.command,
Some(Commands::Plugins {
command: PluginsCommand::Update {
ref name,
json: true,
}
}) if name == "demo"
));
let show = Cli::try_parse_from(["codewhale", "plugins", "show", "demo", "--json"])
.expect("parse plugins show");
assert!(matches!(
show.command,
Some(Commands::Plugins {
command: PluginsCommand::Show {
ref name,
json: true,
}
}) if name == "demo"
));
assert!(telemetry_command_is_read_only(show.command.as_ref()));
}
}
#[cfg(test)]
mod doctor_secret_scan_tests {
use std::path::PathBuf;
+30 -1
View File
@@ -145,7 +145,8 @@ None of these is offered to you proactively. Add the one that fits the job.
## Sources
`codewhale install <spec>` and `/plugin install <spec>` use the same installer.
`codewhale install <spec>`, `codewhale plugins install <spec>`, and
`/plugin install <spec>` use the same installer.
The shell entry does not start a model or require model credentials. Both keep
bundles disabled and untrusted until you explicitly review and enable them.
@@ -248,6 +249,34 @@ matching and the plugin goes inactive until you review again.
- `uninstall` refuses enabled plugins (disable first), deletes the bundle
directory, and removes its persisted trust/enablement entry.
## Shell CLI
Run the full plugin lifecycle from a shell without starting a session:
```sh
codewhale plugins install <spec> [--json]
codewhale plugins show <name> [--json]
codewhale plugins status [name] [--json]
codewhale plugins trust <name> [--review-token TOKEN --yes] [--json]
codewhale plugins enable <name> [--json]
codewhale plugins disable <name> [--json]
codewhale plugins update <name> [--json]
codewhale plugins uninstall <name> [--yes] [--json]
codewhale plugins revoke <name> [--json]
codewhale plugins restage <name> [--json]
```
- `install` uses the same installer as `codewhale install <spec>` and leaves the
bundle disabled and untrusted.
- `show` prints the same capability review as `trust` without prompting or
changing trust state.
- `update` re-reads the recorded source and reports `updated to <version>` or
`already current` (or the network-approval message when a host needs
approval).
- `uninstall` disables an enabled plugin first, then removes it. In a terminal
it asks `Remove <name> <version>? [y/N]` unless `--yes` is given; without a
terminal or with `--json`, `--yes` is required.
## Safety rules
- Every install carries an `.installed-from` provenance marker. The installer
+24 -1
View File
@@ -85,7 +85,8 @@ Codewhale 不会凭空编造远程插件 URL;缺失的插件只会从可用目
`/plugin install <spec>` 的常用来源如下。完整来源和限制见
[英文来源说明](../PLUGINS.md#sources),包括指定版本的 GitHub 与 npm tarball;
`codewhale install <spec>` 是使用同一安装器的 shell 入口,不需要模型凭据。
`codewhale install <spec>` 和 `codewhale plugins install <spec>` 是使用同一安装器的
shell 入口,不需要模型凭据。
```text
/plugin install ./path/to/bundle # local directory (copied)
@@ -164,6 +165,28 @@ Claude 的 `http` 传输映射为 Streamable HTTP。
- `uninstall` 拒绝卸载已启用的插件(先停用),删除插件包目录,
并移除它持久化的信任/启用记录。
## 命令行(Shell CLI)
无需启动会话,也可在命令行完成插件的完整生命周期管理:
```sh
codewhale plugins install <spec> [--json]
codewhale plugins show <name> [--json]
codewhale plugins status [name] [--json]
codewhale plugins trust <name> [--review-token TOKEN --yes] [--json]
codewhale plugins enable <name> [--json]
codewhale plugins disable <name> [--json]
codewhale plugins update <name> [--json]
codewhale plugins uninstall <name> [--yes] [--json]
codewhale plugins revoke <name> [--json]
codewhale plugins restage <name> [--json]
```
- `install` 与 `codewhale install <spec>` 使用同一安装器,安装后保持停用且未受信任。
- `show` 打印与 `trust` 相同的能力审查内容,从不询问确认,也不会授予信任。
- `update` 会重读记录的来源,并报告 `updated to <version>` 或 `already current`(若主机需要审批,则按 `install` 同样的方式提示网络审批错误)。
- `uninstall` 会先停用已启用的插件,再卸载它。在终端中未传 `--yes` 时会询问 `Remove <name> <version>? [y/N]`;非终端或使用 `--json` 时必须传入 `--yes`。
## 安全规则
- 每次安装都会带一个 `.installed-from` 来源标记。缺少这个标记的插件包,
+2 -2
View File
@@ -339,8 +339,8 @@
"sources": [
{
"path": "docs/PLUGINS.md",
"sha256": "8e6aec4cc4dbb82d2dc40e7d99ce857acdaf88902622bba748ec559858351a90",
"content": "# Installing plugins\n\n> 阅读简体中文版:[zh_hans/PLUGINS.md](zh_hans/PLUGINS.md)。\n\nTo create a bundle, start with [Write your first Codewhale plugin](PLUGIN_AUTHORING.md)\nand its runnable Skills example.\n\nThis is the walkthrough for the `/plugin install` on-ramp (v0.9.4, #5182).\n[PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md) remains the contract for the bundle\nformat (`plugin.json`, compatible `kimi.plugin.json` or\n`.claude-plugin/plugin.json`, or legacy `plugin.toml`), discovery, validation, and\nthe trust/enable lifecycle — this document covers how bits get onto disk in\nthe first place.\n\nEverything you add to Codewhale is a plugin. A skill, an MCP connector and a hook\nare each a plugin, or part of one, and each has a kind: Skill, Connector, Hook,\nCommand. A skill you set up by hand is a plugin of the kind Skill, a server you\nconfigure is a plugin of the kind Connector, and a hook you configure is a plugin\nof the kind Hook. They are still managed where they are today: Extensions →\nSkills, MCP and Hooks in the terminal, and Customize › Skills, Connectors and\nHooks on the desktop. Their files, trust rules and commands (`/skills`, `/mcp`,\n`/hooks`) do not change. One Plugins list that shows every kind together is\nplanned for 0.10.3 and is not in this build; today the kind labels appear on the\nparts inside a plugin bundle's detail. \"Connection\" is the word for a model\nprovider or another agent; it is not a kind of plugin.\n\n## Choose what to add\n\n| What you need | Use | Starting point |\n| --- | --- | --- |\n| Reusable instructions that work through your existing tools | A plugin that adds a skill | `/skills`, then `/skill <name>`; [Skills](SKILLS.md) |\n| A reviewed bundle of workflows, tools, commands or agent profiles | A plugin bundle | `/plugin marketplace list`; [first-party catalog](https://github.com/codewhale-hq/codewhale-plugin-marketplace) |\n| Tools or context from a particular local process or remote service | A plugin that adds an MCP connector | `/mcp`; [MCP setup](MCP.md) |\n\nA plugin can combine skills, Markdown commands, Fleet agent profiles, hooks,\nMCP servers and AI-provider declarations. Tools come from an MCP server or an\nexperimental native extension; there is no generic application-UI component in\nthe bundle manifest. Computer Use's Mac helper and Chromewhale's browser\nextension have their own setup after plugin review.\n\nAn MCP connector is one of two kinds: a local process over stdio, or a remote URL over\nStreamable HTTP. Legacy SSE is available when the server still supports it.\nUser-configured remote servers can use environment-backed credentials or the\nhost's OAuth login. Plugin-contributed remote servers require explicit\nenvironment-backed credentials when authentication is needed; their interactive\nOAuth path is not supported. Installing a plugin does not connect an\naccount or establish that a service works.\n\nA local stdio server runs with your operating-system user's access. Requested\nfilesystem roots in a plugin manifest do not sandbox that process.\n\nFor example, configure [Cloudflare's public documentation server](https://developers.cloudflare.com/changelog/post/2026-10-22-cloudflare-mcp-servers-retire-sse/)\ndirectly, or install the `cloudflare-docs` plugin when you also want its skill:\n\n```sh\ncodewhale mcp add cloudflare-docs --url https://docs.mcp.cloudflare.com/mcp\ncodewhale mcp connect cloudflare-docs\ncodewhale mcp tools cloudflare-docs\n```\n\nChoose one setup for that server. `connect` is a diagnostic connection; it does\nnot attach tools to an already running session. In an existing terminal session,\nrun `/mcp reload` after changing configuration.\n\n## Bundle compatibility\n\n| Format | Supported boundary |\n| --- | --- |\n| Native `plugin.json` or legacy `plugin.toml` | Reviewed declarative adapters above; [manifest contract](PLUGIN_BUNDLES.md) |\n| Claude `.claude-plugin/plugin.json` | Skills, commands, agent profiles and MCP subset; hooks, LSP, custom MCP paths and plugin-root expansion are rejected; [compatibility details](CLAUDE_PLUGIN_COMPAT.md) |\n| `kimi.plugin.json` | Compatible skills, commands, agents and MCP declarations; unsupported runtime fields fail closed |\n| Local DSH package | Explicit preview and exact-hash import through `/plugin import dsh`; experimental native runtime, not blanket DSH compatibility or release qualification; [current evidence and limits](DSH_PLUGIN_COMPAT.md) |\n\nNative TypeScript extensions require `extension_host = true` under `[features]`\nand fresh capability review. LSP, requested filesystem roots and lifecycle mutation\nremain inventory-only. Current DSH adapters include work beyond the published\nv0.10.2 CLI; third-party DSH plugins' complete workflows are not established by\nan import succeeding. An imported DSH plugin's UI is served only on the seats the Engine admits ([details](DSH_PLUGIN_COMPAT.md#plugin-ui-dshclient)). Other applicationsLine truncated
"sha256": "6b3003da7fe56e593abc7c9cf0fd3b940b52b2f3881b5c071ade55468d9dbb29",
"content": "# Installing plugins\n\n> 阅读简体中文版:[zh_hans/PLUGINS.md](zh_hans/PLUGINS.md)。\n\nTo create a bundle, start with [Write your first Codewhale plugin](PLUGIN_AUTHORING.md)\nand its runnable Skills example.\n\nThis is the walkthrough for the `/plugin install` on-ramp (v0.9.4, #5182).\n[PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md) remains the contract for the bundle\nformat (`plugin.json`, compatible `kimi.plugin.json` or\n`.claude-plugin/plugin.json`, or legacy `plugin.toml`), discovery, validation, and\nthe trust/enable lifecycle — this document covers how bits get onto disk in\nthe first place.\n\nEverything you add to Codewhale is a plugin. A skill, an MCP connector and a hook\nare each a plugin, or part of one, and each has a kind: Skill, Connector, Hook,\nCommand. A skill you set up by hand is a plugin of the kind Skill, a server you\nconfigure is a plugin of the kind Connector, and a hook you configure is a plugin\nof the kind Hook. They are still managed where they are today: Extensions →\nSkills, MCP and Hooks in the terminal, and Customize › Skills, Connectors and\nHooks on the desktop. Their files, trust rules and commands (`/skills`, `/mcp`,\n`/hooks`) do not change. One Plugins list that shows every kind together is\nplanned for 0.10.3 and is not in this build; today the kind labels appear on the\nparts inside a plugin bundle's detail. \"Connection\" is the word for a model\nprovider or another agent; it is not a kind of plugin.\n\n## Choose what to add\n\n| What you need | Use | Starting point |\n| --- | --- | --- |\n| Reusable instructions that work through your existing tools | A plugin that adds a skill | `/skills`, then `/skill <name>`; [Skills](SKILLS.md) |\n| A reviewed bundle of workflows, tools, commands or agent profiles | A plugin bundle | `/plugin marketplace list`; [first-party catalog](https://github.com/codewhale-hq/codewhale-plugin-marketplace) |\n| Tools or context from a particular local process or remote service | A plugin that adds an MCP connector | `/mcp`; [MCP setup](MCP.md) |\n\nA plugin can combine skills, Markdown commands, Fleet agent profiles, hooks,\nMCP servers and AI-provider declarations. Tools come from an MCP server or an\nexperimental native extension; there is no generic application-UI component in\nthe bundle manifest. Computer Use's Mac helper and Chromewhale's browser\nextension have their own setup after plugin review.\n\nAn MCP connector is one of two kinds: a local process over stdio, or a remote URL over\nStreamable HTTP. Legacy SSE is available when the server still supports it.\nUser-configured remote servers can use environment-backed credentials or the\nhost's OAuth login. Plugin-contributed remote servers require explicit\nenvironment-backed credentials when authentication is needed; their interactive\nOAuth path is not supported. Installing a plugin does not connect an\naccount or establish that a service works.\n\nA local stdio server runs with your operating-system user's access. Requested\nfilesystem roots in a plugin manifest do not sandbox that process.\n\nFor example, configure [Cloudflare's public documentation server](https://developers.cloudflare.com/changelog/post/2026-10-22-cloudflare-mcp-servers-retire-sse/)\ndirectly, or install the `cloudflare-docs` plugin when you also want its skill:\n\n```sh\ncodewhale mcp add cloudflare-docs --url https://docs.mcp.cloudflare.com/mcp\ncodewhale mcp connect cloudflare-docs\ncodewhale mcp tools cloudflare-docs\n```\n\nChoose one setup for that server. `connect` is a diagnostic connection; it does\nnot attach tools to an already running session. In an existing terminal session,\nrun `/mcp reload` after changing configuration.\n\n## Bundle compatibility\n\n| Format | Supported boundary |\n| --- | --- |\n| Native `plugin.json` or legacy `plugin.toml` | Reviewed declarative adapters above; [manifest contract](PLUGIN_BUNDLES.md) |\n| Claude `.claude-plugin/plugin.json` | Skills, commands, agent profiles and MCP subset; hooks, LSP, custom MCP paths and plugin-root expansion are rejected; [compatibility details](CLAUDE_PLUGIN_COMPAT.md) |\n| `kimi.plugin.json` | Compatible skills, commands, agents and MCP declarations; unsupported runtime fields fail closed |\n| Local DSH package | Explicit preview and exact-hash import through `/plugin import dsh`; experimental native runtime, not blanket DSH compatibility or release qualification; [current evidence and limits](DSH_PLUGIN_COMPAT.md) |\n\nNative TypeScript extensions require `extension_host = true` under `[features]`\nand fresh capability review. LSP, requested filesystem roots and lifecycle mutation\nremain inventory-only. Current DSH adapters include work beyond the published\nv0.10.2 CLI; third-party DSH plugins' complete workflows are not established by\nan import succeeding. An imported DSH plugin's UI is served only on the seats the Engine admits ([details](DSH_PLUGIN_COMPAT.md#plugin-ui-dshclient)). Other applicationsLine truncated
},
{
"path": "docs/PLUGIN_BUNDLES.md",