Skip to main content

agent_runtime/commands/
install.rs

1use crate::install::{self, AppliedChange, InstallOptions, Mode};
2use crate::render::manifest::SourceRoot;
3use clap::Args;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7#[derive(Args, Debug)]
8pub struct InstallArgs {
9    /// Source root containing `manifests/`, `core/`, `targets/`, `build/`.
10    /// Defaults to the current working directory.
11    #[arg(long)]
12    pub source_root: Option<PathBuf>,
13    /// Product to install (`codex` or `claude`).
14    #[arg(long)]
15    pub product: String,
16    /// Absolute path of the runtime home (e.g. `~/.codex` / `~/.claude`).
17    /// Relative paths are rejected so install never writes outside an
18    /// explicitly-chosen directory.
19    #[arg(long)]
20    pub live_home: PathBuf,
21    /// Absolute path of the state home (where backups land).
22    #[arg(long)]
23    pub state_home: PathBuf,
24    /// Tag the backup-run root with a `tag-<name>` marker file so
25    /// `gc-backups` (Plan 04 Sprint 2 Task 2.4) can preserve it across
26    /// retention sweeps. Tag must be ASCII alphanumeric / `-` / `_`.
27    #[arg(long)]
28    pub tag: Option<String>,
29    /// Skip the optional `.private/link-map.overrides.yaml` overlay
30    /// merge. Default: merge if file exists.
31    #[arg(long, default_value_t = false)]
32    pub no_overlay: bool,
33    /// Override the overlay file location. When set, the conventional
34    /// `<source-root>/.private/link-map.overrides.yaml` is ignored.
35    #[arg(long, conflicts_with = "no_overlay")]
36    pub overlay_path: Option<PathBuf>,
37    /// Print the resolved plan; do not mutate the filesystem.
38    #[arg(long, conflicts_with = "apply")]
39    pub dry_run: bool,
40    /// Reconcile the runtime home to the plan. Idempotent on the
41    /// second run.
42    #[arg(long, conflicts_with = "dry_run")]
43    pub apply: bool,
44}
45
46pub fn run(args: InstallArgs) -> anyhow::Result<u8> {
47    if !args.live_home.is_absolute() {
48        // Open question Q1 default (resolved 2026-05-21): `--live-home`
49        // must be absolute. Relative paths exit non-zero with a usage
50        // error naming the flag so callers can fix their invocation.
51        anyhow::bail!(
52            "agent-runtime install: --live-home must be absolute (got: {}); pass an absolute path such as /tmp/claude-sandbox or $HOME/.claude",
53            args.live_home.display()
54        );
55    }
56    if !args.state_home.is_absolute() {
57        anyhow::bail!(
58            "agent-runtime install: --state-home must be absolute (got: {})",
59            args.state_home.display()
60        );
61    }
62    if let Some(tag) = args.tag.as_deref()
63        && !install::is_trusted_tag(tag)
64    {
65        // The executor re-validates as defense in depth; the CLI catches
66        // the same shape early so the error message can name the flag.
67        anyhow::bail!(
68            "agent-runtime install: --tag `{tag}` is not a trusted tag name (allowed: ASCII alphanumeric / `-` / `_`)"
69        );
70    }
71    if !args.dry_run && !args.apply {
72        anyhow::bail!("agent-runtime install: pass --dry-run or --apply");
73    }
74    let mode = if args.apply {
75        Mode::Apply
76    } else {
77        Mode::DryRun
78    };
79
80    let root = SourceRoot::from_arg_or_cwd(args.source_root.as_deref())?;
81    #[allow(clippy::disallowed_methods)]
82    let now = SystemTime::now();
83
84    let options = InstallOptions {
85        tag: args.tag.clone(),
86        overlay_enabled: !args.no_overlay,
87        overlay_path: args.overlay_path.clone(),
88    };
89
90    let outcome = install::run(
91        &args.product,
92        root.path(),
93        &args.live_home,
94        &args.state_home,
95        mode,
96        now,
97        &options,
98    )?;
99
100    if let Some(s) = outcome.overlay.as_ref() {
101        // Operator-visible notice that the overlay was consumed; matches
102        // the architecture-doc requirement that dry-run print the
103        // post-merge effective state, not just the inputs.
104        eprintln!(
105            "agent-runtime install: overlay merged (dropped={} replaced={} added={})",
106            s.dropped, s.replaced, s.added,
107        );
108    }
109
110    eprintln!(
111        "agent-runtime install: product={} mode={} actions={} changes={}",
112        outcome.plan.product,
113        if matches!(mode, Mode::Apply) {
114            "apply"
115        } else {
116            "dry-run"
117        },
118        outcome.plan.actions.len(),
119        outcome
120            .changes
121            .iter()
122            .filter(|c| !matches!(c, AppliedChange::NoOp { .. }))
123            .count(),
124    );
125
126    for change in &outcome.changes {
127        print_change(change);
128    }
129
130    Ok(0)
131}
132
133fn print_change(c: &AppliedChange) {
134    eprintln!("{}", format_change(c));
135}
136
137fn format_change(c: &AppliedChange) -> String {
138    match c {
139        AppliedChange::SymlinkCreated {
140            entry_id,
141            dest,
142            source,
143            link_mode,
144        } => format!(
145            "  + {} {} -> {} ({})",
146            link_mode.label(),
147            dest.display(),
148            source.display(),
149            entry_id
150        ),
151        AppliedChange::SymlinkReplaced {
152            entry_id,
153            dest,
154            source,
155            link_mode,
156        } => format!(
157            "  ~ {} {} -> {} (replaced; {})",
158            link_mode.label(),
159            dest.display(),
160            source.display(),
161            entry_id
162        ),
163        AppliedChange::FileBackedUpThenSymlinked {
164            entry_id,
165            dest,
166            source,
167            link_mode,
168            backup,
169        } => format!(
170            "  ! backup {} -> {}, then {} to {} ({})",
171            dest.display(),
172            backup.display(),
173            link_mode.label(),
174            source.display(),
175            entry_id
176        ),
177        AppliedChange::ManagedBlockApplied {
178            entry_id,
179            config_file,
180        } => format!(
181            "  ~ managed-block applied to {} ({})",
182            config_file.display(),
183            entry_id
184        ),
185        AppliedChange::NoOp { entry_id, dest } => {
186            format!("  = no-op {} ({})", dest.display(), entry_id)
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::install::plan::SymlinkLinkMode;
195    use pretty_assertions::assert_eq;
196    use std::path::PathBuf;
197
198    #[test]
199    fn format_change_names_directory_symlink_mode() {
200        let line = format_change(&AppliedChange::SymlinkCreated {
201            entry_id: "reporting.daily-brief".to_string(),
202            dest: PathBuf::from("/tmp/home/skills/reporting/daily-brief"),
203            source: PathBuf::from("/tmp/source/daily-brief"),
204            link_mode: SymlinkLinkMode::Directory,
205        });
206
207        assert_eq!(
208            line,
209            "  + directory symlink /tmp/home/skills/reporting/daily-brief -> /tmp/source/daily-brief (reporting.daily-brief)"
210        );
211    }
212
213    #[test]
214    fn format_change_names_recursive_file_symlink_mode() {
215        let line = format_change(&AppliedChange::SymlinkReplaced {
216            entry_id: "reporting.skills-tree".to_string(),
217            dest: PathBuf::from("/tmp/home/plugins/reporting/skills/foo/SKILL.md"),
218            source: PathBuf::from("/tmp/source/build/skills/foo/SKILL.md"),
219            link_mode: SymlinkLinkMode::RecursiveFile,
220        });
221
222        assert_eq!(
223            line,
224            "  ~ recursive file symlink /tmp/home/plugins/reporting/skills/foo/SKILL.md -> /tmp/source/build/skills/foo/SKILL.md (replaced; reporting.skills-tree)"
225        );
226    }
227}