1use std::collections::BTreeSet;
2use std::path::Path;
3
4use chrono::Utc;
5use serde_json::{Map, Value};
6
7use crate::errors::{CoreError, CoreResult};
8use agent_frontmatter::{
9 remove_agent_mode_field_for_direct_activation, update_agent_activation_field_from_rendered,
10 update_agent_model_field,
11};
12use agents_cleanup::remove_obsolete_specialist_agents;
13
14use markers::update_file_with_markers;
15
16mod agent_frontmatter;
17mod agents_cleanup;
18mod markers;
19
20use ito_config::ConfigContext;
21use ito_config::ito_dir::get_ito_dir_name;
22use ito_templates::project_templates::WorktreeTemplateContext;
23
24pub const TOOL_CLAUDE: &str = "claude";
26pub const TOOL_CODEX: &str = "codex";
28pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
30pub const TOOL_OPENCODE: &str = "opencode";
32pub const TOOL_PI: &str = "pi";
34
35const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
36
37pub fn available_tool_ids() -> &'static [&'static str] {
39 &[
40 TOOL_CLAUDE,
41 TOOL_CODEX,
42 TOOL_GITHUB_COPILOT,
43 TOOL_OPENCODE,
44 TOOL_PI,
45 ]
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct InitOptions {
51 pub tools: BTreeSet<String>,
53 pub force: bool,
55 pub update: bool,
62 pub upgrade: bool,
72}
73
74impl InitOptions {
75 pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
82 Self {
83 tools,
84 force,
85 update,
86 upgrade: false,
87 }
88 }
89
90 pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
98 Self {
99 tools,
100 force: false,
101 update: true,
102 upgrade: true,
103 }
104 }
105
106 pub fn with_upgrade(mut self) -> Self {
113 self.upgrade = true;
114 self.update = true;
115 self.force = false;
116 self
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum InstallMode {
123 Init,
125 Update,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130enum FileOwnership {
131 ItoManaged,
132 UserOwned,
133}
134
135pub fn install_default_templates(
141 project_root: &Path,
142 ctx: &ConfigContext,
143 mode: InstallMode,
144 opts: &InitOptions,
145 worktree_ctx: Option<&WorktreeTemplateContext>,
146) -> CoreResult<()> {
147 let ito_dir_name = get_ito_dir_name(project_root, ctx);
148 let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
149
150 install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
151
152 if mode == InstallMode::Init {
155 ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
156 ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
157 remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
158 }
159
160 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
162
163 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
164 install_agent_templates(project_root, mode, opts)?;
165 Ok(())
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct LegacyPathHit {
171 pub relative_path: String,
173 pub description: &'static str,
175 pub replacement: Option<&'static str>,
177}
178
179pub fn detect_legacy_paths(project_root: &Path) -> Vec<LegacyPathHit> {
181 let skill_roots = [
182 ".claude/skills",
183 ".opencode/skills",
184 ".codex/skills",
185 ".github/skills",
186 ".pi/skills",
187 ];
188 let mut hits = Vec::new();
189 for entry in ito_templates::legacy::LEGACY_ENTRIES {
190 if entry.old_path.starts_with('.') {
191 push_legacy_hit_if_exists(project_root, entry.old_path, entry, &mut hits);
192 } else {
193 for root in skill_roots {
194 let rel = format!("{root}/{}", entry.old_path);
195 push_legacy_hit_if_exists(project_root, &rel, entry, &mut hits);
196 }
197 }
198 }
199 hits.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
200 hits
201}
202
203pub fn remove_legacy_paths(project_root: &Path, hits: &[LegacyPathHit]) -> CoreResult<()> {
205 for hit in hits {
206 let path = project_root.join(&hit.relative_path);
207 if path.is_dir() {
208 std::fs::remove_dir_all(&path)
209 .map_err(|e| CoreError::io(format!("removing {}", path.display()), e))?;
210 } else if path.exists() {
211 std::fs::remove_file(&path)
212 .map_err(|e| CoreError::io(format!("removing {}", path.display()), e))?;
213 }
214 }
215 Ok(())
216}
217
218fn push_legacy_hit_if_exists(
219 project_root: &Path,
220 rel: &str,
221 entry: &ito_templates::legacy::LegacyEntry,
222 hits: &mut Vec<LegacyPathHit>,
223) {
224 let normalized = rel.trim_end_matches('/');
225 if project_root.join(normalized).exists() {
226 hits.push(LegacyPathHit {
227 relative_path: normalized.to_string(),
228 description: entry.description,
229 replacement: entry.new_path,
230 });
231 }
232}
233
234fn ensure_repo_gitignore_ignores_local_configs(
235 project_root: &Path,
236 ito_dir: &str,
237) -> CoreResult<()> {
238 let entry = format!("{ito_dir}/config.local.json");
241 ensure_gitignore_contains_line(project_root, &entry)?;
242
243 let entry = ".local/ito/config.json";
245 ensure_gitignore_contains_line(project_root, entry)?;
246 Ok(())
247}
248
249fn ensure_repo_gitignore_ignores_session_json(
250 project_root: &Path,
251 ito_dir: &str,
252) -> CoreResult<()> {
253 let entry = format!("{ito_dir}/session.json");
254 ensure_gitignore_contains_line(project_root, &entry)
255}
256
257fn ensure_repo_gitignore_ignores_audit_session(
259 project_root: &Path,
260 ito_dir: &str,
261) -> CoreResult<()> {
262 let entry = format!("{ito_dir}/.state/audit/.session");
263 ensure_gitignore_contains_line(project_root, &entry)
264}
265
266fn remove_repo_gitignore_unignores_audit_events(
268 project_root: &Path,
269 ito_dir: &str,
270) -> CoreResult<()> {
271 let entry = format!("!{ito_dir}/.state/audit/");
272 remove_gitignore_exact_line(project_root, &entry)
273}
274
275fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
276 let path = project_root.join(".gitignore");
277 let existing = match ito_common::io::read_to_string_std(&path) {
278 Ok(s) => Some(s),
279 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
280 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
281 };
282
283 let Some(mut s) = existing else {
284 ito_common::io::write_std(&path, format!("{entry}\n"))
285 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
286 return Ok(());
287 };
288
289 if gitignore_has_exact_line(&s, entry) {
290 return Ok(());
291 }
292
293 if !s.ends_with('\n') {
294 s.push('\n');
295 }
296 s.push_str(entry);
297 s.push('\n');
298
299 ito_common::io::write_std(&path, s)
300 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
301 Ok(())
302}
303
304fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
305 let path = project_root.join(".gitignore");
306 let existing = match ito_common::io::read_to_string_std(&path) {
307 Ok(s) => s,
308 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
309 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
310 };
311
312 let mut filtered = Vec::new();
313 let mut removed = false;
314 for line in existing.lines() {
315 if line.trim() == entry {
316 removed = true;
317 continue;
318 }
319 filtered.push(line);
320 }
321 if !removed {
322 return Ok(());
323 }
324
325 let mut updated = filtered.join("\n");
326 if !updated.is_empty() {
327 updated.push('\n');
328 }
329
330 ito_common::io::write_std(&path, updated)
331 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
332 Ok(())
333}
334
335fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
336 contents.lines().map(|l| l.trim()).any(|l| l == entry)
337}
338
339fn install_project_templates(
340 project_root: &Path,
341 ito_dir: &str,
342 mode: InstallMode,
343 opts: &InitOptions,
344 worktree_ctx: Option<&WorktreeTemplateContext>,
345) -> CoreResult<()> {
346 use ito_templates::project_templates::render_project_template;
347
348 let selected = &opts.tools;
349 let current_date = Utc::now().format("%Y-%m-%d").to_string();
350 let state_rel = format!("{ito_dir}/planning/STATE.md");
351 let config_json_rel = format!("{ito_dir}/config.json");
352 let release_tag = release_tag();
353 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
354 let default_ctx = WorktreeTemplateContext::default();
355 let ctx = worktree_ctx.unwrap_or(&default_ctx);
356
357 for f in ito_templates::default_project_files() {
358 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
359 let rel = rel.as_ref();
360
361 if !should_install_project_rel(rel, selected) {
362 continue;
363 }
364
365 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
366 if let Ok(s) = std::str::from_utf8(&bytes) {
367 if rel == state_rel {
368 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
369 } else if rel == config_json_rel {
370 bytes = s
371 .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
372 .into_bytes();
373 }
374 }
375
376 if rel == "AGENTS.md" {
381 bytes = render_project_template(&bytes, ctx).map_err(|e| {
382 CoreError::Validation(format!("Failed to render template {rel}: {e}"))
383 })?;
384 }
385
386 if rel.ends_with(".md")
388 && !rel.ends_with(".md.j2")
389 && let Ok(text) = std::str::from_utf8(&bytes)
390 && text.contains(ito_templates::ITO_START_MARKER)
391 {
392 bytes = ito_templates::stamp_version(text, semver).into_bytes();
393 }
394
395 let ownership = classify_project_file_ownership(rel, ito_dir);
396
397 let target = project_root.join(rel);
398 if rel == ".claude/settings.json" {
399 write_claude_settings(&target, &bytes, mode, opts)?;
400 continue;
401 }
402 write_one(&target, &bytes, mode, opts, ownership)?;
403 }
404
405 Ok(())
406}
407
408fn release_tag() -> String {
409 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
410 if version.starts_with('v') {
411 return version.to_string();
412 }
413
414 format!("v{version}")
415}
416
417fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
418 if rel == "AGENTS.md" {
420 return true;
421 }
422 if rel.starts_with(".ito/") {
423 return true;
424 }
425
426 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
428 return tools.contains(TOOL_CLAUDE);
429 }
430 if rel.starts_with(".opencode/") {
431 return tools.contains(TOOL_OPENCODE);
432 }
433 if rel.starts_with(".github/") {
434 return tools.contains(TOOL_GITHUB_COPILOT);
435 }
436 if rel.starts_with(".codex/") {
437 return tools.contains(TOOL_CODEX);
438 }
439 if rel.starts_with(".pi/") {
440 return tools.contains(TOOL_PI);
441 }
442
443 false
445}
446
447fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
448 let project_md_rel = format!("{ito_dir}/project.md");
449 if rel == project_md_rel {
450 return FileOwnership::UserOwned;
451 }
452
453 let config_json_rel = format!("{ito_dir}/config.json");
454 if rel == config_json_rel {
455 return FileOwnership::UserOwned;
456 }
457
458 let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
459 if rel == user_guidance_rel {
460 return FileOwnership::UserOwned;
461 }
462
463 let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
464 if rel.starts_with(&user_prompts_prefix) {
465 return FileOwnership::UserOwned;
466 }
467
468 let wiki_prefix = format!("{ito_dir}/wiki/");
469 if rel.starts_with(&wiki_prefix) {
470 return FileOwnership::UserOwned;
471 }
472
473 FileOwnership::ItoManaged
474}
475
476fn template_is_entirely_managed(text: &str) -> bool {
485 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
486 return false;
487 };
488 let Some(end) = text.find(ito_templates::ITO_END_MARKER) else {
489 return false;
490 };
491 let before = text[..start].trim();
492 let after = text[end + ito_templates::ITO_END_MARKER.len()..].trim();
493 before.is_empty() && after.is_empty()
494}
495
496fn template_has_prefix_outside_markers(text: &str) -> bool {
503 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
504 return false;
505 };
506 !text[..start].trim().is_empty()
507}
508
509fn render_and_stamp_agent(
514 contents: &[u8],
515 config: Option<&ito_templates::agents::AgentConfig>,
516 target: &Path,
517) -> Vec<u8> {
518 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
519 let rendered = match (std::str::from_utf8(contents), config) {
520 (Ok(template_str), Some(cfg)) => {
521 ito_templates::agents::render_agent_template(template_str, cfg).into_bytes()
522 }
523 _ => contents.to_vec(),
524 };
525 let path_str = target.to_string_lossy();
526 if !path_str.ends_with(".md") || path_str.ends_with(".md.j2") {
527 return rendered;
528 }
529 let Ok(text) = std::str::from_utf8(&rendered) else {
530 return rendered;
531 };
532 if !text.contains(ito_templates::ITO_START_MARKER) {
533 return rendered;
534 }
535 ito_templates::stamp_version(text, semver).into_bytes()
536}
537
538pub(crate) fn write_marker_aware_markdown(
560 target: &Path,
561 rendered_bytes: &[u8],
562 mode: InstallMode,
563 opts: &InitOptions,
564) -> CoreResult<()> {
565 if let Some(parent) = target.parent() {
566 ito_common::io::create_dir_all_std(parent)
567 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
568 }
569
570 let wholesale = |target: &Path| -> CoreResult<()> {
571 ito_common::io::write_std(target, rendered_bytes)
572 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
573 };
574
575 if !target.exists() {
576 return wholesale(target);
577 }
578
579 if mode == InstallMode::Init && opts.force {
580 return wholesale(target);
581 }
582
583 let Ok(text) = std::str::from_utf8(rendered_bytes) else {
584 return wholesale(target);
585 };
586 let Some(block) = ito_templates::extract_managed_block(text) else {
587 return wholesale(target);
588 };
589
590 let existing = ito_common::io::read_to_string_std(target)
591 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
592 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
593 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
594 match (has_start, has_end) {
595 (false, false) => return wholesale(target),
596 (true, true) => {}
597 (true, false) | (false, true) => {
598 return Err(CoreError::Validation(format!(
604 "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
605Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
606 target.display()
607 )));
608 }
609 }
610
611 let _ = update_file_with_markers(
612 target,
613 block,
614 ito_templates::ITO_START_MARKER,
615 ito_templates::ITO_END_MARKER,
616 )
617 .map_err(|e| match e {
618 markers::FsEditError::Io(io_err) => {
619 CoreError::io(format!("updating markers in {}", target.display()), io_err)
620 }
621 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
622 "Failed to update markers in {}: {}",
623 target.display(),
624 marker_err
625 )),
626 })?;
627 Ok(())
628}
629
630fn write_one(
645 target: &Path,
646 rendered_bytes: &[u8],
647 mode: InstallMode,
648 opts: &InitOptions,
649 ownership: FileOwnership,
650) -> CoreResult<()> {
651 if let Some(parent) = target.parent() {
652 ito_common::io::create_dir_all_std(parent)
653 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
654 }
655
656 if let Ok(text) = std::str::from_utf8(rendered_bytes)
658 && let Some(block) = ito_templates::extract_managed_block(text)
659 {
660 if target.exists() {
661 if mode == InstallMode::Init && opts.force {
663 ito_common::io::write_std(target, rendered_bytes)
664 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
665 return Ok(());
666 }
667
668 if ownership == FileOwnership::UserOwned {
673 let updating = mode == InstallMode::Update
674 || (mode == InstallMode::Init && (opts.update || opts.upgrade));
675 if updating {
676 return Ok(());
677 }
678 }
679
680 let existing = ito_common::io::read_to_string_std(target)
682 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
683 let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
684 && existing.contains(ito_templates::ITO_END_MARKER);
685
686 if !has_markers {
687 if opts.upgrade {
688 eprintln!(
692 "warning: skipping upgrade of {} — Ito markers not found.\n\
693 To restore managed upgrade support, re-add the markers manually:\n\
694 \n\
695 {start}\n\
696 <ito-managed content>\n\
697 {end}\n\
698 \n\
699 Then re-run `ito init --upgrade`.",
700 target.display(),
701 start = ito_templates::ITO_START_MARKER,
702 end = ito_templates::ITO_END_MARKER,
703 );
704 return Ok(());
705 }
706
707 if mode == InstallMode::Init && !opts.update {
708 return Err(CoreError::Validation(format!(
710 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
711 target.display()
712 )));
713 }
714
715 let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
721 && !existing.contains(ito_templates::ITO_END_MARKER);
722 if existing_has_no_markers {
723 if template_is_entirely_managed(text)
742 || template_has_prefix_outside_markers(text)
743 {
744 ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
745 CoreError::io(format!("writing {}", target.display()), e)
746 })?;
747 return Ok(());
748 }
749 }
750 }
751
752 update_file_with_markers(
753 target,
754 block,
755 ito_templates::ITO_START_MARKER,
756 ito_templates::ITO_END_MARKER,
757 )
758 .map_err(|e| match e {
759 markers::FsEditError::Io(io_err) => {
760 CoreError::io(format!("updating markers in {}", target.display()), io_err)
761 }
762 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
763 "Failed to update markers in {}: {}",
764 target.display(),
765 marker_err
766 )),
767 })?;
768 } else {
769 ito_common::io::write_std(target, rendered_bytes)
771 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
772 }
773
774 return Ok(());
775 }
776
777 if target.exists() {
778 match mode {
779 InstallMode::Init => {
780 if opts.force {
781 } else if opts.update {
783 if ownership == FileOwnership::UserOwned {
784 return Ok(());
785 }
786 } else {
787 return Err(CoreError::Validation(format!(
788 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
789 target.display()
790 )));
791 }
792 }
793 InstallMode::Update => {
794 if ownership == FileOwnership::UserOwned {
795 return Ok(());
796 }
797 }
798 }
799 }
800
801 ito_common::io::write_std(target, rendered_bytes)
802 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
803 Ok(())
804}
805
806fn write_claude_settings(
807 target: &Path,
808 rendered_bytes: &[u8],
809 mode: InstallMode,
810 opts: &InitOptions,
811) -> CoreResult<()> {
812 if let Some(parent) = target.parent() {
813 ito_common::io::create_dir_all_std(parent)
814 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
815 }
816
817 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
818 return Err(CoreError::Validation(format!(
819 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
820 target.display()
821 )));
822 }
823
824 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
825 CoreError::Validation(format!(
826 "Failed to parse Claude settings template {}: {}",
827 target.display(),
828 e
829 ))
830 })?;
831
832 if !target.exists() || (mode == InstallMode::Init && opts.force) {
833 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
834 CoreError::Validation(format!(
835 "Failed to render Claude settings template {}: {}",
836 target.display(),
837 e
838 ))
839 })?;
840 bytes.push(b'\n');
841 ito_common::io::write_std(target, bytes)
842 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
843 return Ok(());
844 }
845
846 let existing_raw = ito_common::io::read_to_string_std(target)
847 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
848 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
849 return Ok(());
851 };
852
853 merge_json_objects(&mut existing_value, &template_value);
854 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
855 CoreError::Validation(format!(
856 "Failed to render merged Claude settings {}: {}",
857 target.display(),
858 e
859 ))
860 })?;
861 merged.push(b'\n');
862 ito_common::io::write_std(target, merged)
863 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
864 Ok(())
865}
866
867fn merge_json_objects(existing: &mut Value, template: &Value) {
868 let Value::Object(template_map) = template else {
869 *existing = template.clone();
870 return;
871 };
872 if !existing.is_object() {
873 *existing = Value::Object(Map::new());
874 }
875
876 let Some(existing_map) = existing.as_object_mut() else {
877 return;
878 };
879
880 for (key, template_value) in template_map {
881 if let Some(existing_value) = existing_map.get_mut(key) {
882 merge_json_values(existing_value, template_value);
883 } else {
884 existing_map.insert(key.clone(), template_value.clone());
885 }
886 }
887}
888
889fn merge_json_values(existing: &mut Value, template: &Value) {
890 match (existing, template) {
891 (Value::Object(existing_map), Value::Object(template_map)) => {
892 for (key, template_value) in template_map {
893 if let Some(existing_value) = existing_map.get_mut(key) {
894 merge_json_values(existing_value, template_value);
895 } else {
896 existing_map.insert(key.clone(), template_value.clone());
897 }
898 }
899 }
900 (Value::Array(existing_items), Value::Array(template_items)) => {
901 for template_item in template_items {
902 if !existing_items.contains(template_item) {
903 existing_items.push(template_item.clone());
904 }
905 }
906 }
907 (existing_value, template_value) => *existing_value = template_value.clone(),
908 }
909}
910
911fn install_adapter_files(
912 project_root: &Path,
913 mode: InstallMode,
914 opts: &InitOptions,
915 worktree_ctx: Option<&WorktreeTemplateContext>,
916) -> CoreResult<()> {
917 for tool in &opts.tools {
918 match tool.as_str() {
919 TOOL_OPENCODE => {
920 let config_dir = project_root.join(".opencode");
921 let manifests = crate::distribution::opencode_manifests(&config_dir);
922 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
923 }
924 TOOL_CLAUDE => {
925 let manifests = crate::distribution::claude_manifests(project_root);
926 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
927 }
928 TOOL_CODEX => {
929 let manifests = crate::distribution::codex_manifests(project_root);
930 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
931 }
932 TOOL_GITHUB_COPILOT => {
933 let manifests = crate::distribution::github_manifests(project_root);
934 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
935 }
936 TOOL_PI => {
937 let manifests = crate::distribution::pi_manifests(project_root);
938 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
939 }
940 _ => {}
941 }
942 }
943
944 Ok(())
945}
946
947fn install_agent_templates(
949 project_root: &Path,
950 mode: InstallMode,
951 opts: &InitOptions,
952) -> CoreResult<()> {
953 use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
954
955 let configs = default_agent_configs();
956
957 let tool_harness_map = [
959 (TOOL_OPENCODE, Harness::OpenCode),
960 (TOOL_CLAUDE, Harness::ClaudeCode),
961 (TOOL_CODEX, Harness::Codex),
962 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
963 (TOOL_PI, Harness::Pi),
964 ];
965
966 for (tool_id, harness) in tool_harness_map {
967 if !opts.tools.contains(tool_id) {
968 continue;
969 }
970
971 let agent_dir = project_root.join(harness.project_agent_path());
972 let should_remove_obsolete_specialists =
976 mode == InstallMode::Update || opts.update || opts.force;
977 if should_remove_obsolete_specialists {
978 remove_obsolete_specialist_agents(&agent_dir)?;
979 }
980
981 let files = get_agent_files(harness);
983
984 for (rel_path, contents) in files {
985 let target = agent_dir.join(rel_path);
986
987 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
989 Some(AgentTier::Quick)
990 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
991 Some(AgentTier::General)
992 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
993 Some(AgentTier::Thinking)
994 } else {
995 None
996 };
997
998 let mut config = tier.and_then(|t| configs.get(&(harness, t)));
1004 if config.is_none()
1005 && let Ok(s) = std::str::from_utf8(contents)
1006 && s.contains("{{model}}")
1007 {
1008 config = configs.get(&(harness, AgentTier::General));
1009 }
1010
1011 match mode {
1012 InstallMode::Init => {
1013 if target.exists() && !opts.force {
1014 if opts.update {
1015 let rendered = render_and_stamp_agent(contents, config, &target);
1016 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
1017 }
1018 continue;
1019 }
1020
1021 let rendered = render_and_stamp_agent(contents, config, &target);
1022 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
1023 }
1024 InstallMode::Update => {
1025 let rendered = render_and_stamp_agent(contents, config, &target);
1026 if target.exists() {
1027 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
1028 } else {
1029 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
1030 }
1031 }
1032 }
1033 }
1034 }
1035
1036 Ok(())
1037}
1038fn update_existing_agent_template(
1045 target: &Path,
1046 rendered: &[u8],
1047 mode: InstallMode,
1048 opts: &InitOptions,
1049 config: Option<&ito_templates::agents::AgentConfig>,
1050) -> CoreResult<()> {
1051 let existing = ito_common::io::read_to_string_std(target)
1052 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
1053 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
1054 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
1055
1056 match (has_start, has_end) {
1057 (true, true) => write_marker_aware_markdown(target, rendered, mode, opts)?,
1058 (false, false) => {}
1059 (true, false) | (false, true) => {
1060 eprintln!(
1061 "warning: skipping marker update for {}: file has a partial Ito marker pair \
1062 (start={has_start}, end={has_end}). Restore both markers manually, or rerun \
1063 with `--force` to overwrite the file wholesale.",
1064 target.display()
1065 );
1066 }
1067 }
1068
1069 if let Some(cfg) = config {
1070 update_agent_model_field(target, &cfg.model)?;
1071 }
1072 update_agent_activation_field_from_rendered(target, rendered)?;
1073 remove_agent_mode_field_for_direct_activation(target, rendered)?;
1074
1075 Ok(())
1076}
1077
1078#[cfg(test)]
1079mod json_tests;
1080
1081#[cfg(test)]
1082mod installers_tests;