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::{update_agent_activation_field_from_rendered, update_agent_model_field};
9use agents_cleanup::remove_obsolete_specialist_agents;
10
11use markers::update_file_with_markers;
12
13mod agent_frontmatter;
14mod agents_cleanup;
15mod markers;
16
17use ito_config::ConfigContext;
18use ito_config::ito_dir::get_ito_dir_name;
19use ito_templates::project_templates::WorktreeTemplateContext;
20
21pub const TOOL_CLAUDE: &str = "claude";
23pub const TOOL_CODEX: &str = "codex";
25pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
27pub const TOOL_OPENCODE: &str = "opencode";
29pub const TOOL_PI: &str = "pi";
31
32const CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER: &str = "__ITO_RELEASE_TAG__";
33
34pub fn available_tool_ids() -> &'static [&'static str] {
36 &[
37 TOOL_CLAUDE,
38 TOOL_CODEX,
39 TOOL_GITHUB_COPILOT,
40 TOOL_OPENCODE,
41 TOOL_PI,
42 ]
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct InitOptions {
48 pub tools: BTreeSet<String>,
50 pub force: bool,
52 pub update: bool,
59 pub upgrade: bool,
69}
70
71impl InitOptions {
72 pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
79 Self {
80 tools,
81 force,
82 update,
83 upgrade: false,
84 }
85 }
86
87 pub fn new_upgrade(tools: BTreeSet<String>) -> Self {
95 Self {
96 tools,
97 force: false,
98 update: true,
99 upgrade: true,
100 }
101 }
102
103 pub fn with_upgrade(mut self) -> Self {
110 self.upgrade = true;
111 self.update = true;
112 self.force = false;
113 self
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum InstallMode {
120 Init,
122 Update,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127enum FileOwnership {
128 ItoManaged,
129 UserOwned,
130}
131
132pub fn install_default_templates(
138 project_root: &Path,
139 ctx: &ConfigContext,
140 mode: InstallMode,
141 opts: &InitOptions,
142 worktree_ctx: Option<&WorktreeTemplateContext>,
143) -> CoreResult<()> {
144 let ito_dir_name = get_ito_dir_name(project_root, ctx);
145 let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
146
147 install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
148
149 if mode == InstallMode::Init {
152 ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
153 ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
154 remove_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
155 }
156
157 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
159
160 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
161 install_agent_templates(project_root, mode, opts)?;
162 Ok(())
163}
164
165fn ensure_repo_gitignore_ignores_local_configs(
166 project_root: &Path,
167 ito_dir: &str,
168) -> CoreResult<()> {
169 let entry = format!("{ito_dir}/config.local.json");
172 ensure_gitignore_contains_line(project_root, &entry)?;
173
174 let entry = ".local/ito/config.json";
176 ensure_gitignore_contains_line(project_root, entry)?;
177 Ok(())
178}
179
180fn ensure_repo_gitignore_ignores_session_json(
181 project_root: &Path,
182 ito_dir: &str,
183) -> CoreResult<()> {
184 let entry = format!("{ito_dir}/session.json");
185 ensure_gitignore_contains_line(project_root, &entry)
186}
187
188fn ensure_repo_gitignore_ignores_audit_session(
190 project_root: &Path,
191 ito_dir: &str,
192) -> CoreResult<()> {
193 let entry = format!("{ito_dir}/.state/audit/.session");
194 ensure_gitignore_contains_line(project_root, &entry)
195}
196
197fn remove_repo_gitignore_unignores_audit_events(
199 project_root: &Path,
200 ito_dir: &str,
201) -> CoreResult<()> {
202 let entry = format!("!{ito_dir}/.state/audit/");
203 remove_gitignore_exact_line(project_root, &entry)
204}
205
206fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
207 let path = project_root.join(".gitignore");
208 let existing = match ito_common::io::read_to_string_std(&path) {
209 Ok(s) => Some(s),
210 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
211 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
212 };
213
214 let Some(mut s) = existing else {
215 ito_common::io::write_std(&path, format!("{entry}\n"))
216 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
217 return Ok(());
218 };
219
220 if gitignore_has_exact_line(&s, entry) {
221 return Ok(());
222 }
223
224 if !s.ends_with('\n') {
225 s.push('\n');
226 }
227 s.push_str(entry);
228 s.push('\n');
229
230 ito_common::io::write_std(&path, s)
231 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
232 Ok(())
233}
234
235fn remove_gitignore_exact_line(project_root: &Path, entry: &str) -> CoreResult<()> {
236 let path = project_root.join(".gitignore");
237 let existing = match ito_common::io::read_to_string_std(&path) {
238 Ok(s) => s,
239 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
240 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
241 };
242
243 let mut filtered = Vec::new();
244 let mut removed = false;
245 for line in existing.lines() {
246 if line.trim() == entry {
247 removed = true;
248 continue;
249 }
250 filtered.push(line);
251 }
252 if !removed {
253 return Ok(());
254 }
255
256 let mut updated = filtered.join("\n");
257 if !updated.is_empty() {
258 updated.push('\n');
259 }
260
261 ito_common::io::write_std(&path, updated)
262 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
263 Ok(())
264}
265
266fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
267 contents.lines().map(|l| l.trim()).any(|l| l == entry)
268}
269
270fn install_project_templates(
271 project_root: &Path,
272 ito_dir: &str,
273 mode: InstallMode,
274 opts: &InitOptions,
275 worktree_ctx: Option<&WorktreeTemplateContext>,
276) -> CoreResult<()> {
277 use ito_templates::project_templates::render_project_template;
278
279 let selected = &opts.tools;
280 let current_date = Utc::now().format("%Y-%m-%d").to_string();
281 let state_rel = format!("{ito_dir}/planning/STATE.md");
282 let config_json_rel = format!("{ito_dir}/config.json");
283 let release_tag = release_tag();
284 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
285 let default_ctx = WorktreeTemplateContext::default();
286 let ctx = worktree_ctx.unwrap_or(&default_ctx);
287
288 for f in ito_templates::default_project_files() {
289 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
290 let rel = rel.as_ref();
291
292 if !should_install_project_rel(rel, selected) {
293 continue;
294 }
295
296 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
297 if let Ok(s) = std::str::from_utf8(&bytes) {
298 if rel == state_rel {
299 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
300 } else if rel == config_json_rel {
301 bytes = s
302 .replace(CONFIG_SCHEMA_RELEASE_TAG_PLACEHOLDER, &release_tag)
303 .into_bytes();
304 }
305 }
306
307 if rel == "AGENTS.md" {
312 bytes = render_project_template(&bytes, ctx).map_err(|e| {
313 CoreError::Validation(format!("Failed to render template {rel}: {e}"))
314 })?;
315 }
316
317 if rel.ends_with(".md")
319 && !rel.ends_with(".md.j2")
320 && let Ok(text) = std::str::from_utf8(&bytes)
321 && text.contains(ito_templates::ITO_START_MARKER)
322 {
323 bytes = ito_templates::stamp_version(text, semver).into_bytes();
324 }
325
326 let ownership = classify_project_file_ownership(rel, ito_dir);
327
328 let target = project_root.join(rel);
329 if rel == ".claude/settings.json" {
330 write_claude_settings(&target, &bytes, mode, opts)?;
331 continue;
332 }
333 write_one(&target, &bytes, mode, opts, ownership)?;
334 }
335
336 Ok(())
337}
338
339fn release_tag() -> String {
340 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
341 if version.starts_with('v') {
342 return version.to_string();
343 }
344
345 format!("v{version}")
346}
347
348fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
349 if rel == "AGENTS.md" {
351 return true;
352 }
353 if rel.starts_with(".ito/") {
354 return true;
355 }
356
357 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
359 return tools.contains(TOOL_CLAUDE);
360 }
361 if rel.starts_with(".opencode/") {
362 return tools.contains(TOOL_OPENCODE);
363 }
364 if rel.starts_with(".github/") {
365 return tools.contains(TOOL_GITHUB_COPILOT);
366 }
367 if rel.starts_with(".codex/") {
368 return tools.contains(TOOL_CODEX);
369 }
370 if rel.starts_with(".pi/") {
371 return tools.contains(TOOL_PI);
372 }
373
374 false
376}
377
378fn classify_project_file_ownership(rel: &str, ito_dir: &str) -> FileOwnership {
379 let project_md_rel = format!("{ito_dir}/project.md");
380 if rel == project_md_rel {
381 return FileOwnership::UserOwned;
382 }
383
384 let config_json_rel = format!("{ito_dir}/config.json");
385 if rel == config_json_rel {
386 return FileOwnership::UserOwned;
387 }
388
389 let user_guidance_rel = format!("{ito_dir}/user-guidance.md");
390 if rel == user_guidance_rel {
391 return FileOwnership::UserOwned;
392 }
393
394 let user_prompts_prefix = format!("{ito_dir}/user-prompts/");
395 if rel.starts_with(&user_prompts_prefix) {
396 return FileOwnership::UserOwned;
397 }
398
399 FileOwnership::ItoManaged
400}
401
402fn template_is_entirely_managed(text: &str) -> bool {
411 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
412 return false;
413 };
414 let Some(end) = text.find(ito_templates::ITO_END_MARKER) else {
415 return false;
416 };
417 let before = text[..start].trim();
418 let after = text[end + ito_templates::ITO_END_MARKER.len()..].trim();
419 before.is_empty() && after.is_empty()
420}
421
422fn template_has_prefix_outside_markers(text: &str) -> bool {
429 let Some(start) = text.find(ito_templates::ITO_START_MARKER) else {
430 return false;
431 };
432 !text[..start].trim().is_empty()
433}
434
435fn render_and_stamp_agent(
440 contents: &[u8],
441 config: Option<&ito_templates::agents::AgentConfig>,
442 target: &Path,
443) -> Vec<u8> {
444 let semver = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
445 let rendered = match (std::str::from_utf8(contents), config) {
446 (Ok(template_str), Some(cfg)) => {
447 ito_templates::agents::render_agent_template(template_str, cfg).into_bytes()
448 }
449 _ => contents.to_vec(),
450 };
451 let path_str = target.to_string_lossy();
452 if !path_str.ends_with(".md") || path_str.ends_with(".md.j2") {
453 return rendered;
454 }
455 let Ok(text) = std::str::from_utf8(&rendered) else {
456 return rendered;
457 };
458 if !text.contains(ito_templates::ITO_START_MARKER) {
459 return rendered;
460 }
461 ito_templates::stamp_version(text, semver).into_bytes()
462}
463
464pub(crate) fn write_marker_aware_markdown(
486 target: &Path,
487 rendered_bytes: &[u8],
488 mode: InstallMode,
489 opts: &InitOptions,
490) -> CoreResult<()> {
491 if let Some(parent) = target.parent() {
492 ito_common::io::create_dir_all_std(parent)
493 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
494 }
495
496 let wholesale = |target: &Path| -> CoreResult<()> {
497 ito_common::io::write_std(target, rendered_bytes)
498 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))
499 };
500
501 if !target.exists() {
502 return wholesale(target);
503 }
504
505 if mode == InstallMode::Init && opts.force {
506 return wholesale(target);
507 }
508
509 let Ok(text) = std::str::from_utf8(rendered_bytes) else {
510 return wholesale(target);
511 };
512 let Some(block) = ito_templates::extract_managed_block(text) else {
513 return wholesale(target);
514 };
515
516 let existing = ito_common::io::read_to_string_std(target)
517 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
518 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
519 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
520 match (has_start, has_end) {
521 (false, false) => return wholesale(target),
522 (true, true) => {}
523 (true, false) | (false, true) => {
524 return Err(CoreError::Validation(format!(
530 "Refusing to update {}: file has a partial Ito marker pair (start={has_start}, end={has_end}). \
531Restore both markers manually, or rerun with `--force` to overwrite the file wholesale.",
532 target.display()
533 )));
534 }
535 }
536
537 let _ = update_file_with_markers(
538 target,
539 block,
540 ito_templates::ITO_START_MARKER,
541 ito_templates::ITO_END_MARKER,
542 )
543 .map_err(|e| match e {
544 markers::FsEditError::Io(io_err) => {
545 CoreError::io(format!("updating markers in {}", target.display()), io_err)
546 }
547 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
548 "Failed to update markers in {}: {}",
549 target.display(),
550 marker_err
551 )),
552 })?;
553 Ok(())
554}
555
556fn write_one(
571 target: &Path,
572 rendered_bytes: &[u8],
573 mode: InstallMode,
574 opts: &InitOptions,
575 ownership: FileOwnership,
576) -> CoreResult<()> {
577 if let Some(parent) = target.parent() {
578 ito_common::io::create_dir_all_std(parent)
579 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
580 }
581
582 if let Ok(text) = std::str::from_utf8(rendered_bytes)
584 && let Some(block) = ito_templates::extract_managed_block(text)
585 {
586 if target.exists() {
587 if mode == InstallMode::Init && opts.force {
589 ito_common::io::write_std(target, rendered_bytes)
590 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
591 return Ok(());
592 }
593
594 if ownership == FileOwnership::UserOwned {
599 let updating = mode == InstallMode::Update
600 || (mode == InstallMode::Init && (opts.update || opts.upgrade));
601 if updating {
602 return Ok(());
603 }
604 }
605
606 let existing = ito_common::io::read_to_string_std(target)
608 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
609 let has_markers = existing.contains(ito_templates::ITO_START_MARKER)
610 && existing.contains(ito_templates::ITO_END_MARKER);
611
612 if !has_markers {
613 if opts.upgrade {
614 eprintln!(
618 "warning: skipping upgrade of {} — Ito markers not found.\n\
619 To restore managed upgrade support, re-add the markers manually:\n\
620 \n\
621 {start}\n\
622 <ito-managed content>\n\
623 {end}\n\
624 \n\
625 Then re-run `ito init --upgrade`.",
626 target.display(),
627 start = ito_templates::ITO_START_MARKER,
628 end = ito_templates::ITO_END_MARKER,
629 );
630 return Ok(());
631 }
632
633 if mode == InstallMode::Init && !opts.update {
634 return Err(CoreError::Validation(format!(
636 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
637 target.display()
638 )));
639 }
640
641 let existing_has_no_markers = !existing.contains(ito_templates::ITO_START_MARKER)
647 && !existing.contains(ito_templates::ITO_END_MARKER);
648 if existing_has_no_markers {
649 if template_is_entirely_managed(text)
668 || template_has_prefix_outside_markers(text)
669 {
670 ito_common::io::write_std(target, rendered_bytes).map_err(|e| {
671 CoreError::io(format!("writing {}", target.display()), e)
672 })?;
673 return Ok(());
674 }
675 }
676 }
677
678 update_file_with_markers(
679 target,
680 block,
681 ito_templates::ITO_START_MARKER,
682 ito_templates::ITO_END_MARKER,
683 )
684 .map_err(|e| match e {
685 markers::FsEditError::Io(io_err) => {
686 CoreError::io(format!("updating markers in {}", target.display()), io_err)
687 }
688 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
689 "Failed to update markers in {}: {}",
690 target.display(),
691 marker_err
692 )),
693 })?;
694 } else {
695 ito_common::io::write_std(target, rendered_bytes)
697 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
698 }
699
700 return Ok(());
701 }
702
703 if target.exists() {
704 match mode {
705 InstallMode::Init => {
706 if opts.force {
707 } else if opts.update {
709 if ownership == FileOwnership::UserOwned {
710 return Ok(());
711 }
712 } else {
713 return Err(CoreError::Validation(format!(
714 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
715 target.display()
716 )));
717 }
718 }
719 InstallMode::Update => {
720 if ownership == FileOwnership::UserOwned {
721 return Ok(());
722 }
723 }
724 }
725 }
726
727 ito_common::io::write_std(target, rendered_bytes)
728 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
729 Ok(())
730}
731
732fn write_claude_settings(
733 target: &Path,
734 rendered_bytes: &[u8],
735 mode: InstallMode,
736 opts: &InitOptions,
737) -> CoreResult<()> {
738 if let Some(parent) = target.parent() {
739 ito_common::io::create_dir_all_std(parent)
740 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
741 }
742
743 if mode == InstallMode::Init && target.exists() && !opts.force && !opts.update {
744 return Err(CoreError::Validation(format!(
745 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
746 target.display()
747 )));
748 }
749
750 let template_value: Value = serde_json::from_slice(rendered_bytes).map_err(|e| {
751 CoreError::Validation(format!(
752 "Failed to parse Claude settings template {}: {}",
753 target.display(),
754 e
755 ))
756 })?;
757
758 if !target.exists() || (mode == InstallMode::Init && opts.force) {
759 let mut bytes = serde_json::to_vec_pretty(&template_value).map_err(|e| {
760 CoreError::Validation(format!(
761 "Failed to render Claude settings template {}: {}",
762 target.display(),
763 e
764 ))
765 })?;
766 bytes.push(b'\n');
767 ito_common::io::write_std(target, bytes)
768 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
769 return Ok(());
770 }
771
772 let existing_raw = ito_common::io::read_to_string_std(target)
773 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
774 let Ok(mut existing_value) = serde_json::from_str::<Value>(&existing_raw) else {
775 return Ok(());
777 };
778
779 merge_json_objects(&mut existing_value, &template_value);
780 let mut merged = serde_json::to_vec_pretty(&existing_value).map_err(|e| {
781 CoreError::Validation(format!(
782 "Failed to render merged Claude settings {}: {}",
783 target.display(),
784 e
785 ))
786 })?;
787 merged.push(b'\n');
788 ito_common::io::write_std(target, merged)
789 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
790 Ok(())
791}
792
793fn merge_json_objects(existing: &mut Value, template: &Value) {
794 let Value::Object(template_map) = template else {
795 *existing = template.clone();
796 return;
797 };
798 if !existing.is_object() {
799 *existing = Value::Object(Map::new());
800 }
801
802 let Some(existing_map) = existing.as_object_mut() else {
803 return;
804 };
805
806 for (key, template_value) in template_map {
807 if let Some(existing_value) = existing_map.get_mut(key) {
808 merge_json_values(existing_value, template_value);
809 } else {
810 existing_map.insert(key.clone(), template_value.clone());
811 }
812 }
813}
814
815fn merge_json_values(existing: &mut Value, template: &Value) {
816 match (existing, template) {
817 (Value::Object(existing_map), Value::Object(template_map)) => {
818 for (key, template_value) in template_map {
819 if let Some(existing_value) = existing_map.get_mut(key) {
820 merge_json_values(existing_value, template_value);
821 } else {
822 existing_map.insert(key.clone(), template_value.clone());
823 }
824 }
825 }
826 (Value::Array(existing_items), Value::Array(template_items)) => {
827 for template_item in template_items {
828 if !existing_items.contains(template_item) {
829 existing_items.push(template_item.clone());
830 }
831 }
832 }
833 (existing_value, template_value) => *existing_value = template_value.clone(),
834 }
835}
836
837fn install_adapter_files(
838 project_root: &Path,
839 mode: InstallMode,
840 opts: &InitOptions,
841 worktree_ctx: Option<&WorktreeTemplateContext>,
842) -> CoreResult<()> {
843 for tool in &opts.tools {
844 match tool.as_str() {
845 TOOL_OPENCODE => {
846 let config_dir = project_root.join(".opencode");
847 let manifests = crate::distribution::opencode_manifests(&config_dir);
848 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
849 }
850 TOOL_CLAUDE => {
851 let manifests = crate::distribution::claude_manifests(project_root);
852 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
853 }
854 TOOL_CODEX => {
855 let manifests = crate::distribution::codex_manifests(project_root);
856 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
857 }
858 TOOL_GITHUB_COPILOT => {
859 let manifests = crate::distribution::github_manifests(project_root);
860 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
861 }
862 TOOL_PI => {
863 let manifests = crate::distribution::pi_manifests(project_root);
864 crate::distribution::install_manifests(&manifests, worktree_ctx, mode, opts)?;
865 }
866 _ => {}
867 }
868 }
869
870 Ok(())
871}
872
873fn install_agent_templates(
875 project_root: &Path,
876 mode: InstallMode,
877 opts: &InitOptions,
878) -> CoreResult<()> {
879 use ito_templates::agents::{AgentTier, Harness, default_agent_configs, get_agent_files};
880
881 let configs = default_agent_configs();
882
883 let tool_harness_map = [
885 (TOOL_OPENCODE, Harness::OpenCode),
886 (TOOL_CLAUDE, Harness::ClaudeCode),
887 (TOOL_CODEX, Harness::Codex),
888 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
889 (TOOL_PI, Harness::Pi),
890 ];
891
892 for (tool_id, harness) in tool_harness_map {
893 if !opts.tools.contains(tool_id) {
894 continue;
895 }
896
897 let agent_dir = project_root.join(harness.project_agent_path());
898 let should_remove_obsolete_specialists =
902 mode == InstallMode::Update || opts.update || opts.force;
903 if should_remove_obsolete_specialists {
904 remove_obsolete_specialist_agents(&agent_dir)?;
905 }
906
907 let files = get_agent_files(harness);
909
910 for (rel_path, contents) in files {
911 let target = agent_dir.join(rel_path);
912
913 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
915 Some(AgentTier::Quick)
916 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
917 Some(AgentTier::General)
918 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
919 Some(AgentTier::Thinking)
920 } else {
921 None
922 };
923
924 let mut config = tier.and_then(|t| configs.get(&(harness, t)));
930 if config.is_none()
931 && let Ok(s) = std::str::from_utf8(contents)
932 && s.contains("{{model}}")
933 {
934 config = configs.get(&(harness, AgentTier::General));
935 }
936
937 match mode {
938 InstallMode::Init => {
939 if target.exists() && !opts.force {
940 if opts.update {
941 let rendered = render_and_stamp_agent(contents, config, &target);
942 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
943 }
944 continue;
945 }
946
947 let rendered = render_and_stamp_agent(contents, config, &target);
948 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
949 }
950 InstallMode::Update => {
951 let rendered = render_and_stamp_agent(contents, config, &target);
952 if target.exists() {
953 update_existing_agent_template(&target, &rendered, mode, opts, config)?;
954 } else {
955 write_marker_aware_markdown(&target, &rendered, mode, opts)?;
956 }
957 }
958 }
959 }
960 }
961
962 Ok(())
963}
964fn update_existing_agent_template(
971 target: &Path,
972 rendered: &[u8],
973 mode: InstallMode,
974 opts: &InitOptions,
975 config: Option<&ito_templates::agents::AgentConfig>,
976) -> CoreResult<()> {
977 let existing = ito_common::io::read_to_string_std(target)
978 .map_err(|e| CoreError::io(format!("reading {}", target.display()), e))?;
979 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
980 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
981
982 match (has_start, has_end) {
983 (true, true) => write_marker_aware_markdown(target, rendered, mode, opts)?,
984 (false, false) => {}
985 (true, false) | (false, true) => {
986 eprintln!(
987 "warning: skipping marker update for {}: file has a partial Ito marker pair \
988 (start={has_start}, end={has_end}). Restore both markers manually, or rerun \
989 with `--force` to overwrite the file wholesale.",
990 target.display()
991 );
992 }
993 }
994
995 if let Some(cfg) = config {
996 update_agent_model_field(target, &cfg.model)?;
997 }
998 update_agent_activation_field_from_rendered(target, rendered)?;
999
1000 Ok(())
1001}
1002
1003#[cfg(test)]
1004mod json_tests;
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009
1010 #[test]
1011 fn gitignore_created_when_missing() {
1012 let td = tempfile::tempdir().unwrap();
1013 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1014 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1015 assert_eq!(s, ".ito/session.json\n");
1016 }
1017
1018 #[test]
1019 fn gitignore_noop_when_already_present() {
1020 let td = tempfile::tempdir().unwrap();
1021 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
1022 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1023 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1024 assert_eq!(s, ".ito/session.json\n");
1025 }
1026
1027 #[test]
1028 fn gitignore_does_not_duplicate_on_repeated_calls() {
1029 let td = tempfile::tempdir().unwrap();
1030 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
1031 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1032 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1033 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1034 assert_eq!(s, "node_modules\n.ito/session.json\n");
1035 }
1036
1037 #[test]
1038 fn gitignore_audit_session_added() {
1039 let td = tempfile::tempdir().unwrap();
1040 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1041 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1042 assert!(s.contains(".ito/.state/audit/.session"));
1043 }
1044
1045 #[test]
1046 fn gitignore_both_session_entries() {
1047 let td = tempfile::tempdir().unwrap();
1048 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1049 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1050 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1051 assert!(s.contains(".ito/session.json"));
1052 assert!(s.contains(".ito/.state/audit/.session"));
1053 }
1054
1055 #[test]
1056 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
1057 let td = tempfile::tempdir().unwrap();
1058 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
1059 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
1060 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1061 assert_eq!(s, "node_modules\n.ito/session.json\n");
1062 }
1063
1064 #[test]
1065 fn gitignore_legacy_audit_events_unignore_removed() {
1066 let td = tempfile::tempdir().unwrap();
1067 std::fs::write(
1068 td.path().join(".gitignore"),
1069 ".ito/.state/\n!.ito/.state/audit/\n",
1070 )
1071 .unwrap();
1072 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1073 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1074 assert_eq!(s, ".ito/.state/\n");
1075 }
1076
1077 #[test]
1078 fn gitignore_legacy_audit_events_unignore_noop_when_absent() {
1079 let td = tempfile::tempdir().unwrap();
1080 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
1081 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1082 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1083 assert_eq!(s, ".ito/.state/\n");
1084 }
1085
1086 #[test]
1087 fn gitignore_full_audit_setup() {
1088 let td = tempfile::tempdir().unwrap();
1089 std::fs::write(
1091 td.path().join(".gitignore"),
1092 ".ito/.state/\n!.ito/.state/audit/\n",
1093 )
1094 .unwrap();
1095 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
1096 remove_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
1097 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1098 assert!(s.contains(".ito/.state/audit/.session"));
1099 assert!(!s.contains("!.ito/.state/audit/"));
1100 }
1101
1102 #[test]
1103 fn gitignore_ignores_local_configs() {
1104 let td = tempfile::tempdir().unwrap();
1105 ensure_repo_gitignore_ignores_local_configs(td.path(), ".ito").unwrap();
1106 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
1107 assert!(s.contains(".ito/config.local.json"));
1108 assert!(s.contains(".local/ito/config.json"));
1109 }
1110
1111 #[test]
1112 fn gitignore_exact_line_matching_trims_whitespace() {
1113 assert!(gitignore_has_exact_line(" foo \nbar\n", "foo"));
1114 assert!(!gitignore_has_exact_line("foo\n", "bar"));
1115 }
1116
1117 #[test]
1118 fn should_install_project_rel_filters_by_tool_id() {
1119 let mut tools = BTreeSet::new();
1120 tools.insert(TOOL_OPENCODE.to_string());
1121
1122 assert!(should_install_project_rel("AGENTS.md", &tools));
1123 assert!(should_install_project_rel(".ito/config.json", &tools));
1124 assert!(should_install_project_rel(".opencode/config.json", &tools));
1125 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1126 assert!(!should_install_project_rel(".codex/config.json", &tools));
1127 assert!(!should_install_project_rel(
1128 ".github/workflows/x.yml",
1129 &tools
1130 ));
1131 assert!(!should_install_project_rel(".pi/settings.json", &tools));
1132 }
1133
1134 #[test]
1135 fn should_install_project_rel_filters_pi() {
1136 let mut tools = BTreeSet::new();
1137 tools.insert(TOOL_PI.to_string());
1138
1139 assert!(should_install_project_rel(".pi/settings.json", &tools));
1141 assert!(should_install_project_rel(
1142 ".pi/extensions/ito-skills.ts",
1143 &tools
1144 ));
1145
1146 assert!(should_install_project_rel("AGENTS.md", &tools));
1148 assert!(should_install_project_rel(".ito/config.json", &tools));
1149
1150 assert!(!should_install_project_rel(".opencode/config.json", &tools));
1152 assert!(!should_install_project_rel(".claude/settings.json", &tools));
1153 assert!(!should_install_project_rel(".codex/config.json", &tools));
1154 }
1155
1156 #[test]
1157 fn release_tag_is_prefixed_with_v() {
1158 let tag = release_tag();
1159 assert!(tag.starts_with('v'));
1160 }
1161
1162 #[test]
1163 fn write_one_non_marker_files_skip_on_init_update_mode() {
1164 let td = tempfile::tempdir().unwrap();
1165 let target = td.path().join("plain.txt");
1166 std::fs::write(&target, "existing").unwrap();
1167
1168 let opts = InitOptions::new(BTreeSet::new(), false, true);
1169 write_one(
1170 &target,
1171 b"new",
1172 InstallMode::Init,
1173 &opts,
1174 FileOwnership::UserOwned,
1175 )
1176 .unwrap();
1177 let s = std::fs::read_to_string(&target).unwrap();
1178 assert_eq!(s, "existing");
1179 }
1180
1181 #[test]
1182 fn write_one_non_marker_ito_managed_files_overwrite_on_init_update_mode() {
1183 let td = tempfile::tempdir().unwrap();
1184 let target = td.path().join("plain.txt");
1185 std::fs::write(&target, "existing").unwrap();
1186
1187 let opts = InitOptions::new(BTreeSet::new(), false, true);
1188 write_one(
1189 &target,
1190 b"new",
1191 InstallMode::Init,
1192 &opts,
1193 FileOwnership::ItoManaged,
1194 )
1195 .unwrap();
1196 let s = std::fs::read_to_string(&target).unwrap();
1197 assert_eq!(s, "new");
1198 }
1199
1200 #[test]
1201 fn write_one_non_marker_user_owned_files_preserve_on_update_mode() {
1202 let td = tempfile::tempdir().unwrap();
1203 let target = td.path().join("plain.txt");
1204 std::fs::write(&target, "existing").unwrap();
1205
1206 let opts = InitOptions::new(BTreeSet::new(), false, true);
1207 write_one(
1208 &target,
1209 b"new",
1210 InstallMode::Update,
1211 &opts,
1212 FileOwnership::UserOwned,
1213 )
1214 .unwrap();
1215 let s = std::fs::read_to_string(&target).unwrap();
1216 assert_eq!(s, "existing");
1217 }
1218
1219 #[test]
1220 fn write_one_marker_managed_files_refuse_overwrite_without_markers() {
1221 let td = tempfile::tempdir().unwrap();
1222 let target = td.path().join("managed.md");
1223 std::fs::write(&target, "existing without markers\n").unwrap();
1224
1225 let template = format!(
1226 "before\n{}\nmanaged\n{}\nafter\n",
1227 ito_templates::ITO_START_MARKER,
1228 ito_templates::ITO_END_MARKER
1229 );
1230 let opts = InitOptions::new(BTreeSet::new(), false, false);
1231 let err = write_one(
1232 &target,
1233 template.as_bytes(),
1234 InstallMode::Init,
1235 &opts,
1236 FileOwnership::ItoManaged,
1237 )
1238 .unwrap_err();
1239 assert!(err.to_string().contains("Refusing to overwrite"));
1240 }
1241
1242 #[test]
1243 fn write_one_marker_managed_files_update_existing_markers() {
1244 let td = tempfile::tempdir().unwrap();
1245 let target = td.path().join("managed.md");
1246 let existing = format!(
1247 "before\n{}\nold\n{}\nafter\n",
1248 ito_templates::ITO_START_MARKER,
1249 ito_templates::ITO_END_MARKER
1250 );
1251 std::fs::write(&target, existing).unwrap();
1252
1253 let template = format!(
1254 "before\n{}\nnew\n{}\nafter\n",
1255 ito_templates::ITO_START_MARKER,
1256 ito_templates::ITO_END_MARKER
1257 );
1258 let opts = InitOptions::new(BTreeSet::new(), false, false);
1259 write_one(
1260 &target,
1261 template.as_bytes(),
1262 InstallMode::Init,
1263 &opts,
1264 FileOwnership::ItoManaged,
1265 )
1266 .unwrap();
1267 let s = std::fs::read_to_string(&target).unwrap();
1268 assert!(s.contains("new"));
1269 assert!(!s.contains("old"));
1270 }
1271
1272 #[test]
1273 fn write_one_marker_managed_files_error_when_markers_missing_in_update_mode() {
1274 let td = tempfile::tempdir().unwrap();
1275 let target = td.path().join("managed.md");
1276 std::fs::write(
1278 &target,
1279 format!(
1280 "{}\nexisting without end marker\n",
1281 ito_templates::ITO_START_MARKER
1282 ),
1283 )
1284 .unwrap();
1285
1286 let template = format!(
1287 "before\n{}\nmanaged\n{}\nafter\n",
1288 ito_templates::ITO_START_MARKER,
1289 ito_templates::ITO_END_MARKER
1290 );
1291 let opts = InitOptions::new(BTreeSet::new(), false, true);
1292 let err = write_one(
1293 &target,
1294 template.as_bytes(),
1295 InstallMode::Init,
1296 &opts,
1297 FileOwnership::ItoManaged,
1298 )
1299 .unwrap_err();
1300 assert!(err.to_string().contains("Failed to update markers"));
1301 }
1302}