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).

Checks & Reviews:
- Pre-rebase Boat xlarge nextest (-p codewhale-cli -p codewhale-tui -p codewhale-plugins): 177 run, 177 passed, 6408 skipped (20261114T023248Z-cw8161-test-2cc91b)
- Pre-rebase Boat xlarge clippy (-p codewhale-cli -p codewhale-tui -p codewhale-plugins --lib --tests -- -D warnings): pass (20261114T023259Z-cw8161-clippy-dcad34)
- Post-rebase Boat xlarge nextest on c5f449df11: 180 run, 180 passed, 6423 skipped (20261114T034823Z-cw8161-land-088c76)
- sh scripts/preflight.sh --check: preflight: OK
- Independent review e607daf1: Verdict LAND

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]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-11-13 20:05:47 -08:00
1 parent c5f449df11
commit 47e716124c
10 files changed
+725 -28

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
- **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).
+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"),
}
}
+1
View File
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
- **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).
@@ -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.
@@ -251,6 +252,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": "6dd5d430a68dde6001a2571a3d9bdfe9b33ee18dd7e18495e99060ce511f0dae",
"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: Plugins →\nSkills, Connectors 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 applicatLine truncated
"sha256": "72aaa7630f3d66910336065bae70bec811e1ecd526b10a7f99d2a144f9a0d28b",
"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: Plugins →\nSkills, Connectors 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 applicatLine truncated
},
{
"path": "docs/PLUGIN_BUNDLES.md",