use anyhow::{Context, Result, anyhow};
use clap::Args;
use devboy_core::agents::{AgentSnapshot, InstallStatus, bundles, detect_all, pick_primary};
use devboy_skills::{
Agent, DEVBOY_PLUGIN, EmbeddedSkillSource, Environment, HistoricalHashes, InstallOptions,
InstallOutcome, InstallSpec, SkillSource, install_skills_to_target, is_claude_plugin_enabled,
is_codex_plugin_enabled, migrate_legacy_skills_at_target, resolve_targets,
scan_legacy_skills_at_target,
};
use std::io::{self, IsTerminal, Write};
#[derive(Debug, Args)]
pub struct OnboardArgs {
#[arg(long)]
pub agent: Option<String>,
#[arg(long, default_value = "dev")]
pub profile: String,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(long)]
pub dry_run: bool,
}
pub async fn handle(args: OnboardArgs) -> Result<()> {
let snapshots = detect_all();
let chosen = pick_agent(&args, &snapshots)?;
let bundle = bundles::load(&args.profile)?;
println!();
println!("Detected agents:");
print_agent_table(&snapshots, chosen.id);
println!();
println!(
"→ Setting up for {} with profile '{}' ({} skills):",
chosen.display_name,
bundle.name,
bundle.skills.len()
);
for s in &bundle.skills {
println!(" • {s}");
}
println!();
if !confirm(&args)? {
println!("Cancelled.");
return Ok(());
}
let Some(agent) = agent_id_to_skills_agent(chosen.id) else {
println!(
"⚠ install target for '{}' is not implemented yet.",
chosen.id
);
println!(" Bundle plan recorded above; manual install will land in a follow-up.");
return Ok(());
};
install_bundle(agent, &bundle.skills, args.dry_run).await
}
fn pick_agent<'a>(args: &OnboardArgs, snapshots: &'a [AgentSnapshot]) -> Result<&'a AgentSnapshot> {
if let Some(name) = &args.agent {
return snapshots
.iter()
.find(|s| s.id == name)
.ok_or_else(|| anyhow!("no agent named '{name}'. Try `devboy agents list`."));
}
if let Some(primary) = pick_primary(snapshots) {
return Ok(primary);
}
let mut installed: Vec<&AgentSnapshot> = snapshots
.iter()
.filter(|s| s.status == InstallStatus::Yes)
.collect();
installed.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
if installed.is_empty() {
return Err(anyhow!(
"no supported agents detected on this machine.\n \
Install Claude Code / Copilot CLI / Codex / Cursor / Kimi / Gemini and re-run, \
or pass `--agent <id>` to override detection."
));
}
let top: Vec<&str> = installed.iter().take(3).map(|s| s.id).collect();
Err(anyhow!(
"could not pick a primary agent automatically — top candidates: {}.\n \
Re-run with `--agent <id>` to choose one (e.g. `devboy onboard --agent {}`).",
top.join(", "),
top.first().copied().unwrap_or("claude"),
))
}
fn print_agent_table(snapshots: &[AgentSnapshot], chosen_id: &str) {
for s in snapshots {
println!("{}", agent_table_row(s, chosen_id));
}
}
fn agent_table_row(s: &AgentSnapshot, chosen_id: &str) -> String {
let glyph = match s.status {
InstallStatus::Yes => "✓",
InstallStatus::No => "✗",
InstallStatus::Unknown => "?",
};
let marker = if s.id == chosen_id { " ← chosen" } else { "" };
let sessions = s
.sessions
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into());
format!(
" {} {:<14} {:<22} sessions={:>5} score={:.3}{}",
glyph, s.id, s.display_name, sessions, s.score, marker
)
}
fn confirm(args: &OnboardArgs) -> Result<bool> {
if args.yes || args.dry_run {
return Ok(true);
}
if !io::stdin().is_terminal() {
return Err(anyhow!(
"non-interactive shell — re-run with --yes or --dry-run"
));
}
print!("Continue? [Y/n] ");
io::stdout().flush().ok();
let mut buf = String::new();
io::stdin().read_line(&mut buf)?;
let answer = buf.trim().to_lowercase();
Ok(answer.is_empty() || answer == "y" || answer == "yes")
}
fn agent_id_to_skills_agent(id: &str) -> Option<Agent> {
match id {
"claude" => Some(Agent::Claude),
"codex" => Some(Agent::Codex),
"cursor" => Some(Agent::Cursor),
"kimi" => Some(Agent::Kimi),
_ => None,
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct InstallSummary {
installed: u32,
unchanged: u32,
upgraded: u32,
skipped: u32,
}
fn dedup_against_plugin(agent: Agent, home: &std::path::Path) -> bool {
match agent {
Agent::Claude => is_claude_plugin_enabled(home, &DEVBOY_PLUGIN),
Agent::Codex => is_codex_plugin_enabled(home, &DEVBOY_PLUGIN),
_ => false,
}
}
fn run_legacy_sweep(target: &devboy_skills::InstallTarget, dry_run: bool) -> Result<()> {
let scan = scan_legacy_skills_at_target(target)
.with_context(|| format!("legacy scan failed for target {}", target.label))?;
if scan.is_empty() {
return Ok(());
}
let removed = migrate_legacy_skills_at_target(target, dry_run)
.with_context(|| format!("legacy migration failed for target {}", target.label))?;
if !removed.is_empty() {
println!(
" ↳ legacy sweep: removed {n} `devboy-*` duplicate(s){tag}",
n = removed.len(),
tag = if dry_run { " (dry-run)" } else { "" }
);
for r in &removed {
println!(" • {r}");
}
}
let orphans: Vec<_> = scan.iter().filter(|s| !s.canonical_present).collect();
if !orphans.is_empty() {
println!(
" ↳ legacy sweep: {n} `devboy-*` skill(s) without a canonical sibling — left in place:",
n = orphans.len()
);
for o in &orphans {
println!(
" • {legacy} (canonical `{canon}` not present)",
legacy = o.legacy_name,
canon = o.canonical_name
);
}
println!(" Remove manually if unwanted: `devboy skills remove <name>`.");
}
Ok(())
}
fn summarise_outcomes(report: &devboy_skills::InstallReport) -> InstallSummary {
let mut s = InstallSummary::default();
for outcome in report.outcomes.values() {
match outcome {
InstallOutcome::Installed | InstallOutcome::OverwrittenWithForce => s.installed += 1,
InstallOutcome::Unchanged => s.unchanged += 1,
InstallOutcome::Upgraded { .. } => s.upgraded += 1,
InstallOutcome::SkippedUserModified | InstallOutcome::SkippedUnknown => s.skipped += 1,
}
}
s
}
async fn install_bundle(agent: Agent, skill_names: &[String], dry_run: bool) -> Result<()> {
let env = Environment::detect().context("failed to detect environment")?;
if dedup_against_plugin(agent, &env.home) {
let dir = match agent {
Agent::Claude => ".claude",
Agent::Codex => ".codex",
_ => "",
};
println!(
" ⏭ {agent}: skipped — already provided by plugin {plugin_name}@{marketplace} ({dir}/settings.json)",
agent = agent.as_str(),
plugin_name = DEVBOY_PLUGIN.name,
marketplace = DEVBOY_PLUGIN.marketplace,
);
return Ok(());
}
let spec = InstallSpec {
agents: vec![agent],
..Default::default()
};
let targets = resolve_targets(&env, &spec).context("failed to resolve install targets")?;
let source = EmbeddedSkillSource::new();
let mut skills = Vec::with_capacity(skill_names.len());
let mut missing = Vec::new();
for name in skill_names {
match source.load(name).await {
Ok(s) => skills.push(s),
Err(_) => missing.push(name.clone()),
}
}
if !missing.is_empty() {
println!(
"⚠ {} skill(s) not in catalogue and will be skipped:",
missing.len()
);
for m in &missing {
println!(" • {m}");
}
}
if skills.is_empty() {
println!("Nothing to install.");
return Ok(());
}
let history =
HistoricalHashes::load_embedded().context("failed to load embedded skill history")?;
let options = InstallOptions {
force: false,
dry_run,
installed_from: Some(env!("CARGO_PKG_VERSION").to_string()),
};
println!();
for target in &targets {
println!("Installing {} skills to: {}", skills.len(), target.label);
let report = install_skills_to_target(target, &skills, &history, &options)
.with_context(|| format!("install failed for target {}", target.label))?;
let s = summarise_outcomes(&report);
println!(
" ✓ installed={i} unchanged={u} upgraded={up} skipped={sk}{tag}",
i = s.installed,
u = s.unchanged,
up = s.upgraded,
sk = s.skipped,
tag = if dry_run { " (dry-run)" } else { "" }
);
run_legacy_sweep(target, dry_run)?;
}
if !dry_run {
println!();
println!("Done. Run `devboy skills list` to verify.");
} else {
println!();
println!("Dry-run complete. Re-run without --dry-run to apply.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn snap(id: &'static str, score: f64, status: InstallStatus) -> AgentSnapshot {
AgentSnapshot {
id,
display_name: id,
status,
sessions: None,
last_used: None,
score,
paths_checked: vec![],
}
}
fn snap_used(
id: &'static str,
score: f64,
last_used: chrono::DateTime<chrono::Utc>,
) -> AgentSnapshot {
AgentSnapshot {
id,
display_name: id,
status: InstallStatus::Yes,
sessions: Some(10),
last_used: Some(last_used),
score,
paths_checked: vec![],
}
}
fn args(agent: Option<&str>) -> OnboardArgs {
OnboardArgs {
agent: agent.map(|s| s.to_string()),
profile: "dev".to_string(),
yes: true,
dry_run: true,
}
}
#[test]
fn explicit_agent_override_picks_named_snapshot() {
let snaps = vec![
snap("claude", 1.0, InstallStatus::Yes),
snap("codex", 0.5, InstallStatus::Yes),
];
let chosen = pick_agent(&args(Some("codex")), &snaps).unwrap();
assert_eq!(chosen.id, "codex");
}
#[test]
fn explicit_agent_override_returns_error_for_unknown_id() {
let snaps = vec![snap("claude", 1.0, InstallStatus::Yes)];
let err = pick_agent(&args(Some("nonexistent")), &snaps).unwrap_err();
assert!(err.to_string().contains("nonexistent"));
assert!(err.to_string().contains("agents list"));
}
#[test]
fn auto_picks_when_recency_dominates() {
let now = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
let snaps = vec![
snap_used("claude", 0.8, now),
snap_used("codex", 0.7, now - chrono::Duration::days(7)),
];
let chosen = pick_agent(&args(None), &snaps).unwrap();
assert_eq!(chosen.id, "claude");
}
#[test]
fn auto_returns_error_with_top_candidates_when_scores_are_close() {
let now = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
let snaps = vec![
snap_used("claude", 0.6, now),
snap_used("copilot", 0.55, now),
];
let err = pick_agent(&args(None), &snaps).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("top candidates"));
assert!(msg.contains("claude"));
assert!(msg.contains("--agent"));
}
#[test]
fn auto_returns_no_supported_agents_when_nothing_is_installed() {
let snaps = vec![
snap("claude", 0.0, InstallStatus::No),
snap("codex", 0.0, InstallStatus::No),
];
let err = pick_agent(&args(None), &snaps).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no supported agents detected"));
assert!(!msg.contains("top candidates"));
}
#[test]
fn agent_id_mapping_covers_supported_install_targets() {
assert!(matches!(
agent_id_to_skills_agent("claude"),
Some(Agent::Claude)
));
assert!(matches!(
agent_id_to_skills_agent("codex"),
Some(Agent::Codex)
));
assert!(matches!(
agent_id_to_skills_agent("cursor"),
Some(Agent::Cursor)
));
assert!(matches!(
agent_id_to_skills_agent("kimi"),
Some(Agent::Kimi)
));
}
#[test]
fn agent_id_mapping_returns_none_for_unsupported_agents() {
assert!(agent_id_to_skills_agent("copilot").is_none());
assert!(agent_id_to_skills_agent("gemini").is_none());
assert!(agent_id_to_skills_agent("antigravity").is_none());
assert!(agent_id_to_skills_agent("unknown").is_none());
}
#[test]
fn summarise_outcomes_buckets_each_variant() {
use devboy_skills::{InstallOutcome, InstallReport};
let mut report = InstallReport::default();
report
.outcomes
.insert("a".to_string(), InstallOutcome::Installed);
report
.outcomes
.insert("b".to_string(), InstallOutcome::Unchanged);
report.outcomes.insert(
"c".to_string(),
InstallOutcome::Upgraded { from_version: None },
);
report
.outcomes
.insert("d".to_string(), InstallOutcome::SkippedUserModified);
report
.outcomes
.insert("e".to_string(), InstallOutcome::SkippedUnknown);
report
.outcomes
.insert("f".to_string(), InstallOutcome::OverwrittenWithForce);
let s = summarise_outcomes(&report);
assert_eq!(s.installed, 2);
assert_eq!(s.unchanged, 1);
assert_eq!(s.upgraded, 1);
assert_eq!(s.skipped, 2);
}
#[test]
fn summarise_outcomes_handles_empty_report() {
let report = devboy_skills::InstallReport::default();
assert_eq!(summarise_outcomes(&report), InstallSummary::default());
}
#[test]
fn confirm_returns_true_with_yes_flag() {
let mut a = args(None);
a.yes = true;
a.dry_run = false;
assert!(confirm(&a).unwrap());
}
#[test]
fn confirm_returns_true_with_dry_run_flag() {
let mut a = args(None);
a.yes = false;
a.dry_run = true;
assert!(confirm(&a).unwrap());
}
#[test]
fn agent_table_row_marks_chosen_with_arrow() {
let s = AgentSnapshot {
id: "claude",
display_name: "Claude Code",
status: InstallStatus::Yes,
sessions: Some(42),
last_used: None,
score: 0.9,
paths_checked: vec![],
};
let row = agent_table_row(&s, "claude");
assert!(row.contains("← chosen"));
assert!(row.contains("✓"));
assert!(row.contains("claude"));
assert!(row.contains("42"));
}
#[test]
fn agent_table_row_omits_arrow_for_non_chosen() {
let s = AgentSnapshot {
id: "codex",
display_name: "Codex CLI",
status: InstallStatus::Yes,
sessions: None,
last_used: None,
score: 0.4,
paths_checked: vec![],
};
let row = agent_table_row(&s, "claude");
assert!(!row.contains("← chosen"));
assert!(row.contains("-")); }
#[test]
fn agent_table_row_glyph_per_status() {
for (status, expected) in [
(InstallStatus::Yes, "✓"),
(InstallStatus::No, "✗"),
(InstallStatus::Unknown, "?"),
] {
let s = AgentSnapshot {
id: "x",
display_name: "X",
status,
sessions: None,
last_used: None,
score: 0.0,
paths_checked: vec![],
};
let row = agent_table_row(&s, "");
assert!(
row.contains(expected),
"row {row:?} missing glyph {expected}"
);
}
}
}