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