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 {
280                ito_common::io::write_std(target, rendered_bytes)
281                    .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
282                return Ok(());
283            }
284
285            if mode == InstallMode::Init && !opts.force && !opts.update {
286                // If the file exists but doesn't contain Ito markers, mimic TS init behavior:
287                // refuse to overwrite without --force or --update.
288                let existing = ito_common::io::read_to_string_or_default(target);
289                let has_start = existing.contains(ito_templates::ITO_START_MARKER);
290                let has_end = existing.contains(ito_templates::ITO_END_MARKER);
291                if !(has_start && has_end) {
292                    return Err(CoreError::Validation(format!(
293                        "Refusing to overwrite existing file without markers: {} (re-run with --force)",
294                        target.display()
295                    )));
296                }
297            }
298
299            update_file_with_markers(
300                target,
301                block,
302                ito_templates::ITO_START_MARKER,
303                ito_templates::ITO_END_MARKER,
304            )
305            .map_err(|e| match e {
306                markers::FsEditError::Io(io_err) => {
307                    CoreError::io(format!("updating markers in {}", target.display()), io_err)
308                }
309                markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
310                    "Failed to update markers in {}: {}",
311                    target.display(),
312                    marker_err
313                )),
314            })?;
315        } else {
316            // New file: write the template bytes verbatim so output matches embedded assets.
317            ito_common::io::write_std(target, rendered_bytes)
318                .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
319        }
320
321        return Ok(());
322    }
323
324    // Non-marker-managed files: init refuses to overwrite unless --force.
325    // With --update, silently skip existing files to preserve user edits.
326    if mode == InstallMode::Init && target.exists() && !opts.force {
327        if opts.update {
328            return Ok(());
329        }
330        return Err(CoreError::Validation(format!(
331            "Refusing to overwrite existing file without markers: {} (re-run with --force)",
332            target.display()
333        )));
334    }
335
336    ito_common::io::write_std(target, rendered_bytes)
337        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
338    Ok(())
339}
340
341fn install_adapter_files(
342    project_root: &Path,
343    _mode: InstallMode,
344    opts: &InitOptions,
345    worktree_ctx: Option<&WorktreeTemplateContext>,
346) -> CoreResult<()> {
347    for tool in &opts.tools {
348        match tool.as_str() {
349            TOOL_OPENCODE => {
350                let config_dir = project_root.join(".opencode");
351                let manifests = crate::distribution::opencode_manifests(&config_dir);
352                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
353            }
354            TOOL_CLAUDE => {
355                let manifests = crate::distribution::claude_manifests(project_root);
356                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
357            }
358            TOOL_CODEX => {
359                let manifests = crate::distribution::codex_manifests(project_root);
360                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
361            }
362            TOOL_GITHUB_COPILOT => {
363                let manifests = crate::distribution::github_manifests(project_root);
364                crate::distribution::install_manifests(&manifests, worktree_ctx)?;
365            }
366            _ => {}
367        }
368    }
369
370    Ok(())
371}
372
373/// Install Ito agent templates (ito-quick, ito-general, ito-thinking)
374fn install_agent_templates(
375    project_root: &Path,
376    mode: InstallMode,
377    opts: &InitOptions,
378) -> CoreResult<()> {
379    use ito_templates::agents::{
380        AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
381    };
382
383    let configs = default_agent_configs();
384
385    // Map tool names to harnesses
386    let tool_harness_map = [
387        (TOOL_OPENCODE, Harness::OpenCode),
388        (TOOL_CLAUDE, Harness::ClaudeCode),
389        (TOOL_CODEX, Harness::Codex),
390        (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
391    ];
392
393    for (tool_id, harness) in tool_harness_map {
394        if !opts.tools.contains(tool_id) {
395            continue;
396        }
397
398        let agent_dir = project_root.join(harness.project_agent_path());
399
400        // Get agent template files for this harness
401        let files = get_agent_files(harness);
402
403        for (rel_path, contents) in files {
404            let target = agent_dir.join(rel_path);
405
406            // Parse the template and determine which tier it is
407            let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
408                Some(AgentTier::Quick)
409            } else if rel_path.contains("ito-general") || rel_path.contains("general") {
410                Some(AgentTier::General)
411            } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
412                Some(AgentTier::Thinking)
413            } else {
414                None
415            };
416
417            // Get config for this tier
418            let config = tier.and_then(|t| configs.get(&(harness, t)));
419
420            match mode {
421                InstallMode::Init => {
422                    if target.exists() {
423                        if opts.update {
424                            // --update: only update model field in existing agent files
425                            if let Some(cfg) = config {
426                                update_agent_model_field(&target, &cfg.model)?;
427                            }
428                            continue;
429                        }
430                        if !opts.force {
431                            // Default init: skip existing files
432                            continue;
433                        }
434                    }
435
436                    // Render full template
437                    let rendered = if let Some(cfg) = config {
438                        if let Ok(template_str) = std::str::from_utf8(contents) {
439                            render_agent_template(template_str, cfg).into_bytes()
440                        } else {
441                            contents.to_vec()
442                        }
443                    } else {
444                        contents.to_vec()
445                    };
446
447                    // Ensure parent directory exists
448                    if let Some(parent) = target.parent() {
449                        ito_common::io::create_dir_all_std(parent).map_err(|e| {
450                            CoreError::io(format!("creating directory {}", parent.display()), e)
451                        })?;
452                    }
453
454                    ito_common::io::write_std(&target, rendered)
455                        .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
456                }
457                InstallMode::Update => {
458                    // During update: only update model in existing ito agent files
459                    if !target.exists() {
460                        // File doesn't exist, create it
461                        let rendered = if let Some(cfg) = config {
462                            if let Ok(template_str) = std::str::from_utf8(contents) {
463                                render_agent_template(template_str, cfg).into_bytes()
464                            } else {
465                                contents.to_vec()
466                            }
467                        } else {
468                            contents.to_vec()
469                        };
470
471                        if let Some(parent) = target.parent() {
472                            ito_common::io::create_dir_all_std(parent).map_err(|e| {
473                                CoreError::io(format!("creating directory {}", parent.display()), e)
474                            })?;
475                        }
476                        ito_common::io::write_std(&target, rendered).map_err(|e| {
477                            CoreError::io(format!("writing {}", target.display()), e)
478                        })?;
479                    } else if let Some(cfg) = config {
480                        // File exists, only update model field in frontmatter
481                        update_agent_model_field(&target, &cfg.model)?;
482                    }
483                }
484            }
485        }
486    }
487
488    Ok(())
489}
490
491/// Update only the model field in an existing agent file's frontmatter
492fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
493    let content = ito_common::io::read_to_string_or_default(path);
494
495    // Only update files with frontmatter
496    if !content.starts_with("---") {
497        return Ok(());
498    }
499
500    // Find frontmatter boundaries
501    let rest = &content[3..];
502    let Some(end_idx) = rest.find("\n---") else {
503        return Ok(());
504    };
505
506    let frontmatter = &rest[..end_idx];
507    let body = &rest[end_idx + 4..]; // Skip "\n---"
508
509    // Update model field in frontmatter using simple string replacement
510    let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
511
512    // Reconstruct file
513    let updated = format!("---{}\n---{}", updated_frontmatter, body);
514    ito_common::io::write_std(path, updated)
515        .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
516
517    Ok(())
518}
519
520/// Update the model field in YAML frontmatter string
521fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
522    let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
523    let mut found = false;
524
525    for line in &mut lines {
526        if line.trim_start().starts_with("model:") {
527            *line = format!("model: \"{}\"", new_model);
528            found = true;
529            break;
530        }
531    }
532
533    // If no model field found, add it
534    if !found {
535        lines.push(format!("model: \"{}\"", new_model));
536    }
537
538    lines.join("\n")
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn gitignore_created_when_missing() {
547        let td = tempfile::tempdir().unwrap();
548        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
549        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
550        assert_eq!(s, ".ito/session.json\n");
551    }
552
553    #[test]
554    fn gitignore_noop_when_already_present() {
555        let td = tempfile::tempdir().unwrap();
556        std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
557        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
558        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
559        assert_eq!(s, ".ito/session.json\n");
560    }
561
562    #[test]
563    fn gitignore_does_not_duplicate_on_repeated_calls() {
564        let td = tempfile::tempdir().unwrap();
565        std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
566        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
567        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
568        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
569        assert_eq!(s, "node_modules\n.ito/session.json\n");
570    }
571
572    #[test]
573    fn gitignore_audit_session_added() {
574        let td = tempfile::tempdir().unwrap();
575        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
576        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
577        assert!(s.contains(".ito/.state/audit/.session"));
578    }
579
580    #[test]
581    fn gitignore_both_session_entries() {
582        let td = tempfile::tempdir().unwrap();
583        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
584        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
585        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
586        assert!(s.contains(".ito/session.json"));
587        assert!(s.contains(".ito/.state/audit/.session"));
588    }
589
590    #[test]
591    fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
592        let td = tempfile::tempdir().unwrap();
593        std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
594        ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
595        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
596        assert_eq!(s, "node_modules\n.ito/session.json\n");
597    }
598
599    #[test]
600    fn gitignore_audit_events_unignored() {
601        let td = tempfile::tempdir().unwrap();
602        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
603        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
604        assert!(s.contains("!.ito/.state/audit/"));
605    }
606
607    #[test]
608    fn gitignore_full_audit_setup() {
609        let td = tempfile::tempdir().unwrap();
610        // Simulate a broad .state/ ignore
611        std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
612        ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
613        ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
614        let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
615        assert!(s.contains(".ito/.state/audit/.session"));
616        assert!(s.contains("!.ito/.state/audit/"));
617    }
618}