diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index 715074a9..43204668 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -32,6 +32,10 @@ //! * No `+x` is granted on extracted files. The optional `/skill trust ` //! command writes a `.trusted` marker; tool-execution gating is a separate //! concern that lives next to the tool registry. +//! * Claude Code plugin archives that contain multiple skills are rejected with +//! an explicit migration message. CodeWhale can install individual +//! `SKILL.md` bundles, including `.claude/skills//SKILL.md`, but it +//! does not execute `plugin.json` plugin runtimes or custom command bundles. use std::fs; use std::io::{Read, Write}; @@ -228,6 +232,10 @@ pub enum InstallError { MissingFrontmatterField(&'static str), #[error("symlinks are not allowed in skill tarballs")] SymlinkRejected, + #[error( + "Claude Code plugin archive contains multiple SKILL.md entries; CodeWhale installs one SKILL.md bundle at a time and does not run plugin.json/custom-command runtimes. Install or migrate an individual skills/ directory instead" + )] + ClaudePluginBundle, #[error("skill '{0}' is already installed; use update or remove it first")] AlreadyInstalled(String), #[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")] @@ -1081,6 +1089,8 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result { let mut total_size: u64 = 0; let mut prefix: Option = None; let mut skill_md_relative: Option<(SkillMdCandidate, Vec)> = None; + let mut skill_md_candidate_count: usize = 0; + let mut has_claude_plugin_manifest = false; let mut link_paths: Vec = Vec::new(); for entry in archive @@ -1098,6 +1108,9 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result { if !is_safe_path(&path) { return Err(InstallError::PathTraversal(path_str).into()); } + if is_claude_plugin_manifest_path(&path) { + has_claude_plugin_manifest = true; + } // Track total size against `max_size` (uncompressed). We honor `header // .size` rather than streaming-read every file; tar archives are @@ -1144,6 +1157,7 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result { if entry_type.is_file() { let stripped = strip_prefix(&path_str, prefix.as_deref().unwrap_or("")); if let Some(candidate) = skill_md_candidate(&stripped) { + skill_md_candidate_count += 1; let mut buf = Vec::new(); entry .read_to_end(&mut buf) @@ -1162,6 +1176,9 @@ fn scan_tarball(bytes: &[u8], max_size: u64) -> Result { } let prefix = prefix.unwrap_or_default(); + if has_claude_plugin_manifest && skill_md_candidate_count > 1 { + return Err(InstallError::ClaudePluginBundle.into()); + } let (skill_md, skill_md_bytes) = skill_md_relative .ok_or(InstallError::MissingSkillMd) .map_err(anyhow::Error::from)?; @@ -1232,6 +1249,21 @@ fn skill_md_candidate(stripped_path: &str) -> Option { None } +fn is_claude_plugin_manifest_path(path: &Path) -> bool { + let parts: Vec = path + .components() + .filter_map(|component| match component { + Component::Normal(part) => Some(part.to_string_lossy().to_string()), + _ => None, + }) + .collect(); + + parts.windows(2).any(|window| { + window[0].eq_ignore_ascii_case(".claude-plugin") + && window[1].eq_ignore_ascii_case("plugin.json") + }) +} + fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> { let cursor = std::io::Cursor::new(bytes); let gz = GzDecoder::new(cursor); diff --git a/crates/tui/tests/skill_install.rs b/crates/tui/tests/skill_install.rs index f30cd559..19e9a814 100644 --- a/crates/tui/tests/skill_install.rs +++ b/crates/tui/tests/skill_install.rs @@ -363,6 +363,48 @@ async fn install_accepts_nested_workflow_pack_skill_directory() { shutdown(tx, handle); } +#[tokio::test] +async fn install_rejects_multi_skill_claude_plugin_archive() { + let tarball = make_tarball(&[ + ( + "repo-main/.claude-plugin/plugin.json", + br#"{"name":"workflow-pack","version":"1.0.0"}"#, + ), + ( + "repo-main/skills/plan/SKILL.md", + &skill_md("plan", "Planning skill"), + ), + ( + "repo-main/skills/review/SKILL.md", + &skill_md("review", "Review skill"), + ), + ]); + let (url, tx, handle) = spawn_tarball_server(tarball); + + let tmp = TempDir::new().unwrap(); + let policy = allow_all_policy(); + let err = install::install( + InstallSource::DirectUrl(url), + tmp.path(), + install::DEFAULT_MAX_SIZE_BYTES, + &policy, + false, + ) + .await + .expect_err("multi-skill Claude plugin archive should not be flattened"); + let msg = format!("{err:#}"); + assert!( + msg.contains("Claude Code plugin archive contains multiple SKILL.md entries"), + "expected Claude plugin compatibility error, got: {msg}" + ); + assert!( + std::fs::read_dir(tmp.path()).unwrap().next().is_none(), + "rejected plugin archive must not write an installed skill" + ); + + shutdown(tx, handle); +} + #[tokio::test] async fn install_accepts_single_skill_subdirectory_archive() { let tarball = make_tarball(&[ diff --git a/docs/CLAUDE_PLUGIN_COMPAT.md b/docs/CLAUDE_PLUGIN_COMPAT.md new file mode 100644 index 00000000..4ec3adb9 --- /dev/null +++ b/docs/CLAUDE_PLUGIN_COMPAT.md @@ -0,0 +1,35 @@ +# Claude Plugin Compatibility + +CodeWhale treats Claude Code skill folders as instruction bundles when they are +plain `SKILL.md` directories. It does not run Claude Code plugin runtimes. + +## Supported + +- Workspace or global `.claude/skills//SKILL.md` directories discovered by + the normal skill registry. +- GitHub or tarball installs that contain one selected skill directory such as + `skills//SKILL.md`, `.agents/skills//SKILL.md`, + `.claude/skills//SKILL.md`, or a nested package layout ending in + `skills//SKILL.md`. +- Companion files inside the selected skill directory, such as `references/`, + `examples/`, or scripts that are only used after the skill is explicitly + loaded and trusted. + +## Not Supported As A Plugin Runtime + +Claude Code plugin features remain outside the v0.8.60 compatibility boundary: + +- `.claude-plugin/plugin.json` metadata and activation semantics. +- Custom slash-command bundles. +- Plugin build steps, compiled TypeScript agents, dashboard servers, shared + plugin state, or token-gated service processes. +- Frontmatter fields that require Claude-specific runtime behavior, such as + `model: inherit`. + +If a Claude Code plugin repository contains multiple skills, install or migrate +one `skills/` directory at a time. `/skill install` rejects multi-skill +plugin archives with a clear message so it never silently chooses one skill and +drops the plugin runtime behavior. + +For richer integrations, wrap the plugin's executable surface as MCP, hooks, or +a CodeWhale skill that names the external command explicitly. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 297fe67b..a3719d79 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1006,7 +1006,9 @@ If you are upgrading from older releases: agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem `~/.claude/skills`. First launch installs versioned bundled skills for common workflows including skill creation, delegation, MCP/plugin scaffolding, - documents, presentations, spreadsheets, PDFs, and Feishu/Lark. + documents, presentations, spreadsheets, PDFs, and Feishu/Lark. See + [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary + between portable `SKILL.md` bundles and Claude Code plugin runtimes. - `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with legacy `~/.deepseek/mcp.json` fallback when the CodeWhale path is absent. It is visible in `/config` and can be changed from the TUI. The new path is diff --git a/docs/GUIDE.md b/docs/GUIDE.md index b4dafef0..1e9b3872 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -404,8 +404,9 @@ work. Read the local guidance before editing, and keep any contribution within the repository's conventions. Next: see [SKILL_INVOCATION_DESIGN.md](SKILL_INVOCATION_DESIGN.md) for skill -activation behavior and [CONFIGURATION.md](CONFIGURATION.md) for config paths -and project authority. +activation behavior, [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for +Claude Code skill/plugin compatibility, and [CONFIGURATION.md](CONFIGURATION.md) +for config paths and project authority. ## 10. Getting Help