mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
1
Commits
main
...
ci/cw-8161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d692be3473 |
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®istry, 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(®istry, 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
@@ -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(
|
||||
®istry,
|
||||
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
@@ -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
@@ -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` 来源标记。缺少这个标记的插件包,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user