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