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
19 changed files with 727 additions and 2066 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` 来源标记。缺少这个标记的插件包,
-10
View File
@@ -2,16 +2,6 @@
## Unreleased — 0.2.0
- Add data displays: `DataSparkline`, `DataBars` and `DataTrend` (a braille
line chart). The words carry every value; the ink draws in from a caller
progress (`MotionStep::drawn`) and is at its end state under reduced or
still motion. Bars glide from their previous values.
- Add `DataDisplay`, the same displays as JSON an agent can send:
`DataDisplay::from_json` validates it (finite numbers, at most 1024 points
or 24 bars, labels up to 120 characters), `describe` says the same facts as
plain text, and `DATA_DISPLAY_SCHEMA` is its tool schema. Try
`cargo run --example data`.
- Reexport the shared avatar contract from Codewhale Protocol. Rendering and
artwork stay in this library. Protocol 0.10.3 registry proof must precede
this package publication.
-1
View File
@@ -57,7 +57,6 @@ Gallery names are the exact names accepted by the gallery example; the
| Engine `agent_roster.rs::render_agent_roster`, `widgets/workflow_panel.rs::row_receipt_text`, `gate_receipts.rs`; GPUI `usage.rs::TurnRow`, `workbar.rs::Readout`, `working_context.rs::ExecutionReceipt` | `Receipt`, `ReceiptTable`, `ReceiptValue`, `Cost`, formatting helpers | Primitive/composite | `receipt-row`, `receipt-table`, `receipt-table-compact`, `receipt-table-minimal`, `receipt-table-clipped` |
| Engine `diff_render.rs::BoundedDiffRender`, `render_diff_bounded`, `history/file_mutation.rs`; GPUI `review.rs::DiffRowKind`, `workspace/render/review.rs::ReviewRows` | `Diff`, `DiffLine`, `DiffKind`, `DiffGutter`, `DiffHighlight`, `parse_unified` | Primitive | `diff`, `diff-wrapped`, `diff-no-numbers`, `diff-highlighted`, `review-scene` |
| Engine `widgets/workflow_panel.rs::WorkflowPanel`, `WorkflowPanelRow`, `history/checklist.rs`; GPUI `plans.rs`, `workspace/render/workers.rs` | `WorkflowTree`, `TreeNode`, `TreeState`, `CountBar` | Primitive/composite | `workflow-tree`, `workflow-tree-selected`, `workflow-tree-clipped`, `workflow-tree-long`, `count-bars` |
| Kit-native; data from the host or an agent's JSON (CW-8202) | `DataSparkline`, `DataBars`, `DataBarItem`, `DataTrend`, `DataDisplay`, `DataDisplayView`, `DataWords`, `DATA_DISPLAY_SCHEMA`, `MotionStep::drawn` | Primitive | `data-sparkline`, `data-bars`, `data-bars-growing`, `data-trend`, `data-trend-drawing`, `data-from-agent` |
| Engine `approval/view.rs::ApprovalView`, `approval/elevation.rs::ElevationView`, `auto_review.rs`; GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/review.rs::Workspace::render_restore_confirmation` | `ApprovalCard`, `ApprovalSubject`, `ApprovalState`, `ApprovalChoice`, `ApprovalPaint`, `DecisionBand`, `DecisionBandPlan`, `DecisionBandAction`, `DecisionBandSave`, `ReviewVerdict`, `ReviewAggregate` | Composite | `approval-command`, `approval-outside`, `approval-patch`, `approval-elevation`, `approval-clipped`, `approval-spoofed`, `approval-native-band`, `approval-native-band-collapsed`, `review-verdicts`, `review-aggregate` |
| GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/files.rs::Workspace::render_artifact_column` | `AttentionQueue`, `AttentionItem`, `ArtifactShelf`, `Artifact` | Composite | `attention-queue`, `attention-focused`, `attention-narrow`, `attention-empty`, `artifact`, `artifact-shelf`, `artifact-narrow`, `artifact-unknown`, `artifact-empty` |
| Engine `views/mod.rs::ConfigView`, `ConfigRow`, `ConfigView::render_setting_detail`; GPUI `settings.rs::Spec`, `Applies`, `workspace/settings/mod.rs::Settings` | `SettingRow`, `SettingDetail`, `SettingWords` | Composite | `setting-row`, `setting-detail` |
-276
View File
@@ -1,276 +0,0 @@
//! Live data displays: `cargo run --example data`.
//!
//! A sparkline takes a new value every 400 ms, the trend draws in, and the
//! bars glide to new values every three seconds. r replays, m switches
//! between full and reduced motion, q or Escape exits.
//!
//! `--json FILE` shows one agent-sent [`DataDisplay`] instead.
//! `--text` prints the final frame and each display's plain description.
//! `--svg DIR` writes frames at fixed draw-in points.
use std::{
io,
path::PathBuf,
time::{Duration, Instant},
};
use codewhale_ratatui::{
DataBarItem, DataDisplay, DataWords, MotionEasing, MotionMode, MotionPolicy, MotionStep,
MotionTiming, Paint, Role, Theme,
testing::{self, Profile},
};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend, buffer::Buffer, layout::Rect};
const TICK: Duration = Duration::from_millis(400);
const RESHUFFLE: Duration = Duration::from_secs(3);
const DRAW_IN: MotionTiming =
MotionTiming::space(Duration::from_millis(1_400), MotionEasing::EaseOut);
const GLIDE: MotionTiming = MotionTiming::PANEL;
struct Noise(u64);
impl Noise {
fn next(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
(self.0 >> 33) as f64 / f64::from(1u32 << 31)
}
}
struct Board {
noise: Noise,
requests: Vec<f64>,
latency: Vec<f64>,
bars: DataDisplay,
bars_before: DataDisplay,
draw: MotionStep,
glide: MotionStep,
agent: Option<DataDisplay>,
}
fn crate_bars(noise: &mut Noise) -> DataDisplay {
DataDisplay::Bars {
label: "Tests by crate".into(),
unit: "tests".into(),
items: ["engine", "tui", "runtime", "protocol", "config"]
.into_iter()
.map(|name| DataBarItem::new(name, (40.0 + noise.next() * 400.0).round()))
.collect(),
}
}
impl Board {
fn new(agent: Option<DataDisplay>) -> Self {
let mut noise = Noise(7);
let latency = (0..96)
.map(|i| 80.0 + 45.0 * (f64::from(i) / 9.0).sin() + noise.next() * 30.0)
.collect();
let requests = (0..40).map(|_| 20.0 + noise.next() * 60.0).collect();
let bars = crate_bars(&mut noise);
Self {
noise,
requests,
latency,
bars_before: DataDisplay::Bars {
label: String::new(),
unit: String::new(),
items: Vec::new(),
},
bars,
draw: MotionStep::at_rest(0.0),
glide: MotionStep::at_rest(1.0),
agent,
}
}
fn replay(&mut self, now: Instant, policy: MotionPolicy) {
self.draw = MotionStep::at_rest(0.0);
self.draw.go(1.0, now, DRAW_IN, policy);
}
fn tick(&mut self) {
let last = self.requests.last().copied().unwrap_or(50.0);
let next = (last + (self.noise.next() - 0.5) * 30.0).clamp(5.0, 120.0);
self.requests.push(next.round());
if self.requests.len() > 200 {
self.requests.remove(0);
}
}
fn reshuffle(&mut self, now: Instant, policy: MotionPolicy) {
self.bars_before = std::mem::replace(&mut self.bars, crate_bars(&mut self.noise));
self.glide = MotionStep::at_rest(0.0);
self.glide.go(1.0, now, GLIDE, policy);
}
fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme, draw: f32, glide: f32) {
buf.set_style(area, theme.bg(Role::Background));
let inner = Rect::new(
area.x + 2,
area.y + 1,
area.width.saturating_sub(4),
area.height.saturating_sub(2),
);
if let Some(agent) = &self.agent {
agent.view().drawn(draw).paint(inner, buf, theme);
return;
}
let row =
|y: u16, h: u16| Rect::new(inner.x, inner.y + y, inner.width, h).intersection(inner);
let requests = DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
};
let latency = DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
};
requests.view().drawn(draw).paint(row(0, 1), buf, theme);
latency.view().drawn(draw).paint(row(2, 9), buf, theme);
self.bars
.view()
.from(&self.bars_before)
.drawn(glide.min(draw))
.paint(row(12, 6), buf, theme);
}
fn displays(&self) -> Vec<DataDisplay> {
if let Some(agent) = &self.agent {
return vec![agent.clone()];
}
vec![
DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
},
DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
},
self.bars.clone(),
]
}
}
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let value = |flag: &str| {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
};
let agent = match value("--json") {
Some(path) => {
let json = std::fs::read_to_string(&path)?;
match DataDisplay::from_json(&json) {
Ok(shown) => Some(shown),
Err(e) => {
eprintln!("{path}: {e}");
std::process::exit(2);
}
}
}
None => None,
};
let board = Board::new(agent);
if args.iter().any(|a| a == "--text") {
let buf = testing::render(84, 20, |area, buf| {
board.paint(area, buf, &Profile::DarkTrue.theme(), 1.0, 1.0);
});
println!("{}", testing::text(&buf));
for shown in board.displays() {
println!("{}", shown.describe(&DataWords::default()));
}
return Ok(());
}
if let Some(dir) = value("--svg") {
let dir = PathBuf::from(dir);
std::fs::create_dir_all(&dir)?;
let theme = Profile::DarkTrue.theme();
for (i, at) in [0.2_f32, 0.5, 0.8, 1.0].into_iter().enumerate() {
let buf = testing::render(84, 20, |area, buf| board.paint(area, buf, &theme, at, at));
let path = dir.join(format!("data-{i}.svg"));
std::fs::write(&path, testing::svg(&buf, &theme))?;
println!("{}", path.display());
}
return Ok(());
}
run(board)
}
fn run(mut board: Board) -> io::Result<()> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(out))?;
let theme = Theme::detect().tui();
let mut mode = MotionMode::Full;
let mut policy = MotionPolicy::new(mode, &theme);
let start = Instant::now();
board.replay(start, policy);
let (mut next_tick, mut next_shuffle) = (start + TICK, start + RESHUFFLE);
let result = loop {
let now = Instant::now();
if now >= next_tick {
board.tick();
next_tick = now + TICK;
}
if now >= next_shuffle {
board.reshuffle(now, policy);
next_shuffle = now + RESHUFFLE;
}
let (draw, glide) = (
board.draw.drawn(now, policy),
board.glide.drawn(now, policy),
);
terminal.draw(|f| board.paint(f.area(), f.buffer_mut(), &theme, draw, glide))?;
let due = [
board.draw.next_frame_at(now, policy),
board.glide.next_frame_at(now, policy),
]
.into_iter()
.flatten()
.chain([next_tick, next_shuffle])
.min()
.unwrap_or(next_tick);
if event::poll(due.saturating_duration_since(Instant::now()))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
KeyCode::Char('r') => board.replay(Instant::now(), policy),
KeyCode::Char('m') => {
mode = if mode == MotionMode::Full {
MotionMode::Reduced
} else {
MotionMode::Full
};
policy = MotionPolicy::new(mode, &theme);
}
_ => {}
}
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
File diff suppressed because it is too large. Load diff
-2
View File
@@ -59,12 +59,10 @@ pub use tabs::*;
pub use toggle::*;
// Package Display.
mod chart;
mod diff;
mod progress;
mod receipt;
mod tree;
pub use chart::*;
pub use diff::*;
pub use progress::*;
pub use receipt::*;
-8
View File
@@ -366,14 +366,6 @@ impl MotionStep {
}
}
/// Progress for a draw-in, in `0..=1`: eased where `policy` lets space
/// move, the target where it does not. Feed it to a data display's
/// `drawn`.
#[must_use]
pub fn drawn(&self, now: Instant, policy: MotionPolicy) -> f32 {
self.shown(now, policy, MotionChannel::Space)
}
/// A column between `from` and `to`: slide a marker, nudge a panel.
#[must_use]
pub fn offset(&self, now: Instant, policy: MotionPolicy, from: u16, to: u16) -> u16 {
@@ -1,360 +0,0 @@
---
source: src/components/chart.rs
assertion_line: 1242
expression: "testing::snapshot(12, |area, buf, theme|\n{\n let row = |y: u16, h: u16|\n { Rect::new(area.x, area.y + y, area.width, h).intersection(area) };\n DataSparkline::new(\"Requests\",\n &values).unit(\"rpm\").paint(row(0, 1), buf, theme);\n DataBars::new(\"Tokens by model\",\n &items).unit(\"tokens\").paint(row(2, 5), buf, theme);\n DataTrend::new(\"Latency\",\n &values).unit(\"ms\").rows(5).paint(row(7, 5), buf, theme);\n})"
---
== dark-truecolor · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..26 fg=Accent bg=Background
r0 c26..28 bg=Background
r0 c28..32 fg=Muted bg=Background
r0 c32..40 fg=Foreground bg=Background
r1 c0..40 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..40 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..26 fg=Accent bg=Background
r3 c26..28 bg=Background
r3 c28..40 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..20 fg=Accent bg=Background
r4 c20..28 bg=Background
r4 c28..39 fg=Muted bg=Background
r4 c39..40 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..13 fg=Accent bg=Background
r5 c13..28 bg=Background
r5 c28..39 fg=Muted bg=Background
r5 c39..40 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..28 bg=Background
r6 c28..36 fg=Muted bg=Background
r6 c36..40 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..40 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..11 bg=Background
r8 c11..18 fg=Accent bg=Background
r8 c18..31 bg=Background
r8 c31..38 fg=Accent bg=Background
r8 c38..40 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..12 fg=Accent bg=Background
r9 c12..18 bg=Background
r9 c18..19 fg=Accent bg=Background
r9 c19..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..37 bg=Background
r9 c37..40 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..19 bg=Background
r10 c19..22 fg=Accent bg=Background
r10 c22..28 bg=Background
r10 c28..30 fg=Accent bg=Background
r10 c30..40 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..21 bg=Background
r11 c21..28 fg=Accent bg=Background
r11 c28..40 bg=Background
== dark-truecolor · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..38 fg=Accent bg=Background
r0 c38..40 bg=Background
r0 c40..44 fg=Muted bg=Background
r0 c44..52 fg=Foreground bg=Background
r0 c52..54 bg=Background
r0 c54..58 fg=Muted bg=Background
r0 c58..66 fg=Foreground bg=Background
r0 c66..68 bg=Background
r0 c68..72 fg=Muted bg=Background
r0 c72..80 fg=Foreground bg=Background
r1 c0..80 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..80 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..66 fg=Accent bg=Background
r3 c66..68 bg=Background
r3 c68..80 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..45 fg=Accent bg=Background
r4 c45..68 bg=Background
r4 c68..79 fg=Muted bg=Background
r4 c79..80 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..23 fg=Accent bg=Background
r5 c23..68 bg=Background
r5 c68..79 fg=Muted bg=Background
r5 c79..80 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..68 bg=Background
r6 c68..76 fg=Muted bg=Background
r6 c76..80 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..80 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..14 bg=Background
r8 c14..29 fg=Accent bg=Background
r8 c29..60 bg=Background
r8 c60..61 fg=Accent bg=Background
r8 c61..62 bg=Background
r8 c62..75 fg=Accent bg=Background
r8 c75..80 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..14 fg=Accent bg=Background
r9 c14..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..56 bg=Background
r9 c56..60 fg=Accent bg=Background
r9 c60..61 bg=Background
r9 c61..62 fg=Accent bg=Background
r9 c62..75 bg=Background
r9 c75..80 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..32 bg=Background
r10 c32..38 fg=Accent bg=Background
r10 c38..53 bg=Background
r10 c53..57 fg=Accent bg=Background
r10 c57..80 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..38 bg=Background
r11 c38..53 fg=Accent bg=Background
r11 c53..80 bg=Background
== dark-truecolor · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..58 fg=Accent bg=Background
r0 c58..60 bg=Background
r0 c60..64 fg=Muted bg=Background
r0 c64..72 fg=Foreground bg=Background
r0 c72..74 bg=Background
r0 c74..78 fg=Muted bg=Background
r0 c78..86 fg=Foreground bg=Background
r0 c86..88 bg=Background
r0 c88..92 fg=Muted bg=Background
r0 c92..100 fg=Foreground bg=Background
r0 c100..120 bg=Background
r1 c0..120 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..120 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..106 fg=Accent bg=Background
r3 c106..108 bg=Background
r3 c108..120 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..71 fg=Accent bg=Background
r4 c71..108 bg=Background
r4 c108..119 fg=Muted bg=Background
r4 c119..120 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..33 fg=Accent bg=Background
r5 c33..108 bg=Background
r5 c108..119 fg=Muted bg=Background
r5 c119..120 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..108 bg=Background
r6 c108..116 fg=Muted bg=Background
r6 c116..120 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..120 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..16 bg=Background
r8 c16..40 fg=Accent bg=Background
r8 c40..89 bg=Background
r8 c89..91 fg=Accent bg=Background
r8 c91..92 bg=Background
r8 c92..112 fg=Accent bg=Background
r8 c112..120 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..17 fg=Accent bg=Background
r9 c17..40 bg=Background
r9 c40..45 fg=Accent bg=Background
r9 c45..83 bg=Background
r9 c83..89 fg=Accent bg=Background
r9 c89..90 bg=Background
r9 c90..92 fg=Accent bg=Background
r9 c92..112 bg=Background
r9 c112..120 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..11 fg=Accent bg=Background
r10 c11..45 bg=Background
r10 c45..54 fg=Accent bg=Background
r10 c54..77 bg=Background
r10 c77..83 fg=Accent bg=Background
r10 c83..120 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..54 bg=Background
r11 c54..78 fg=Accent bg=Background
r11 c78..120 bg=Background
== no-color · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
== no-color · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
== no-color · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
== ascii · 40x12
Requests _.---=####=--.__ now 45.6 rpm
Tokens by model
opus ################## 12.4k tokens
sonnet ############ 7900 tokens
haiku ##### 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms | ....... .....:.
|... : ... ...
|. ... ..
17.2 ms | .:.....
== ascii · 80x12
Requests _________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ########################################################## 12.4k tokens
sonnet ##################################### 7900 tokens
haiku ############### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ............... . .............
| .... ... .... . .....
|. ...... ....
17.2 ms | ...............
== ascii · 120x12
Requests --==##########----.._________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ################################################################################################## 12.4k tokens
sonnet ############################################################### 7900 tokens
haiku ######################### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ........................ .. ....................
| ....... ..... ...... .. ........
|.. ......... ......
17.2 ms | ........................
-118
View File
@@ -1,118 +0,0 @@
//! Gallery: data displays fully drawn, mid draw-in, empty and from agent JSON.
use ratatui::{buffer::Buffer, layout::Rect};
use super::Entry;
use crate::{DataBarItem, DataBars, DataDisplay, DataSparkline, DataTrend, Paint, Theme};
fn series() -> Vec<f64> {
(0..64)
.map(|i| 120.0 + 60.0 * (f64::from(i) / 6.0).sin() + f64::from((i * 7) % 11) * 3.0)
.collect()
}
fn items() -> Vec<DataBarItem> {
vec![
DataBarItem::new("engine", 412.0),
DataBarItem::new("tui", 268.0),
DataBarItem::new("runtime", 131.0),
DataBarItem::new("protocol", 44.0),
]
}
fn row(area: Rect, y: u16, h: u16) -> Rect {
Rect::new(area.x, area.y.saturating_add(y), area.width, h).intersection(area)
}
fn sparklines(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataSparkline::new("Requests", &values)
.unit("rpm")
.paint(row(area, 0, 1), buf, theme);
DataSparkline::new("Requests", &values)
.unit("rpm")
.drawn(0.45)
.paint(row(area, 1, 1), buf, theme);
DataSparkline::new("Requests", &[]).paint(row(area, 2, 1), buf, theme);
}
fn bars(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items).paint(row(area, 0, 5), buf, theme);
}
fn bars_growing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items)
.previous(&[300.0, 300.0, 300.0, 300.0])
.drawn(0.5)
.paint(row(area, 0, 5), buf, theme);
}
fn trend(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.paint(area, buf, theme);
}
fn trend_drawing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.drawn(0.6)
.paint(area, buf, theme);
}
fn from_agent(area: Rect, buf: &mut Buffer, theme: &Theme) {
let shown = DataDisplay::from_json(
r#"{"kind":"bars","label":"Tokens this turn","unit":"tokens",
"items":[{"label":"read","value":18200},{"label":"wrote","value":2400},
{"label":"cached","value":61000}]}"#,
)
.expect("gallery fixture is a valid data display");
shown.view().paint(area, buf, theme);
}
pub(crate) fn entries() -> Vec<Entry> {
vec![
Entry {
name: "data-sparkline",
width: 80,
height: 3,
draw: sparklines,
},
Entry {
name: "data-bars",
width: 60,
height: 5,
draw: bars,
},
Entry {
name: "data-bars-growing",
width: 60,
height: 5,
draw: bars_growing,
},
Entry {
name: "data-trend",
width: 72,
height: 7,
draw: trend,
},
Entry {
name: "data-trend-drawing",
width: 72,
height: 7,
draw: trend_drawing,
},
Entry {
name: "data-from-agent",
width: 60,
height: 4,
draw: from_agent,
},
]
}
-2
View File
@@ -31,7 +31,6 @@ mod approval;
mod artifacts;
mod atmosphere;
mod attention;
mod chart;
mod chrome;
mod display;
mod habitat;
@@ -105,7 +104,6 @@ pub fn entries() -> Vec<Entry> {
lists::entries(),
chrome::entries(),
display::entries(),
chart::entries(),
approval::entries(),
motion::entries(),
workspace::entries(),
+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",