use std::path::{Path, PathBuf};
use crate::skills::install::{
agents_md_install, agents_md_state, agents_md_uninstall, file_copy_install, file_copy_state,
file_copy_uninstall, InstallOutcome, SkillState,
};
use crate::skills::{self, Skill};
use crate::{CliError, CliResult};
const ALL_AGENTS: [Agent; 5] = [
Agent::ClaudeCode,
Agent::Cursor,
Agent::Codex,
Agent::OpenCode,
Agent::Antigravity,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Agent {
ClaudeCode,
Cursor,
Codex,
OpenCode,
Antigravity,
}
impl Agent {
fn parse(s: &str) -> Result<Self, CliError> {
match s {
"claude-code" => Ok(Agent::ClaudeCode),
"cursor" => Ok(Agent::Cursor),
"codex" => Ok(Agent::Codex),
"opencode" => Ok(Agent::OpenCode),
"antigravity" => Ok(Agent::Antigravity),
other => Err(CliError::Other {
message: format!(
"unknown agent `{other}`; supported: claude-code, cursor, \
codex, opencode, antigravity (try --list-agents)"
),
exit_code: 2,
}),
}
}
fn slug(self) -> &'static str {
match self {
Agent::ClaudeCode => "claude-code",
Agent::Cursor => "cursor",
Agent::Codex => "codex",
Agent::OpenCode => "opencode",
Agent::Antigravity => "antigravity",
}
}
fn pretty(self) -> &'static str {
match self {
Agent::ClaudeCode => "Claude Code",
Agent::Cursor => "Cursor",
Agent::Codex => "Codex",
Agent::OpenCode => "OpenCode",
Agent::Antigravity => "Antigravity",
}
}
}
#[aristo::intent(
"The per-skill install progression and the `ok: N skill(s) installed \
for <slug>.` success line are identical at project scope and user \
scope, modulo the on-disk target path. The post-success scope-tip \
line legitimately differs: project scope prints a hint pointing \
at `--user`; user scope prints nothing (the user already chose \
the broader scope, so the cross-scope hint would be noise). A \
refactor that changes the install progression or success-summary \
wording for only one scope would break the symmetric core; a \
refactor that adds the project-scope tip to user scope would \
re-introduce the noise the asymmetry exists to avoid.",
verify = "neural",
id = "install_skills_scope_symmetry"
)]
pub(crate) fn install(
agent: Option<String>,
list_agents: bool,
user: bool,
update: bool,
check: bool,
hook_format: bool,
) -> CliResult<()> {
if hook_format {
return run_session_start_hook();
}
if list_agents {
emit_agent_list();
return Ok(());
}
if check {
return run_check(agent, user);
}
if update {
return match agent {
Some(name) => repin_agent(&name, user),
None => update_all_installed(user),
};
}
let agent = agent.ok_or_else(|| CliError::Other {
message: "missing --agent flag (try `aristo install-skills --list-agents`)".to_string(),
exit_code: 2,
})?;
let agent = Agent::parse(&agent)?;
let root = scope_root(user)?;
let scope_label = if user { "user-level" } else { "project-level" };
println!();
println!(
"→ Installing Aristo skills for {} ({}) …",
agent.pretty(),
scope_label
);
match agent {
Agent::ClaudeCode | Agent::Cursor | Agent::Antigravity => {
install_file_copy_agent(agent, &root)?;
}
Agent::Codex | Agent::OpenCode => {
install_agents_md_agent(agent, &root)?;
}
}
let count = skills::bundled().len();
println!();
println!(
"ok: {count} skill{plural} installed for {slug}.",
plural = if count == 1 { "" } else { "s" },
slug = agent.slug()
);
print_install_tip(agent, user);
Ok(())
}
#[aristo::intent(
"`install_skills` followed by `uninstall_skills` leaves the project's \
relevant on-disk state identical to before either ran (modulo files \
the user hand-modified). File-copy agents: the per-skill files we \
wrote are removed. AGENTS.md agents: the marker-delimited block is \
stripped; surrounding content preserved. Idempotent on \
uninstall-while-uninstalled.",
verify = "test",
id = "uninstall_skills_reverses_install"
)]
pub(crate) fn uninstall(agent: String, user: bool, force: bool) -> CliResult<()> {
let _ = force; let agent = Agent::parse(&agent)?;
let root = scope_root(user)?;
let scope_label = if user { "user-level" } else { "project-level" };
println!();
println!(
"→ Uninstalling Aristo skills for {} ({}) …",
agent.pretty(),
scope_label
);
let removed = match agent {
Agent::ClaudeCode | Agent::Cursor | Agent::Antigravity => {
uninstall_file_copy_agent(agent, &root)?
}
Agent::Codex | Agent::OpenCode => uninstall_agents_md_agent(agent, &root)?,
};
println!();
if removed > 0 {
println!(
"ok: {removed} skill{plural} removed for {slug}.",
plural = if removed == 1 { "" } else { "s" },
slug = agent.slug()
);
} else {
println!(
"ok: nothing to do (no Aristo skills installed for {}).",
agent.slug()
);
}
Ok(())
}
fn scope_root(user: bool) -> CliResult<PathBuf> {
if user {
dirs::home_dir().ok_or_else(|| CliError::Other {
message: "could not resolve user home directory for --user install".to_string(),
exit_code: 1,
})
} else {
Ok(std::env::current_dir()?)
}
}
fn install_file_copy_agent(agent: Agent, root: &std::path::Path) -> CliResult<()> {
for skill in skills::bundled() {
let target = file_copy_target(agent, root, skill);
let outcome = file_copy_install(&target, skill).map_err(CliError::Io)?;
let display = relative_display(root, &target);
match outcome {
InstallOutcome::Created => println!(" • Wrote: {display}"),
InstallOutcome::Updated => println!(" • Updated: {display}"),
InstallOutcome::Unchanged => println!(" • Unchanged: {display}"),
}
}
if matches!(agent, Agent::ClaudeCode) {
let settings_display = relative_display(root, &root.join(".claude").join("settings.json"));
let inserted = super::install_skills_hook::install_claude_hook(root)?;
if inserted {
println!(" • Wrote review-session hook to: {settings_display}");
} else {
println!(" • Review-session hook already present: {settings_display}");
}
let hook_inserted = super::install_skills_hook::install_session_start_hook(root)?;
if hook_inserted {
println!(" • Wrote skill-staleness SessionStart hook to: {settings_display}");
} else {
println!(" • Skill-staleness SessionStart hook already present: {settings_display}");
}
super::install_skills_hook::install_nudge_surface(root)?;
println!(" • Wrote nudge-engine hooks + statusLine to: {settings_display}");
}
Ok(())
}
fn install_agents_md_agent(agent: Agent, root: &std::path::Path) -> CliResult<()> {
let target = root.join("AGENTS.md");
let bundled = skills::bundled();
let refs: Vec<&Skill> = bundled.iter().collect();
let outcome = agents_md_install(&target, &refs).map_err(CliError::Io)?;
let display = relative_display(root, &target);
match outcome {
InstallOutcome::Created => println!(" • Created: {display}"),
InstallOutcome::Updated => {
println!(" • Updated: {display} (block replaced; user content preserved)")
}
InstallOutcome::Unchanged => println!(" • Unchanged: {display}"),
}
let _ = agent; Ok(())
}
fn uninstall_file_copy_agent(agent: Agent, root: &std::path::Path) -> CliResult<usize> {
let mut removed = 0;
for skill in skills::bundled() {
let target = file_copy_target(agent, root, skill);
if file_copy_uninstall(&target).map_err(CliError::Io)? {
println!(" • Removed: {}", relative_display(root, &target));
removed += 1;
}
}
if matches!(agent, Agent::ClaudeCode) {
let settings_display = relative_display(root, &root.join(".claude").join("settings.json"));
if super::install_skills_hook::uninstall_claude_hook(root)? {
println!(" • Removed review-session hook from: {settings_display}");
removed += 1;
}
if super::install_skills_hook::uninstall_session_start_hook(root)? {
println!(" • Removed skill-staleness SessionStart hook from: {settings_display}");
removed += 1;
}
super::install_skills_hook::uninstall_nudge_surface(root)?;
}
Ok(removed)
}
fn uninstall_agents_md_agent(_agent: Agent, root: &std::path::Path) -> CliResult<usize> {
let target = root.join("AGENTS.md");
if agents_md_uninstall(&target).map_err(CliError::Io)? {
println!(
" • Stripped Aristo block from: {}",
relative_display(root, &target)
);
Ok(1)
} else {
Ok(0)
}
}
fn file_copy_target(agent: Agent, root: &std::path::Path, skill: &Skill) -> PathBuf {
match agent {
Agent::ClaudeCode => root
.join(".claude")
.join("skills")
.join(skill.name)
.join("SKILL.md"),
Agent::Cursor => root
.join(".cursor")
.join("rules")
.join(format!("{}.mdc", skill.name)),
Agent::Antigravity => root
.join(".antigravity")
.join("skills")
.join(format!("{}.md", skill.name)),
Agent::Codex | Agent::OpenCode => unreachable!("AGENTS.md agents take a different path"),
}
}
fn relative_display(root: &std::path::Path, target: &std::path::Path) -> String {
target
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| target.display().to_string())
}
fn emit_agent_list() {
println!("Supported agents:");
println!(" • claude-code — file copy to .claude/skills/<skill>/SKILL.md");
println!(" • cursor — file copy to .cursor/rules/<skill>.mdc");
println!(" • codex — AGENTS.md section injection");
println!(" • opencode — AGENTS.md section injection (shares block with codex)");
println!(" • antigravity — file copy to .antigravity/skills/<skill>.md (format TBD)");
println!();
println!("Default install scope: project-level. Pass --user to install at user level");
println!("(skills available across all projects on this machine).");
}
fn print_install_tip(agent: Agent, user: bool) {
if user {
return;
}
let (dir, user_dir) = match agent {
Agent::ClaudeCode => (".claude/", "~/.claude/skills/"),
Agent::Cursor => (".cursor/", "~/.cursor/rules/"),
Agent::Codex | Agent::OpenCode => return,
Agent::Antigravity => (".antigravity/", "~/.antigravity/skills/"),
};
println!();
println!("Tip: commit {dir} to share skills with your team. To install globally");
println!("instead, pass --user (writes to {user_dir}).");
}
enum AuditState {
NotInstalled,
UpToDate,
Stale { installed_version: Option<String> },
}
pub(crate) struct AuditRow {
pub(crate) agent_pretty: &'static str,
pub(crate) scope: &'static str,
pub(crate) summary: String,
pub(crate) stale: bool,
}
fn audit_agent(agent: Agent, root: &Path) -> AuditState {
let states: Vec<SkillState> = match agent {
Agent::ClaudeCode | Agent::Cursor | Agent::Antigravity => skills::bundled()
.iter()
.map(|s| file_copy_state(&file_copy_target(agent, root, s), s))
.collect(),
Agent::Codex | Agent::OpenCode => {
let refs: Vec<&Skill> = skills::bundled().iter().collect();
vec![agents_md_state(&root.join("AGENTS.md"), &refs)]
}
};
let mut present = false;
let mut stale_version: Option<Option<String>> = None;
for state in states {
match state {
SkillState::Missing => {}
SkillState::UpToDate => present = true,
SkillState::Stale { installed_version } => {
present = true;
stale_version.get_or_insert(installed_version);
}
}
}
match (present, stale_version) {
(false, _) => AuditState::NotInstalled,
(true, Some(v)) => AuditState::Stale {
installed_version: v,
},
(true, None) => AuditState::UpToDate,
}
}
pub(crate) fn audit_rows(project_root: Option<&Path>, user_root: Option<&Path>) -> Vec<AuditRow> {
let current = env!("CARGO_PKG_VERSION");
let mut rows = Vec::new();
for (scope, maybe_root) in [("project", project_root), ("user", user_root)] {
let Some(root) = maybe_root else { continue };
for agent in ALL_AGENTS {
match audit_agent(agent, root) {
AuditState::NotInstalled => {}
AuditState::UpToDate => rows.push(AuditRow {
agent_pretty: agent.pretty(),
scope,
summary: format!("v{current}"),
stale: false,
}),
AuditState::Stale { installed_version } => {
let from = installed_version.unwrap_or_else(|| "?".to_string());
rows.push(AuditRow {
agent_pretty: agent.pretty(),
scope,
summary: format!("v{from} (binary v{current})"),
stale: true,
});
}
}
}
}
rows
}
fn standard_audit() -> Vec<AuditRow> {
let cwd = std::env::current_dir().ok();
let home = dirs::home_dir();
audit_rows(cwd.as_deref(), home.as_deref())
}
pub(crate) fn stale_skills_notice() -> Option<String> {
standard_audit().iter().any(|r| r.stale).then(|| {
"\nSome installed aristo skills were generated by an older aristo.\n\
Refresh them with: aristo install-skills --update \
(run `aristo install-skills --check` for details)."
.to_string()
})
}
fn run_session_start_hook() -> CliResult<()> {
if let Some(context) = stale_skills_hook_context() {
let json = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": context,
}
});
println!("{json}");
}
Ok(())
}
fn stale_skills_hook_context() -> Option<String> {
let stale: Vec<AuditRow> = standard_audit().into_iter().filter(|r| r.stale).collect();
(!stale.is_empty()).then(|| build_hook_context(&stale))
}
fn build_hook_context(stale: &[AuditRow]) -> String {
let current = env!("CARGO_PKG_VERSION");
let mut list = String::new();
for r in stale {
list.push_str(&format!(
"\n - {} ({}): {}",
r.agent_pretty, r.scope, r.summary
));
}
format!(
"The Aristo skills installed in this workspace were generated by an older aristo than the \
installed CLI (v{current}), so they may not match its current commands and flags:{list}\n\n\
Tell the user their Aristo skills are out of date and offer to refresh them. If they agree, \
run `aristo install-skills --update` (it re-pins the installed skills to v{current}); the \
refreshed skills take effect on the next session or after `/reload-skills`. Nothing has been \
changed yet — this is a session-start heads-up."
)
}
fn run_check(agent: Option<String>, user: bool) -> CliResult<()> {
let project = if user {
None
} else {
std::env::current_dir().ok()
};
let user_root = dirs::home_dir();
let mut rows = audit_rows(project.as_deref(), user_root.as_deref());
if let Some(name) = &agent {
let want = Agent::parse(name)?;
rows.retain(|r| r.agent_pretty == want.pretty());
}
println!();
println!(
"Aristo skills — installed vs binary v{}:",
env!("CARGO_PKG_VERSION")
);
if rows.is_empty() {
let where_ = if user {
"user scope"
} else {
"this project or user scope"
};
println!(" (none installed in {where_})");
println!();
println!("Install with: aristo install-skills --agent <name>");
return Ok(());
}
for r in &rows {
let mark = if r.stale { "stale" } else { "ok" };
println!(
" [{mark:>5}] {:<13} {:<8} {}",
r.agent_pretty, r.scope, r.summary
);
}
let stale = rows.iter().filter(|r| r.stale).count();
println!();
if stale == 0 {
println!("ok: all installed skills are current.");
Ok(())
} else {
let plural = if stale == 1 { "" } else { "s" };
println!("{stale} installed skill set{plural} out of date. Re-pin with:");
println!(" aristo install-skills --update");
Err(CliError::Silent { exit_code: 1 })
}
}
fn repin_agent(agent: &str, user: bool) -> CliResult<()> {
let agent = Agent::parse(agent)?;
let root = scope_root(user)?;
println!();
println!(
"→ Re-pinning Aristo skills for {} ({}) …",
agent.pretty(),
scope_word(user)
);
match agent {
Agent::ClaudeCode | Agent::Cursor | Agent::Antigravity => {
install_file_copy_agent(agent, &root)?
}
Agent::Codex | Agent::OpenCode => install_agents_md_agent(agent, &root)?,
}
println!();
println!(
"ok: re-pinned {} skills to {}.",
agent.slug(),
env!("CARGO_PKG_VERSION")
);
Ok(())
}
fn update_all_installed(user_only: bool) -> CliResult<()> {
let mut scopes: Vec<(bool, PathBuf)> = Vec::new();
if user_only {
scopes.push((true, scope_root(true)?));
} else {
if let Ok(p) = scope_root(false) {
scopes.push((false, p));
}
if let Ok(h) = scope_root(true) {
scopes.push((true, h));
}
}
let healed = update_scopes(&scopes)?;
println!();
if healed > 0 {
println!(
"ok: re-pinned installed Aristo skills to {}.",
env!("CARGO_PKG_VERSION")
);
} else {
println!("ok: no Aristo skills installed — nothing to update.");
println!("Install with: aristo install-skills --agent <name>");
}
Ok(())
}
#[aristo::intent(
"Re-pins ONLY agents that already have skills installed under a scope — \
it never creates a fresh install for an absent agent. It heals existing \
state, it does not broadly install: a user who only set up Claude Code \
must not silently gain Cursor / Codex files from running an update.",
verify = "test",
id = "update_all_heals_only_installed_agents"
)]
fn update_scopes(scopes: &[(bool, PathBuf)]) -> CliResult<usize> {
let mut healed = 0usize;
for (user, root) in scopes {
for agent in ALL_AGENTS {
if matches!(audit_agent(agent, root), AuditState::NotInstalled) {
continue;
}
healed += 1;
println!();
println!("→ Re-pinning {} ({}) …", agent.pretty(), scope_word(*user));
match agent {
Agent::ClaudeCode | Agent::Cursor | Agent::Antigravity => {
install_file_copy_agent(agent, root)?
}
Agent::Codex | Agent::OpenCode => install_agents_md_agent(agent, root)?,
}
}
}
Ok(healed)
}
fn scope_word(user: bool) -> &'static str {
if user {
"user-level"
} else {
"project-level"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_parse_accepts_all_phase1_names() {
assert_eq!(Agent::parse("claude-code").unwrap(), Agent::ClaudeCode);
assert_eq!(Agent::parse("cursor").unwrap(), Agent::Cursor);
assert_eq!(Agent::parse("codex").unwrap(), Agent::Codex);
assert_eq!(Agent::parse("opencode").unwrap(), Agent::OpenCode);
assert_eq!(Agent::parse("antigravity").unwrap(), Agent::Antigravity);
}
#[test]
fn agent_parse_rejects_unknown_with_helpful_message() {
let err = Agent::parse("emacs").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("emacs"));
assert!(msg.contains("--list-agents"));
}
use crate::skills::install::file_copy_install;
fn install_claude(root: &std::path::Path) {
for s in skills::bundled() {
file_copy_install(&file_copy_target(Agent::ClaudeCode, root, s), s).unwrap();
}
}
#[test]
fn audit_agent_reports_not_installed_then_uptodate_then_stale() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
assert!(matches!(
audit_agent(Agent::ClaudeCode, root),
AuditState::NotInstalled
));
install_claude(root);
assert!(matches!(
audit_agent(Agent::ClaudeCode, root),
AuditState::UpToDate
));
let first = &skills::bundled()[0];
std::fs::write(
file_copy_target(Agent::ClaudeCode, root, first),
"---\nsdk_version: 0.0.1\n---\nold\n",
)
.unwrap();
match audit_agent(Agent::ClaudeCode, root) {
AuditState::Stale { installed_version } => {
assert_eq!(installed_version.as_deref(), Some("0.0.1"));
}
_ => panic!("expected Stale"),
}
}
#[test]
fn audit_rows_omits_uninstalled_agents() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
install_claude(root);
let rows = audit_rows(Some(root), None);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].agent_pretty, "Claude Code");
assert_eq!(rows[0].scope, "project");
assert!(!rows[0].stale);
}
#[test]
fn update_all_heals_only_installed_agents() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
install_claude(&root);
let first = &skills::bundled()[0];
std::fs::write(file_copy_target(Agent::ClaudeCode, &root, first), "stale").unwrap();
let healed = update_scopes(&[(false, root.clone())]).unwrap();
assert_eq!(healed, 1);
assert!(matches!(
audit_agent(Agent::ClaudeCode, &root),
AuditState::UpToDate
));
assert!(matches!(
audit_agent(Agent::Cursor, &root),
AuditState::NotInstalled
));
assert!(
!root.join(".cursor").exists(),
"cursor dir must not be created by an update"
);
assert!(
!root.join("AGENTS.md").exists(),
"codex/opencode block must not be created by an update"
);
}
#[test]
fn update_scopes_no_installs_heals_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let healed = update_scopes(&[(false, tmp.path().to_path_buf())]).unwrap();
assert_eq!(healed, 0);
}
#[test]
fn hook_context_instructs_agent_to_offer_refresh() {
let rows = vec![AuditRow {
agent_pretty: "Claude Code",
scope: "project",
summary: "v0.2.0 (binary v0.2.1)".to_string(),
stale: true,
}];
let ctx = build_hook_context(&rows);
assert!(ctx.contains("Claude Code"), "must name the stale agent");
assert!(
ctx.contains("aristo install-skills --update"),
"must tell the agent the exact refresh command"
);
assert!(
ctx.contains("offer to refresh"),
"must instruct the agent to offer, not silently act"
);
assert!(
ctx.contains("Nothing has been changed"),
"must make clear it's notify-only"
);
}
}