diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4bd1961 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,25 @@ +{ + "name": "datadog-pup", + "owner": { + "name": "Datadog", + "email": "support@datadoghq.com" + }, + "metadata": { + "description": "Datadog API CLI with skills and domain agents for AI coding assistants" + }, + "plugins": [ + { + "name": "pup", + "source": "./", + "description": "Datadog API CLI with 49 command groups, 300+ subcommands. Skills and domain agents for monitoring, logs, APM, security, and infrastructure.", + "version": "0.62.0", + "author": { + "name": "Datadog", + "email": "support@datadoghq.com" + }, + "license": "Apache-2.0", + "keywords": ["datadog", "monitoring", "logs", "apm", "metrics", "security", "infrastructure"], + "category": "observability" + } + ] +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..a7a3058 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "pup", + "version": "0.62.0", + "description": "Datadog API CLI with 49 command groups, 300+ subcommands. Skills and domain agents for monitoring, logs, APM, security, and infrastructure.", + "author": { + "name": "Datadog", + "email": "support@datadoghq.com" + }, + "repository": "https://github.com/DataDog/pup", + "license": "Apache-2.0", + "keywords": ["datadog", "monitoring", "logs", "apm", "metrics", "security", "infrastructure"], + "skills": "./skills/" +} diff --git a/README.md b/README.md index 1b45182..50ca7bd 100644 --- a/README.md +++ b/README.md @@ -499,28 +499,40 @@ See `docs/examples/runbooks/` for ready-to-use examples and [docs/EXAMPLES.md](d Pup ships a set of skills and domain agents embedded in the binary, installable to any AI coding assistant. Run `pup skills list` to see what's available in the version you have installed. ```bash -# Install all skills and agents for your AI assistant +# Install all skills and agents for the auto-detected platform pup skills install -# Install for a specific tool -pup skills install --target-agent=claude-code -pup skills install --target-agent=cursor +# Install for a specific platform (positional arg) +pup skills install claude +pup skills install cursor +pup skills install codex +pup skills install opencode +pup skills install pi + +# Install for every supported platform at once +pup skills install all + +# By default installs go to the user-global directory; --project keeps them local +pup skills install claude --project # List available skills and agents pup skills list pup skills list --type=skill pup skills list --type=agent -# Install a specific skill -pup skills install dd-monitors +# Install a specific skill by name +pup skills install claude --name dd-monitors ``` -For Claude Code, skills install to `.claude/skills/` and agents install to `.claude/agents/` (native subagent format). For other tools, everything installs as `SKILL.md` in the tool's skills directory. +For Claude Code, skills install to `~/.claude/skills/` (or `.claude/skills/` with `--project`) and agents install to `~/.claude/agents/` (native subagent format). For Cursor, Codex, and opencode, everything installs as `SKILL.md` under that tool's skills directory (e.g. `~/.cursor/skills/`, `~/.codex/skills/`, `~/.config/opencode/skills/`). -Pup is also available as a **Claude Code plugin marketplace**: +Pup ships plugin manifest files for several AI coding assistants: ``` +# Claude Code /plugin marketplace add DataDog/pup + +# Codex (reads .codex-plugin/plugin.json from the repo, or marketplace.json from ~/.agents/plugins/) ``` ## ACP Server diff --git a/SKILL.md b/SKILL.md index ed8ed07..ba22483 100644 --- a/SKILL.md +++ b/SKILL.md @@ -17,13 +17,22 @@ Rust-based CLI for Datadog APIs. 49 command groups, 300+ subcommands across 53 c ## Install Skills ```bash -# Install all skills and agents for your AI coding assistant +# Install all skills and agents for the auto-detected AI assistant pup skills install -# Or install specific skills -pup skills install dd-pup -pup skills install dd-monitors -pup skills install dd-logs +# Or install for a specific platform (claude, cursor, codex, opencode, pi) +pup skills install claude +pup skills install codex +pup skills install cursor + +# Install for every supported platform at once +pup skills install all + +# Install a single skill by name +pup skills install claude --name dd-pup + +# Default scope is user-global; pass --project to install into the repo +pup skills install claude --project # List all available skills pup skills list diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 856395a..0948138 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -77,7 +77,7 @@ pup [options] # Nested commands | code-coverage | branch-summary, commit-summary | src/commands/code_coverage.rs | ✅ | | hamr | connections (get, create) | src/commands/hamr.rs | ✅ | | fleet | agents (list, get, versions, tracers), deployments (list, get, configure, upgrade, cancel), schedules (list, get, create, update, delete, trigger), tracers (list), clusters (list), instrumented-pods (list) | src/commands/fleet.rs | ✅ | -| skills | list, install, path (entry types: skill, agent, extension; `--platform`/`--user` for extensions) | src/commands/skills.rs | ✅ | +| skills | list, install, path (positional ``: claude/cursor/codex/opencode/windsurf/gemini/pi/all; `--name`, `--type`, `--project` for project-local scope) | src/commands/skills.rs | ✅ | | runbooks | list, describe, run, import, validate | src/commands/runbooks.rs | ✅ | | workflows | get, create, update, delete, run, instances (list, get, cancel), connections (get, create, update, delete) | src/commands/workflows.rs | ✅ | | investigations | list, get, trigger | src/commands/investigations.rs | ✅ | diff --git a/skills/extensions/dd-pup-pi/README.md b/skills/extensions/dd-pup-pi/README.md index bc56a3a..d101fd9 100644 --- a/skills/extensions/dd-pup-pi/README.md +++ b/skills/extensions/dd-pup-pi/README.md @@ -12,14 +12,11 @@ brew tap datadog-labs/pack brew install pup pup auth login -# 2. install the extension -# Default: project-local when run inside a git repo -# (/.pi/extensions/dd-pup-pi/), user-global otherwise -# (~/.pi/agent/extensions/dd-pup-pi/). -pup skills install --platform=pi - -# Force user-global install regardless of cwd: -pup skills install --platform=pi --user +# 2. install the extension (defaults to user-global ~/.pi/agent/extensions) +pup skills install pi + +# Install project-local instead (/.pi/extensions/dd-pup-pi): +pup skills install pi --project ``` pi auto-discovers the extension on next launch (or via `/reload`). diff --git a/src/commands/skills.rs b/src/commands/skills.rs index 60a3f58..bca9981 100644 --- a/src/commands/skills.rs +++ b/src/commands/skills.rs @@ -2,6 +2,40 @@ use anyhow::{bail, Result}; use crate::skills; +/// Resolve the platform list from CLI input, validating each entry. +/// +/// Validation runs in every mode — `--dir` may override the destination path +/// but does not let the caller pass a typo'd platform name silently. A +/// mistyped platform still has user-visible meaning (it's printed in success +/// messages, it controls extension-vs-skill routing, etc.), so we always +/// require it to be a recognized value. +fn resolve_or_bail(input: Option<&str>) -> Result> { + let auto_detected = input.map(|s| s.trim().is_empty()).unwrap_or(true); + let platforms = skills::resolve_platform_list(input); + if platforms.iter().any(|p| p.is_empty()) { + bail!( + "could not auto-detect AI assistant. Specify a platform: claude, \ + cursor, codex, opencode, windsurf, gemini, pi, or `all`." + ); + } + for p in &platforms { + if skills::lookup_platform(p).is_none() { + if auto_detected { + bail!( + "auto-detected '{p}' is not a supported platform. Specify \ + one explicitly: claude, cursor, codex, opencode, windsurf, \ + gemini, pi, or `all`." + ); + } + bail!( + "unknown platform: '{p}'. Supported: claude, cursor, codex, \ + opencode, windsurf, gemini, pi, or `all`." + ); + } + } + Ok(platforms) +} + pub fn list(cfg: &crate::config::Config, entry_type: Option) -> Result<()> { let entries: Vec<_> = skills::SKILLS .iter() @@ -36,24 +70,17 @@ pub fn list(cfg: &crate::config::Config, entry_type: Option) -> Result<( Ok(()) } -#[allow(clippy::too_many_arguments)] pub fn install( cfg: &crate::config::Config, + platform: Option, name: Option, - target_agent: Option, dir: Option, entry_type: Option, - platform: Option, - user_scope: bool, + project: bool, ) -> Result<()> { - let (project_root, in_project) = skills::project_root_or_cwd(); - let agent = skills::resolve_agent(target_agent.as_deref()); - let platform_slug = skills::resolve_platform(platform.as_deref(), &agent); - - // Default scope for extensions: - // - explicit --user wins - // - else: project-local if a project root (.git) was found, else user-global - let extensions_user_scope = user_scope || !in_project; + let (project_root, _) = skills::project_root_or_cwd(); + let user_scope = !project; + let platforms = resolve_or_bail(platform.as_deref())?; let entries: Vec<_> = skills::SKILLS .iter() @@ -65,19 +92,6 @@ pub fn install( Some(t) => e.entry_type == t.as_str(), None => true, }) - // When --platform is set, scope extensions to that platform. - // Skills and agents are unaffected by --platform. - .filter(|e| { - if e.entry_type != "extension" { - return true; - } - if platform_slug.is_empty() { - // No platform context — only install extensions when the user - // asked for them explicitly (by --type=extension or by name). - return entry_type.as_deref() == Some("extension") || name.is_some(); - } - e.platform == platform_slug - }) .collect(); if let Some(ref n) = name { @@ -87,34 +101,51 @@ pub fn install( } let mut installed_files = 0usize; - let mut installed_entries = 0usize; let mut dirs_used = std::collections::BTreeSet::new(); - for entry in &entries { - let targets = skills::install_paths( - entry, - &agent, - &platform_slug, - &project_root, - dir.as_deref(), - extensions_user_scope, - )?; - installed_entries += 1; - for (path, content) in targets { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - dirs_used.insert(parent.display().to_string()); + let mut entry_hits = std::collections::BTreeSet::new(); + for plat in &platforms { + for entry in &entries { + let targets = + skills::install_paths(entry, plat, &project_root, dir.as_deref(), user_scope)?; + 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()); + } + std::fs::write(&path, &content)?; + installed_files += 1; } - std::fs::write(&path, &content)?; - installed_files += 1; } } + // If the user filtered with --name or --type but nothing actually + // installed across the selected platforms, the command succeeded by doing + // nothing — surface that as an error so a typo doesn't silently no-op. + if entry_hits.is_empty() && (name.is_some() || entry_type.is_some()) { + let filter = match (&name, &entry_type) { + (Some(n), Some(t)) => format!("name={n}, type={t}"), + (Some(n), None) => format!("name={n}"), + (None, Some(t)) => format!("type={t}"), + (None, None) => unreachable!(), + }; + bail!( + "no install target matched {filter} on the selected platform(s): {}", + platforms.join(", "), + ); + } + + let installed_entries = entry_hits.len(); if cfg.agent_mode { let directories: Vec<_> = dirs_used.into_iter().collect(); let result = serde_json::json!({ "installed": installed_entries, "files": installed_files, "directories": directories, + "platforms": platforms, }); crate::formatter::format_and_print(&result, &cfg.output_format, cfg.agent_mode, None)?; } else { @@ -122,41 +153,162 @@ pub fn install( println!(" {d}"); } println!( - "Installed {} entry(ies), {} file(s)", - installed_entries, installed_files + "Installed {} entry(ies), {} file(s) across {} platform(s)", + installed_entries, + installed_files, + platforms.len(), ); } Ok(()) } -pub fn path( - target_agent: Option, - platform: Option, - user_scope: bool, -) -> Result<()> { - let (project_root, in_project) = skills::project_root_or_cwd(); - let agent = skills::resolve_agent(target_agent.as_deref()); - let sd = skills::skills_dir(&agent, &project_root); - let ad = skills::agents_dir(&agent, &project_root); - if sd == ad { - println!("skills: {}", sd.display()); - } else { - println!("skills: {}", sd.display()); - println!("agents: {}", ad.display()); - } - - let platform_slug = skills::resolve_platform(platform.as_deref(), &agent); - if !platform_slug.is_empty() { - let scope = user_scope || !in_project; - if let Some(ed) = skills::extensions_dir(&platform_slug, &project_root, scope) { - println!( - "extensions: {} (platform: {}, scope: {})", - ed.display(), - platform_slug, - if scope { "user" } else { "project" }, - ); +pub fn path(platform: Option, project: bool) -> Result<()> { + let (project_root, _) = skills::project_root_or_cwd(); + let user_scope = !project; + let platforms = resolve_or_bail(platform.as_deref())?; + let scope_label = if user_scope { "user" } else { "project" }; + + for plat in &platforms { + println!("platform: {plat} (scope: {scope_label})"); + let sd = skills::skills_dir(plat, &project_root, user_scope); + if let Some(ref sd) = sd { + println!(" skills: {}", sd.display()); + } + if let Some(ad) = skills::agents_dir(plat, &project_root, user_scope) { + // Suppress redundant agents path when it matches skills (most + // platforms share the dir; only Claude Code splits them). + if sd.as_ref() != Some(&ad) { + println!(" agents: {}", ad.display()); + } + } + if let Some(ed) = skills::extensions_dir(plat, &project_root, user_scope) { + println!(" extensions: {}", ed.display()); } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::test_support::TempDir; + + fn base_cfg() -> Config { + Config { + api_key: None, + app_key: None, + access_token: None, + site: "datadoghq.com".to_string(), + site_explicit: false, + org: None, + output_format: crate::config::OutputFormat::Json, + auto_approve: false, + agent_mode: false, + read_only: false, + } + } + + #[test] + fn resolve_or_bail_normalizes_alias() { + let p = resolve_or_bail(Some("claude")).unwrap(); + assert_eq!(p, vec!["claude-code".to_string()]); + } + + #[test] + fn resolve_or_bail_expands_all() { + let p = resolve_or_bail(Some("all")).unwrap(); + // All known platforms — must include each canonical name. + for expected in ["claude-code", "cursor", "codex", "opencode", "pi"] { + assert!(p.iter().any(|x| x == expected), "missing {expected}"); + } + } + + #[test] + fn resolve_or_bail_rejects_unknown_platform() { + let err = resolve_or_bail(Some("clood")).unwrap_err().to_string(); + assert!(err.contains("unknown platform"), "got: {err}"); + assert!(err.contains("clood"), "got: {err}"); + } + + #[test] + fn resolve_or_bail_dir_override_still_validates() { + // --dir does NOT exempt the platform name from validation — a typo + // would otherwise silently write to the override dir with a + // misleading "success" message. + assert!(resolve_or_bail(Some("clood")).is_err()); + } + + #[test] + fn install_dir_override_writes_named_skill() { + let tmp = TempDir::new("install_dir_named"); + let cfg = base_cfg(); + install( + &cfg, + Some("claude".to_string()), + Some("dd-pup".to_string()), + Some(tmp.path().to_str().unwrap().to_string()), + None, + false, + ) + .unwrap(); + let file = tmp.path().join("dd-pup").join("SKILL.md"); + assert!(file.exists(), "expected {} to exist", file.display()); + let body = std::fs::read_to_string(&file).unwrap(); + assert!(body.contains("name: dd-pup")); + } + + #[test] + fn install_bails_when_named_entry_does_not_apply_to_platform() { + let cfg = base_cfg(); + // dd-pup-pi is a pi-only extension. Trying to install it on claude + // (without --dir) must error rather than silently succeed. + 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("dd-pup-pi"), "got: {err}"); + } + + #[test] + fn install_bails_when_type_filter_matches_nothing_on_platform() { + let cfg = base_cfg(); + // Skills don't apply to pi. + let err = install( + &cfg, + Some("pi".to_string()), + None, + None, + Some("skill".to_string()), + false, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("no install target"), "got: {err}"); + assert!(err.contains("type=skill"), "got: {err}"); + } + + #[test] + fn install_bails_when_named_entry_does_not_exist() { + let cfg = base_cfg(); + let err = install( + &cfg, + Some("claude".to_string()), + Some("nonexistent-skill".to_string()), + None, + None, + false, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("skill not found"), "got: {err}"); + } +} diff --git a/src/main.rs b/src/main.rs index 4462dca..c3e6fb8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2319,31 +2319,36 @@ enum Commands { /// compose pup commands. /// /// ENTRY TYPES: - /// skill Single-file markdown guide installed under the agent's skills dir + /// skill Single-file markdown guide installed under the platform's skills dir /// agent Domain subagent (Claude Code subagent format or SKILL.md fallback) /// extension Multi-file bundle for a coding-agent platform (e.g. pi) /// - /// EXTENSION SCOPE: - /// By default extensions install project-local when run inside a git - /// repository (e.g. /.pi/extensions//), and user-global - /// otherwise (e.g. ~/.pi/agent/extensions//). Pass --user to force - /// user-global. Skills and agents always install project-local. + /// PLATFORMS: + /// claude (or claude-code), cursor, codex, opencode, windsurf, gemini, pi + /// Pass `all` to install for every supported platform. + /// If omitted, pup auto-detects the platform from the environment. + /// + /// SCOPE: + /// By default, installs go to the user-global directory (e.g. + /// ~/.claude/skills, ~/.cursor/skills, ~/.codex/skills, + /// ~/.config/opencode/skills, ~/.pi/agent/extensions). Pass --project to + /// install into the current project instead (e.g. /.claude/skills). /// /// COMMANDS: /// list List available skills, agents, and extensions - /// install Install entries for the detected AI coding assistant + /// install Install entries for one or more platforms /// path Show where entries would be installed /// /// EXAMPLES: /// pup skills list /// pup skills install - /// pup skills install dd-pup - /// pup skills install --type=agent - /// pup skills install --target-agent=cursor - /// pup skills install --platform=pi - /// pup skills install --platform=pi --user + /// pup skills install claude + /// pup skills install codex --project + /// pup skills install all + /// pup skills install pi --name=dd-pup-pi + /// pup skills install --type=agent claude /// pup skills path - /// pup skills path --platform=pi + /// pup skills path pi #[cfg(not(target_arch = "wasm32"))] #[command(verbatim_doc_comment)] Skills { @@ -9115,39 +9120,32 @@ enum SkillsActions { #[arg(long = "type", name = "type")] entry_type: Option, }, - /// Install skills for the detected AI coding assistant + /// Install skills, agents, and extensions for one or more platforms Install { + /// Target platform (auto-detected from environment if omitted). + #[arg(value_enum)] + platform: Option, /// Install a specific skill, agent, or extension by name + #[arg(long)] name: Option, - /// Override detected AI agent (claude-code, cursor, codex, windsurf, gemini-code) - #[arg(long = "target-agent")] - target_agent: Option, /// Override install directory #[arg(long)] dir: Option, /// Filter by type: skill, agent, extension #[arg(long = "type", name = "type")] entry_type: Option, - /// Extension platform (e.g. pi). Required for extension installs unless - /// the detected agent maps to a platform. + /// Install into the current project instead of the user-global location #[arg(long)] - platform: Option, - /// Install extensions to the user-global directory (e.g. ~/.pi/agent/extensions) - /// instead of the project-local one. Has no effect on skills/agents. - #[arg(long = "user")] - user_scope: bool, + project: bool, }, /// Show where skills/agents/extensions would be installed Path { - /// Override detected AI agent - #[arg(long = "target-agent")] - target_agent: Option, - /// Extension platform (e.g. pi) + /// Target platform (auto-detected from environment if omitted). + #[arg(value_enum)] + platform: Option, + /// Show project-local install paths instead of the user-global default #[arg(long)] - platform: Option, - /// Show user-global extension path instead of project-local - #[arg(long = "user")] - user_scope: bool, + project: bool, }, } @@ -14263,26 +14261,22 @@ async fn main_inner() -> anyhow::Result<()> { Commands::Skills { action } => match action { SkillsActions::List { entry_type } => commands::skills::list(&cfg, entry_type)?, SkillsActions::Install { + platform, name, - target_agent, dir, entry_type, - platform, - user_scope, + project, } => commands::skills::install( &cfg, + platform.map(|p| p.as_canonical().to_string()), name, - target_agent, dir, entry_type, - platform, - user_scope, + project, )?, - SkillsActions::Path { - target_agent, - platform, - user_scope, - } => commands::skills::path(target_agent, platform, user_scope)?, + SkillsActions::Path { platform, project } => { + commands::skills::path(platform.map(|p| p.as_canonical().to_string()), project)? + } }, // --- Product Analytics --- Commands::ProductAnalytics { action } => { diff --git a/src/skills.rs b/src/skills.rs index cfc395e..42f943b 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -504,42 +504,198 @@ pub static SKILLS: &[SkillEntry] = &[ }, ]; -/// Resolve the detected agent name, applying override if provided. -pub fn resolve_agent(agent: Option<&str>) -> String { - agent - .map(String::from) - .unwrap_or_else(|| crate::useragent::detect_agent_info().name) +/// Static description of one supported AI-coding-assistant platform. +/// +/// Each platform tells us where skills, agents, and extension bundles live for +/// both project-local and user-global scopes. Empty path strings mean "not +/// supported" — e.g. `pi` has no skills/agents dirs, and most platforms have +/// no extensions dir. +pub struct PlatformSpec { + /// Canonical platform name as users type it on the CLI. + pub name: &'static str, + /// Additional accepted names (e.g. `claude` for `claude-code`). + pub aliases: &'static [&'static str], + /// Project-local skills dir, relative to project root. + pub project_skills: &'static str, + /// User-global skills dir, relative to $HOME. + pub user_skills: &'static str, + /// Project-local agents dir; if empty, agents share the skills dir. + pub project_agents: &'static str, + /// User-global agents dir; if empty, agents share the user skills dir. + pub user_agents: &'static str, + /// Project-local extensions dir, relative to project root. + pub project_extensions: &'static str, + /// User-global extensions dir, relative to $HOME. + pub user_extensions: &'static str, + /// True iff agents install as Claude-Code-style `.md` subagents + /// rather than `SKILL.md` files. + pub uses_agent_md: bool, } -/// Resolve the extension platform slug. -/// -/// Precedence: -/// 1. explicit `--platform` flag value -/// 2. mapping from the detected/resolved agent name (e.g. `pi-dev` -> `pi`) -/// 3. empty string (caller must decide how to handle) +/// Registry of supported platforms. +pub static PLATFORMS: &[PlatformSpec] = &[ + PlatformSpec { + name: "claude-code", + aliases: &["claude"], + project_skills: ".claude/skills", + user_skills: ".claude/skills", + project_agents: ".claude/agents", + user_agents: ".claude/agents", + project_extensions: "", + user_extensions: "", + uses_agent_md: true, + }, + PlatformSpec { + name: "cursor", + aliases: &[], + project_skills: ".cursor/skills", + user_skills: ".cursor/skills", + project_agents: "", + user_agents: "", + project_extensions: "", + user_extensions: "", + uses_agent_md: false, + }, + PlatformSpec { + name: "codex", + aliases: &[], + project_skills: ".codex/skills", + user_skills: ".codex/skills", + project_agents: "", + user_agents: "", + project_extensions: "", + user_extensions: "", + uses_agent_md: false, + }, + PlatformSpec { + name: "opencode", + aliases: &[], + project_skills: ".opencode/skills", + user_skills: ".config/opencode/skills", + project_agents: "", + user_agents: "", + project_extensions: "", + user_extensions: "", + uses_agent_md: false, + }, + PlatformSpec { + name: "windsurf", + aliases: &[], + project_skills: ".windsurf/skills", + user_skills: ".windsurf/skills", + project_agents: "", + user_agents: "", + project_extensions: "", + user_extensions: "", + uses_agent_md: false, + }, + PlatformSpec { + name: "gemini-code", + aliases: &["gemini"], + project_skills: ".gemini/skills", + user_skills: ".gemini/skills", + project_agents: "", + user_agents: "", + project_extensions: "", + user_extensions: "", + uses_agent_md: false, + }, + PlatformSpec { + name: "pi", + aliases: &["pi-dev"], + project_skills: "", + user_skills: "", + project_agents: "", + user_agents: "", + project_extensions: ".pi/extensions", + user_extensions: ".pi/agent/extensions", + uses_agent_md: false, + }, +]; + +/// CLI-typed selector for `pup skills install ` and `pup skills +/// path `. Each canonical variant maps onto an entry in +/// [`PLATFORMS`] via [`SkillsPlatform::as_canonical`]; aliases (`claude`, +/// `gemini`, `pi-dev`) are accepted for ergonomics. The `All` variant +/// expands to every supported platform — see [`resolve_platform_list`]. /// -/// Supported platforms today: `pi`. -pub fn resolve_platform(platform: Option<&str>, agent: &str) -> String { - if let Some(p) = platform { - if !p.is_empty() { - return p.to_string(); +/// Keep this in sync with [`PLATFORMS`]: the `platform_enum_matches_table` +/// test enforces the mapping. +#[cfg(feature = "native")] +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum SkillsPlatform { + #[clap(alias = "claude")] + ClaudeCode, + Cursor, + Codex, + Opencode, + Windsurf, + #[clap(alias = "gemini")] + GeminiCode, + #[clap(alias = "pi-dev")] + Pi, + All, +} + +#[cfg(feature = "native")] +impl SkillsPlatform { + /// Canonical platform name as used by [`lookup_platform`]. `All` returns + /// `"all"`, which [`resolve_platform_list`] expands into every supported + /// platform. + pub fn as_canonical(self) -> &'static str { + match self { + SkillsPlatform::ClaudeCode => "claude-code", + SkillsPlatform::Cursor => "cursor", + SkillsPlatform::Codex => "codex", + SkillsPlatform::Opencode => "opencode", + SkillsPlatform::Windsurf => "windsurf", + SkillsPlatform::GeminiCode => "gemini-code", + SkillsPlatform::Pi => "pi", + SkillsPlatform::All => "all", } } - match agent { - "pi-dev" | "pi" => "pi".to_string(), - _ => String::new(), - } } -/// Determine the install directory for an extension on a given platform. +/// Look up a platform by canonical name or alias. Returns `None` for unknown. +pub fn lookup_platform(name: &str) -> Option<&'static PlatformSpec> { + PLATFORMS + .iter() + .find(|p| p.name == name || p.aliases.contains(&name)) +} + +/// Resolve the canonical platform name from a CLI input. /// -/// When `user_scope` is true, returns the per-user global location -/// (`~/.pi/agent/extensions` for pi). Otherwise returns the project-local -/// location (`/.pi/extensions` for pi). +/// `None` or empty input falls back to environment detection. Aliases are +/// normalized to the canonical name. Unknown names return the input unchanged +/// so the caller can produce a useful error. +pub fn resolve_platform_name(input: Option<&str>) -> String { + let raw = input.unwrap_or("").trim(); + if raw.is_empty() { + let detected = crate::useragent::detect_agent_info().name; + return lookup_platform(&detected) + .map(|p| p.name.to_string()) + .unwrap_or(detected); + } + lookup_platform(raw) + .map(|p| p.name.to_string()) + .unwrap_or_else(|| raw.to_string()) +} + +/// Expand a CLI platform input into the list of platforms to operate on. /// -/// Returns `None` for unsupported platforms, or in user-scope mode when the -/// home directory cannot be resolved (HOME/USERPROFILE unset). The caller is -/// expected to surface this as an error — see [`install_paths`]. +/// - `Some("all")` → every platform in [`PLATFORMS`]. +/// - `Some(name)` → that single platform (canonicalized via aliases). +/// - `None` or empty → auto-detected platform from the environment. +pub fn resolve_platform_list(input: Option<&str>) -> Vec { + let raw = input.unwrap_or("").trim(); + if raw.eq_ignore_ascii_case("all") { + return PLATFORMS.iter().map(|p| p.name.to_string()).collect(); + } + vec![resolve_platform_name(input)] +} + +/// Determine the extensions install directory for a platform. pub fn extensions_dir(platform: &str, project_root: &Path, user_scope: bool) -> Option { extensions_dir_with_home( platform, @@ -557,67 +713,112 @@ pub fn extensions_dir_with_home( project_root: &Path, user_scope: bool, ) -> Option { - match platform { - "pi" => { - if user_scope { - Some(home?.join(".pi").join("agent").join("extensions")) - } else { - Some(project_root.join(".pi").join("extensions")) - } - } - _ => None, - } + let spec = lookup_platform(platform)?; + let sub = if user_scope { + spec.user_extensions + } else { + spec.project_extensions + }; + resolve_relative(sub, home, project_root, user_scope) } -/// Determine the skills install directory for the given agent. -/// If an existing skills directory is found, use it regardless of detected agent. -pub fn skills_dir(agent: &str, project_root: &Path) -> PathBuf { - let existing_dirs = [ - ".agents/skills", - ".claude/skills", - ".cursor/skills", - ".windsurf/skills", - ".gemini/skills", - ]; - for dir in &existing_dirs { - let path = project_root.join(dir); - if path.is_dir() { - return path; - } - } +/// Determine the skills install directory for a platform. +pub fn skills_dir(platform: &str, project_root: &Path, user_scope: bool) -> Option { + skills_dir_with_home( + platform, + dirs::home_dir().as_deref(), + project_root, + user_scope, + ) +} + +/// Same as [`skills_dir`] but takes an explicit `home` directory. +pub fn skills_dir_with_home( + platform: &str, + home: Option<&Path>, + project_root: &Path, + user_scope: bool, +) -> Option { + let spec = lookup_platform(platform)?; + let sub = if user_scope { + spec.user_skills + } else { + spec.project_skills + }; + resolve_relative(sub, home, project_root, user_scope) +} - match agent { - "claude-code" => project_root.join(".claude/skills"), - "codex" | "opencode" => project_root.join(".agents/skills"), - "cursor" => project_root.join(".cursor/skills"), - "windsurf" => project_root.join(".windsurf/skills"), - "gemini-code" => project_root.join(".gemini/skills"), - _ => project_root.join(".agents/skills"), +/// Determine the agents (subagents) install directory for a platform. +/// +/// If the platform has no dedicated agents dir, agents share the skills dir. +pub fn agents_dir(platform: &str, project_root: &Path, user_scope: bool) -> Option { + agents_dir_with_home( + platform, + dirs::home_dir().as_deref(), + project_root, + user_scope, + ) +} + +/// Same as [`agents_dir`] but takes an explicit `home` directory. +pub fn agents_dir_with_home( + platform: &str, + home: Option<&Path>, + project_root: &Path, + user_scope: bool, +) -> Option { + let spec = lookup_platform(platform)?; + let sub = if user_scope { + spec.user_agents + } else { + spec.project_agents + }; + if sub.is_empty() { + // Empty sentinel means "share the skills dir for this scope." + return skills_dir_with_home(platform, home, project_root, user_scope); } + resolve_relative(sub, home, project_root, user_scope) } -/// Determine the agents (subagents) install directory for the given agent. -/// Claude Code uses `.claude/agents/`; other tools use their skills directory. -pub fn agents_dir(agent: &str, project_root: &Path) -> PathBuf { - match agent { - "claude-code" => project_root.join(".claude/agents"), - _ => skills_dir(agent, project_root), +/// Resolve a forward-slash-separated relative subpath against either the home +/// directory (user scope) or the project root (project scope). Returns `None` +/// when the subpath is empty (the sentinel for "not applicable") or when user +/// scope is requested but `home` is unavailable. +fn resolve_relative( + sub: &str, + home: Option<&Path>, + project_root: &Path, + user_scope: bool, +) -> Option { + if sub.is_empty() { + return None; } + let mut base = if user_scope { + home?.to_path_buf() + } else { + project_root.to_path_buf() + }; + for part in sub.split('/') { + base.push(part); + } + Some(base) } -/// Determine the install path for a single (single-file) entry. +/// Determine the install path for a single-file skill or agent entry. /// -/// Skills always go to `//SKILL.md`. -/// Agents go to `/.md` for Claude Code (subagent format), -/// or `//SKILL.md` for other tools. +/// Skills install to `//SKILL.md`. Agents install to +/// `/.md` for platforms with [`PlatformSpec::uses_agent_md`] +/// (Claude Code subagent format), and `//SKILL.md` elsewhere. /// +/// Returns `None` when the platform has no skills/agents dir (e.g. `pi`). /// Panics if called for an `extension` entry; use [`install_paths`] for those. pub fn install_path( entry: &SkillEntry, - agent: &str, + platform: &str, project_root: &Path, dir_override: Option<&str>, -) -> (PathBuf, InstallFormat) { + user_scope: bool, +) -> Option<(PathBuf, InstallFormat)> { debug_assert_ne!( entry.entry_type, "extension", "install_path() does not handle extensions; use install_paths()" @@ -625,72 +826,70 @@ pub fn install_path( if let Some(d) = dir_override { // Explicit --dir: everything as SKILL.md - return ( + return Some(( PathBuf::from(d).join(entry.name).join("SKILL.md"), InstallFormat::SkillMd, - ); + )); } - if entry.entry_type == "agent" && agent == "claude-code" { - // Claude Code subagent: .claude/agents/.md - let dir = agents_dir(agent, project_root); - ( + let spec = lookup_platform(platform)?; + if entry.entry_type == "agent" && spec.uses_agent_md { + let dir = agents_dir(platform, project_root, user_scope)?; + Some(( dir.join(format!("{}.md", entry.name)), InstallFormat::AgentMd, - ) + )) } else { - // Everything else: //SKILL.md - let dir = skills_dir(agent, project_root); - ( + let dir = skills_dir(platform, project_root, user_scope)?; + Some(( dir.join(entry.name).join("SKILL.md"), InstallFormat::SkillMd, - ) + )) } } /// Resolve install destinations for any entry, including multi-file extensions. /// /// Returns a list of `(absolute_path, contents)` tuples. For skills and agents -/// this is always a single-element list using [`install_path`] + [`format_content`]. -/// For extensions this expands to one entry per bundled file. +/// this is a single-element list. For extensions this expands to one entry +/// per bundled file. +/// +/// Returns `Ok(vec![])` (no-op) when the entry isn't applicable to the +/// platform (e.g. asking for a `pi` extension on `claude-code`, or asking for +/// a skill on `pi`). The caller can treat an empty result as "skip". pub fn install_paths( entry: &SkillEntry, - agent: &str, platform: &str, project_root: &Path, dir_override: Option<&str>, user_scope: bool, ) -> anyhow::Result> { - if entry.entry_type != "extension" { - let (path, fmt) = install_path(entry, agent, project_root, dir_override); - return Ok(vec![(path, format_content(entry, &fmt))]); - } - - // entry_type == "extension": materialize each bundled file under the - // platform-appropriate extension directory. - let base = if let Some(d) = dir_override { - PathBuf::from(d).join(entry.name) - } else { - let plat = if platform.is_empty() { - entry.platform + if entry.entry_type == "extension" { + let base = if let Some(d) = dir_override { + PathBuf::from(d).join(entry.name) } else { - platform + // 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![]); + }; + root.join(entry.name) }; - let root = extensions_dir(plat, project_root, user_scope).ok_or_else(|| { - anyhow::anyhow!( - "unknown or unsupported extension platform: '{}' (entry: {})", - plat, - entry.name, - ) - })?; - root.join(entry.name) - }; + return Ok(entry + .files + .iter() + .map(|(rel, body)| (base.join(rel), (*body).to_string())) + .collect()); + } - Ok(entry - .files - .iter() - .map(|(rel, body)| (base.join(rel), (*body).to_string())) - .collect()) + let Some((path, fmt)) = install_path(entry, platform, project_root, dir_override, user_scope) + else { + return Ok(vec![]); + }; + Ok(vec![(path, format_content(entry, &fmt))]) } #[derive(Debug, PartialEq)] @@ -881,63 +1080,96 @@ mod tests { } #[test] - fn test_skills_dir_claude_code() { + fn test_skills_dir_claude_code_project() { let root = PathBuf::from("/tmp/test-project"); assert_eq!( - skills_dir("claude-code", &root), - root.join(".claude/skills") + skills_dir_with_home("claude-code", None, &root, false), + Some(root.join(".claude/skills")) ); } #[test] - fn test_skills_dir_cursor() { + fn test_skills_dir_cursor_project() { let root = PathBuf::from("/tmp/test-project"); - assert_eq!(skills_dir("cursor", &root), root.join(".cursor/skills")); + assert_eq!( + skills_dir_with_home("cursor", None, &root, false), + Some(root.join(".cursor/skills")) + ); } #[test] - fn test_skills_dir_codex() { + fn test_skills_dir_codex_project() { let root = PathBuf::from("/tmp/test-project"); - assert_eq!(skills_dir("codex", &root), root.join(".agents/skills")); + assert_eq!( + skills_dir_with_home("codex", None, &root, false), + Some(root.join(".codex/skills")) + ); } #[test] - fn test_skills_dir_windsurf() { + fn test_skills_dir_opencode_project() { let root = PathBuf::from("/tmp/test-project"); - assert_eq!(skills_dir("windsurf", &root), root.join(".windsurf/skills")); + assert_eq!( + skills_dir_with_home("opencode", None, &root, false), + Some(root.join(".opencode/skills")) + ); } #[test] - fn test_skills_dir_unknown_defaults() { - let root = PathBuf::from("/tmp/test-project"); + fn test_skills_dir_opencode_user() { + let home = PathBuf::from("/tmp/fake-home"); assert_eq!( - skills_dir("unknown-agent", &root), - root.join(".agents/skills") + skills_dir_with_home("opencode", Some(&home), &PathBuf::from("/unused"), true), + Some(home.join(".config/opencode/skills")) ); } #[test] - fn test_skills_dir_respects_existing() { - let tmp = std::env::temp_dir().join("pup-test-existing-skills"); - let existing = tmp.join(".cursor/skills"); - std::fs::create_dir_all(&existing).unwrap(); - assert_eq!(skills_dir("claude-code", &tmp), existing); - std::fs::remove_dir_all(&tmp).unwrap(); + fn test_skills_dir_claude_user() { + let home = PathBuf::from("/tmp/fake-home"); + assert_eq!( + skills_dir_with_home("claude-code", Some(&home), &PathBuf::from("/unused"), true), + Some(home.join(".claude/skills")) + ); } #[test] - fn test_agents_dir_claude_code() { + fn test_skills_dir_pi_returns_none() { let root = PathBuf::from("/tmp/test-project"); + assert_eq!(skills_dir_with_home("pi", None, &root, false), None); + } + + #[test] + fn test_skills_dir_unknown_returns_none() { + let root = PathBuf::from("/tmp/test-project"); + assert_eq!(skills_dir_with_home("nope", None, &root, false), None); + } + + #[test] + fn test_agents_dir_claude_code_project() { + let root = PathBuf::from("/tmp/test-project"); + assert_eq!( + agents_dir_with_home("claude-code", None, &root, false), + Some(root.join(".claude/agents")) + ); + } + + #[test] + fn test_agents_dir_claude_code_user() { + let home = PathBuf::from("/tmp/fake-home"); assert_eq!( - agents_dir("claude-code", &root), - root.join(".claude/agents") + agents_dir_with_home("claude-code", Some(&home), &PathBuf::from("/unused"), true), + Some(home.join(".claude/agents")) ); } #[test] - fn test_agents_dir_cursor_falls_back() { + fn test_agents_dir_cursor_falls_back_to_skills() { let root = PathBuf::from("/tmp/test-project"); - assert_eq!(agents_dir("cursor", &root), root.join(".cursor/skills")); + assert_eq!( + agents_dir_with_home("cursor", None, &root, false), + Some(root.join(".cursor/skills")) + ); } fn entry(name: &'static str, entry_type: &'static str, content: &'static str) -> SkillEntry { @@ -955,7 +1187,7 @@ mod tests { fn test_install_path_skill_claude_code() { let root = PathBuf::from("/tmp/test-project"); let e = entry("dd-pup", "skill", ""); - let (path, fmt) = install_path(&e, "claude-code", &root, None); + let (path, fmt) = install_path(&e, "claude-code", &root, None, false).unwrap(); assert_eq!(path, root.join(".claude/skills/dd-pup/SKILL.md")); assert_eq!(fmt, InstallFormat::SkillMd); } @@ -964,7 +1196,7 @@ mod tests { fn test_install_path_agent_claude_code() { let root = PathBuf::from("/tmp/test-project"); let e = entry("logs", "agent", ""); - let (path, fmt) = install_path(&e, "claude-code", &root, None); + let (path, fmt) = install_path(&e, "claude-code", &root, None, false).unwrap(); assert_eq!(path, root.join(".claude/agents/logs.md")); assert_eq!(fmt, InstallFormat::AgentMd); } @@ -973,20 +1205,36 @@ mod tests { fn test_install_path_agent_cursor_as_skill() { let root = PathBuf::from("/tmp/test-project"); let e = entry("logs", "agent", ""); - let (path, fmt) = install_path(&e, "cursor", &root, None); + let (path, fmt) = install_path(&e, "cursor", &root, None, false).unwrap(); assert_eq!(path, root.join(".cursor/skills/logs/SKILL.md")); assert_eq!(fmt, InstallFormat::SkillMd); } + #[test] + fn test_install_path_agent_codex_as_skill() { + let root = PathBuf::from("/tmp/test-project"); + let e = entry("logs", "agent", ""); + let (path, fmt) = install_path(&e, "codex", &root, None, false).unwrap(); + assert_eq!(path, root.join(".codex/skills/logs/SKILL.md")); + assert_eq!(fmt, InstallFormat::SkillMd); + } + #[test] fn test_install_path_dir_override() { let root = PathBuf::from("/tmp/test-project"); let e = entry("logs", "agent", ""); - let (path, fmt) = install_path(&e, "claude-code", &root, Some("/tmp/out")); + let (path, fmt) = install_path(&e, "claude-code", &root, Some("/tmp/out"), false).unwrap(); assert_eq!(path, PathBuf::from("/tmp/out/logs/SKILL.md")); assert_eq!(fmt, InstallFormat::SkillMd); } + #[test] + fn test_install_path_skill_on_pi_returns_none() { + let root = PathBuf::from("/tmp/test-project"); + let e = entry("dd-pup", "skill", ""); + assert!(install_path(&e, "pi", &root, None, false).is_none()); + } + #[test] fn test_format_as_skill_md_adds_name() { let e = SkillEntry { @@ -1021,26 +1269,145 @@ mod tests { assert!(result.contains("# No Frontmatter")); } - // ---- extension helpers --------------------------------------------------- + // ---- platform resolution ------------------------------------------------- + + #[test] + fn test_lookup_platform_canonical() { + assert_eq!( + lookup_platform("claude-code").map(|p| p.name), + Some("claude-code") + ); + assert_eq!(lookup_platform("codex").map(|p| p.name), Some("codex")); + assert_eq!( + lookup_platform("opencode").map(|p| p.name), + Some("opencode") + ); + assert_eq!(lookup_platform("pi").map(|p| p.name), Some("pi")); + } + + #[test] + fn test_lookup_platform_aliases() { + assert_eq!( + lookup_platform("claude").map(|p| p.name), + Some("claude-code") + ); + assert_eq!(lookup_platform("pi-dev").map(|p| p.name), Some("pi")); + assert_eq!( + lookup_platform("gemini").map(|p| p.name), + Some("gemini-code") + ); + } + + #[test] + fn test_lookup_platform_unknown() { + assert!(lookup_platform("nope").is_none()); + assert!(lookup_platform("").is_none()); + } + + #[test] + fn test_resolve_platform_name_canonical_passthrough() { + assert_eq!(resolve_platform_name(Some("cursor")), "cursor"); + } #[test] - fn test_resolve_platform_explicit_wins() { - assert_eq!(resolve_platform(Some("pi"), "claude-code"), "pi"); - assert_eq!(resolve_platform(Some("pi"), ""), "pi"); + fn test_resolve_platform_name_alias_normalizes() { + assert_eq!(resolve_platform_name(Some("claude")), "claude-code"); + assert_eq!(resolve_platform_name(Some("pi-dev")), "pi"); } #[test] - fn test_resolve_platform_from_agent() { - assert_eq!(resolve_platform(None, "pi"), "pi"); - assert_eq!(resolve_platform(None, "pi-dev"), "pi"); + fn test_resolve_platform_name_unknown_passthrough() { + assert_eq!(resolve_platform_name(Some("nope")), "nope"); } #[test] - fn test_resolve_platform_empty_for_unknown() { - assert_eq!(resolve_platform(None, "claude-code"), ""); - assert_eq!(resolve_platform(Some(""), "claude-code"), ""); + fn test_resolve_platform_list_all_expands() { + let list = resolve_platform_list(Some("all")); + let expected: Vec = PLATFORMS.iter().map(|p| p.name.to_string()).collect(); + assert_eq!(list, expected); } + #[test] + fn test_resolve_platform_list_all_case_insensitive() { + assert_eq!(resolve_platform_list(Some("ALL")).len(), PLATFORMS.len()); + } + + #[test] + fn platform_enum_matches_table() { + // Every non-`All` SkillsPlatform variant must canonicalize to a real + // entry in PLATFORMS, and every PLATFORMS entry must be reachable + // from the enum. Failing this means the CLI accepts a value the + // runtime can't service, or the runtime supports a platform users + // can't select. + use clap::ValueEnum; + let table: std::collections::BTreeSet<&str> = PLATFORMS.iter().map(|p| p.name).collect(); + let mut from_enum = std::collections::BTreeSet::new(); + for variant in SkillsPlatform::value_variants() { + let canonical = variant.as_canonical(); + if canonical == "all" { + continue; + } + assert!( + lookup_platform(canonical).is_some(), + "SkillsPlatform::{variant:?} -> '{canonical}' not in PLATFORMS", + ); + from_enum.insert(canonical); + } + assert_eq!(table, from_enum, "PLATFORMS and SkillsPlatform diverge"); + } + + #[test] + fn platform_enum_aliases_match_table_aliases() { + // Aliases live in two places: `#[clap(alias = ...)]` on each variant + // (parsed by clap from the CLI) and `PlatformSpec.aliases` (used by + // `lookup_platform` at runtime). They must agree, or the CLI will + // accept a name the runtime can't resolve. + use clap::ValueEnum; + for variant in SkillsPlatform::value_variants() { + let canonical = variant.as_canonical(); + if canonical == "all" { + continue; + } + let spec = lookup_platform(canonical).expect("variant maps to a known platform"); + let pv = variant + .to_possible_value() + .expect("non-hidden value enum variant"); + let clap_aliases: std::collections::BTreeSet<&str> = pv + .get_name_and_aliases() + .filter(|a| *a != canonical) + .collect(); + let table_aliases: std::collections::BTreeSet<&str> = + spec.aliases.iter().copied().collect(); + assert_eq!( + clap_aliases, table_aliases, + "alias drift for SkillsPlatform::{variant:?}", + ); + } + } + + #[test] + fn platform_enum_all_does_not_resolve_to_a_spec() { + // `All` is a CLI quantifier, not a platform — it must not have a + // PLATFORMS row, and `as_canonical()` returns the sentinel "all" + // that `resolve_platform_list` expands. + assert_eq!(SkillsPlatform::All.as_canonical(), "all"); + assert!(lookup_platform("all").is_none()); + } + + #[test] + fn test_resolve_platform_list_single() { + assert_eq!( + resolve_platform_list(Some("cursor")), + vec!["cursor".to_string()] + ); + assert_eq!( + resolve_platform_list(Some("claude")), + vec!["claude-code".to_string()] + ); + } + + // ---- extension helpers --------------------------------------------------- + #[test] fn test_extensions_dir_pi_project_scope() { let root = PathBuf::from("/tmp/proj"); @@ -1076,15 +1443,30 @@ mod tests { assert_eq!(extensions_dir("", &root, false), None); } + #[test] + fn test_extensions_dir_claude_returns_none() { + // Claude has no extensions concept. + let root = PathBuf::from("/tmp/proj"); + assert_eq!(extensions_dir("claude-code", &root, false), None); + } + #[test] fn test_install_paths_skill_single_file() { let root = PathBuf::from("/tmp/proj"); let e = entry("dd-pup", "skill", "body"); - let paths = install_paths(&e, "claude-code", "", &root, None, false).unwrap(); + let paths = install_paths(&e, "claude-code", &root, None, false).unwrap(); assert_eq!(paths.len(), 1); assert_eq!(paths[0].0, root.join(".claude/skills/dd-pup/SKILL.md")); } + #[test] + fn test_install_paths_skill_on_pi_is_empty() { + let root = PathBuf::from("/tmp/proj"); + let e = entry("dd-pup", "skill", "body"); + let paths = install_paths(&e, "pi", &root, None, false).unwrap(); + assert!(paths.is_empty()); + } + #[test] fn test_install_paths_extension_expands_files() { static FILES: &[(&str, &str)] = &[("index.ts", "// js"), ("package.json", "{}")]; @@ -1094,7 +1476,7 @@ mod tests { ..entry("dd-pup-pi", "extension", "") }; let root = PathBuf::from("/tmp/proj"); - let paths = install_paths(&e, "claude-code", "pi", &root, None, false).unwrap(); + let paths = install_paths(&e, "pi", &root, None, false).unwrap(); assert_eq!(paths.len(), 2); assert_eq!(paths[0].0, root.join(".pi/extensions/dd-pup-pi/index.ts")); assert_eq!(paths[0].1, "// js"); @@ -1105,44 +1487,30 @@ mod tests { } #[test] - fn test_install_paths_extension_dir_override() { + fn test_install_paths_extension_skipped_for_wrong_platform() { static FILES: &[(&str, &str)] = &[("index.ts", "// js")]; let e = SkillEntry { platform: "pi", files: FILES, ..entry("dd-pup-pi", "extension", "") }; - let paths = install_paths( - &e, - "claude-code", - "pi", - &PathBuf::from("/unused"), - Some("/tmp/out"), - false, - ) - .unwrap(); - assert_eq!(paths.len(), 1); - assert_eq!(paths[0].0, PathBuf::from("/tmp/out/dd-pup-pi/index.ts")); + let root = PathBuf::from("/tmp/proj"); + let paths = install_paths(&e, "claude-code", &root, None, false).unwrap(); + assert!(paths.is_empty(), "pi extension must not install on claude"); } #[test] - fn test_install_paths_extension_unknown_platform_errors() { + fn test_install_paths_extension_dir_override() { static FILES: &[(&str, &str)] = &[("index.ts", "// js")]; let e = SkillEntry { - platform: "bogus", + platform: "pi", files: FILES, - ..entry("dd-pup-bogus", "extension", "") + ..entry("dd-pup-pi", "extension", "") }; - let err = install_paths( - &e, - "claude-code", - "bogus", - &PathBuf::from("/tmp/proj"), - None, - false, - ) - .unwrap_err(); - assert!(err.to_string().contains("bogus")); + let paths = + install_paths(&e, "pi", &PathBuf::from("/unused"), Some("/tmp/out"), false).unwrap(); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].0, PathBuf::from("/tmp/out/dd-pup-pi/index.ts")); } #[test] diff --git a/src/test_support.rs b/src/test_support.rs index 7ff3fc4..17a7778 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -92,3 +92,30 @@ pub(crate) fn write_temp_json(name: &str, content: &str) -> std::path::PathBuf { std::fs::write(&path, content).unwrap(); path } + +/// Self-cleaning temporary directory for tests that need real filesystem +/// scratch space. Disambiguates with `subsec_nanos()` to survive parallel +/// test threads. Drops via [`Drop`] so callers don't have to remember cleanup. +pub(crate) struct TempDir(std::path::PathBuf); + +impl TempDir { + pub(crate) fn new(label: &str) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("pup_test_{label}_{nanos}")); + std::fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + + pub(crate) fn path(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +}