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 #[arg(long)]
12 pub source_root: Option<PathBuf>,
13 #[arg(long)]
15 pub product: String,
16 #[arg(long)]
20 pub live_home: PathBuf,
21 #[arg(long)]
23 pub state_home: PathBuf,
24 #[arg(long)]
28 pub tag: Option<String>,
29 #[arg(long, default_value_t = false)]
32 pub no_overlay: bool,
33 #[arg(long, conflicts_with = "no_overlay")]
36 pub overlay_path: Option<PathBuf>,
37 #[arg(long, conflicts_with = "apply")]
39 pub dry_run: bool,
40 #[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 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 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 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}