Skip to main content

ito_core/installers/
mod.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3
4use chrono::Utc;
5use serde_json::{Map, Value};
6
7use crate::errors::{CoreError, CoreResult};
8use agent_frontmatter::{
9    remove_agent_mode_field_for_direct_activation, update_agent_activation_field_from_rendered,
10    update_agent_model_field,
11};
12use agents_cleanup::remove_obsolete_specialist_agents;
13
14use markers::update_file_with_markers;
15
16mod agent_frontmatter;
17mod agents_cleanup;
18mod markers;
19mod project_guidance_cleanup;
20mod retired_cleanup;
21
22use ito_config::ConfigContext;
23use ito_config::ito_dir::get_ito_dir_name;
24use ito_templates::project_templates::WorktreeTemplateContext;
25
26/// Tool id for Claude Code.
27pub const TOOL_CLAUDE: &str = "claude";
28/// Tool id for Codex.
29pub const TOOL_CODEX: &str = "codex";
30/// Tool id for GitHub Copilot.
31pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
32/// Tool id for OpenCode.
33pub const TOOL_OPENCODE: &str = "opencode";
34/// Tool id for Pi.
35pub const TOOL_PI: &str = "pi";
36
37const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
38
39/// Return the set of supported tool ids.
40pub fn available_tool_ids() -> &'static [&'static str] {
41    &[
42        TOOL_CLAUDE,
43        TOOL_CODEX,
44        TOOL_GITHUB_COPILOT,
45        TOOL_OPENCODE,
46        TOOL_PI,
47    ]
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51/// Options that control template installation behavior.
52pub struct InitOptions {
53    /// Selected tool ids.
54    pub tools: BTreeSet<String>,
55    /// Overwrite existing files when `true`.
56    pub force: bool,
57    /// When `true`, update managed files while preserving user-edited files.
58    ///
59    /// In this mode, non-marker files that already exist are silently skipped
60    /// instead of triggering an error. Marker-managed files still get their
61    /// managed blocks updated. Adapter files, skills, and commands are
62    /// overwritten as usual.
63    pub update: bool,
64    /// When `true`, perform a marker-scoped upgrade of prompt/template assets.
65    ///
66    /// Only content between `<!-- ITO:START -->` and `<!-- ITO:END -->` markers
67    /// is replaced from the embedded templates. All content outside the managed
68    /// block is preserved exactly. When a marker-managed file is found to be
69    /// missing valid Ito markers the file is left unchanged and actionable
70    /// guidance is emitted rather than returning an error.
71    ///
72    /// `upgrade` implies `update` semantics (user-owned files are preserved).
73    pub upgrade: bool,
74}
75
76impl InitOptions {
77    /// Constructs an `InitOptions` configured for a standard (non-upgrade) installation.
78    ///
79    /// The returned value has `upgrade` set to `false`. The `force` flag controls whether
80    /// existing files may be overwritten, and `update` enables update semantics that merge
81    /// managed marker blocks instead of unconditional replacement.
82    ///
83    pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
84        Self {
85            tools,
86            force,
87            update,
88            upgrade: false,
89        }
90    }
91
92    /// Constructs `InitOptions` configured for upgrade mode.
93    ///
94    /// In upgrade mode the options enable update semantics and preserve user-owned
95    /// files. The `force` flag is disabled and `update` and `upgrade` are enabled,
96    /// so marker-managed files missing Ito markers are left unchanged with guidance
97    /// rather than causing an error.
98    ///
99    pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
100        Self {
101            tools,
102            force: false,
103            update: true,
104            upgrade: true,
105        }
106    }
107
108    /// Enable upgrade mode and its update semantics on this `InitOptions`.
109    ///
110    /// When upgrade is enabled it implies `update = true` and `force = false`; this
111    /// method sets all three fields so that `force` cannot override the non-destructive
112    /// marker-scoped upgrade behavior.
113    ///
114    pub fn with_upgrade(mut self) -> Self {
115        self.upgrade = true;
116        self.update = true;
117        self.force = false;
118        self
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123/// Installation mode used by the installer.
124pub enum InstallMode {
125    /// Initial installation (`ito init`).
126    Init,
127    /// Update installation (`ito update`).
128    Update,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132enum FileOwnership {
133    ItoManaged,
134    UserOwned,
135}
136
137/// Install the default project templates and selected tool adapters.
138///
139/// When `worktree_ctx` is `Some`, templates containing Jinja2 syntax will be
140/// rendered with the given worktree configuration. When `None`, a disabled
141/// default context is used.
142pub fn install_default_templates(
143    project_root: &Path,
144    ctx: &ConfigContext,
145    mode: InstallMode,
146    opts: &InitOptions,
147    worktree_ctx: Option<&WorktreeTemplateContext>,
148) -> CoreResult<()> {
149    let ito_dir_name = get_ito_dir_name(project_root, ctx);
150    let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
151
152    if mode == InstallMode::Update || opts.update || opts.upgrade || opts.force {
153        let report = retired_cleanup::cleanup_retired_surfaces(project_root, &opts.tools)?;
154        for removed in report.removed {
155            let replacement = removed.replacement.unwrap_or("no Ito replacement");
156            eprintln!(
157                "removed retired Ito surface {}; replacement: {replacement}",
158                removed.path.display()
159            );
160        }
161        for preserved in report.preserved {
162            let replacement = preserved.replacement.unwrap_or("no Ito replacement");
163            eprintln!(
164                "warning: preserving retired Ito surface {} because it contains user content outside the managed shell; replacement: {replacement}",
165                preserved.path.display()
166            );
167        }
168    }
169
170    install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
171
172    // The removed tmux skill occupied an Ito-owned skill directory in every
173    // harness. Update-style installs prune only those exact legacy paths;
174    // unrelated tmux configuration remains user-owned and untouched.
175    if mode == InstallMode::Update || opts.update || opts.force {
176        remove_obsolete_tmux_skills(project_root)?;
177    }
178
179    // Repository-local ignore rules for per-worktree state.
180    // This is not a templated file: we update `.gitignore` directly to preserve existing content.
181    if mode == InstallMode::Init {
182        ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
183        ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
184        remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
185    }
186
187    // Local (per-developer) config overlays should never be committed.
188    ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
189
190    install_adapter_files(project_root, mode, opts, worktree_ctx)?;
191    install_agent_templates(project_root, mode, opts)?;
192    Ok(())
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196/// A known legacy Ito-managed path found in a project.
197pub struct LegacyPathHit {
198    /// Path relative to the project root.
199    pub relative_path: String,
200    /// Human-readable reason this path is legacy.
201    pub description: &'static str,
202    /// Replacement path, when a current artifact supersedes the legacy path.
203    pub replacement: Option<&'static str>,
204}
205
206/// Detect known legacy Ito-managed paths under the project root.
207pub fn detect_legacy_paths(project_root: &Path) -> Vec<LegacyPathHit> {
208    let skill_roots = [
209        ".claude/skills",
210        ".opencode/skills",
211        ".codex/skills",
212        ".github/skills",
213        ".pi/skills",
214    ];
215    let mut hits = Vec::new();
216    for entry in ito_templates::legacy::LEGACY_ENTRIES {
217        if entry.old_path.starts_with('.') {
218            push_legacy_hit_if_exists(project_root, entry.old_path, entry, &mut hits);
219        } else {
220            for root in skill_roots {
221                let rel = format!("{root}/{}", entry.old_path);
222                push_legacy_hit_if_exists(project_root, &rel, entry, &mut hits);
223            }
224        }
225    }
226    hits.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
227    hits
228}
229
230/// Remove previously detected legacy Ito-managed paths from the project root.
231pub fn remove_legacy_paths(project_root: &Path, hits: &[LegacyPathHit]) -> CoreResult<()> {
232    for hit in hits {
233        let path = project_root.join(&hit.relative_path);
234        let metadata = match std::fs::symlink_metadata(&path) {
235            Ok(metadata) => metadata,
236            Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
237            Err(err) => {
238                return Err(CoreError::io(format!("reading {}", path.display()), err));
239            }
240        };
241        if metadata.is_dir() && !metadata.file_type().is_symlink() {
242            std::fs::remove_dir_all(&path)
243                .map_err(|e| CoreError::io(format!("removing {}", path.display()), e))?;
244        } else {
245            std::fs::remove_file(&path)
246                .map_err(|e| CoreError::io(format!("removing {}", path.display()), e))?;
247        }
248    }
249    Ok(())
250}
251
252fn push_legacy_hit_if_exists(
253    project_root: &Path,
254    rel: &str,
255    entry: &ito_templates::legacy::LegacyEntry,
256    hits: &mut Vec<LegacyPathHit>,
257) {
258    let normalized = rel.trim_end_matches('/');
259    if std::fs::symlink_metadata(project_root.join(normalized)).is_ok() {
260        hits.push(LegacyPathHit {
261            relative_path: normalized.to_string(),
262            description: entry.description,
263            replacement: entry.new_path,
264        });
265    }
266}
267
268fn remove_obsolete_tmux_skills(project_root: &Path) -> CoreResult<()> {
269    let hits: Vec<_> = detect_legacy_paths(project_root)
270        .into_iter()
271        .filter(|hit| hit.relative_path.ends_with("/ito-tmux"))
272        .collect();
273    remove_legacy_paths(project_root, &hits)
274}
275
276fn ensure_repo_gitignore_ignores_local_configs(
277    project_root: &Path,
278    ito_dir: &str,
279) -> CoreResult<()> {
280    // Strategy/worktree settings are often personal preferences; users can keep
281    // them in a local overlay file.
282    let entry = format!("{ito_dir}/config.local.json");
283    ensure_gitignore_contains_line(project_root, &entry)?;
284
285    // Optional convention: keep local configs under `.local/`.
286    let entry = ".local/ito/config.json";
287    ensure_gitignore_contains_line(project_root, entry)?;
288    Ok(())
289}
290
291fn ensure_repo_gitignore_ignores_session_json(
292    project_root: &Path,
293    ito_dir: &str,
294) -> CoreResult<()> {
295    let entry = format!("{ito_dir}/session.json");
296    ensure_gitignore_contains_line(project_root, &entry)
297}
298
299/// Ensure `.ito/.state/audit/.session` is gitignored (per-worktree UUID).
300fn ensure_repo_gitignore_ignores_audit_session(
301    project_root: &Path,
302    ito_dir: &str,
303) -> CoreResult<()> {
304    let entry = format!("{ito_dir}/.state/audit/.session");
305    ensure_gitignore_contains_line(project_root, &entry)
306}
307
308/// Remove the legacy audit events unignore so worktree audit logs stay untracked.
309fn remove_repo_gitignore_unignores_audit_events(
310    project_root: &Path,
311    ito_dir: &str,
312) -> CoreResult<()> {
313    let entry = format!("!{ito_dir}/.state/audit/");
314    remove_gitignore_exact_line(project_root, &entry)
315}
316
317fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
318    let path = project_root.join(".gitignore");
319    let existing = match ito_common::io::read_to_string_std(&path) {
320        Ok(s) => Some(s),
321        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
322        Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
323    };
324
325    let Some(mut s) = existing else {
326        ito_common::io::write_std(&path, format!("{entry}\n"))
327            .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
328        return Ok(());
329    };
330
331    if gitignore_has_exact_line(&s, entry) {
332        return Ok(());
333    }
334
335    if !s.ends_with('\n') {
336        s.push('\n');
337    }
338    s.push_str(entry);
339    s.push('\n');
340
341    ito_common::io::write_std(&path, s)
342        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
343    Ok(())
344}
345
346fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
347    let path = project_root.join(".gitignore");
348    let existing = match ito_common::io::read_to_string_std(&path) {
349        Ok(s) => s,
350        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
351        Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
352    };
353
354    let mut filtered = Vec::new();
355    let mut removed = false;
356    for line in existing.lines() {
357        if line.trim() == entry {
358            removed = true;
359            continue;
360        }
361        filtered.push(line);
362    }
363    if !removed {
364        return Ok(());
365    }
366
367    let mut updated = filtered.join("\n");
368    if !updated.is_empty() {
369        updated.push('\n');
370    }
371
372    ito_common::io::write_std(&path, updated)
373        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
374    Ok(())
375}
376
377fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
378    contents.lines().map(|l| l.trim()).any(|l| l == entry)
379}
380
381fn install_project_templates(
382    project_root: &Path,
383    ito_dir: &str,
384    mode: InstallMode,
385    opts: &InitOptions,
386    worktree_ctx: Option<&WorktreeTemplateContext>,
387) -> CoreResult<()> {
388    use ito_templates::project_templates::render_project_template;
389
390    let selected = &opts.tools;
391    let current_date = Utc::now().format("%Y-%m-%d").to_string();
392    let state_rel = format!("{ito_dir}/planning/STATE.md");
393    let config_json_rel = format!("{ito_dir}/config.json");
394    let release_tag = release_tag();
395    let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
396    let default_ctx = WorktreeTemplateContext::default();
397    let ctx = worktree_ctx.unwrap_or(&default_ctx);
398
399    for f in ito_templates::default_project_files() {
400        let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
401        let rel = rel.as_ref();
402
403        if !should_install_project_rel(rel, selected) {
404            continue;
405        }
406
407        let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
408        if let Ok(s) = std::str::from_utf8(&bytes) {
409            if rel == state_rel {
410                bytes = s.replace("__CURRENT_DATE__", &current_date).into_bytes();
411            } else if rel == config_json_rel {
412                bytes = s
413                    .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
414                    .into_bytes();
415            }
416        }
417
418        // Render worktree-aware project templates (AGENTS.md) with worktree
419        // config. Only AGENTS.md uses Jinja2 for worktree rendering; other
420        // files (e.g., .ito/commands/) may contain `{{` as user-facing prompt
421        // placeholders that must NOT be processed by minijinja.
422        if rel == "AGENTS.md" {
423            bytes = render_project_template(&bytes, ctx).map_err(|e| {
424                CoreError::Validation(format!("Failed to render template {rel}: {e}"))
425            })?;
426        }
427
428        // Stamp every managed-block markdown file with the current CLI version.
429        if rel.ends_with(".md")
430            && !rel.ends_with(".md.j2")
431            && let Ok(text) = std::str::from_utf8(&bytes)
432            && text.contains(ito_templates::ITO_START_MARKER)
433        {
434            bytes = ito_templates::stamp_version(text, semver).into_bytes();
435        }
436
437        let ownership = classify_project_file_ownership(rel, ito_dir);
438
439        let target = project_root.join(rel);
440        if rel == "AGENTS.md"
441            && (mode == InstallMode::Update || opts.update || opts.upgrade)
442            && project_guidance_cleanup::remove_retired_default_guidance(&target)?
443        {
444            eprintln!(
445                "removed retired Ito default project guidance from {}",
446                target.display()
447            );
448        }
449        if rel == ".claude/settings.json" {
450            write_claude_settings(&target, &bytes, mode, opts)?;
451            continue;
452        }
453        write_one(&target, &bytes, mode, opts, ownership)?;
454    }
455
456    Ok(())
457}
458
459fn release_tag() -> String {
460    let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
461    if version.starts_with('v') {
462        return version.to_string();
463    }
464
465    format!("v{version}")
466}
467
468fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
469    // Always install Ito project assets.
470    if rel == "AGENTS.md" {
471        return true;
472    }
473    if rel.starts_with(".ito/") {
474        return true;
475    }
476
477    // Tool-specific assets.
478    if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
479        return tools.contains(TOOL_CLAUDE);
480    }
481    if rel.starts_with(".opencode/") {
482        return tools.contains(TOOL_OPENCODE);
483    }
484    if rel.starts_with(".github/") {
485        return tools.contains(TOOL_GITHUB_COPILOT);
486    }
487    if rel.starts_with(".codex/") {
488        return tools.contains(TOOL_CODEX);
489    }
490    if rel.starts_with(".pi/") {
491        return tools.contains(TOOL_PI);
492    }
493
494    // Unknown/unclassified: only install when tools=all (caller controls via set contents).
495    false
496}
497
498fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
499    let project_md_rel = format!("{ito_dir}/project.md");
500    if rel == project_md_rel {
501        return FileOwnership::UserOwned;
502    }
503
504    let config_json_rel = format!("{ito_dir}/config.json");
505    if rel == config_json_rel {
506        return FileOwnership::UserOwned;
507    }
508
509    let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
510    if rel == user_guidance_rel {
511        return FileOwnership::UserOwned;
512    }
513
514    let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
515    if rel.starts_with(&user_prompts_prefix) {
516        return FileOwnership::UserOwned;
517    }
518
519    let wiki_prefix = format!("{ito_dir}/wiki/");
520    if rel.starts_with(&wiki_prefix) {
521        return FileOwnership::UserOwned;
522    }
523
524    FileOwnership::ItoManaged
525}
526
527/// Returns `true` when the rendered template's managed block spans the entire
528/// file — i.e. nothing meaningful sits outside the `<!-- ITO:START -->` /
529/// `<!-- ITO:END -->` markers. Such files have no user-editable region, so on
530/// update they can be rewritten wholesale to drop stale content from previous
531/// versions.
532///
533/// The rendered template bytes come from embedded assets that always place
534/// markers on their own lines, so a raw `find` is sufficient here.
535fn template_is_entirely_managed(text: &str) -> bool {
536    let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
537        return false;
538    };
539    let Some(end) = text.find(ito_templates::ITO_END_MARKER) else {
540        return false;
541    };
542    let before = text[..start].trim();
543    let after = text[end + ito_templates::ITO_END_MARKER.len()..].trim();
544    before.is_empty() && after.is_empty()
545}
546
547/// Returns `true` when the rendered template has non-trivial content sitting
548/// **before** its managed block (typically YAML frontmatter). Used by the
549/// first-marker migration path: when an existing on-disk file has no markers
550/// and the template has frontmatter + markers, the marker-prepend strategy
551/// would re-order the existing frontmatter and corrupt the file. In that case
552/// the safer migration is to write the rendered template wholesale.
553fn template_has_prefix_outside_markers(text: &str) -> bool {
554    let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
555        return false;
556    };
557    !text[..start].trim().is_empty()
558}
559
560/// Render an agent template and stamp the managed block (if any) with the
561/// current CLI version. Used by both `Init` and `Update` paths so newly
562/// written agent files always carry an `ITO:VERSION` line consistent with the
563/// installer.
564fn render_and_stamp_agent(
565    contents: &[u8],
566    config: Option<&ito_templates::agents::AgentConfig>,
567    target: &Path,
568) -> Vec<u8> {
569    let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
570    let rendered = match (std::str::from_utf8(contents), config) {
571        (Ok(template_str), Some(cfg)) => {
572            ito_templates::agents::render_agent_template(template_str, cfg).into_bytes()
573        }
574        _ => contents.to_vec(),
575    };
576    let path_str = target.to_string_lossy();
577    if !path_str.ends_with(".md") || path_str.ends_with(".md.j2") {
578        return rendered;
579    }
580    let Ok(text) = std::str::from_utf8(&rendered) else {
581        return rendered;
582    };
583    if !text.contains(ito_templates::ITO_START_MARKER) {
584        return rendered;
585    }
586    ito_templates::stamp_version(text, semver).into_bytes()
587}
588
589/// Write a rendered managed-block markdown file with marker-scoped update
590/// semantics, suitable for installer paths that do not need the full
591/// `write_one` ownership/upgrade machinery (e.g. the harness manifest
592/// installer in `distribution.rs`).
593///
594/// Behaviour:
595///
596/// - **No target on disk** → write `rendered_bytes` verbatim.
597/// - **`mode == Init` && `opts.force`** → wholesale overwrite (matches the
598///   `--force` semantics in `write_one`).
599/// - **Template has no managed block** → wholesale overwrite (caller wanted
600///   plain replacement).
601/// - **Existing target has no markers** → wholesale overwrite. Treats the
602///   file as legacy from before managed markers were retrofitted; no user
603///   content is at risk because there was no marker boundary to honour.
604/// - **Existing target has markers** → marker-scoped update via
605///   `update_file_with_markers`, preserving everything outside the managed
606///   block byte-for-byte.
607///
608/// Returns `Ok(())` on success. Errors mirror `write_one` (IO + marker
609/// validation diagnostics).
610pub(crate) fn write_marker_aware_markdown(
611    target: &Path,
612    rendered_bytes: &[u8],
613    mode: InstallMode,
614    opts: &InitOptions,
615) -> CoreResult<()> {
616    if let Some(parent) = target.parent() {
617        ito_common::io::create_dir_all_std(parent)
618            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
619    }
620
621    let wholesale = |target: &Path| -> CoreResult<()> {
622        ito_common::io::write_std(target, rendered_bytes)
623            .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
624    };
625
626    if !target.exists() {
627        return wholesale(target);
628    }
629
630    if mode == InstallMode::Init && opts.force {
631        return wholesale(target);
632    }
633
634    let Ok(text) = std::str::from_utf8(rendered_bytes) else {
635        return wholesale(target);
636    };
637    let Some(block) = ito_templates::extract_managed_block(text) else {
638        return wholesale(target);
639    };
640
641    let existing = ito_common::io::read_to_string_std(target)
642        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
643    let has_start = existing.contains(ito_templates::ITO_START_MARKER);
644    let has_end = existing.contains(ito_templates::ITO_END_MARKER);
645    match (has_start, has_end) {
646        (false, false) => return wholesale(target),
647        (true, true) => {}
648        (true, false) | (false, true) => {
649            // Partial marker pair indicates the user (or some other tool)
650            // damaged the managed region. Refusing to write here mirrors
651            // `update_file_with_markers`' error path and prevents silently
652            // clobbering user content. The user must restore the markers
653            // (or pass `--force`) before update can proceed.
654            return Err(CoreError::Validation(format!(
655                "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
656Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
657                target.display()
658            )));
659        }
660    }
661
662    let _ = update_file_with_markers(
663        target,
664        block,
665        ito_templates::ITO_START_MARKER,
666        ito_templates::ITO_END_MARKER,
667    )
668    .map_err(|e| match e {
669        markers::FsEditError::Io(io_err) => {
670            CoreError::io(format!("updating markers in {}", target.display()), io_err)
671        }
672        markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
673            "Failed to update markers in {}: {}",
674            target.display(),
675            marker_err
676        )),
677    })?;
678    Ok(())
679}
680
681/// Writes a rendered template to `target`, handling Ito-managed marker blocks, overwrite/update semantics,
682/// and ownership rules.
683///
684/// When the rendered template contains Ito start/end markers, this function treats the file as
685/// marker-managed: it will update only the managed block when the target exists (honoring `--force`,
686/// `--update`, and `--upgrade` semantics), or write the template verbatim when the target does not
687/// exist. For non-marker files, behavior depends on `mode`, `opts.force`, `opts.update`, and `ownership`:
688/// - On Init: `--force` overwrites; `--update` skips user-owned files; otherwise the function refuses
689///   to overwrite existing files without markers.
690/// - On Update: skips user-owned files; otherwise writes/overwrites the target.
691///
692/// Errors are returned for IO failures and for invalid marker states when an update is attempted
693/// (except when `opts.upgrade` is true, in which case a missing marker in an expected marker-managed
694/// file produces a warning and the existing file is preserved).
695fn write_one(
696    target: &Path,
697    rendered_bytes: &[u8],
698    mode: InstallMode,
699    opts: &InitOptions,
700    ownership: FileOwnership,
701) -> CoreResult<()> {
702    if let Some(parent) = target.parent() {
703        ito_common::io::create_dir_all_std(parent)
704            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
705    }
706
707    // Marker-managed files: template contains markers; we extract the inner block.
708    if let Ok(text) = std::str::from_utf8(rendered_bytes)
709        && let Some(block) = ito_templates::extract_managed_block(text)
710    {
711        if target.exists() {
712            // --force always overwrites the file wholesale on init.
713            if mode == InstallMode::Init && opts.force {
714                ito_common::io::write_std(target, rendered_bytes)
715                    .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
716                return Ok(());
717            }
718
719            // User-owned files keep their content untouched on update / non-forced
720            // init even when the template now ships managed markers. Updating the
721            // managed block here would clobber user edits to files Ito only seeds
722            // (e.g. .ito/project.md, .ito/user-prompts/*.md).
723            if ownership == FileOwnership::UserOwned {
724                let updating = mode == InstallMode::Update
725                    || (mode == InstallMode::Init && (opts.update || opts.upgrade));
726                if updating {
727                    return Ok(());
728                }
729            }
730
731            // Read the existing file once and check for both Ito markers.
732            let existing = ito_common::io::read_to_string_std(target)
733                .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
734            let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
735                && existing.contains(ito_templates::ITO_END_MARKER);
736
737            if !has_markers {
738                if opts.upgrade {
739                    // Upgrade fail-safe: when a file is expected to be marker-managed but no
740                    // longer contains valid Ito markers, preserve the file unchanged and emit
741                    // actionable guidance rather than returning an error.
742                    eprintln!(
743                        "warning: skipping upgrade of {} — Ito markers not found.\n\
744                        To restore managed upgrade support, re-add the markers manually:\n\
745                        \n\
746                        {start}\n\
747                        <ito-managed content>\n\
748                        {end}\n\
749                        \n\
750                        Then re-run `ito init --upgrade`.",
751                        target.display(),
752                        start = ito_templates::ITO_START_MARKER,
753                        end = ito_templates::ITO_END_MARKER,
754                    );
755                    return Ok(());
756                }
757
758                if mode == InstallMode::Init && !opts.update {
759                    // Plain init: refuse to overwrite without --force or --update.
760                    return Err(CoreError::Validation(format!(
761                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
762                        target.display()
763                    )));
764                }
765
766                // update / `init --update` against an Ito-managed file that
767                // predates marker rollout. Only safe to wholesale-rewrite when
768                // the existing file is genuinely marker-free (a partial
769                // marker pair indicates the user has manually edited the
770                // managed region and we should error rather than overwrite).
771                let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
772                    && !existing.contains(ito_templates::ITO_END_MARKER);
773                if existing_has_no_markers {
774                    // Decide between wholesale rewrite and marker-prepend
775                    // based on the template's shape:
776                    //
777                    // - If the template's managed block spans the entire
778                    //   file, there is no user-editable region; rewrite
779                    //   wholesale to drop stale content from the previous
780                    //   version.
781                    //
782                    // - If the template has a non-empty prefix (typically
783                    //   YAML frontmatter) above the managed block,
784                    //   marker-prepend would re-order the existing
785                    //   frontmatter and corrupt the file. Rewrite wholesale
786                    //   instead.
787                    //
788                    // - Otherwise (template has only markers, no surrounding
789                    //   content), fall through to `update_file_with_markers`
790                    //   which prepends the managed block to the existing
791                    //   file while preserving user content.
792                    if template_is_entirely_managed(text)
793                        || template_has_prefix_outside_markers(text)
794                    {
795                        ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
796                            CoreError::io(format!("writing {}", target.display()), e)
797                        })?;
798                        return Ok(());
799                    }
800                }
801            }
802
803            update_file_with_markers(
804                target,
805                block,
806                ito_templates::ITO_START_MARKER,
807                ito_templates::ITO_END_MARKER,
808            )
809            .map_err(|e| match e {
810                markers::FsEditError::Io(io_err) => {
811                    CoreError::io(format!("updating markers in {}", target.display()), io_err)
812                }
813                markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
814                    "Failed to update markers in {}: {}",
815                    target.display(),
816                    marker_err
817                )),
818            })?;
819        } else {
820            // New file: write the template bytes verbatim so output matches embedded assets.
821            ito_common::io::write_std(target, rendered_bytes)
822                .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
823        }
824
825        return Ok(());
826    }
827
828    if target.exists() {
829        match mode {
830            InstallMode::Init => {
831                if opts.force {
832                    // --force always overwrites on init.
833                } else if opts.update {
834                    if ownership == FileOwnership::UserOwned {
835                        return Ok(());
836                    }
837                } else {
838                    return Err(CoreError::Validation(format!(
839                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
840                        target.display()
841                    )));
842                }
843            }
844            InstallMode::Update => {
845                if ownership == FileOwnership::UserOwned {
846                    return Ok(());
847                }
848            }
849        }
850    }
851
852    ito_common::io::write_std(target, rendered_bytes)
853        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
854    Ok(())
855}
856
857fn write_claude_settings(
858    target: &Path,
859    rendered_bytes: &[u8],
860    mode: InstallMode,
861    opts: &InitOptions,
862) -> CoreResult<()> {
863    if let Some(parent) = target.parent() {
864        ito_common::io::create_dir_all_std(parent)
865            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
866    }
867
868    if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
869        return Err(CoreError::Validation(format!(
870            "Refusing to overwrite existing file without markers: {} (re-run with --force)",
871            target.display()
872        )));
873    }
874
875    let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
876        CoreError::Validation(format!(
877            "Failed to parse Claude settings template {}: {}",
878            target.display(),
879            e
880        ))
881    })?;
882
883    if !target.exists() || (mode == InstallMode::Init && opts.force) {
884        let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
885            CoreError::Validation(format!(
886                "Failed to render Claude settings template {}: {}",
887                target.display(),
888                e
889            ))
890        })?;
891        bytes.push(b'\n');
892        ito_common::io::write_std(target, bytes)
893            .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
894        return Ok(());
895    }
896
897    let existing_raw = ito_common::io::read_to_string_std(target)
898        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
899    let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
900        // Preserve user-owned files that are not valid JSON during update flows.
901        return Ok(());
902    };
903
904    merge_json_objects(&mut existing_value, &template_value);
905    let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
906        CoreError::Validation(format!(
907            "Failed to render merged Claude settings {}: {}",
908            target.display(),
909            e
910        ))
911    })?;
912    merged.push(b'\n');
913    ito_common::io::write_std(target, merged)
914        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
915    Ok(())
916}
917
918fn merge_json_objects(existing: &mut Value, template: &Value) {
919    let Value::Object(template_map) = template else {
920        *existing = template.clone();
921        return;
922    };
923    if !existing.is_object() {
924        *existing = Value::Object(Map::new());
925    }
926
927    let Some(existing_map) = existing.as_object_mut() else {
928        return;
929    };
930
931    for (key, template_value) in template_map {
932        if let Some(existing_value) = existing_map.get_mut(key) {
933            merge_json_values(existing_value, template_value);
934        } else {
935            existing_map.insert(key.clone(), template_value.clone());
936        }
937    }
938}
939
940fn merge_json_values(existing: &mut Value, template: &Value) {
941    match (existing, template) {
942        (Value::Object(existing_map), Value::Object(template_map)) => {
943            for (key, template_value) in template_map {
944                if let Some(existing_value) = existing_map.get_mut(key) {
945                    merge_json_values(existing_value, template_value);
946                } else {
947                    existing_map.insert(key.clone(), template_value.clone());
948                }
949            }
950        }
951        (Value::Array(existing_items), Value::Array(template_items)) => {
952            for template_item in template_items {
953                if !existing_items.contains(template_item) {
954                    existing_items.push(template_item.clone());
955                }
956            }
957        }
958        (existing_value, template_value) => *existing_value = template_value.clone(),
959    }
960}
961
962fn install_adapter_files(
963    project_root: &Path,
964    mode: InstallMode,
965    opts: &InitOptions,
966    worktree_ctx: Option<&WorktreeTemplateContext>,
967) -> CoreResult<()> {
968    for tool in &opts.tools {
969        match tool.as_str() {
970            TOOL_OPENCODE => {
971                let config_dir = project_root.join(".opencode");
972                let manifests = crate::distribution::opencode_manifests(&config_dir);
973                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
974            }
975            TOOL_CLAUDE => {
976                let manifests = crate::distribution::claude_manifests(project_root);
977                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
978            }
979            TOOL_CODEX => {
980                let manifests = crate::distribution::codex_manifests(project_root);
981                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
982            }
983            TOOL_GITHUB_COPILOT => {
984                let manifests = crate::distribution::github_manifests(project_root);
985                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
986            }
987            TOOL_PI => {
988                let manifests = crate::distribution::pi_manifests(project_root);
989                crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
990            }
991            _ => {}
992        }
993    }
994
995    Ok(())
996}
997
998/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
999fn install_agent_templates(
1000    project_root: &Path,
1001    mode: InstallMode,
1002    opts: &InitOptions,
1003) -> CoreResult<()> {
1004    use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
1005
1006    let configs = default_agent_configs();
1007
1008    // Map tool names to harnesses
1009    let tool_harness_map = [
1010        (TOOL_OPENCODE, Harness::OpenCode),
1011        (TOOL_CLAUDE, Harness::ClaudeCode),
1012        (TOOL_CODEX, Harness::Codex),
1013        (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
1014        (TOOL_PI, Harness::Pi),
1015    ];
1016
1017    for (tool_id, harness) in tool_harness_map {
1018        if !opts.tools.contains(tool_id) {
1019            continue;
1020        }
1021
1022        let Some(agent_path) = harness.project_agent_path() else {
1023            continue;
1024        };
1025        let agent_dir = project_root.join(agent_path);
1026        // Update-style installs and forceful re-inits should both clear the
1027        // legacy `ito-orchestrator-*` specialist assets before writing the new
1028        // `ito-*` names. Plain init keeps untouched user files in place.
1029        let should_remove_obsolete_specialists =
1030            mode == InstallMode::Update || opts.update || opts.force;
1031        if should_remove_obsolete_specialists {
1032            remove_obsolete_specialist_agents(&agent_dir)?;
1033        }
1034
1035        // Get agent template files for this harness
1036        let files = get_agent_files(harness);
1037
1038        for (rel_path, contents) in files {
1039            let target = agent_dir.join(rel_path);
1040
1041            // Parse the template and determine which tier it is
1042            let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
1043                Some(AgentTier::Quick)
1044            } else if rel_path.contains("ito-general") || rel_path.contains("general") {
1045                Some(AgentTier::General)
1046            } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
1047                Some(AgentTier::Thinking)
1048            } else {
1049                None
1050            };
1051
1052            // Get config for this tier.
1053            //
1054            // Some harness agent templates are not tiered but still include
1055            // `{{model}}` placeholders (e.g., coordinator agents). In that case,
1056            // render using the harness's General tier defaults.
1057            let mut config = tier.and_then(|t| configs.get(&(harness, t)));
1058            if config.is_none()
1059                && let Ok(s) = std::str::from_utf8(contents)
1060                && s.contains("{{model}}")
1061            {
1062                config = configs.get(&(harness, AgentTier::General));
1063            }
1064
1065            match mode {
1066                InstallMode::Init => {
1067                    if target.exists() && !opts.force {
1068                        if opts.update {
1069                            let rendered = render_and_stamp_agent(contents, config, &target);
1070                            update_existing_agent_template(&target, &rendered, mode, opts, config)?;
1071                        }
1072                        continue;
1073                    }
1074
1075                    let rendered = render_and_stamp_agent(contents, config, &target);
1076                    write_marker_aware_markdown(&target, &rendered, mode, opts)?;
1077                    normalize_agent_frontmatter(&target, &rendered, config)?;
1078                }
1079                InstallMode::Update => {
1080                    let rendered = render_and_stamp_agent(contents, config, &target);
1081                    if target.exists() {
1082                        update_existing_agent_template(&target, &rendered, mode, opts, config)?;
1083                    } else {
1084                        write_marker_aware_markdown(&target, &rendered, mode, opts)?;
1085                        normalize_agent_frontmatter(&target, &rendered, config)?;
1086                    }
1087                }
1088            }
1089        }
1090    }
1091
1092    Ok(())
1093}
1094/// Updates an existing agent template without clobbering user-owned bodies.
1095///
1096/// Files with complete Ito markers get their managed block refreshed. Legacy
1097/// markerless files keep their body and only receive frontmatter model updates.
1098/// Partial marker pairs are treated like damaged managed regions: preserve the
1099/// body for compatibility, but warn so the user can repair the file.
1100fn update_existing_agent_template(
1101    target: &Path,
1102    rendered: &[u8],
1103    mode: InstallMode,
1104    opts: &InitOptions,
1105    config: Option<&ito_templates::agents::AgentConfig>,
1106) -> CoreResult<()> {
1107    let existing = ito_common::io::read_to_string_std(target)
1108        .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
1109    let has_start = existing.contains(ito_templates::ITO_START_MARKER);
1110    let has_end = existing.contains(ito_templates::ITO_END_MARKER);
1111
1112    match (has_start, has_end) {
1113        (true, true) => write_marker_aware_markdown(target, rendered, mode, opts)?,
1114        (false, false) => {}
1115        (true, false) | (false, true) => {
1116            eprintln!(
1117                "warning: skipping marker update for {}: file has a partial Ito marker pair \
1118                (start={has_start}, end={has_end}). Restore both markers manually, or rerun \
1119                with `--force` to overwrite the file wholesale.",
1120                target.display()
1121            );
1122        }
1123    }
1124
1125    normalize_agent_frontmatter(target, rendered, config)
1126}
1127
1128fn normalize_agent_frontmatter(
1129    target: &Path,
1130    rendered: &[u8],
1131    config: Option<&ito_templates::agents::AgentConfig>,
1132) -> CoreResult<()> {
1133    if let Some(config) = config {
1134        update_agent_model_field(target, &config.model)?;
1135    }
1136    update_agent_activation_field_from_rendered(target, rendered)?;
1137    remove_agent_mode_field_for_direct_activation(target, rendered)
1138}
1139
1140#[cfg(test)]
1141mod json_tests;
1142
1143#[cfg(test)]
1144mod installers_tests;