From b333373f2a92625e4d014dd48b5d74cbb6b95fb5 Mon Sep 17 00:00:00 2001 From: samfert Date: Wed, 1 Jul 2026 23:42:06 -0300 Subject: [PATCH] feat(skills): add devin platform target Add Devin (Cognition's coding agent) as a supported platform for `pup skills install`. Devin reads skills from `.agents/skills/` (project-local) and `~/.agents/skills/` (user-global); like cursor and codex it uses `SKILL.md` files, not Claude-style `.md` subagents. - Register `devin` in PLATFORMS and add the SkillsPlatform::Devin variant (+ as_canonical arm), kept in sync by platform_enum_matches_table - Auto-detect Devin sessions via DEVIN_SESSION_ID. Because that variable is a per-session id rather than a truthy flag, detect it by presence of a non-empty value, without changing the existing truthy detector table - Surface `devin` in the `skills` --help text, the resolve error hints, the README Agent Skills section, and docs/COMMANDS.md - Add positive and negative unit tests: lookup, install paths for both scopes, SKILL.md (not subagent) format, and env detection Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 3 ++- docs/COMMANDS.md | 2 +- src/commands/skills.rs | 6 ++--- src/main.rs | 2 +- src/skills.rs | 50 ++++++++++++++++++++++++++++++++++++++++++ src/useragent.rs | 44 +++++++++++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2cb67900..253722e4 100644 --- a/README.md +++ b/README.md @@ -573,6 +573,7 @@ pup skills install cursor pup skills install codex pup skills install opencode pup skills install pi +pup skills install devin # Install for every supported platform at once pup skills install all @@ -589,7 +590,7 @@ pup skills list --type=agent pup skills install claude --name dd-monitors ``` -For Claude Code, skills install to `~/.claude/skills/` (or `.claude/skills/` with `--project`) and agents install to `~/.claude/agents/` (native subagent format). If the `CLAUDE_CONFIG_DIR` environment variable is set, user-scope installs go to `$CLAUDE_CONFIG_DIR/skills/` and `$CLAUDE_CONFIG_DIR/agents/` instead of `~/.claude/`. 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/`). +For Claude Code, skills install to `~/.claude/skills/` (or `.claude/skills/` with `--project`) and agents install to `~/.claude/agents/` (native subagent format). If the `CLAUDE_CONFIG_DIR` environment variable is set, user-scope installs go to `$CLAUDE_CONFIG_DIR/skills/` and `$CLAUDE_CONFIG_DIR/agents/` instead of `~/.claude/`. For Cursor, Codex, opencode, and Devin, everything installs as `SKILL.md` under that tool's skills directory (e.g. `~/.cursor/skills/`, `~/.codex/skills/`, `~/.config/opencode/skills/`, and Devin's `~/.agents/skills/` — or `.agents/skills/` with `--project`). Pup ships plugin manifest files for several AI coding assistants: diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 92f25396..b0f96277 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 (positional ``: claude/cursor/codex/opencode/windsurf/gemini/pi/all; `--name`, `--type`, `--project` for project-local scope) | src/commands/skills.rs | ✅ | +| skills | list, install, path (positional ``: claude/cursor/codex/opencode/windsurf/gemini/pi/devin/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/src/commands/skills.rs b/src/commands/skills.rs index b01a820f..6513a0f7 100644 --- a/src/commands/skills.rs +++ b/src/commands/skills.rs @@ -15,7 +15,7 @@ fn resolve_or_bail(input: Option<&str>) -> Result> { 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`." + cursor, codex, opencode, windsurf, gemini, pi, devin, or `all`." ); } for p in &platforms { @@ -24,12 +24,12 @@ fn resolve_or_bail(input: Option<&str>) -> Result> { bail!( "auto-detected '{p}' is not a supported platform. Specify \ one explicitly: claude, cursor, codex, opencode, windsurf, \ - gemini, pi, or `all`." + gemini, pi, devin, or `all`." ); } bail!( "unknown platform: '{p}'. Supported: claude, cursor, codex, \ - opencode, windsurf, gemini, pi, or `all`." + opencode, windsurf, gemini, pi, devin, or `all`." ); } } diff --git a/src/main.rs b/src/main.rs index a07ed17b..fa209b14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2411,7 +2411,7 @@ enum Commands { /// extension Multi-file bundle for a coding-agent platform (e.g. pi) /// /// PLATFORMS: - /// claude (or claude-code), cursor, codex, opencode, windsurf, gemini, pi + /// claude (or claude-code), cursor, codex, opencode, windsurf, gemini, pi, devin /// Pass `all` to install for every supported platform. /// If omitted, pup auto-detects the platform from the environment. /// diff --git a/src/skills.rs b/src/skills.rs index 0d2a52c2..be48f2f5 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -642,6 +642,17 @@ pub static PLATFORMS: &[PlatformSpec] = &[ user_extensions: ".pi/agent/extensions", uses_agent_md: false, }, + PlatformSpec { + name: "devin", + aliases: &[], + project_skills: ".agents/skills", + user_skills: ".agents/skills", + project_agents: "", + user_agents: "", + project_extensions: "", + user_extensions: "", + uses_agent_md: false, + }, ]; /// CLI-typed selector for `pup skills install ` and `pup skills @@ -666,6 +677,7 @@ pub enum SkillsPlatform { GeminiCode, #[clap(alias = "pi-dev")] Pi, + Devin, All, } @@ -683,6 +695,7 @@ impl SkillsPlatform { SkillsPlatform::Windsurf => "windsurf", SkillsPlatform::GeminiCode => "gemini-code", SkillsPlatform::Pi => "pi", + SkillsPlatform::Devin => "devin", SkillsPlatform::All => "all", } } @@ -1239,6 +1252,24 @@ mod tests { ); } + #[test] + fn test_skills_dir_devin_project_scope() { + let root = PathBuf::from("/tmp/test-project"); + assert_eq!( + skills_dir_with_home("devin", None, &root, false, None), + Some(root.join(".agents/skills")) + ); + } + + #[test] + fn test_skills_dir_devin_user_scope() { + let home = PathBuf::from("/tmp/fake-home"); + assert_eq!( + skills_dir_with_home("devin", Some(&home), &PathBuf::from("/unused"), true, None), + Some(home.join(".agents/skills")) + ); + } + #[test] fn test_skills_dir_unknown_returns_none() { let root = PathBuf::from("/tmp/test-project"); @@ -1422,6 +1453,16 @@ mod tests { assert_eq!(fmt, InstallFormat::SkillMd); } + #[test] + fn test_install_path_agent_devin_as_skill() { + let root = PathBuf::from("/tmp/test-project"); + let e = entry("logs", "agent", ""); + let (path, fmt) = install_path(&e, "devin", &root, None, false).unwrap(); + assert_eq!(path, root.join(".agents/skills/logs/SKILL.md")); + assert_ne!(path, root.join(".agents/skills/logs.md")); + assert_eq!(fmt, InstallFormat::SkillMd); + } + #[test] fn test_install_path_dir_override() { let root = PathBuf::from("/tmp/test-project"); @@ -1499,6 +1540,15 @@ mod tests { assert_eq!(lookup_platform("pi").map(|p| p.name), Some("pi")); } + #[test] + fn test_lookup_platform_devin() { + let spec = lookup_platform("devin").expect("devin must be a known platform"); + assert_eq!(spec.name, "devin"); + assert!(spec.aliases.is_empty()); + assert!(!spec.uses_agent_md); + assert!(!spec.is_extension_only()); + } + #[test] fn test_lookup_platform_aliases() { assert_eq!( diff --git a/src/useragent.rs b/src/useragent.rs index c6977446..6016e381 100644 --- a/src/useragent.rs +++ b/src/useragent.rs @@ -74,6 +74,10 @@ fn is_env_truthy(key: &str) -> bool { } } +fn is_env_present(key: &str) -> bool { + std::env::var(key).is_ok_and(|val| !val.is_empty()) +} + pub fn detect_agent_info() -> AgentInfo { for detector in AGENT_DETECTORS { for env_var in detector.env_vars { @@ -85,6 +89,12 @@ pub fn detect_agent_info() -> AgentInfo { } } } + if is_env_present("DEVIN_SESSION_ID") { + return AgentInfo { + name: "devin".to_string(), + detected: true, + }; + } AgentInfo { name: String::new(), detected: false, @@ -169,6 +179,7 @@ mod tests { } std::env::remove_var("FORCE_AGENT_MODE"); std::env::remove_var("PUP_AGENT_MODE"); + std::env::remove_var("DEVIN_SESSION_ID"); } #[test] @@ -186,6 +197,18 @@ mod tests { std::env::remove_var("__PUP_TEST_FALSE__"); } + #[test] + fn test_is_env_present() { + let _guard = ENV_LOCK.blocking_lock(); + std::env::set_var("__PUP_TEST_PRESENT__", "some-session-id"); + assert!(is_env_present("__PUP_TEST_PRESENT__")); + std::env::set_var("__PUP_TEST_PRESENT_EMPTY__", ""); + assert!(!is_env_present("__PUP_TEST_PRESENT_EMPTY__")); + assert!(!is_env_present("__PUP_TEST_PRESENT_MISSING__")); + std::env::remove_var("__PUP_TEST_PRESENT__"); + std::env::remove_var("__PUP_TEST_PRESENT_EMPTY__"); + } + #[test] fn test_user_agent_format() { let _guard = ENV_LOCK.blocking_lock(); @@ -374,6 +397,27 @@ mod tests { std::env::remove_var("PI_CODING_AGENT"); } + #[test] + fn test_detect_agent_info_devin() { + let _guard = ENV_LOCK.blocking_lock(); + clear_all_agent_vars(); + std::env::set_var("DEVIN_SESSION_ID", "devin-run-abc123"); + let info = detect_agent_info(); + assert!(info.detected); + assert_eq!(info.name, "devin"); + std::env::remove_var("DEVIN_SESSION_ID"); + } + + #[test] + fn test_detect_agent_info_devin_empty_not_detected() { + let _guard = ENV_LOCK.blocking_lock(); + clear_all_agent_vars(); + std::env::set_var("DEVIN_SESSION_ID", ""); + let info = detect_agent_info(); + assert!(!info.detected); + std::env::remove_var("DEVIN_SESSION_ID"); + } + #[test] fn test_detect_agent_info_generic_agent() { let _guard = ENV_LOCK.blocking_lock();