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;
19mod project_guidance_cleanup;
20mod retired_cleanup;
21
22use ito_config::ConfigContext;
23use ito_config::ito_dir::get_ito_dir_name;
24use ito_templates::project_templates::WorktreeTemplateContext;
25
26pub const TOOL_CLAUDE: &str = "claude";
28pub const TOOL_CODEX: &str = "codex";
30pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
32pub const TOOL_OPENCODE: &str = "opencode";
34pub const TOOL_PI: &str = "pi";
36
37const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
38
39pub fn available_tool_ids() -> &'static [&'static str] {
41 &[
42 TOOL_CLAUDE,
43 TOOL_CODEX,
44 TOOL_GITHUB_COPILOT,
45 TOOL_OPENCODE,
46 TOOL_PI,
47 ]
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct InitOptions {
53 pub tools: BTreeSet<String>,
55 pub force: bool,
57 pub update: bool,
64 pub upgrade: bool,
74}
75
76impl InitOptions {
77 pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
84 Self {
85 tools,
86 force,
87 update,
88 upgrade: false,
89 }
90 }
91
92 pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
100 Self {
101 tools,
102 force: false,
103 update: true,
104 upgrade: true,
105 }
106 }
107
108 pub fn with_upgrade(mut self) -> Self {
115 self.upgrade = true;
116 self.update = true;
117 self.force = false;
118 self
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum InstallMode {
125 Init,
127 Update,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132enum FileOwnership {
133 ItoManaged,
134 UserOwned,
135}
136
137pub fn install_default_templates(
143 project_root: &Path,
144 ctx: &ConfigContext,
145 mode: InstallMode,
146 opts: &InitOptions,
147 worktree_ctx: Option<&WorktreeTemplateContext>,
148) -> CoreResult<()> {
149 let ito_dir_name = get_ito_dir_name(project_root, ctx);
150 let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
151
152 if mode == InstallMode::Update || opts.update || opts.upgrade || opts.force {
153 let report = retired_cleanup::cleanup_retired_surfaces(project_root, &opts.tools)?;
154 for removed in report.removed {
155 let replacement = removed.replacement.unwrap_or("no Ito replacement");
156 eprintln!(
157 "removed retired Ito surface {}; replacement: {replacement}",
158 removed.path.display()
159 );
160 }
161 for preserved in report.preserved {
162 let replacement = preserved.replacement.unwrap_or("no Ito replacement");
163 eprintln!(
164 "warning: preserving retired Ito surface {} because it contains user content outside the managed shell; replacement: {replacement}",
165 preserved.path.display()
166 );
167 }
168 }
169
170 install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
171
172 if mode == InstallMode::Update || opts.update || opts.force {
176 remove_obsolete_tmux_skills(project_root)?;
177 }
178
179 if mode == InstallMode::Init {
182 ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
183 ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
184 remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
185 }
186
187 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
189
190 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
191 install_agent_templates(project_root, mode, opts)?;
192 Ok(())
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct LegacyPathHit {
198 pub relative_path: String,
200 pub description: &'static str,
202 pub replacement: Option<&'static str>,
204}
205
206pub fn detect_legacy_paths(project_root: &Path) -> Vec<LegacyPathHit> {
208 let skill_roots = [
209 ".claude/skills",
210 ".opencode/skills",
211 ".codex/skills",
212 ".github/skills",
213 ".pi/skills",
214 ];
215 let mut hits = Vec::new();
216 for entry in ito_templates::legacy::LEGACY_ENTRIES {
217 if entry.old_path.starts_with('.') {
218 push_legacy_hit_if_exists(project_root, entry.old_path, entry, &mut hits);
219 } else {
220 for root in skill_roots {
221 let rel = format!("{root}/{}", entry.old_path);
222 push_legacy_hit_if_exists(project_root, &rel, entry, &mut hits);
223 }
224 }
225 }
226 hits.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
227 hits
228}
229
230pub fn remove_legacy_paths(project_root: &Path, hits: &[LegacyPathHit]) -> CoreResult<()> {
232 for hit in hits {
233 let path = project_root.join(&hit.relative_path);
234 let metadata = match std::fs::symlink_metadata(&path) {
235 Ok(metadata) => metadata,
236 Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
237 Err(err) => {
238 return Err(CoreError::io(format!("reading {}", path.display()), err));
239 }
240 };
241 if metadata.is_dir() && !metadata.file_type().is_symlink() {
242 std::fs::remove_dir_all(&path)
243 .map_err(|e| CoreError::io(format!("removing {}", path.display()), e))?;
244 } else {
245 std::fs::remove_file(&path)
246 .map_err(|e| CoreError::io(format!("removing {}", path.display()), e))?;
247 }
248 }
249 Ok(())
250}
251
252fn push_legacy_hit_if_exists(
253 project_root: &Path,
254 rel: &str,
255 entry: &ito_templates::legacy::LegacyEntry,
256 hits: &mut Vec<LegacyPathHit>,
257) {
258 let normalized = rel.trim_end_matches('/');
259 if std::fs::symlink_metadata(project_root.join(normalized)).is_ok() {
260 hits.push(LegacyPathHit {
261 relative_path: normalized.to_string(),
262 description: entry.description,
263 replacement: entry.new_path,
264 });
265 }
266}
267
268fn remove_obsolete_tmux_skills(project_root: &Path) -> CoreResult<()> {
269 let hits: Vec<_> = detect_legacy_paths(project_root)
270 .into_iter()
271 .filter(|hit| hit.relative_path.ends_with("/ito-tmux"))
272 .collect();
273 remove_legacy_paths(project_root, &hits)
274}
275
276fn ensure_repo_gitignore_ignores_local_configs(
277 project_root: &Path,
278 ito_dir: &str,
279) -> CoreResult<()> {
280 let entry = format!("{ito_dir}/config.local.json");
283 ensure_gitignore_contains_line(project_root, &entry)?;
284
285 let entry = ".local/ito/config.json";
287 ensure_gitignore_contains_line(project_root, entry)?;
288 Ok(())
289}
290
291fn ensure_repo_gitignore_ignores_session_json(
292 project_root: &Path,
293 ito_dir: &str,
294) -> CoreResult<()> {
295 let entry = format!("{ito_dir}/session.json");
296 ensure_gitignore_contains_line(project_root, &entry)
297}
298
299fn ensure_repo_gitignore_ignores_audit_session(
301 project_root: &Path,
302 ito_dir: &str,
303) -> CoreResult<()> {
304 let entry = format!("{ito_dir}/.state/audit/.session");
305 ensure_gitignore_contains_line(project_root, &entry)
306}
307
308fn remove_repo_gitignore_unignores_audit_events(
310 project_root: &Path,
311 ito_dir: &str,
312) -> CoreResult<()> {
313 let entry = format!("!{ito_dir}/.state/audit/");
314 remove_gitignore_exact_line(project_root, &entry)
315}
316
317fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
318 let path = project_root.join(".gitignore");
319 let existing = match ito_common::io::read_to_string_std(&path) {
320 Ok(s) => Some(s),
321 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
322 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
323 };
324
325 let Some(mut s) = existing else {
326 ito_common::io::write_std(&path, format!("{entry}\n"))
327 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
328 return Ok(());
329 };
330
331 if gitignore_has_exact_line(&s, entry) {
332 return Ok(());
333 }
334
335 if !s.ends_with('\n') {
336 s.push('\n');
337 }
338 s.push_str(entry);
339 s.push('\n');
340
341 ito_common::io::write_std(&path, s)
342 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
343 Ok(())
344}
345
346fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
347 let path = project_root.join(".gitignore");
348 let existing = match ito_common::io::read_to_string_std(&path) {
349 Ok(s) => s,
350 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
351 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
352 };
353
354 let mut filtered = Vec::new();
355 let mut removed = false;
356 for line in existing.lines() {
357 if line.trim() == entry {
358 removed = true;
359 continue;
360 }
361 filtered.push(line);
362 }
363 if !removed {
364 return Ok(());
365 }
366
367 let mut updated = filtered.join("\n");
368 if !updated.is_empty() {
369 updated.push('\n');
370 }
371
372 ito_common::io::write_std(&path, updated)
373 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
374 Ok(())
375}
376
377fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
378 contents.lines().map(|l| l.trim()).any(|l| l == entry)
379}
380
381fn install_project_templates(
382 project_root: &Path,
383 ito_dir: &str,
384 mode: InstallMode,
385 opts: &InitOptions,
386 worktree_ctx: Option<&WorktreeTemplateContext>,
387) -> CoreResult<()> {
388 use ito_templates::project_templates::render_project_template;
389
390 let selected = &opts.tools;
391 let current_date = Utc::now().format("%Y-%m-%d").to_string();
392 let state_rel = format!("{ito_dir}/planning/STATE.md");
393 let config_json_rel = format!("{ito_dir}/config.json");
394 let release_tag = release_tag();
395 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
396 let default_ctx = WorktreeTemplateContext::default();
397 let ctx = worktree_ctx.unwrap_or(&default_ctx);
398
399 for f in ito_templates::default_project_files() {
400 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
401 let rel = rel.as_ref();
402
403 if !should_install_project_rel(rel, selected) {
404 continue;
405 }
406
407 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
408 if let Ok(s) = std::str::from_utf8(&bytes) {
409 if rel == state_rel {
410 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
411 } else if rel == config_json_rel {
412 bytes = s
413 .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
414 .into_bytes();
415 }
416 }
417
418 if rel == "AGENTS.md" {
423 bytes = render_project_template(&bytes, ctx).map_err(|e| {
424 CoreError::Validation(format!("Failed to render template {rel}: {e}"))
425 })?;
426 }
427
428 if rel.ends_with(".md")
430 && !rel.ends_with(".md.j2")
431 && let Ok(text) = std::str::from_utf8(&bytes)
432 && text.contains(ito_templates::ITO_START_MARKER)
433 {
434 bytes = ito_templates::stamp_version(text, semver).into_bytes();
435 }
436
437 let ownership = classify_project_file_ownership(rel, ito_dir);
438
439 let target = project_root.join(rel);
440 if rel == "AGENTS.md"
441 && (mode == InstallMode::Update || opts.update || opts.upgrade)
442 && project_guidance_cleanup::remove_retired_default_guidance(&target)?
443 {
444 eprintln!(
445 "removed retired Ito default project guidance from {}",
446 target.display()
447 );
448 }
449 if rel == ".claude/settings.json" {
450 write_claude_settings(&target, &bytes, mode, opts)?;
451 continue;
452 }
453 write_one(&target, &bytes, mode, opts, ownership)?;
454 }
455
456 Ok(())
457}
458
459fn release_tag() -> String {
460 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
461 if version.starts_with('v') {
462 return version.to_string();
463 }
464
465 format!("v{version}")
466}
467
468fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
469 if rel == "AGENTS.md" {
471 return true;
472 }
473 if rel.starts_with(".ito/") {
474 return true;
475 }
476
477 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
479 return tools.contains(TOOL_CLAUDE);
480 }
481 if rel.starts_with(".opencode/") {
482 return tools.contains(TOOL_OPENCODE);
483 }
484 if rel.starts_with(".github/") {
485 return tools.contains(TOOL_GITHUB_COPILOT);
486 }
487 if rel.starts_with(".codex/") {
488 return tools.contains(TOOL_CODEX);
489 }
490 if rel.starts_with(".pi/") {
491 return tools.contains(TOOL_PI);
492 }
493
494 false
496}
497
498fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
499 let project_md_rel = format!("{ito_dir}/project.md");
500 if rel == project_md_rel {
501 return FileOwnership::UserOwned;
502 }
503
504 let config_json_rel = format!("{ito_dir}/config.json");
505 if rel == config_json_rel {
506 return FileOwnership::UserOwned;
507 }
508
509 let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
510 if rel == user_guidance_rel {
511 return FileOwnership::UserOwned;
512 }
513
514 let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
515 if rel.starts_with(&user_prompts_prefix) {
516 return FileOwnership::UserOwned;
517 }
518
519 let wiki_prefix = format!("{ito_dir}/wiki/");
520 if rel.starts_with(&wiki_prefix) {
521 return FileOwnership::UserOwned;
522 }
523
524 FileOwnership::ItoManaged
525}
526
527fn template_is_entirely_managed(text: &str) -> bool {
536 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
537 return false;
538 };
539 let Some(end) = text.find(ito_templates::ITO_END_MARKER) else {
540 return false;
541 };
542 let before = text[..start].trim();
543 let after = text[end + ito_templates::ITO_END_MARKER.len()..].trim();
544 before.is_empty() && after.is_empty()
545}
546
547fn template_has_prefix_outside_markers(text: &str) -> bool {
554 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
555 return false;
556 };
557 !text[..start].trim().is_empty()
558}
559
560fn render_and_stamp_agent(
565 contents: &[u8],
566 config: Option<&ito_templates::agents::AgentConfig>,
567 target: &Path,
568) -> Vec<u8> {
569 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
570 let rendered = match (std::str::from_utf8(contents), config) {
571 (Ok(template_str), Some(cfg)) => {
572 ito_templates::agents::render_agent_template(template_str, cfg).into_bytes()
573 }
574 _ => contents.to_vec(),
575 };
576 let path_str = target.to_string_lossy();
577 if !path_str.ends_with(".md") || path_str.ends_with(".md.j2") {
578 return rendered;
579 }
580 let Ok(text) = std::str::from_utf8(&rendered) else {
581 return rendered;
582 };
583 if !text.contains(ito_templates::ITO_START_MARKER) {
584 return rendered;
585 }
586 ito_templates::stamp_version(text, semver).into_bytes()
587}
588
589pub(crate) fn write_marker_aware_markdown(
611 target: &Path,
612 rendered_bytes: &[u8],
613 mode: InstallMode,
614 opts: &InitOptions,
615) -> CoreResult<()> {
616 if let Some(parent) = target.parent() {
617 ito_common::io::create_dir_all_std(parent)
618 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
619 }
620
621 let wholesale = |target: &Path| -> CoreResult<()> {
622 ito_common::io::write_std(target, rendered_bytes)
623 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
624 };
625
626 if !target.exists() {
627 return wholesale(target);
628 }
629
630 if mode == InstallMode::Init && opts.force {
631 return wholesale(target);
632 }
633
634 let Ok(text) = std::str::from_utf8(rendered_bytes) else {
635 return wholesale(target);
636 };
637 let Some(block) = ito_templates::extract_managed_block(text) else {
638 return wholesale(target);
639 };
640
641 let existing = ito_common::io::read_to_string_std(target)
642 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
643 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
644 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
645 match (has_start, has_end) {
646 (false, false) => return wholesale(target),
647 (true, true) => {}
648 (true, false) | (false, true) => {
649 return Err(CoreError::Validation(format!(
655 "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
656Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
657 target.display()
658 )));
659 }
660 }
661
662 let _ = update_file_with_markers(
663 target,
664 block,
665 ito_templates::ITO_START_MARKER,
666 ito_templates::ITO_END_MARKER,
667 )
668 .map_err(|e| match e {
669 markers::FsEditError::Io(io_err) => {
670 CoreError::io(format!("updating markers in {}", target.display()), io_err)
671 }
672 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
673 "Failed to update markers in {}: {}",
674 target.display(),
675 marker_err
676 )),
677 })?;
678 Ok(())
679}
680
681fn write_one(
696 target: &Path,
697 rendered_bytes: &[u8],
698 mode: InstallMode,
699 opts: &InitOptions,
700 ownership: FileOwnership,
701) -> CoreResult<()> {
702 if let Some(parent) = target.parent() {
703 ito_common::io::create_dir_all_std(parent)
704 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
705 }
706
707 if let Ok(text) = std::str::from_utf8(rendered_bytes)
709 && let Some(block) = ito_templates::extract_managed_block(text)
710 {
711 if target.exists() {
712 if mode == InstallMode::Init && opts.force {
714 ito_common::io::write_std(target, rendered_bytes)
715 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
716 return Ok(());
717 }
718
719 if ownership == FileOwnership::UserOwned {
724 let updating = mode == InstallMode::Update
725 || (mode == InstallMode::Init && (opts.update || opts.upgrade));
726 if updating {
727 return Ok(());
728 }
729 }
730
731 let existing = ito_common::io::read_to_string_std(target)
733 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
734 let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
735 && existing.contains(ito_templates::ITO_END_MARKER);
736
737 if !has_markers {
738 if opts.upgrade {
739 eprintln!(
743 "warning: skipping upgrade of {} — Ito markers not found.\n\
744 To restore managed upgrade support, re-add the markers manually:\n\
745 \n\
746 {start}\n\
747 <ito-managed content>\n\
748 {end}\n\
749 \n\
750 Then re-run `ito init --upgrade`.",
751 target.display(),
752 start = ito_templates::ITO_START_MARKER,
753 end = ito_templates::ITO_END_MARKER,
754 );
755 return Ok(());
756 }
757
758 if mode == InstallMode::Init && !opts.update {
759 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 existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
772 && !existing.contains(ito_templates::ITO_END_MARKER);
773 if existing_has_no_markers {
774 if template_is_entirely_managed(text)
793 || template_has_prefix_outside_markers(text)
794 {
795 ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
796 CoreError::io(format!("writing {}", target.display()), e)
797 })?;
798 return Ok(());
799 }
800 }
801 }
802
803 update_file_with_markers(
804 target,
805 block,
806 ito_templates::ITO_START_MARKER,
807 ito_templates::ITO_END_MARKER,
808 )
809 .map_err(|e| match e {
810 markers::FsEditError::Io(io_err) => {
811 CoreError::io(format!("updating markers in {}", target.display()), io_err)
812 }
813 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
814 "Failed to update markers in {}: {}",
815 target.display(),
816 marker_err
817 )),
818 })?;
819 } else {
820 ito_common::io::write_std(target, rendered_bytes)
822 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
823 }
824
825 return Ok(());
826 }
827
828 if target.exists() {
829 match mode {
830 InstallMode::Init => {
831 if opts.force {
832 } else if opts.update {
834 if ownership == FileOwnership::UserOwned {
835 return Ok(());
836 }
837 } else {
838 return Err(CoreError::Validation(format!(
839 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
840 target.display()
841 )));
842 }
843 }
844 InstallMode::Update => {
845 if ownership == FileOwnership::UserOwned {
846 return Ok(());
847 }
848 }
849 }
850 }
851
852 ito_common::io::write_std(target, rendered_bytes)
853 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
854 Ok(())
855}
856
857fn write_claude_settings(
858 target: &Path,
859 rendered_bytes: &[u8],
860 mode: InstallMode,
861 opts: &InitOptions,
862) -> CoreResult<()> {
863 if let Some(parent) = target.parent() {
864 ito_common::io::create_dir_all_std(parent)
865 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
866 }
867
868 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
869 return Err(CoreError::Validation(format!(
870 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
871 target.display()
872 )));
873 }
874
875 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
876 CoreError::Validation(format!(
877 "Failed to parse Claude settings template {}: {}",
878 target.display(),
879 e
880 ))
881 })?;
882
883 if !target.exists() || (mode == InstallMode::Init && opts.force) {
884 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
885 CoreError::Validation(format!(
886 "Failed to render Claude settings template {}: {}",
887 target.display(),
888 e
889 ))
890 })?;
891 bytes.push(b'\n');
892 ito_common::io::write_std(target, bytes)
893 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
894 return Ok(());
895 }
896
897 let existing_raw = ito_common::io::read_to_string_std(target)
898 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
899 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
900 return Ok(());
902 };
903
904 merge_json_objects(&mut existing_value, &template_value);
905 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
906 CoreError::Validation(format!(
907 "Failed to render merged Claude settings {}: {}",
908 target.display(),
909 e
910 ))
911 })?;
912 merged.push(b'\n');
913 ito_common::io::write_std(target, merged)
914 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
915 Ok(())
916}
917
918fn merge_json_objects(existing: &mut Value, template: &Value) {
919 let Value::Object(template_map) = template else {
920 *existing = template.clone();
921 return;
922 };
923 if !existing.is_object() {
924 *existing = Value::Object(Map::new());
925 }
926
927 let Some(existing_map) = existing.as_object_mut() else {
928 return;
929 };
930
931 for (key, template_value) in template_map {
932 if let Some(existing_value) = existing_map.get_mut(key) {
933 merge_json_values(existing_value, template_value);
934 } else {
935 existing_map.insert(key.clone(), template_value.clone());
936 }
937 }
938}
939
940fn merge_json_values(existing: &mut Value, template: &Value) {
941 match (existing, template) {
942 (Value::Object(existing_map), Value::Object(template_map)) => {
943 for (key, template_value) in template_map {
944 if let Some(existing_value) = existing_map.get_mut(key) {
945 merge_json_values(existing_value, template_value);
946 } else {
947 existing_map.insert(key.clone(), template_value.clone());
948 }
949 }
950 }
951 (Value::Array(existing_items), Value::Array(template_items)) => {
952 for template_item in template_items {
953 if !existing_items.contains(template_item) {
954 existing_items.push(template_item.clone());
955 }
956 }
957 }
958 (existing_value, template_value) => *existing_value = template_value.clone(),
959 }
960}
961
962fn install_adapter_files(
963 project_root: &Path,
964 mode: InstallMode,
965 opts: &InitOptions,
966 worktree_ctx: Option<&WorktreeTemplateContext>,
967) -> CoreResult<()> {
968 for tool in &opts.tools {
969 match tool.as_str() {
970 TOOL_OPENCODE => {
971 let config_dir = project_root.join(".opencode");
972 let manifests = crate::distribution::opencode_manifests(&config_dir);
973 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
974 }
975 TOOL_CLAUDE => {
976 let manifests = crate::distribution::claude_manifests(project_root);
977 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
978 }
979 TOOL_CODEX => {
980 let manifests = crate::distribution::codex_manifests(project_root);
981 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
982 }
983 TOOL_GITHUB_COPILOT => {
984 let manifests = crate::distribution::github_manifests(project_root);
985 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
986 }
987 TOOL_PI => {
988 let manifests = crate::distribution::pi_manifests(project_root);
989 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
990 }
991 _ => {}
992 }
993 }
994
995 Ok(())
996}
997
998fn install_agent_templates(
1000 project_root: &Path,
1001 mode: InstallMode,
1002 opts: &InitOptions,
1003) -> CoreResult<()> {
1004 use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
1005
1006 let configs = default_agent_configs();
1007
1008 let tool_harness_map = [
1010 (TOOL_OPENCODE, Harness::OpenCode),
1011 (TOOL_CLAUDE, Harness::ClaudeCode),
1012 (TOOL_CODEX, Harness::Codex),
1013 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
1014 (TOOL_PI, Harness::Pi),
1015 ];
1016
1017 for (tool_id, harness) in tool_harness_map {
1018 if !opts.tools.contains(tool_id) {
1019 continue;
1020 }
1021
1022 let Some(agent_path) = harness.project_agent_path() else {
1023 continue;
1024 };
1025 let agent_dir = project_root.join(agent_path);
1026 let should_remove_obsolete_specialists =
1030 mode == InstallMode::Update || opts.update || opts.force;
1031 if should_remove_obsolete_specialists {
1032 remove_obsolete_specialist_agents(&agent_dir)?;
1033 }
1034
1035 let files = get_agent_files(harness);
1037
1038 for (rel_path, contents) in files {
1039 let target = agent_dir.join(rel_path);
1040
1041 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
1043 Some(AgentTier::Quick)
1044 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
1045 Some(AgentTier::General)
1046 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
1047 Some(AgentTier::Thinking)
1048 } else {
1049 None
1050 };
1051
1052 let mut config = tier.and_then(|t| configs.get(&(harness, t)));
1058 if config.is_none()
1059 && let Ok(s) = std::str::from_utf8(contents)
1060 && s.contains("{{model}}")
1061 {
1062 config = configs.get(&(harness, AgentTier::General));
1063 }
1064
1065 match mode {
1066 InstallMode::Init => {
1067 if target.exists() && !opts.force {
1068 if opts.update {
1069 let rendered = render_and_stamp_agent(contents, config, &target);
1070 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
1071 }
1072 continue;
1073 }
1074
1075 let rendered = render_and_stamp_agent(contents, config, &target);
1076 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
1077 normalize_agent_frontmatter(&target, &rendered, config)?;
1078 }
1079 InstallMode::Update => {
1080 let rendered = render_and_stamp_agent(contents, config, &target);
1081 if target.exists() {
1082 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
1083 } else {
1084 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
1085 normalize_agent_frontmatter(&target, &rendered, config)?;
1086 }
1087 }
1088 }
1089 }
1090 }
1091
1092 Ok(())
1093}
1094fn update_existing_agent_template(
1101 target: &Path,
1102 rendered: &[u8],
1103 mode: InstallMode,
1104 opts: &InitOptions,
1105 config: Option<&ito_templates::agents::AgentConfig>,
1106) -> CoreResult<()> {
1107 let existing = ito_common::io::read_to_string_std(target)
1108 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
1109 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
1110 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
1111
1112 match (has_start, has_end) {
1113 (true, true) => write_marker_aware_markdown(target, rendered, mode, opts)?,
1114 (false, false) => {}
1115 (true, false) | (false, true) => {
1116 eprintln!(
1117 "warning: skipping marker update for {}: file has a partial Ito marker pair \
1118 (start={has_start}, end={has_end}). Restore both markers manually, or rerun \
1119 with `--force` to overwrite the file wholesale.",
1120 target.display()
1121 );
1122 }
1123 }
1124
1125 normalize_agent_frontmatter(target, rendered, config)
1126}
1127
1128fn normalize_agent_frontmatter(
1129 target: &Path,
1130 rendered: &[u8],
1131 config: Option<&ito_templates::agents::AgentConfig>,
1132) -> CoreResult<()> {
1133 if let Some(config) = config {
1134 update_agent_model_field(target, &config.model)?;
1135 }
1136 update_agent_activation_field_from_rendered(target, rendered)?;
1137 remove_agent_mode_field_for_direct_activation(target, rendered)
1138}
1139
1140#[cfg(test)]
1141mod json_tests;
1142
1143#[cfg(test)]
1144mod installers_tests;