Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand 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:

Expand Down
2 changes: 1 addition & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ pup <domain> <subgroup> <action> [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 `<platform>`: claude/cursor/codex/opencode/windsurf/gemini/pi/all; `--name`, `--type`, `--project` for project-local scope) | src/commands/skills.rs | ✅ |
| skills | list, install, path (positional `<platform>`: 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 | ✅ |
Expand Down
6 changes: 3 additions & 3 deletions src/commands/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ fn resolve_or_bail(input: Option<&str>) -> Result<Vec<String>> {
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 {
Expand All @@ -24,12 +24,12 @@ fn resolve_or_bail(input: Option<&str>) -> Result<Vec<String>> {
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`."
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
50 changes: 50 additions & 0 deletions src/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <platform>` and `pup skills
Expand All @@ -666,6 +677,7 @@ pub enum SkillsPlatform {
GeminiCode,
#[clap(alias = "pi-dev")]
Pi,
Devin,
All,
}

Expand All @@ -683,6 +695,7 @@ impl SkillsPlatform {
SkillsPlatform::Windsurf => "windsurf",
SkillsPlatform::GeminiCode => "gemini-code",
SkillsPlatform::Pi => "pi",
SkillsPlatform::Devin => "devin",
SkillsPlatform::All => "all",
}
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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!(
Expand Down
44 changes: 44 additions & 0 deletions src/useragent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading