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
17pub const TOOL_CLAUDE: &str = "claude";
19pub const TOOL_CODEX: &str = "codex";
21pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
23pub const TOOL_OPENCODE: &str = "opencode";
25pub const TOOL_PI: &str = "pi";
27
28const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
29
30pub fn available_tool_ids() -> &'static [&'static str] {
32 &[
33 TOOL_CLAUDE,
34 TOOL_CODEX,
35 TOOL_GITHUB_COPILOT,
36 TOOL_OPENCODE,
37 TOOL_PI,
38 ]
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct InitOptions {
44 pub tools: BTreeSet<String>,
46 pub force: bool,
48 pub update: bool,
55 pub upgrade: bool,
65}
66
67impl InitOptions {
68 pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
75 Self {
76 tools,
77 force,
78 update,
79 upgrade: false,
80 }
81 }
82
83 pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
91 Self {
92 tools,
93 force: false,
94 update: true,
95 upgrade: true,
96 }
97 }
98
99 pub fn with_upgrade(mut self) -> Self {
106 self.upgrade = true;
107 self.update = true;
108 self.force = false;
109 self
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum InstallMode {
116 Init,
118 Update,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123enum FileOwnership {
124 ItoManaged,
125 UserOwned,
126}
127
128pub fn install_default_templates(
134 project_root: &Path,
135 ctx: &ConfigContext,
136 mode: InstallMode,
137 opts: &InitOptions,
138 worktree_ctx: Option<&WorktreeTemplateContext>,
139) -> CoreResult<()> {
140 let ito_dir_name = get_ito_dir_name(project_root, ctx);
141 let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
142
143 install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
144
145 if mode == InstallMode::Init {
148 ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
149 ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
150 remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
151 }
152
153 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
155
156 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
157 install_agent_templates(project_root, mode, opts)?;
158 Ok(())
159}
160
161fn ensure_repo_gitignore_ignores_local_configs(
162 project_root: &Path,
163 ito_dir: &str,
164) -> CoreResult<()> {
165 let entry = format!("{ito_dir}/config.local.json");
168 ensure_gitignore_contains_line(project_root, &entry)?;
169
170 let entry = ".local/ito/config.json";
172 ensure_gitignore_contains_line(project_root, entry)?;
173 Ok(())
174}
175
176fn ensure_repo_gitignore_ignores_session_json(
177 project_root: &Path,
178 ito_dir: &str,
179) -> CoreResult<()> {
180 let entry = format!("{ito_dir}/session.json");
181 ensure_gitignore_contains_line(project_root, &entry)
182}
183
184fn ensure_repo_gitignore_ignores_audit_session(
186 project_root: &Path,
187 ito_dir: &str,
188) -> CoreResult<()> {
189 let entry = format!("{ito_dir}/.state/audit/.session");
190 ensure_gitignore_contains_line(project_root, &entry)
191}
192
193fn remove_repo_gitignore_unignores_audit_events(
195 project_root: &Path,
196 ito_dir: &str,
197) -> CoreResult<()> {
198 let entry = format!("!{ito_dir}/.state/audit/");
199 remove_gitignore_exact_line(project_root, &entry)
200}
201
202fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
203 let path = project_root.join(".gitignore");
204 let existing = match ito_common::io::read_to_string_std(&path) {
205 Ok(s) => Some(s),
206 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
207 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
208 };
209
210 let Some(mut s) = existing else {
211 ito_common::io::write_std(&path, format!("{entry}\n"))
212 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
213 return Ok(());
214 };
215
216 if gitignore_has_exact_line(&s, entry) {
217 return Ok(());
218 }
219
220 if !s.ends_with('\n') {
221 s.push('\n');
222 }
223 s.push_str(entry);
224 s.push('\n');
225
226 ito_common::io::write_std(&path, s)
227 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
228 Ok(())
229}
230
231fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
232 let path = project_root.join(".gitignore");
233 let existing = match ito_common::io::read_to_string_std(&path) {
234 Ok(s) => s,
235 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
236 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
237 };
238
239 let mut filtered = Vec::new();
240 let mut removed = false;
241 for line in existing.lines() {
242 if line.trim() == entry {
243 removed = true;
244 continue;
245 }
246 filtered.push(line);
247 }
248 if !removed {
249 return Ok(());
250 }
251
252 let mut updated = filtered.join("\n");
253 if !updated.is_empty() {
254 updated.push('\n');
255 }
256
257 ito_common::io::write_std(&path, updated)
258 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
259 Ok(())
260}
261
262fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
263 contents.lines().map(|l| l.trim()).any(|l| l == entry)
264}
265
266fn install_project_templates(
267 project_root: &Path,
268 ito_dir: &str,
269 mode: InstallMode,
270 opts: &InitOptions,
271 worktree_ctx: Option<&WorktreeTemplateContext>,
272) -> CoreResult<()> {
273 use ito_templates::project_templates::render_project_template;
274
275 let selected = &opts.tools;
276 let current_date = Utc::now().format("%Y-%m-%d").to_string();
277 let state_rel = format!("{ito_dir}/planning/STATE.md");
278 let config_json_rel = format!("{ito_dir}/config.json");
279 let release_tag = release_tag();
280 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
281 let default_ctx = WorktreeTemplateContext::default();
282 let ctx = worktree_ctx.unwrap_or(&default_ctx);
283
284 for f in ito_templates::default_project_files() {
285 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
286 let rel = rel.as_ref();
287
288 if !should_install_project_rel(rel, selected) {
289 continue;
290 }
291
292 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
293 if let Ok(s) = std::str::from_utf8(&bytes) {
294 if rel == state_rel {
295 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
296 } else if rel == config_json_rel {
297 bytes = s
298 .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
299 .into_bytes();
300 }
301 }
302
303 if rel == "AGENTS.md" {
308 bytes = render_project_template(&bytes, ctx).map_err(|e| {
309 CoreError::Validation(format!("Failed to render template {rel}: {e}"))
310 })?;
311 }
312
313 if rel.ends_with(".md")
315 && !rel.ends_with(".md.j2")
316 && let Ok(text) = std::str::from_utf8(&bytes)
317 && text.contains(ito_templates::ITO_START_MARKER)
318 {
319 bytes = ito_templates::stamp_version(text, semver).into_bytes();
320 }
321
322 let ownership = classify_project_file_ownership(rel, ito_dir);
323
324 let target = project_root.join(rel);
325 if rel == ".claude/settings.json" {
326 write_claude_settings(&target, &bytes, mode, opts)?;
327 continue;
328 }
329 write_one(&target, &bytes, mode, opts, ownership)?;
330 }
331
332 Ok(())
333}
334
335fn release_tag() -> String {
336 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
337 if version.starts_with('v') {
338 return version.to_string();
339 }
340
341 format!("v{version}")
342}
343
344fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
345 if rel == "AGENTS.md" {
347 return true;
348 }
349 if rel.starts_with(".ito/") {
350 return true;
351 }
352
353 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
355 return tools.contains(TOOL_CLAUDE);
356 }
357 if rel.starts_with(".opencode/") {
358 return tools.contains(TOOL_OPENCODE);
359 }
360 if rel.starts_with(".github/") {
361 return tools.contains(TOOL_GITHUB_COPILOT);
362 }
363 if rel.starts_with(".codex/") {
364 return tools.contains(TOOL_CODEX);
365 }
366 if rel.starts_with(".pi/") {
367 return tools.contains(TOOL_PI);
368 }
369
370 false
372}
373
374fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
375 let project_md_rel = format!("{ito_dir}/project.md");
376 if rel == project_md_rel {
377 return FileOwnership::UserOwned;
378 }
379
380 let config_json_rel = format!("{ito_dir}/config.json");
381 if rel == config_json_rel {
382 return FileOwnership::UserOwned;
383 }
384
385 let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
386 if rel == user_guidance_rel {
387 return FileOwnership::UserOwned;
388 }
389
390 let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
391 if rel.starts_with(&user_prompts_prefix) {
392 return FileOwnership::UserOwned;
393 }
394
395 FileOwnership::ItoManaged
396}
397
398fn template_is_entirely_managed(text: &str) -> bool {
407 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
408 return false;
409 };
410 let Some(end) = text.find(ito_templates::ITO_END_MARKER) else {
411 return false;
412 };
413 let before = text[..start].trim();
414 let after = text[end + ito_templates::ITO_END_MARKER.len()..].trim();
415 before.is_empty() && after.is_empty()
416}
417
418fn template_has_prefix_outside_markers(text: &str) -> bool {
425 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
426 return false;
427 };
428 !text[..start].trim().is_empty()
429}
430
431fn render_and_stamp_agent(
436 contents: &[u8],
437 config: Option<&ito_templates::agents::AgentConfig>,
438 target: &Path,
439) -> Vec<u8> {
440 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
441 let rendered = match (std::str::from_utf8(contents), config) {
442 (Ok(template_str), Some(cfg)) => {
443 ito_templates::agents::render_agent_template(template_str, cfg).into_bytes()
444 }
445 _ => contents.to_vec(),
446 };
447 let path_str = target.to_string_lossy();
448 if !path_str.ends_with(".md") || path_str.ends_with(".md.j2") {
449 return rendered;
450 }
451 let Ok(text) = std::str::from_utf8(&rendered) else {
452 return rendered;
453 };
454 if !text.contains(ito_templates::ITO_START_MARKER) {
455 return rendered;
456 }
457 ito_templates::stamp_version(text, semver).into_bytes()
458}
459
460fn refresh_agent_version_stamp(target: &Path) -> CoreResult<()> {
466 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
467 let existing = ito_common::io::read_to_string_std(target)
468 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
469 if !existing.contains(ito_templates::ITO_START_MARKER) {
470 return Ok(());
471 }
472 let stamped = ito_templates::stamp_version(&existing, semver);
473 if stamped == existing {
474 return Ok(());
475 }
476 ito_common::io::write_std(target, stamped.into_bytes())
477 .map_err(|e| CoreError::io(format!("stamping {}", target.display()), e))
478}
479
480pub(crate) fn write_marker_aware_markdown(
502 target: &Path,
503 rendered_bytes: &[u8],
504 mode: InstallMode,
505 opts: &InitOptions,
506) -> CoreResult<()> {
507 if let Some(parent) = target.parent() {
508 ito_common::io::create_dir_all_std(parent)
509 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
510 }
511
512 let wholesale = |target: &Path| -> CoreResult<()> {
513 ito_common::io::write_std(target, rendered_bytes)
514 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
515 };
516
517 if !target.exists() {
518 return wholesale(target);
519 }
520
521 if mode == InstallMode::Init && opts.force {
522 return wholesale(target);
523 }
524
525 let Ok(text) = std::str::from_utf8(rendered_bytes) else {
526 return wholesale(target);
527 };
528 let Some(block) = ito_templates::extract_managed_block(text) else {
529 return wholesale(target);
530 };
531
532 let existing = ito_common::io::read_to_string_std(target)
533 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
534 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
535 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
536 match (has_start, has_end) {
537 (false, false) => return wholesale(target),
538 (true, true) => {}
539 (true, false) | (false, true) => {
540 return Err(CoreError::Validation(format!(
546 "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
547Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
548 target.display()
549 )));
550 }
551 }
552
553 let _ = update_file_with_markers(
554 target,
555 block,
556 ito_templates::ITO_START_MARKER,
557 ito_templates::ITO_END_MARKER,
558 )
559 .map_err(|e| match e {
560 markers::FsEditError::Io(io_err) => {
561 CoreError::io(format!("updating markers in {}", target.display()), io_err)
562 }
563 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
564 "Failed to update markers in {}: {}",
565 target.display(),
566 marker_err
567 )),
568 })?;
569 Ok(())
570}
571
572fn write_one(
587 target: &Path,
588 rendered_bytes: &[u8],
589 mode: InstallMode,
590 opts: &InitOptions,
591 ownership: FileOwnership,
592) -> CoreResult<()> {
593 if let Some(parent) = target.parent() {
594 ito_common::io::create_dir_all_std(parent)
595 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
596 }
597
598 if let Ok(text) = std::str::from_utf8(rendered_bytes)
600 && let Some(block) = ito_templates::extract_managed_block(text)
601 {
602 if target.exists() {
603 if mode == InstallMode::Init && opts.force {
605 ito_common::io::write_std(target, rendered_bytes)
606 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
607 return Ok(());
608 }
609
610 if ownership == FileOwnership::UserOwned {
615 let updating = mode == InstallMode::Update
616 || (mode == InstallMode::Init && (opts.update || opts.upgrade));
617 if updating {
618 return Ok(());
619 }
620 }
621
622 let existing = ito_common::io::read_to_string_std(target)
624 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
625 let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
626 && existing.contains(ito_templates::ITO_END_MARKER);
627
628 if !has_markers {
629 if opts.upgrade {
630 eprintln!(
634 "warning: skipping upgrade of {} — Ito markers not found.\n\
635 To restore managed upgrade support, re-add the markers manually:\n\
636 \n\
637 {start}\n\
638 <ito-managed content>\n\
639 {end}\n\
640 \n\
641 Then re-run `ito init --upgrade`.",
642 target.display(),
643 start = ito_templates::ITO_START_MARKER,
644 end = ito_templates::ITO_END_MARKER,
645 );
646 return Ok(());
647 }
648
649 if mode == InstallMode::Init && !opts.update {
650 return Err(CoreError::Validation(format!(
652 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
653 target.display()
654 )));
655 }
656
657 let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
663 && !existing.contains(ito_templates::ITO_END_MARKER);
664 if existing_has_no_markers {
665 if template_is_entirely_managed(text)
684 || template_has_prefix_outside_markers(text)
685 {
686 ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
687 CoreError::io(format!("writing {}", target.display()), e)
688 })?;
689 return Ok(());
690 }
691 }
692 }
693
694 update_file_with_markers(
695 target,
696 block,
697 ito_templates::ITO_START_MARKER,
698 ito_templates::ITO_END_MARKER,
699 )
700 .map_err(|e| match e {
701 markers::FsEditError::Io(io_err) => {
702 CoreError::io(format!("updating markers in {}", target.display()), io_err)
703 }
704 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
705 "Failed to update markers in {}: {}",
706 target.display(),
707 marker_err
708 )),
709 })?;
710 } else {
711 ito_common::io::write_std(target, rendered_bytes)
713 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
714 }
715
716 return Ok(());
717 }
718
719 if target.exists() {
720 match mode {
721 InstallMode::Init => {
722 if opts.force {
723 } else if opts.update {
725 if ownership == FileOwnership::UserOwned {
726 return Ok(());
727 }
728 } else {
729 return Err(CoreError::Validation(format!(
730 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
731 target.display()
732 )));
733 }
734 }
735 InstallMode::Update => {
736 if ownership == FileOwnership::UserOwned {
737 return Ok(());
738 }
739 }
740 }
741 }
742
743 ito_common::io::write_std(target, rendered_bytes)
744 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
745 Ok(())
746}
747
748fn write_claude_settings(
749 target: &Path,
750 rendered_bytes: &[u8],
751 mode: InstallMode,
752 opts: &InitOptions,
753) -> CoreResult<()> {
754 if let Some(parent) = target.parent() {
755 ito_common::io::create_dir_all_std(parent)
756 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
757 }
758
759 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
760 return Err(CoreError::Validation(format!(
761 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
762 target.display()
763 )));
764 }
765
766 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
767 CoreError::Validation(format!(
768 "Failed to parse Claude settings template {}: {}",
769 target.display(),
770 e
771 ))
772 })?;
773
774 if !target.exists() || (mode == InstallMode::Init && opts.force) {
775 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
776 CoreError::Validation(format!(
777 "Failed to render Claude settings template {}: {}",
778 target.display(),
779 e
780 ))
781 })?;
782 bytes.push(b'\n');
783 ito_common::io::write_std(target, bytes)
784 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
785 return Ok(());
786 }
787
788 let existing_raw = ito_common::io::read_to_string_std(target)
789 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
790 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
791 return Ok(());
793 };
794
795 merge_json_objects(&mut existing_value, &template_value);
796 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
797 CoreError::Validation(format!(
798 "Failed to render merged Claude settings {}: {}",
799 target.display(),
800 e
801 ))
802 })?;
803 merged.push(b'\n');
804 ito_common::io::write_std(target, merged)
805 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
806 Ok(())
807}
808
809fn merge_json_objects(existing: &mut Value, template: &Value) {
810 let Value::Object(template_map) = template else {
811 *existing = template.clone();
812 return;
813 };
814 if !existing.is_object() {
815 *existing = Value::Object(Map::new());
816 }
817
818 let Some(existing_map) = existing.as_object_mut() else {
819 return;
820 };
821
822 for (key, template_value) in template_map {
823 if let Some(existing_value) = existing_map.get_mut(key) {
824 merge_json_values(existing_value, template_value);
825 } else {
826 existing_map.insert(key.clone(), template_value.clone());
827 }
828 }
829}
830
831fn merge_json_values(existing: &mut Value, template: &Value) {
832 match (existing, template) {
833 (Value::Object(existing_map), Value::Object(template_map)) => {
834 for (key, template_value) in template_map {
835 if let Some(existing_value) = existing_map.get_mut(key) {
836 merge_json_values(existing_value, template_value);
837 } else {
838 existing_map.insert(key.clone(), template_value.clone());
839 }
840 }
841 }
842 (Value::Array(existing_items), Value::Array(template_items)) => {
843 for template_item in template_items {
844 if !existing_items.contains(template_item) {
845 existing_items.push(template_item.clone());
846 }
847 }
848 }
849 (existing_value, template_value) => *existing_value = template_value.clone(),
850 }
851}
852
853fn install_adapter_files(
854 project_root: &Path,
855 mode: InstallMode,
856 opts: &InitOptions,
857 worktree_ctx: Option<&WorktreeTemplateContext>,
858) -> CoreResult<()> {
859 for tool in &opts.tools {
860 match tool.as_str() {
861 TOOL_OPENCODE => {
862 let config_dir = project_root.join(".opencode");
863 let manifests = crate::distribution::opencode_manifests(&config_dir);
864 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
865 }
866 TOOL_CLAUDE => {
867 let manifests = crate::distribution::claude_manifests(project_root);
868 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
869 }
870 TOOL_CODEX => {
871 let manifests = crate::distribution::codex_manifests(project_root);
872 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
873 }
874 TOOL_GITHUB_COPILOT => {
875 let manifests = crate::distribution::github_manifests(project_root);
876 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
877 }
878 TOOL_PI => {
879 let manifests = crate::distribution::pi_manifests(project_root);
880 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
881 }
882 _ => {}
883 }
884 }
885
886 Ok(())
887}
888
889fn install_agent_templates(
891 project_root: &Path,
892 mode: InstallMode,
893 opts: &InitOptions,
894) -> CoreResult<()> {
895 use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
896
897 let configs = default_agent_configs();
898
899 let tool_harness_map = [
901 (TOOL_OPENCODE, Harness::OpenCode),
902 (TOOL_CLAUDE, Harness::ClaudeCode),
903 (TOOL_CODEX, Harness::Codex),
904 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
905 (TOOL_PI, Harness::Pi),
906 ];
907
908 for (tool_id, harness) in tool_harness_map {
909 if !opts.tools.contains(tool_id) {
910 continue;
911 }
912
913 let agent_dir = project_root.join(harness.project_agent_path());
914
915 let files = get_agent_files(harness);
917
918 for (rel_path, contents) in files {
919 let target = agent_dir.join(rel_path);
920
921 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
923 Some(AgentTier::Quick)
924 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
925 Some(AgentTier::General)
926 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
927 Some(AgentTier::Thinking)
928 } else {
929 None
930 };
931
932 let mut config = tier.and_then(|t| configs.get(&(harness, t)));
938 if config.is_none()
939 && let Ok(s) = std::str::from_utf8(contents)
940 && s.contains("{{model}}")
941 {
942 config = configs.get(&(harness, AgentTier::General));
943 }
944
945 match mode {
946 InstallMode::Init => {
947 if target.exists() {
948 if opts.update {
949 if let Some(cfg) = config {
951 update_agent_model_field(&target, &cfg.model)?;
952 }
953 continue;
954 }
955 if !opts.force {
956 continue;
958 }
959 }
960
961 let rendered = render_and_stamp_agent(contents, config, &target);
963
964 if let Some(parent) = target.parent() {
966 ito_common::io::create_dir_all_std(parent).map_err(|e| {
967 CoreError::io(format!("creating directory {}", parent.display()), e)
968 })?;
969 }
970
971 ito_common::io::write_std(&target, rendered)
972 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
973 }
974 InstallMode::Update => {
975 if !target.exists() {
977 let rendered = render_and_stamp_agent(contents, config, &target);
979
980 if let Some(parent) = target.parent() {
981 ito_common::io::create_dir_all_std(parent).map_err(|e| {
982 CoreError::io(format!("creating directory {}", parent.display()), e)
983 })?;
984 }
985 ito_common::io::write_std(&target, rendered).map_err(|e| {
986 CoreError::io(format!("writing {}", target.display()), e)
987 })?;
988 } else if let Some(cfg) = config {
989 update_agent_model_field(&target, &cfg.model)?;
991 refresh_agent_version_stamp(&target)?;
995 }
996 }
997 }
998 }
999 }
1000
1001 Ok(())
1002}
1003
1004fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
1006 let content = ito_common::io::read_to_string_or_default(path);
1007
1008 if !content.starts_with("---") {
1010 return Ok(());
1011 }
1012
1013 let rest = &content[3..];
1015 let Some(end_idx) = rest.find("\n---") else {
1016 return Ok(());
1017 };
1018
1019 let frontmatter = &rest[..end_idx];
1020 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
1024
1025 let updated = format!("---{}\n---{}", updated_frontmatter, body);
1027 ito_common::io::write_std(path, updated)
1028 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
1029
1030 Ok(())
1031}
1032
1033fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
1035 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
1036 let mut found = false;
1037
1038 for line in &mut lines {
1039 if line.trim_start().starts_with("model:") {
1040 *line = format!("model: \"{}\"", new_model);
1041 found = true;
1042 break;
1043 }
1044 }
1045
1046 if !found {
1048 lines.push(format!("model: \"{}\"", new_model));
1049 }
1050
1051 lines.join("\n")
1052}
1053
1054#[cfg(test)]
1055mod json_tests;
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 #[test]
1062 fn gitignore_created_when_missing() {
1063 let td = tempfile::tempdir().unwrap();
1064 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1065 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1066 assert_eq!(s, ".ito/session.json\n");
1067 }
1068
1069 #[test]
1070 fn gitignore_noop_when_already_present() {
1071 let td = tempfile::tempdir().unwrap();
1072 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
1073 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1074 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1075 assert_eq!(s, ".ito/session.json\n");
1076 }
1077
1078 #[test]
1079 fn gitignore_does_not_duplicate_on_repeated_calls() {
1080 let td = tempfile::tempdir().unwrap();
1081 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
1082 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1083 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1084 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1085 assert_eq!(s, "node_modules\n.ito/session.json\n");
1086 }
1087
1088 #[test]
1089 fn gitignore_audit_session_added() {
1090 let td = tempfile::tempdir().unwrap();
1091 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1092 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1093 assert!(s.contains(".ito/.state/audit/.session"));
1094 }
1095
1096 #[test]
1097 fn gitignore_both_session_entries() {
1098 let td = tempfile::tempdir().unwrap();
1099 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1100 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1101 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1102 assert!(s.contains(".ito/session.json"));
1103 assert!(s.contains(".ito/.state/audit/.session"));
1104 }
1105
1106 #[test]
1107 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
1108 let td = tempfile::tempdir().unwrap();
1109 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
1110 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1111 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1112 assert_eq!(s, "node_modules\n.ito/session.json\n");
1113 }
1114
1115 #[test]
1116 fn gitignore_legacy_audit_events_unignore_removed() {
1117 let td = tempfile::tempdir().unwrap();
1118 std::fs::write(
1119 td.path().join(".gitignore"),
1120 ".ito/.state/\n!.ito/.state/audit/\n",
1121 )
1122 .unwrap();
1123 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1124 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1125 assert_eq!(s, ".ito/.state/\n");
1126 }
1127
1128 #[test]
1129 fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
1130 let td = tempfile::tempdir().unwrap();
1131 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
1132 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1133 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1134 assert_eq!(s, ".ito/.state/\n");
1135 }
1136
1137 #[test]
1138 fn gitignore_full_audit_setup() {
1139 let td = tempfile::tempdir().unwrap();
1140 std::fs::write(
1142 td.path().join(".gitignore"),
1143 ".ito/.state/\n!.ito/.state/audit/\n",
1144 )
1145 .unwrap();
1146 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1147 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1148 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1149 assert!(s.contains(".ito/.state/audit/.session"));
1150 assert!(!s.contains("!.ito/.state/audit/"));
1151 }
1152
1153 #[test]
1154 fn gitignore_ignores_local_configs() {
1155 let td = tempfile::tempdir().unwrap();
1156 ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
1157 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1158 assert!(s.contains(".ito/config.local.json"));
1159 assert!(s.contains(".local/ito/config.json"));
1160 }
1161
1162 #[test]
1163 fn gitignore_exact_line_matching_trims_whitespace() {
1164 assert!(gitignore_has_exact_line(" foo \nbar\n", "foo"));
1165 assert!(!gitignore_has_exact_line("foo\n", "bar"));
1166 }
1167
1168 #[test]
1169 fn should_install_project_rel_filters_by_tool_id() {
1170 let mut tools = BTreeSet::new();
1171 tools.insert(TOOL_OPENCODE.to_string());
1172
1173 assert!(should_install_project_rel("AGENTS.md", &tools));
1174 assert!(should_install_project_rel(".ito/config.json", &tools));
1175 assert!(should_install_project_rel(".opencode/config.json", &tools));
1176 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1177 assert!(!should_install_project_rel(".codex/config.json", &tools));
1178 assert!(!should_install_project_rel(
1179 ".github/workflows/x.yml",
1180 &tools
1181 ));
1182 assert!(!should_install_project_rel(".pi/settings.json", &tools));
1183 }
1184
1185 #[test]
1186 fn should_install_project_rel_filters_pi() {
1187 let mut tools = BTreeSet::new();
1188 tools.insert(TOOL_PI.to_string());
1189
1190 assert!(should_install_project_rel(".pi/settings.json", &tools));
1192 assert!(should_install_project_rel(
1193 ".pi/extensions/ito-skills.ts",
1194 &tools
1195 ));
1196
1197 assert!(should_install_project_rel("AGENTS.md", &tools));
1199 assert!(should_install_project_rel(".ito/config.json", &tools));
1200
1201 assert!(!should_install_project_rel(".opencode/config.json", &tools));
1203 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1204 assert!(!should_install_project_rel(".codex/config.json", &tools));
1205 }
1206
1207 #[test]
1208 fn release_tag_is_prefixed_with_v() {
1209 let tag = release_tag();
1210 assert!(tag.starts_with('v'));
1211 }
1212
1213 #[test]
1214 fn update_model_in_yaml_replaces_or_inserts() {
1215 let yaml = "name: test\nmodel: \"old\"\n";
1216 let updated = update_model_in_yaml(yaml, "new");
1217 assert!(updated.contains("model: \"new\""));
1218
1219 let yaml = "name: test\n";
1220 let updated = update_model_in_yaml(yaml, "new");
1221 assert!(updated.contains("model: \"new\""));
1222 }
1223
1224 #[test]
1225 fn update_agent_model_field_updates_frontmatter_when_present() {
1226 let td = tempfile::tempdir().unwrap();
1227 let path = td.path().join("agent.md");
1228 std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
1229 update_agent_model_field(&path, "new").unwrap();
1230 let s = std::fs::read_to_string(&path).unwrap();
1231 assert!(s.contains("model: \"new\""));
1232
1233 let path = td.path().join("no-frontmatter.md");
1234 std::fs::write(&path, "no frontmatter\n").unwrap();
1235 update_agent_model_field(&path, "newer").unwrap();
1236 let s = std::fs::read_to_string(&path).unwrap();
1237 assert_eq!(s, "no frontmatter\n");
1238 }
1239
1240 #[test]
1241 fn write_one_non_marker_files_skip_on_init_update_mode() {
1242 let td = tempfile::tempdir().unwrap();
1243 let target = td.path().join("plain.txt");
1244 std::fs::write(&target, "existing").unwrap();
1245
1246 let opts = InitOptions::new(BTreeSet::new(), false, true);
1247 write_one(
1248 &target,
1249 b"new",
1250 InstallMode::Init,
1251 &opts,
1252 FileOwnership::UserOwned,
1253 )
1254 .unwrap();
1255 let s = std::fs::read_to_string(&target).unwrap();
1256 assert_eq!(s, "existing");
1257 }
1258
1259 #[test]
1260 fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
1261 let td = tempfile::tempdir().unwrap();
1262 let target = td.path().join("plain.txt");
1263 std::fs::write(&target, "existing").unwrap();
1264
1265 let opts = InitOptions::new(BTreeSet::new(), false, true);
1266 write_one(
1267 &target,
1268 b"new",
1269 InstallMode::Init,
1270 &opts,
1271 FileOwnership::ItoManaged,
1272 )
1273 .unwrap();
1274 let s = std::fs::read_to_string(&target).unwrap();
1275 assert_eq!(s, "new");
1276 }
1277
1278 #[test]
1279 fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
1280 let td = tempfile::tempdir().unwrap();
1281 let target = td.path().join("plain.txt");
1282 std::fs::write(&target, "existing").unwrap();
1283
1284 let opts = InitOptions::new(BTreeSet::new(), false, true);
1285 write_one(
1286 &target,
1287 b"new",
1288 InstallMode::Update,
1289 &opts,
1290 FileOwnership::UserOwned,
1291 )
1292 .unwrap();
1293 let s = std::fs::read_to_string(&target).unwrap();
1294 assert_eq!(s, "existing");
1295 }
1296
1297 #[test]
1298 fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
1299 let td = tempfile::tempdir().unwrap();
1300 let target = td.path().join("managed.md");
1301 std::fs::write(&target, "existing without markers\n").unwrap();
1302
1303 let template = format!(
1304 "before\n{}\nmanaged\n{}\nafter\n",
1305 ito_templates::ITO_START_MARKER,
1306 ito_templates::ITO_END_MARKER
1307 );
1308 let opts = InitOptions::new(BTreeSet::new(), false, false);
1309 let err = write_one(
1310 &target,
1311 template.as_bytes(),
1312 InstallMode::Init,
1313 &opts,
1314 FileOwnership::ItoManaged,
1315 )
1316 .unwrap_err();
1317 assert!(err.to_string().contains("Refusing to overwrite"));
1318 }
1319
1320 #[test]
1321 fn write_one_marker_managed_files_update_existing_markers() {
1322 let td = tempfile::tempdir().unwrap();
1323 let target = td.path().join("managed.md");
1324 let existing = format!(
1325 "before\n{}\nold\n{}\nafter\n",
1326 ito_templates::ITO_START_MARKER,
1327 ito_templates::ITO_END_MARKER
1328 );
1329 std::fs::write(&target, existing).unwrap();
1330
1331 let template = format!(
1332 "before\n{}\nnew\n{}\nafter\n",
1333 ito_templates::ITO_START_MARKER,
1334 ito_templates::ITO_END_MARKER
1335 );
1336 let opts = InitOptions::new(BTreeSet::new(), false, false);
1337 write_one(
1338 &target,
1339 template.as_bytes(),
1340 InstallMode::Init,
1341 &opts,
1342 FileOwnership::ItoManaged,
1343 )
1344 .unwrap();
1345 let s = std::fs::read_to_string(&target).unwrap();
1346 assert!(s.contains("new"));
1347 assert!(!s.contains("old"));
1348 }
1349
1350 #[test]
1351 fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
1352 let td = tempfile::tempdir().unwrap();
1353 let target = td.path().join("managed.md");
1354 std::fs::write(
1356 &target,
1357 format!(
1358 "{}\nexisting without end marker\n",
1359 ito_templates::ITO_START_MARKER
1360 ),
1361 )
1362 .unwrap();
1363
1364 let template = format!(
1365 "before\n{}\nmanaged\n{}\nafter\n",
1366 ito_templates::ITO_START_MARKER,
1367 ito_templates::ITO_END_MARKER
1368 );
1369 let opts = InitOptions::new(BTreeSet::new(), false, true);
1370 let err = write_one(
1371 &target,
1372 template.as_bytes(),
1373 InstallMode::Init,
1374 &opts,
1375 FileOwnership::ItoManaged,
1376 )
1377 .unwrap_err();
1378 assert!(err.to_string().contains("Failed to update markers"));
1379 }
1380}