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
460pub(crate) fn write_marker_aware_markdown(
482 target: &Path,
483 rendered_bytes: &[u8],
484 mode: InstallMode,
485 opts: &InitOptions,
486) -> CoreResult<()> {
487 if let Some(parent) = target.parent() {
488 ito_common::io::create_dir_all_std(parent)
489 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
490 }
491
492 let wholesale = |target: &Path| -> CoreResult<()> {
493 ito_common::io::write_std(target, rendered_bytes)
494 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
495 };
496
497 if !target.exists() {
498 return wholesale(target);
499 }
500
501 if mode == InstallMode::Init && opts.force {
502 return wholesale(target);
503 }
504
505 let Ok(text) = std::str::from_utf8(rendered_bytes) else {
506 return wholesale(target);
507 };
508 let Some(block) = ito_templates::extract_managed_block(text) else {
509 return wholesale(target);
510 };
511
512 let existing = ito_common::io::read_to_string_std(target)
513 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
514 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
515 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
516 match (has_start, has_end) {
517 (false, false) => return wholesale(target),
518 (true, true) => {}
519 (true, false) | (false, true) => {
520 return Err(CoreError::Validation(format!(
526 "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
527Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
528 target.display()
529 )));
530 }
531 }
532
533 let _ = update_file_with_markers(
534 target,
535 block,
536 ito_templates::ITO_START_MARKER,
537 ito_templates::ITO_END_MARKER,
538 )
539 .map_err(|e| match e {
540 markers::FsEditError::Io(io_err) => {
541 CoreError::io(format!("updating markers in {}", target.display()), io_err)
542 }
543 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
544 "Failed to update markers in {}: {}",
545 target.display(),
546 marker_err
547 )),
548 })?;
549 Ok(())
550}
551
552fn write_one(
567 target: &Path,
568 rendered_bytes: &[u8],
569 mode: InstallMode,
570 opts: &InitOptions,
571 ownership: FileOwnership,
572) -> CoreResult<()> {
573 if let Some(parent) = target.parent() {
574 ito_common::io::create_dir_all_std(parent)
575 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
576 }
577
578 if let Ok(text) = std::str::from_utf8(rendered_bytes)
580 && let Some(block) = ito_templates::extract_managed_block(text)
581 {
582 if target.exists() {
583 if mode == InstallMode::Init && opts.force {
585 ito_common::io::write_std(target, rendered_bytes)
586 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
587 return Ok(());
588 }
589
590 if ownership == FileOwnership::UserOwned {
595 let updating = mode == InstallMode::Update
596 || (mode == InstallMode::Init && (opts.update || opts.upgrade));
597 if updating {
598 return Ok(());
599 }
600 }
601
602 let existing = ito_common::io::read_to_string_std(target)
604 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
605 let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
606 && existing.contains(ito_templates::ITO_END_MARKER);
607
608 if !has_markers {
609 if opts.upgrade {
610 eprintln!(
614 "warning: skipping upgrade of {} — Ito markers not found.\n\
615 To restore managed upgrade support, re-add the markers manually:\n\
616 \n\
617 {start}\n\
618 <ito-managed content>\n\
619 {end}\n\
620 \n\
621 Then re-run `ito init --upgrade`.",
622 target.display(),
623 start = ito_templates::ITO_START_MARKER,
624 end = ito_templates::ITO_END_MARKER,
625 );
626 return Ok(());
627 }
628
629 if mode == InstallMode::Init && !opts.update {
630 return Err(CoreError::Validation(format!(
632 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
633 target.display()
634 )));
635 }
636
637 let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
643 && !existing.contains(ito_templates::ITO_END_MARKER);
644 if existing_has_no_markers {
645 if template_is_entirely_managed(text)
664 || template_has_prefix_outside_markers(text)
665 {
666 ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
667 CoreError::io(format!("writing {}", target.display()), e)
668 })?;
669 return Ok(());
670 }
671 }
672 }
673
674 update_file_with_markers(
675 target,
676 block,
677 ito_templates::ITO_START_MARKER,
678 ito_templates::ITO_END_MARKER,
679 )
680 .map_err(|e| match e {
681 markers::FsEditError::Io(io_err) => {
682 CoreError::io(format!("updating markers in {}", target.display()), io_err)
683 }
684 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
685 "Failed to update markers in {}: {}",
686 target.display(),
687 marker_err
688 )),
689 })?;
690 } else {
691 ito_common::io::write_std(target, rendered_bytes)
693 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
694 }
695
696 return Ok(());
697 }
698
699 if target.exists() {
700 match mode {
701 InstallMode::Init => {
702 if opts.force {
703 } else if opts.update {
705 if ownership == FileOwnership::UserOwned {
706 return Ok(());
707 }
708 } else {
709 return Err(CoreError::Validation(format!(
710 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
711 target.display()
712 )));
713 }
714 }
715 InstallMode::Update => {
716 if ownership == FileOwnership::UserOwned {
717 return Ok(());
718 }
719 }
720 }
721 }
722
723 ito_common::io::write_std(target, rendered_bytes)
724 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
725 Ok(())
726}
727
728fn write_claude_settings(
729 target: &Path,
730 rendered_bytes: &[u8],
731 mode: InstallMode,
732 opts: &InitOptions,
733) -> CoreResult<()> {
734 if let Some(parent) = target.parent() {
735 ito_common::io::create_dir_all_std(parent)
736 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
737 }
738
739 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
740 return Err(CoreError::Validation(format!(
741 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
742 target.display()
743 )));
744 }
745
746 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
747 CoreError::Validation(format!(
748 "Failed to parse Claude settings template {}: {}",
749 target.display(),
750 e
751 ))
752 })?;
753
754 if !target.exists() || (mode == InstallMode::Init && opts.force) {
755 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
756 CoreError::Validation(format!(
757 "Failed to render Claude settings template {}: {}",
758 target.display(),
759 e
760 ))
761 })?;
762 bytes.push(b'\n');
763 ito_common::io::write_std(target, bytes)
764 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
765 return Ok(());
766 }
767
768 let existing_raw = ito_common::io::read_to_string_std(target)
769 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
770 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
771 return Ok(());
773 };
774
775 merge_json_objects(&mut existing_value, &template_value);
776 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
777 CoreError::Validation(format!(
778 "Failed to render merged Claude settings {}: {}",
779 target.display(),
780 e
781 ))
782 })?;
783 merged.push(b'\n');
784 ito_common::io::write_std(target, merged)
785 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
786 Ok(())
787}
788
789fn merge_json_objects(existing: &mut Value, template: &Value) {
790 let Value::Object(template_map) = template else {
791 *existing = template.clone();
792 return;
793 };
794 if !existing.is_object() {
795 *existing = Value::Object(Map::new());
796 }
797
798 let Some(existing_map) = existing.as_object_mut() else {
799 return;
800 };
801
802 for (key, template_value) in template_map {
803 if let Some(existing_value) = existing_map.get_mut(key) {
804 merge_json_values(existing_value, template_value);
805 } else {
806 existing_map.insert(key.clone(), template_value.clone());
807 }
808 }
809}
810
811fn merge_json_values(existing: &mut Value, template: &Value) {
812 match (existing, template) {
813 (Value::Object(existing_map), Value::Object(template_map)) => {
814 for (key, template_value) in template_map {
815 if let Some(existing_value) = existing_map.get_mut(key) {
816 merge_json_values(existing_value, template_value);
817 } else {
818 existing_map.insert(key.clone(), template_value.clone());
819 }
820 }
821 }
822 (Value::Array(existing_items), Value::Array(template_items)) => {
823 for template_item in template_items {
824 if !existing_items.contains(template_item) {
825 existing_items.push(template_item.clone());
826 }
827 }
828 }
829 (existing_value, template_value) => *existing_value = template_value.clone(),
830 }
831}
832
833fn install_adapter_files(
834 project_root: &Path,
835 mode: InstallMode,
836 opts: &InitOptions,
837 worktree_ctx: Option<&WorktreeTemplateContext>,
838) -> CoreResult<()> {
839 for tool in &opts.tools {
840 match tool.as_str() {
841 TOOL_OPENCODE => {
842 let config_dir = project_root.join(".opencode");
843 let manifests = crate::distribution::opencode_manifests(&config_dir);
844 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
845 }
846 TOOL_CLAUDE => {
847 let manifests = crate::distribution::claude_manifests(project_root);
848 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
849 }
850 TOOL_CODEX => {
851 let manifests = crate::distribution::codex_manifests(project_root);
852 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
853 }
854 TOOL_GITHUB_COPILOT => {
855 let manifests = crate::distribution::github_manifests(project_root);
856 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
857 }
858 TOOL_PI => {
859 let manifests = crate::distribution::pi_manifests(project_root);
860 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
861 }
862 _ => {}
863 }
864 }
865
866 Ok(())
867}
868
869fn install_agent_templates(
871 project_root: &Path,
872 mode: InstallMode,
873 opts: &InitOptions,
874) -> CoreResult<()> {
875 use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
876
877 let configs = default_agent_configs();
878
879 let tool_harness_map = [
881 (TOOL_OPENCODE, Harness::OpenCode),
882 (TOOL_CLAUDE, Harness::ClaudeCode),
883 (TOOL_CODEX, Harness::Codex),
884 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
885 (TOOL_PI, Harness::Pi),
886 ];
887
888 for (tool_id, harness) in tool_harness_map {
889 if !opts.tools.contains(tool_id) {
890 continue;
891 }
892
893 let agent_dir = project_root.join(harness.project_agent_path());
894
895 let files = get_agent_files(harness);
897
898 for (rel_path, contents) in files {
899 let target = agent_dir.join(rel_path);
900
901 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
903 Some(AgentTier::Quick)
904 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
905 Some(AgentTier::General)
906 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
907 Some(AgentTier::Thinking)
908 } else {
909 None
910 };
911
912 let mut config = tier.and_then(|t| configs.get(&(harness, t)));
918 if config.is_none()
919 && let Ok(s) = std::str::from_utf8(contents)
920 && s.contains("{{model}}")
921 {
922 config = configs.get(&(harness, AgentTier::General));
923 }
924
925 match mode {
926 InstallMode::Init => {
927 if target.exists() && !opts.force {
928 if opts.update {
929 let rendered = render_and_stamp_agent(contents, config, &target);
930 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
931 }
932 continue;
933 }
934
935 let rendered = render_and_stamp_agent(contents, config, &target);
936 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
937 }
938 InstallMode::Update => {
939 let rendered = render_and_stamp_agent(contents, config, &target);
940 if target.exists() {
941 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
942 } else {
943 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
944 }
945 }
946 }
947 }
948 }
949
950 Ok(())
951}
952
953fn update_existing_agent_template(
960 target: &Path,
961 rendered: &[u8],
962 mode: InstallMode,
963 opts: &InitOptions,
964 config: Option<&ito_templates::agents::AgentConfig>,
965) -> CoreResult<()> {
966 let existing = ito_common::io::read_to_string_std(target)
967 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
968 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
969 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
970
971 match (has_start, has_end) {
972 (true, true) => write_marker_aware_markdown(target, rendered, mode, opts)?,
973 (false, false) => {}
974 (true, false) | (false, true) => {
975 eprintln!(
976 "warning: skipping marker update for {}: file has a partial Ito marker pair \
977 (start={has_start}, end={has_end}). Restore both markers manually, or rerun \
978 with `--force` to overwrite the file wholesale.",
979 target.display()
980 );
981 }
982 }
983
984 if let Some(cfg) = config {
985 update_agent_model_field(target, &cfg.model)?;
986 }
987
988 Ok(())
989}
990
991fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
993 let content = ito_common::io::read_to_string_or_default(path);
994
995 if !content.starts_with("---") {
997 return Ok(());
998 }
999
1000 let rest = &content[3..];
1002 let Some(end_idx) = rest.find("\n---") else {
1003 return Ok(());
1004 };
1005
1006 let frontmatter = &rest[..end_idx];
1007 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
1011
1012 let updated = format!("---{}\n---{}", updated_frontmatter, body);
1014 ito_common::io::write_std(path, updated)
1015 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
1016
1017 Ok(())
1018}
1019
1020fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
1022 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
1023 let mut found = false;
1024
1025 for line in &mut lines {
1026 if line.trim_start().starts_with("model:") {
1027 *line = format!("model: \"{}\"", new_model);
1028 found = true;
1029 break;
1030 }
1031 }
1032
1033 if !found {
1035 lines.push(format!("model: \"{}\"", new_model));
1036 }
1037
1038 lines.join("\n")
1039}
1040
1041#[cfg(test)]
1042mod json_tests;
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 #[test]
1049 fn gitignore_created_when_missing() {
1050 let td = tempfile::tempdir().unwrap();
1051 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1052 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1053 assert_eq!(s, ".ito/session.json\n");
1054 }
1055
1056 #[test]
1057 fn gitignore_noop_when_already_present() {
1058 let td = tempfile::tempdir().unwrap();
1059 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
1060 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1061 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1062 assert_eq!(s, ".ito/session.json\n");
1063 }
1064
1065 #[test]
1066 fn gitignore_does_not_duplicate_on_repeated_calls() {
1067 let td = tempfile::tempdir().unwrap();
1068 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
1069 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1070 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1071 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1072 assert_eq!(s, "node_modules\n.ito/session.json\n");
1073 }
1074
1075 #[test]
1076 fn gitignore_audit_session_added() {
1077 let td = tempfile::tempdir().unwrap();
1078 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1079 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1080 assert!(s.contains(".ito/.state/audit/.session"));
1081 }
1082
1083 #[test]
1084 fn gitignore_both_session_entries() {
1085 let td = tempfile::tempdir().unwrap();
1086 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1087 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1088 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1089 assert!(s.contains(".ito/session.json"));
1090 assert!(s.contains(".ito/.state/audit/.session"));
1091 }
1092
1093 #[test]
1094 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
1095 let td = tempfile::tempdir().unwrap();
1096 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
1097 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1098 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1099 assert_eq!(s, "node_modules\n.ito/session.json\n");
1100 }
1101
1102 #[test]
1103 fn gitignore_legacy_audit_events_unignore_removed() {
1104 let td = tempfile::tempdir().unwrap();
1105 std::fs::write(
1106 td.path().join(".gitignore"),
1107 ".ito/.state/\n!.ito/.state/audit/\n",
1108 )
1109 .unwrap();
1110 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1111 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1112 assert_eq!(s, ".ito/.state/\n");
1113 }
1114
1115 #[test]
1116 fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
1117 let td = tempfile::tempdir().unwrap();
1118 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
1119 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1120 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1121 assert_eq!(s, ".ito/.state/\n");
1122 }
1123
1124 #[test]
1125 fn gitignore_full_audit_setup() {
1126 let td = tempfile::tempdir().unwrap();
1127 std::fs::write(
1129 td.path().join(".gitignore"),
1130 ".ito/.state/\n!.ito/.state/audit/\n",
1131 )
1132 .unwrap();
1133 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1134 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1135 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1136 assert!(s.contains(".ito/.state/audit/.session"));
1137 assert!(!s.contains("!.ito/.state/audit/"));
1138 }
1139
1140 #[test]
1141 fn gitignore_ignores_local_configs() {
1142 let td = tempfile::tempdir().unwrap();
1143 ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
1144 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1145 assert!(s.contains(".ito/config.local.json"));
1146 assert!(s.contains(".local/ito/config.json"));
1147 }
1148
1149 #[test]
1150 fn gitignore_exact_line_matching_trims_whitespace() {
1151 assert!(gitignore_has_exact_line(" foo \nbar\n", "foo"));
1152 assert!(!gitignore_has_exact_line("foo\n", "bar"));
1153 }
1154
1155 #[test]
1156 fn should_install_project_rel_filters_by_tool_id() {
1157 let mut tools = BTreeSet::new();
1158 tools.insert(TOOL_OPENCODE.to_string());
1159
1160 assert!(should_install_project_rel("AGENTS.md", &tools));
1161 assert!(should_install_project_rel(".ito/config.json", &tools));
1162 assert!(should_install_project_rel(".opencode/config.json", &tools));
1163 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1164 assert!(!should_install_project_rel(".codex/config.json", &tools));
1165 assert!(!should_install_project_rel(
1166 ".github/workflows/x.yml",
1167 &tools
1168 ));
1169 assert!(!should_install_project_rel(".pi/settings.json", &tools));
1170 }
1171
1172 #[test]
1173 fn should_install_project_rel_filters_pi() {
1174 let mut tools = BTreeSet::new();
1175 tools.insert(TOOL_PI.to_string());
1176
1177 assert!(should_install_project_rel(".pi/settings.json", &tools));
1179 assert!(should_install_project_rel(
1180 ".pi/extensions/ito-skills.ts",
1181 &tools
1182 ));
1183
1184 assert!(should_install_project_rel("AGENTS.md", &tools));
1186 assert!(should_install_project_rel(".ito/config.json", &tools));
1187
1188 assert!(!should_install_project_rel(".opencode/config.json", &tools));
1190 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1191 assert!(!should_install_project_rel(".codex/config.json", &tools));
1192 }
1193
1194 #[test]
1195 fn release_tag_is_prefixed_with_v() {
1196 let tag = release_tag();
1197 assert!(tag.starts_with('v'));
1198 }
1199
1200 #[test]
1201 fn update_model_in_yaml_replaces_or_inserts() {
1202 let yaml = "name: test\nmodel: \"old\"\n";
1203 let updated = update_model_in_yaml(yaml, "new");
1204 assert!(updated.contains("model: \"new\""));
1205
1206 let yaml = "name: test\n";
1207 let updated = update_model_in_yaml(yaml, "new");
1208 assert!(updated.contains("model: \"new\""));
1209 }
1210
1211 #[test]
1212 fn update_agent_model_field_updates_frontmatter_when_present() {
1213 let td = tempfile::tempdir().unwrap();
1214 let path = td.path().join("agent.md");
1215 std::fs::write(&path, "---\nname: test\nmodel: \"old\"\n---\nbody\n").unwrap();
1216 update_agent_model_field(&path, "new").unwrap();
1217 let s = std::fs::read_to_string(&path).unwrap();
1218 assert!(s.contains("model: \"new\""));
1219
1220 let path = td.path().join("no-frontmatter.md");
1221 std::fs::write(&path, "no frontmatter\n").unwrap();
1222 update_agent_model_field(&path, "newer").unwrap();
1223 let s = std::fs::read_to_string(&path).unwrap();
1224 assert_eq!(s, "no frontmatter\n");
1225 }
1226
1227 #[test]
1228 fn write_one_non_marker_files_skip_on_init_update_mode() {
1229 let td = tempfile::tempdir().unwrap();
1230 let target = td.path().join("plain.txt");
1231 std::fs::write(&target, "existing").unwrap();
1232
1233 let opts = InitOptions::new(BTreeSet::new(), false, true);
1234 write_one(
1235 &target,
1236 b"new",
1237 InstallMode::Init,
1238 &opts,
1239 FileOwnership::UserOwned,
1240 )
1241 .unwrap();
1242 let s = std::fs::read_to_string(&target).unwrap();
1243 assert_eq!(s, "existing");
1244 }
1245
1246 #[test]
1247 fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
1248 let td = tempfile::tempdir().unwrap();
1249 let target = td.path().join("plain.txt");
1250 std::fs::write(&target, "existing").unwrap();
1251
1252 let opts = InitOptions::new(BTreeSet::new(), false, true);
1253 write_one(
1254 &target,
1255 b"new",
1256 InstallMode::Init,
1257 &opts,
1258 FileOwnership::ItoManaged,
1259 )
1260 .unwrap();
1261 let s = std::fs::read_to_string(&target).unwrap();
1262 assert_eq!(s, "new");
1263 }
1264
1265 #[test]
1266 fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
1267 let td = tempfile::tempdir().unwrap();
1268 let target = td.path().join("plain.txt");
1269 std::fs::write(&target, "existing").unwrap();
1270
1271 let opts = InitOptions::new(BTreeSet::new(), false, true);
1272 write_one(
1273 &target,
1274 b"new",
1275 InstallMode::Update,
1276 &opts,
1277 FileOwnership::UserOwned,
1278 )
1279 .unwrap();
1280 let s = std::fs::read_to_string(&target).unwrap();
1281 assert_eq!(s, "existing");
1282 }
1283
1284 #[test]
1285 fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
1286 let td = tempfile::tempdir().unwrap();
1287 let target = td.path().join("managed.md");
1288 std::fs::write(&target, "existing without markers\n").unwrap();
1289
1290 let template = format!(
1291 "before\n{}\nmanaged\n{}\nafter\n",
1292 ito_templates::ITO_START_MARKER,
1293 ito_templates::ITO_END_MARKER
1294 );
1295 let opts = InitOptions::new(BTreeSet::new(), false, false);
1296 let err = write_one(
1297 &target,
1298 template.as_bytes(),
1299 InstallMode::Init,
1300 &opts,
1301 FileOwnership::ItoManaged,
1302 )
1303 .unwrap_err();
1304 assert!(err.to_string().contains("Refusing to overwrite"));
1305 }
1306
1307 #[test]
1308 fn write_one_marker_managed_files_update_existing_markers() {
1309 let td = tempfile::tempdir().unwrap();
1310 let target = td.path().join("managed.md");
1311 let existing = format!(
1312 "before\n{}\nold\n{}\nafter\n",
1313 ito_templates::ITO_START_MARKER,
1314 ito_templates::ITO_END_MARKER
1315 );
1316 std::fs::write(&target, existing).unwrap();
1317
1318 let template = format!(
1319 "before\n{}\nnew\n{}\nafter\n",
1320 ito_templates::ITO_START_MARKER,
1321 ito_templates::ITO_END_MARKER
1322 );
1323 let opts = InitOptions::new(BTreeSet::new(), false, false);
1324 write_one(
1325 &target,
1326 template.as_bytes(),
1327 InstallMode::Init,
1328 &opts,
1329 FileOwnership::ItoManaged,
1330 )
1331 .unwrap();
1332 let s = std::fs::read_to_string(&target).unwrap();
1333 assert!(s.contains("new"));
1334 assert!(!s.contains("old"));
1335 }
1336
1337 #[test]
1338 fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
1339 let td = tempfile::tempdir().unwrap();
1340 let target = td.path().join("managed.md");
1341 std::fs::write(
1343 &target,
1344 format!(
1345 "{}\nexisting without end marker\n",
1346 ito_templates::ITO_START_MARKER
1347 ),
1348 )
1349 .unwrap();
1350
1351 let template = format!(
1352 "before\n{}\nmanaged\n{}\nafter\n",
1353 ito_templates::ITO_START_MARKER,
1354 ito_templates::ITO_END_MARKER
1355 );
1356 let opts = InitOptions::new(BTreeSet::new(), false, true);
1357 let err = write_one(
1358 &target,
1359 template.as_bytes(),
1360 InstallMode::Init,
1361 &opts,
1362 FileOwnership::ItoManaged,
1363 )
1364 .unwrap_err();
1365 assert!(err.to_string().contains("Failed to update markers"));
1366 }
1367}