Skip to main content

ito_core/installers/
mod.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3
4use chrono::Utc;
5
6use crate::errors::{CoreError, CoreResult};
7
8use markers::update_file_with_markers;
9
10mod markers;
11
12use ito_config::ConfigContext;
13use ito_config::ito_dir::get_ito_dir_name;
14use ito_templates::project_templates::WorktreeTemplateContext;
15
16/// Tool id for Claude Code.
17pub const TOOL_CLAUDE: &str = "claude";
18/// Tool id for Codex.
19pub const TOOL_CODEX: &str = "codex";
20/// Tool id for GitHub Copilot.
21pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
22/// Tool id for OpenCode.
23pub const TOOL_OPENCODE: &str = "opencode";
24
25/// Return the set of supported tool ids.
26pub fn available_tool_ids() -> &'static [&'static str] {
27    &[TOOL_CLAUDE, TOOL_CODEX, TOOL_GITHUB_COPILOT, TOOL_OPENCODE]
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31/// Options that control template installation behavior.
32pub struct InitOptions {
33    /// Selected tool ids.
34    pub tools: BTreeSet<String>,
35    /// Overwrite existing files when `true`.
36    pub force: bool,
37    /// When `true`, update managed files while preserving user-edited files.
38    ///
39    /// In this mode, non-marker files that already exist are silently skipped
40    /// instead of triggering an error. Marker-managed files still get their
41    /// managed blocks updated. Adapter files, skills, and commands are
42    /// overwritten as usual.
43    pub update: bool,
44}
45
46impl InitOptions {
47    /// Create new init options.
48    pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
49        Self {
50            tools,
51            force,
52            update,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58/// Installation mode used by the installer.
59pub enum InstallMode {
60    /// Initial installation (`ito init`).
61    Init,
62    /// Update installation (`ito update`).
63    Update,
64}
65
66/// Install the default project templates and selected tool adapters.
67///
68/// When `worktree_ctx` is `Some`, templates containing Jinja2 syntax will be
69/// rendered with the given worktree configuration. When `None`, a disabled
70/// default context is used.
71pub fn install_default_templates(
72    project_root: &Path,
73    ctx: &ConfigContext,
74    mode: InstallMode,
75    opts: &InitOptions,
76    worktree_ctx: Option<&WorktreeTemplateContext>,
77) -> CoreResult<()> {
78    let ito_dir_name = get_ito_dir_name(project_root, ctx);
79    let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
80
81    install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
82
83    // Repository-local ignore rules for per-worktree state.
84    // This is not a templated file: we update `.gitignore` directly to preserve existing content.
85    if mode == InstallMode::Init {
86        ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
87        ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
88        // Un-ignore audit event log so it is git-tracked even if .state/ is broadly ignored.
89        ensure_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
90    }
91
92    // Local (per-developer) config overlays should never be committed.
93    ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
94
95    install_adapter_files(project_root, mode, opts, worktree_ctx)?;
96    install_agent_templates(project_root, mode, opts)?;
97    Ok(())
98}
99
100fn ensure_repo_gitignore_ignores_local_configs(
101    project_root: &Path,
102    ito_dir: &str,
103) -> CoreResult<()> {
104    // Strategy/worktree settings are often personal preferences; users can keep
105    // them in a local overlay file.
106    let entry = format!("{ito_dir}/config.local.json");
107    ensure_gitignore_contains_line(project_root, &entry)?;
108
109    // Optional convention: keep local configs under `.local/`.
110    let entry = ".local/ito/config.json";
111    ensure_gitignore_contains_line(project_root, entry)?;
112    Ok(())
113}
114
115fn ensure_repo_gitignore_ignores_session_json(
116    project_root: &Path,
117    ito_dir: &str,
118) -> CoreResult<()> {
119    let entry = format!("{ito_dir}/session.json");
120    ensure_gitignore_contains_line(project_root, &entry)
121}
122
123/// Ensure `.ito/.state/audit/.session` is gitignored (per-worktree UUID).
124fn ensure_repo_gitignore_ignores_audit_session(
125    project_root: &Path,
126    ito_dir: &str,
127) -> CoreResult<()> {
128    let entry = format!("{ito_dir}/.state/audit/.session");
129    ensure_gitignore_contains_line(project_root, &entry)
130}
131
132/// Un-ignore the audit events directory so `events.jsonl` is git-tracked.
133///
134/// If `.ito/.state/` is broadly gitignored (e.g., by a user rule or template),
135/// we add `!.ito/.state/audit/` to override the ignore and ensure the audit
136/// event log is committed alongside other project artifacts.
137fn ensure_repo_gitignore_unignores_audit_events(
138    project_root: &Path,
139    ito_dir: &str,
140) -> CoreResult<()> {
141    let entry = format!("!{ito_dir}/.state/audit/");
142    ensure_gitignore_contains_line(project_root, &entry)
143}
144
145fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
146    let path = project_root.join(".gitignore");
147    let existing = match ito_common::io::read_to_string_std(&path) {
148        Ok(s) => Some(s),
149        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
150        Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
151    };
152
153    let Some(mut s) = existing else {
154        ito_common::io::write_std(&path, format!("{entry}\n"))
155            .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
156        return Ok(());
157    };
158
159    if gitignore_has_exact_line(&s, entry) {
160        return Ok(());
161    }
162
163    if !s.ends_with('\n') {
164        s.push('\n');
165    }
166    s.push_str(entry);
167    s.push('\n');
168
169    ito_common::io::write_std(&path, s)
170        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
171    Ok(())
172}
173
174fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
175    contents.lines().map(|l| l.trim()).any(|l| l == entry)
176}
177
178fn install_project_templates(
179    project_root: &Path,
180    ito_dir: &str,
181    mode: InstallMode,
182    opts: &InitOptions,
183    worktree_ctx: Option<&WorktreeTemplateContext>,
184) -> CoreResult<()> {
185    use ito_templates::project_templates::render_project_template;
186
187    let selected = &opts.tools;
188    let current_date = Utc::now().format("%Y-%m-%d").to_string();
189    let state_rel = format!("{ito_dir}/planning/STATE.md");
190    let project_md_rel = format!("{ito_dir}/project.md");
191    let config_json_rel = format!("{ito_dir}/config.json");
192    let default_ctx = WorktreeTemplateContext::default();
193    let ctx = worktree_ctx.unwrap_or(&default_ctx);
194
195    for f in ito_templates::default_project_files() {
196        let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
197        if !should_install_project_rel(rel.as_ref(), selected) {
198            continue;
199        }
200
201        let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
202        if rel.as_ref() == state_rel
203            && let Ok(s) = std::str::from_utf8(&bytes)
204        {
205            bytes = s.replace("__CURRENT_DATE__", &current_date).into_bytes();
206        }
207
208        // Render worktree-aware project templates (AGENTS.md) with worktree
209        // config. Only AGENTS.md uses Jinja2 for worktree rendering; other
210        // files (e.g., .ito/commands/) may contain `{{` as user-facing prompt
211        // placeholders that must NOT be processed by minijinja.
212        if rel.as_ref() == "AGENTS.md" {
213            bytes = render_project_template(&bytes, ctx).map_err(|e| {
214                CoreError::Validation(format!("Failed to render template {}: {}", rel.as_ref(), e))
215            })?;
216        }
217
218        // Preserve project-owned config/docs on `ito update`.
219        // These files are explicitly user-editable (e.g. `ito init` tells users
220        // to edit `.ito/project.md` and `.ito/config.json`), so `ito update`
221        // must not clobber them once they exist.
222        if mode == InstallMode::Update
223            && (rel.as_ref() == project_md_rel || rel.as_ref() == config_json_rel)
224            && project_root.join(rel.as_ref()).exists()
225        {
226            continue;
227        }
228
229        let target = project_root.join(rel.as_ref());
230        write_one(&target, &bytes, mode, opts)?;
231    }
232
233    Ok(())
234}
235
236fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
237    // Always install Ito project assets.
238    if rel == "AGENTS.md" {
239        return true;
240    }
241    if rel.starts_with(".ito/") {
242        return true;
243    }
244
245    // Tool-specific assets.
246    if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
247        return tools.contains(TOOL_CLAUDE);
248    }
249    if rel.starts_with(".opencode/") {
250        return tools.contains(TOOL_OPENCODE);
251    }
252    if rel.starts_with(".github/") {
253        return tools.contains(TOOL_GITHUB_COPILOT);
254    }
255    if rel.starts_with(".codex/") {
256        return tools.contains(TOOL_CODEX);
257    }
258
259    // Unknown/unclassified: only install when tools=all (caller controls via set contents).
260    false
261}
262
263fn write_one(
264    target: &Path,
265    rendered_bytes: &[u8],
266    mode: InstallMode,
267    opts: &InitOptions,
268) -> CoreResult<()> {
269    if let Some(parent) = target.parent() {
270        ito_common::io::create_dir_all_std(parent)
271            .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
272    }
273
274    // Marker-managed files: template contains markers; we extract the inner block.
275    if let Ok(text) = std::str::from_utf8(rendered_bytes)
276        && let Some(block) = ito_templates::extract_managed_block(text)
277    {
278        if target.exists() {
279            if mode == InstallMode::Init && !opts.force && !opts.update {
280                // If the file exists but doesn't contain Ito markers, mimic TS init behavior:
281                // refuse to overwrite without --force or --update.
282                let existing = ito_common::io::read_to_string_or_default(target);
283                let has_start = existing.contains(ito_templates::ITO_START_MARKER);
284                let has_end = existing.contains(ito_templates::ITO_END_MARKER);
285                if !(has_start && has_end) {
286                    return Err(CoreError::Validation(format!(
287                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
288                        target.display()
289                    )));
290                }
291            }
292
293            update_file_with_markers(
294                target,
295                block,
296                ito_templates::ITO_START_MARKER,
297                ito_templates::ITO_END_MARKER,
298            )
299            .map_err(|e| match e {
300                markers::FsEditError::Io(io_err) => {
301                    CoreError::io(format!("updating markers in {}", target.display()), io_err)
302                }
303                markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
304                    "Failed to update markers in {}: {}",
305                    target.display(),
306                    marker_err
307                )),
308            })?;
309        } else {
310            // New file: write the template bytes verbatim so output matches embedded assets.
311            ito_common::io::write_std(target, rendered_bytes)
312                .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
313        }
314
315        return Ok(());
316    }
317
318    // Non-marker-managed files: init refuses to overwrite unless --force.
319    // With --update, silently skip existing files to preserve user edits.
320    if mode == InstallMode::Init && target.exists() && !opts.force {
321        if opts.update {
322            return Ok(());
323        }
324        return Err(CoreError::Validation(format!(
325            "Refusing to overwrite existing file without markers: {} (re-run with --force)",
326            target.display()
327        )));
328    }
329
330    ito_common::io::write_std(target, rendered_bytes)
331        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
332    Ok(())
333}
334
335fn install_adapter_files(
336    project_root: &Path,
337    _mode: InstallMode,
338    opts: &InitOptions,
339    worktree_ctx: Option<&WorktreeTemplateContext>,
340) -> CoreResult<()> {
341    for tool in &opts.tools {
342        match tool.as_str() {
343            TOOL_OPENCODE => {
344                let config_dir = project_root.join(".opencode");
345                let manifests = crate::distribution::opencode_manifests(&config_dir);
346                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
347            }
348            TOOL_CLAUDE => {
349                let manifests = crate::distribution::claude_manifests(project_root);
350                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
351            }
352            TOOL_CODEX => {
353                let manifests = crate::distribution::codex_manifests(project_root);
354                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
355            }
356            TOOL_GITHUB_COPILOT => {
357                let manifests = crate::distribution::github_manifests(project_root);
358                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
359            }
360            _ => {}
361        }
362    }
363
364    Ok(())
365}
366
367/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
368fn install_agent_templates(
369    project_root: &Path,
370    mode: InstallMode,
371    opts: &InitOptions,
372) -> CoreResult<()> {
373    use ito_templates::agents::{
374        AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
375    };
376
377    let configs = default_agent_configs();
378
379    // Map tool names to harnesses
380    let tool_harness_map = [
381        (TOOL_OPENCODE, Harness::OpenCode),
382        (TOOL_CLAUDE, Harness::ClaudeCode),
383        (TOOL_CODEX, Harness::Codex),
384        (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
385    ];
386
387    for (tool_id, harness) in tool_harness_map {
388        if !opts.tools.contains(tool_id) {
389            continue;
390        }
391
392        let agent_dir = project_root.join(harness.project_agent_path());
393
394        // Get agent template files for this harness
395        let files = get_agent_files(harness);
396
397        for (rel_path, contents) in files {
398            let target = agent_dir.join(rel_path);
399
400            // Parse the template and determine which tier it is
401            let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
402                Some(AgentTier::Quick)
403            } else if rel_path.contains("ito-general") || rel_path.contains("general") {
404                Some(AgentTier::General)
405            } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
406                Some(AgentTier::Thinking)
407            } else {
408                None
409            };
410
411            // Get config for this tier
412            let config = tier.and_then(|t| configs.get(&(harness, t)));
413
414            match mode {
415                InstallMode::Init => {
416                    if target.exists() {
417                        if opts.update {
418                            // --update: only update model field in existing agent files
419                            if let Some(cfg) = config {
420                                update_agent_model_field(&target, &cfg.model)?;
421                            }
422                            continue;
423                        }
424                        if !opts.force {
425                            // Default init: skip existing files
426                            continue;
427                        }
428                    }
429
430                    // Render full template
431                    let rendered = if let Some(cfg) = config {
432                        if let Ok(template_str) = std::str::from_utf8(contents) {
433                            render_agent_template(template_str, cfg).into_bytes()
434                        } else {
435                            contents.to_vec()
436                        }
437                    } else {
438                        contents.to_vec()
439                    };
440
441                    // Ensure parent directory exists
442                    if let Some(parent) = target.parent() {
443                        ito_common::io::create_dir_all_std(parent).map_err(|e| {
444                            CoreError::io(format!("creating directory {}", parent.display()), e)
445                        })?;
446                    }
447
448                    ito_common::io::write_std(&target, rendered)
449                        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
450                }
451                InstallMode::Update => {
452                    // During update: only update model in existing ito agent files
453                    if !target.exists() {
454                        // File doesn't exist, create it
455                        let rendered = if let Some(cfg) = config {
456                            if let Ok(template_str) = std::str::from_utf8(contents) {
457                                render_agent_template(template_str, cfg).into_bytes()
458                            } else {
459                                contents.to_vec()
460                            }
461                        } else {
462                            contents.to_vec()
463                        };
464
465                        if let Some(parent) = target.parent() {
466                            ito_common::io::create_dir_all_std(parent).map_err(|e| {
467                                CoreError::io(format!("creating directory {}", parent.display()), e)
468                            })?;
469                        }
470                        ito_common::io::write_std(&target, rendered).map_err(|e| {
471                            CoreError::io(format!("writing {}", target.display()), e)
472                        })?;
473                    } else if let Some(cfg) = config {
474                        // File exists, only update model field in frontmatter
475                        update_agent_model_field(&target, &cfg.model)?;
476                    }
477                }
478            }
479        }
480    }
481
482    Ok(())
483}
484
485/// Update only the model field in an existing agent file's frontmatter
486fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
487    let content = ito_common::io::read_to_string_or_default(path);
488
489    // Only update files with frontmatter
490    if !content.starts_with("---") {
491        return Ok(());
492    }
493
494    // Find frontmatter boundaries
495    let rest = &content[3..];
496    let Some(end_idx) = rest.find("\n---") else {
497        return Ok(());
498    };
499
500    let frontmatter = &rest[..end_idx];
501    let body = &rest[end_idx + 4..]; // Skip "\n---"
502
503    // Update model field in frontmatter using simple string replacement
504    let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
505
506    // Reconstruct file
507    let updated = format!("---{}\n---{}", updated_frontmatter, body);
508    ito_common::io::write_std(path, updated)
509        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
510
511    Ok(())
512}
513
514/// Update the model field in YAML frontmatter string
515fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
516    let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
517    let mut found = false;
518
519    for line in &mut lines {
520        if line.trim_start().starts_with("model:") {
521            *line = format!("model: \"{}\"", new_model);
522            found = true;
523            break;
524        }
525    }
526
527    // If no model field found, add it
528    if !found {
529        lines.push(format!("model: \"{}\"", new_model));
530    }
531
532    lines.join("\n")
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn gitignore_created_when_missing() {
541        let td = tempfile::tempdir().unwrap();
542        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
543        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
544        assert_eq!(s, ".ito/session.json\n");
545    }
546
547    #[test]
548    fn gitignore_noop_when_already_present() {
549        let td = tempfile::tempdir().unwrap();
550        std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
551        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
552        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
553        assert_eq!(s, ".ito/session.json\n");
554    }
555
556    #[test]
557    fn gitignore_does_not_duplicate_on_repeated_calls() {
558        let td = tempfile::tempdir().unwrap();
559        std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
560        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
561        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
562        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
563        assert_eq!(s, "node_modules\n.ito/session.json\n");
564    }
565
566    #[test]
567    fn gitignore_audit_session_added() {
568        let td = tempfile::tempdir().unwrap();
569        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
570        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
571        assert!(s.contains(".ito/.state/audit/.session"));
572    }
573
574    #[test]
575    fn gitignore_both_session_entries() {
576        let td = tempfile::tempdir().unwrap();
577        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
578        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
579        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
580        assert!(s.contains(".ito/session.json"));
581        assert!(s.contains(".ito/.state/audit/.session"));
582    }
583
584    #[test]
585    fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
586        let td = tempfile::tempdir().unwrap();
587        std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
588        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
589        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
590        assert_eq!(s, "node_modules\n.ito/session.json\n");
591    }
592
593    #[test]
594    fn gitignore_audit_events_unignored() {
595        let td = tempfile::tempdir().unwrap();
596        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
597        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
598        assert!(s.contains("!.ito/.state/audit/"));
599    }
600
601    #[test]
602    fn gitignore_full_audit_setup() {
603        let td = tempfile::tempdir().unwrap();
604        // Simulate a broad .state/ ignore
605        std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
606        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
607        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
608        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
609        assert!(s.contains(".ito/.state/audit/.session"));
610        assert!(s.contains("!.ito/.state/audit/"));
611    }
612}