diff --git a/src/commands/skills.rs b/src/commands/skills.rs index bca9981..1ed527c 100644 --- a/src/commands/skills.rs +++ b/src/commands/skills.rs @@ -54,6 +54,8 @@ pub fn list(cfg: &crate::config::Config, entry_type: Option) -> Result<( "description": e.description, }); if e.entry_type == "extension" { + // serde_json::json!({}) always produces Value::Object, so as_object_mut() + // is always Some here; the if-let is a safe defensive pattern. if let Some(obj) = v.as_object_mut() { obj.insert("platform".to_string(), serde_json::json!(e.platform)); obj.insert( @@ -82,7 +84,7 @@ pub fn install( let user_scope = !project; let platforms = resolve_or_bail(platform.as_deref())?; - let entries: Vec<_> = skills::SKILLS + let mut entries: Vec<_> = skills::SKILLS .iter() .filter(|e| match &name { Some(n) => e.name == n.as_str(), @@ -100,9 +102,38 @@ pub fn install( } } + // When all platforms are selected, always include extension bundles for + // extension-only platforms (e.g. dd-pup-pi for pi), even when a --name or + // --type filter would otherwise exclude them. `all` means "full experience + // on every platform", and pi-style platforms can only receive content via + // their extension bundle, not individual skills. The --type filter is + // intentionally bypassed for these entries; it applies to skill-capable + // platforms only. + let all_selected = platform + .as_deref() + .is_some_and(|p| p.trim().eq_ignore_ascii_case("all")); + if all_selected { + let already_included: std::collections::BTreeSet<&str> = + entries.iter().map(|e| e.name).collect(); + for e in skills::SKILLS.iter() { + if e.entry_type == "extension" && !already_included.contains(e.name) { + entries.push(e); + } + } + // No platform guard here: install_paths returns empty when entry.platform + // doesn't match the current platform (unless --dir is set), so non-matching + // extensions are naturally skipped. This also handles future extensions for + // non-extension-only platforms without needing to update this loop. + } + let mut installed_files = 0usize; let mut dirs_used = std::collections::BTreeSet::new(); let mut entry_hits = std::collections::BTreeSet::new(); + let mut platforms_hit = std::collections::BTreeSet::new(); + // Deduplicate write targets by path so that `--dir` installs across multiple + // platforms don't write and count the same file more than once. + let mut pending_writes: std::collections::BTreeMap = + std::collections::BTreeMap::new(); for plat in &platforms { for entry in &entries { let targets = @@ -110,16 +141,46 @@ pub fn install( if targets.is_empty() { continue; } - entry_hits.insert(entry.name); for (path, content) in targets { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - dirs_used.insert(parent.display().to_string()); + use std::collections::btree_map::Entry; + match pending_writes.entry(path) { + Entry::Vacant(v) => { + v.insert(content); + } + Entry::Occupied(o) if *o.get() != content => { + // Defensive guard: with install_paths enforcing the + // platform match for extensions before the --dir + // override, and with format_as_skill_md / + // format_as_agent_md currently producing identical + // output, this branch is unreachable today. It will + // fire if a future format diverges between platforms. + bail!( + "conflicting content for '{}': multiple platforms produced \ + different output for the same --dir destination; use \ + separate --dir values per platform", + o.key().display() + ); + } + Entry::Occupied(_) => {} // identical content: dedup is correct } - std::fs::write(&path, &content)?; - installed_files += 1; } + // Record success only after all files for this entry are accepted + // without conflict, so entry_hits/platforms_hit reflect what was + // actually committed to pending_writes. + entry_hits.insert(entry.name); + platforms_hit.insert(plat.as_str()); + } + } + for (path, content) in &pending_writes { + // install_paths always produces paths with at least one directory + // component, so parent() is Some in practice. The if-let guards + // against any future degenerate path without panicking. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + dirs_used.insert(parent.display().to_string()); } + std::fs::write(path, content)?; + installed_files += 1; } // If the user filtered with --name or --type but nothing actually @@ -132,6 +193,44 @@ pub fn install( (None, Some(t)) => format!("type={t}"), (None, None) => unreachable!(), }; + + // If the filter targets a skill/agent but all selected platforms are + // extension-only (no skills dir), give an actionable hint. + let ext_only: Vec<&str> = platforms + .iter() + .filter(|p| { + // resolve_or_bail already validated every platform in `platforms`. + skills::lookup_platform(p) + .expect("platform already validated by resolve_or_bail") + .is_extension_only() + }) + .map(String::as_str) + .collect(); + // Derive from the already-filtered `entries` list rather than rescanning + // SKILLS — avoids contradictory messages when --name names an extension + // while --type names a non-extension type simultaneously. + let filter_is_skill_or_agent = + !entries.is_empty() && entries.iter().all(|e| e.entry_type != "extension"); + if !ext_only.is_empty() && filter_is_skill_or_agent && ext_only.len() == platforms.len() { + let available: Vec<&str> = skills::SKILLS + .iter() + .filter(|e| e.entry_type == "extension" && ext_only.contains(&e.platform)) + .map(|e| e.name) + .collect(); + bail!( + "no install target matched {filter} on the selected platform(s): {}. \ + {} only support extensions, not skills or agents. \ + Available extension(s): {}", + platforms.join(", "), + ext_only.join(", "), + if available.is_empty() { + "(none)".to_string() + } else { + available.join(", ") + } + ); + } + bail!( "no install target matched {filter} on the selected platform(s): {}", platforms.join(", "), @@ -145,7 +244,7 @@ pub fn install( "installed": installed_entries, "files": installed_files, "directories": directories, - "platforms": platforms, + "platforms": platforms_hit.iter().collect::>(), }); crate::formatter::format_and_print(&result, &cfg.output_format, cfg.agent_mode, None)?; } else { @@ -156,7 +255,7 @@ pub fn install( "Installed {} entry(ies), {} file(s) across {} platform(s)", installed_entries, installed_files, - platforms.len(), + platforms_hit.len(), ); } @@ -281,7 +380,7 @@ mod tests { #[test] fn install_bails_when_type_filter_matches_nothing_on_platform() { let cfg = base_cfg(); - // Skills don't apply to pi. + // Skills don't apply to pi — should get the richer extension-only hint. let err = install( &cfg, Some("pi".to_string()), @@ -294,6 +393,127 @@ mod tests { .to_string(); assert!(err.contains("no install target"), "got: {err}"); assert!(err.contains("type=skill"), "got: {err}"); + assert!(err.contains("only support extensions"), "got: {err}"); + assert!(err.contains("dd-pup-pi"), "got: {err}"); + } + + #[test] + fn install_pi_named_skill_gives_extension_only_hint() { + let cfg = base_cfg(); + // dd-apm is a skill; pi only supports extensions — this is the exact + // scenario from the bug report (pup skills install --name dd-apm pi). + let err = install( + &cfg, + Some("pi".to_string()), + Some("dd-apm".to_string()), + None, + None, + false, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("no install target"), "got: {err}"); + assert!(err.contains("name=dd-apm"), "got: {err}"); + assert!(err.contains("only support extensions"), "got: {err}"); + assert!(err.contains("dd-pup-pi"), "got: {err}"); + } + + #[test] + fn install_pi_type_agent_gives_extension_only_hint() { + let cfg = base_cfg(); + let err = install( + &cfg, + Some("pi".to_string()), + None, + None, + Some("agent".to_string()), + false, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("only support extensions"), "got: {err}"); + assert!(err.contains("dd-pup-pi"), "got: {err}"); + } + + #[test] + fn install_extension_on_wrong_platform_gives_generic_error_not_hint() { + let cfg = base_cfg(); + // dd-pup-pi is a pi extension; installing it on claude must give the + // generic error (not the extension-only hint, since claude is not + // extension-only). + let err = install( + &cfg, + Some("claude".to_string()), + Some("dd-pup-pi".to_string()), + None, + None, + false, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("no install target"), "got: {err}"); + assert!(!err.contains("only support extensions"), "got: {err}"); + } + + #[test] + fn install_all_with_named_skill_also_installs_pi_extension() { + let tmp = TempDir::new("install_all_pi"); + let cfg = base_cfg(); + // --name dd-apm would normally skip pi (it's a skill, not an extension), + // but `all` means "full experience everywhere", so dd-pup-pi must also + // be installed for pi. + install( + &cfg, + Some("all".to_string()), + Some("dd-apm".to_string()), + Some(tmp.path().to_str().unwrap().to_string()), + None, + false, + ) + .unwrap(); + assert!( + tmp.path().join("dd-apm/SKILL.md").exists(), + "dd-apm skill should be installed" + ); + assert!( + tmp.path().join("dd-pup-pi/index.ts").exists(), + "dd-pup-pi extension should be installed when `all` is selected" + ); + assert!(tmp.path().join("dd-pup-pi/package.json").exists()); + // With path dedup, --dir writes each unique destination exactly once: + // dd-apm/SKILL.md + dd-pup-pi/index.ts + dd-pup-pi/package.json + dd-pup-pi/README.md + let file_count = std::fs::read_dir(tmp.path()) + .unwrap() + .flat_map(|d| { + std::fs::read_dir(d.unwrap().path()) + .unwrap() + .map(|f| f.unwrap().path()) + }) + .count(); + assert_eq!( + file_count, 4, + "expected 4 unique files (1 skill + 3 extension)" + ); + } + + #[test] + fn install_all_with_type_filter_also_installs_pi_extension() { + let tmp = TempDir::new("install_all_type_pi"); + let cfg = base_cfg(); + // --type skill would normally skip pi, but `all` forces dd-pup-pi. + install( + &cfg, + Some("all".to_string()), + None, + Some(tmp.path().to_str().unwrap().to_string()), + Some("skill".to_string()), + false, + ) + .unwrap(); + assert!( + tmp.path().join("dd-pup-pi/index.ts").exists(), + "dd-pup-pi extension should be installed even with --type skill when `all` is used" + ); } #[test] diff --git a/src/skills.rs b/src/skills.rs index 37a0237..0513be4 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -548,6 +548,21 @@ pub struct PlatformSpec { pub uses_agent_md: bool, } +impl PlatformSpec { + /// Returns true when this platform supports only extensions (no skills or + /// agents directory in any scope). + /// + /// An empty agents path means agents share the skills dir; if skills is + /// also empty the platform has no text-content support and can only receive + /// extension bundles (e.g. pi). + pub fn is_extension_only(&self) -> bool { + self.user_skills.is_empty() + && self.project_skills.is_empty() + && self.user_agents.is_empty() + && self.project_agents.is_empty() + } +} + /// Registry of supported platforms. pub static PLATFORMS: &[PlatformSpec] = &[ PlatformSpec { @@ -840,6 +855,13 @@ pub fn install_path( "install_path() does not handle extensions; use install_paths()" ); + // Extension-only platforms (e.g. pi) have no skills or agents directory; + // skills and agents cannot install there even when --dir overrides the path. + let spec = lookup_platform(platform)?; + if spec.is_extension_only() { + return None; + } + if let Some(d) = dir_override { // Explicit --dir: everything as SKILL.md return Some(( @@ -847,8 +869,6 @@ pub fn install_path( InstallFormat::SkillMd, )); } - - let spec = lookup_platform(platform)?; if entry.entry_type == "agent" && spec.uses_agent_md { let dir = agents_dir(platform, project_root, user_scope)?; Some(( @@ -881,14 +901,17 @@ pub fn install_paths( user_scope: bool, ) -> anyhow::Result> { if entry.entry_type == "extension" { + // Extensions are tied to a specific platform; only install when the + // platform matches, even when --dir overrides the destination path. + // Without this guard a `--dir all` install would produce files for a + // pi-only extension on every platform in the loop, inflating the + // reported platform count with platforms that received no real content. + if entry.platform != platform { + return Ok(vec![]); + } let base = if let Some(d) = dir_override { PathBuf::from(d).join(entry.name) } else { - // Extensions are tied to a specific platform; only install when - // that platform matches the current target. - if entry.platform != platform { - return Ok(vec![]); - } let Some(root) = extensions_dir(platform, project_root, user_scope) else { return Ok(vec![]); }; @@ -938,26 +961,10 @@ pub fn format_as_skill_md(entry: &SkillEntry) -> String { } /// Format content for Claude Code agent .md install (adds name: to frontmatter). +/// Currently identical to [`format_as_skill_md`]; the two will diverge when +/// agent `.md` files require Claude-Code-specific frontmatter fields. pub fn format_as_agent_md(entry: &SkillEntry) -> String { - if entry.content.starts_with("---") { - let end = entry.content[3..].find("---"); - if let Some(pos) = end { - let frontmatter = &entry.content[3..3 + pos]; - if frontmatter.contains("name:") { - return entry.content.to_string(); - } - return format!( - "---\nname: {}\n{}---{}", - entry.name, - frontmatter, - &entry.content[3 + pos + 3..] - ); - } - } - format!( - "---\nname: {}\ndescription: {}\n---\n\n{}", - entry.name, entry.description, entry.content - ) + format_as_skill_md(entry) } /// Format content for the given install format. @@ -1051,6 +1058,11 @@ mod tests { "extension {} has empty platform", entry.name ); + assert!( + entry.content.is_empty(), + "extension {} must not have content (content is for skills/agents only)", + entry.name + ); for (rel, body) in entry.files { assert!(!rel.is_empty(), "empty file path in {}", entry.name); assert!( @@ -1251,6 +1263,15 @@ mod tests { assert!(install_path(&e, "pi", &root, None, false).is_none()); } + #[test] + fn test_install_path_skill_on_pi_with_dir_override_returns_none() { + // --dir does not grant an extension-only platform the ability to install + // skills; the platform capability check runs before the dir override. + let root = PathBuf::from("/tmp/test-project"); + let e = entry("dd-pup", "skill", ""); + assert!(install_path(&e, "pi", &root, Some("/tmp/out"), false).is_none()); + } + #[test] fn test_format_as_skill_md_adds_name() { let e = SkillEntry { @@ -1542,4 +1563,62 @@ mod tests { assert!(names.contains(&"package.json")); assert!(names.contains(&"README.md")); } + + #[test] + fn test_platform_extension_only_structural_invariant() { + // Every platform must be consistently classified: extension-only + // platforms must have at least one extensions dir, and non-extension-only + // platforms must have at least one skills dir. + for spec in PLATFORMS { + if spec.is_extension_only() { + assert!( + !spec.user_extensions.is_empty() || !spec.project_extensions.is_empty(), + "extension-only platform '{}' must have at least one extensions directory", + spec.name + ); + } else { + assert!( + !spec.user_skills.is_empty() || !spec.project_skills.is_empty(), + "non-extension-only platform '{}' must have at least one skills directory", + spec.name + ); + } + } + } + + #[test] + fn test_is_extension_only_pi() { + assert!(lookup_platform("pi").unwrap().is_extension_only()); + } + + #[test] + fn test_is_extension_only_false_for_skill_platforms() { + for name in &[ + "claude-code", + "cursor", + "codex", + "opencode", + "windsurf", + "gemini-code", + ] { + assert!( + !lookup_platform(name).unwrap().is_extension_only(), + "{name} should not be extension-only" + ); + } + } + + #[test] + fn extension_platform_fields_are_recognized() { + for e in SKILLS { + if e.entry_type == "extension" { + assert!( + lookup_platform(e.platform).is_some(), + "extension '{}' has unrecognized platform slug '{}'", + e.name, + e.platform + ); + } + } + } }