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