1use std::collections::BTreeSet;
2use std::path::Path;
3
4use chrono::Utc;
5
6use crate::errors::{CoreError, CoreResult};
7
8use markers::update_file_with_markers;
9
10mod markers;
11
12use ito_config::ConfigContext;
13use ito_config::ito_dir::get_ito_dir_name;
14use ito_templates::project_templates::WorktreeTemplateContext;
15
16pub const TOOL_CLAUDE: &str = "claude";
18pub const TOOL_CODEX: &str = "codex";
20pub const TOOL_GITHUB_COPILOT: &str = "github-copilot";
22pub const TOOL_OPENCODE: &str = "opencode";
24
25pub fn available_tool_ids() -> &'static [&'static str] {
27 &[TOOL_CLAUDE, TOOL_CODEX, TOOL_GITHUB_COPILOT, TOOL_OPENCODE]
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct InitOptions {
33 pub tools: BTreeSet<String>,
35 pub force: bool,
37 pub update: bool,
44}
45
46impl InitOptions {
47 pub fn new(tools: BTreeSet<String>, force: bool, update: bool) -> Self {
49 Self {
50 tools,
51 force,
52 update,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum InstallMode {
60 Init,
62 Update,
64}
65
66pub fn install_default_templates(
72 project_root: &Path,
73 ctx: &ConfigContext,
74 mode: InstallMode,
75 opts: &InitOptions,
76 worktree_ctx: Option<&WorktreeTemplateContext>,
77) -> CoreResult<()> {
78 let ito_dir_name = get_ito_dir_name(project_root, ctx);
79 let ito_dir = ito_templates::normalize_ito_dir(&ito_dir_name);
80
81 install_project_templates(project_root, &ito_dir, mode, opts, worktree_ctx)?;
82
83 if mode == InstallMode::Init {
86 ensure_repo_gitignore_ignores_session_json(project_root, &ito_dir)?;
87 ensure_repo_gitignore_ignores_audit_session(project_root, &ito_dir)?;
88 ensure_repo_gitignore_unignores_audit_events(project_root, &ito_dir)?;
90 }
91
92 ensure_repo_gitignore_ignores_local_configs(project_root, &ito_dir)?;
94
95 install_adapter_files(project_root, mode, opts, worktree_ctx)?;
96 install_agent_templates(project_root, mode, opts)?;
97 Ok(())
98}
99
100fn ensure_repo_gitignore_ignores_local_configs(
101 project_root: &Path,
102 ito_dir: &str,
103) -> CoreResult<()> {
104 let entry = format!("{ito_dir}/config.local.json");
107 ensure_gitignore_contains_line(project_root, &entry)?;
108
109 let entry = ".local/ito/config.json";
111 ensure_gitignore_contains_line(project_root, entry)?;
112 Ok(())
113}
114
115fn ensure_repo_gitignore_ignores_session_json(
116 project_root: &Path,
117 ito_dir: &str,
118) -> CoreResult<()> {
119 let entry = format!("{ito_dir}/session.json");
120 ensure_gitignore_contains_line(project_root, &entry)
121}
122
123fn ensure_repo_gitignore_ignores_audit_session(
125 project_root: &Path,
126 ito_dir: &str,
127) -> CoreResult<()> {
128 let entry = format!("{ito_dir}/.state/audit/.session");
129 ensure_gitignore_contains_line(project_root, &entry)
130}
131
132fn ensure_repo_gitignore_unignores_audit_events(
138 project_root: &Path,
139 ito_dir: &str,
140) -> CoreResult<()> {
141 let entry = format!("!{ito_dir}/.state/audit/");
142 ensure_gitignore_contains_line(project_root, &entry)
143}
144
145fn ensure_gitignore_contains_line(project_root: &Path, entry: &str) -> CoreResult<()> {
146 let path = project_root.join(".gitignore");
147 let existing = match ito_common::io::read_to_string_std(&path) {
148 Ok(s) => Some(s),
149 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
150 Err(e) => return Err(CoreError::io(format!("reading {}", path.display()), e)),
151 };
152
153 let Some(mut s) = existing else {
154 ito_common::io::write_std(&path, format!("{entry}\n"))
155 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
156 return Ok(());
157 };
158
159 if gitignore_has_exact_line(&s, entry) {
160 return Ok(());
161 }
162
163 if !s.ends_with('\n') {
164 s.push('\n');
165 }
166 s.push_str(entry);
167 s.push('\n');
168
169 ito_common::io::write_std(&path, s)
170 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
171 Ok(())
172}
173
174fn gitignore_has_exact_line(contents: &str, entry: &str) -> bool {
175 contents.lines().map(|l| l.trim()).any(|l| l == entry)
176}
177
178fn install_project_templates(
179 project_root: &Path,
180 ito_dir: &str,
181 mode: InstallMode,
182 opts: &InitOptions,
183 worktree_ctx: Option<&WorktreeTemplateContext>,
184) -> CoreResult<()> {
185 use ito_templates::project_templates::render_project_template;
186
187 let selected = &opts.tools;
188 let current_date = Utc::now().format("%Y-%m-%d").to_string();
189 let state_rel = format!("{ito_dir}/planning/STATE.md");
190 let project_md_rel = format!("{ito_dir}/project.md");
191 let config_json_rel = format!("{ito_dir}/config.json");
192 let default_ctx = WorktreeTemplateContext::default();
193 let ctx = worktree_ctx.unwrap_or(&default_ctx);
194
195 for f in ito_templates::default_project_files() {
196 let rel = ito_templates::render_rel_path(f.relative_path, ito_dir);
197 if !should_install_project_rel(rel.as_ref(), selected) {
198 continue;
199 }
200
201 let mut bytes = ito_templates::render_bytes(f.contents, ito_dir).into_owned();
202 if rel.as_ref() == state_rel
203 && let Ok(s) = std::str::from_utf8(&bytes)
204 {
205 bytes = s.replace("__CURRENT_DATE__", ¤t_date).into_bytes();
206 }
207
208 if rel.as_ref() == "AGENTS.md" {
213 bytes = render_project_template(&bytes, ctx).map_err(|e| {
214 CoreError::Validation(format!("Failed to render template {}: {}", rel.as_ref(), e))
215 })?;
216 }
217
218 if mode == InstallMode::Update
223 && (rel.as_ref() == project_md_rel || rel.as_ref() == config_json_rel)
224 && project_root.join(rel.as_ref()).exists()
225 {
226 continue;
227 }
228
229 let target = project_root.join(rel.as_ref());
230 write_one(&target, &bytes, mode, opts)?;
231 }
232
233 Ok(())
234}
235
236fn should_install_project_rel(rel: &str, tools: &BTreeSet<String>) -> bool {
237 if rel == "AGENTS.md" {
239 return true;
240 }
241 if rel.starts_with(".ito/") {
242 return true;
243 }
244
245 if rel == "CLAUDE.md" || rel.starts_with(".claude/") {
247 return tools.contains(TOOL_CLAUDE);
248 }
249 if rel.starts_with(".opencode/") {
250 return tools.contains(TOOL_OPENCODE);
251 }
252 if rel.starts_with(".github/") {
253 return tools.contains(TOOL_GITHUB_COPILOT);
254 }
255 if rel.starts_with(".codex/") {
256 return tools.contains(TOOL_CODEX);
257 }
258
259 false
261}
262
263fn write_one(
264 target: &Path,
265 rendered_bytes: &[u8],
266 mode: InstallMode,
267 opts: &InitOptions,
268) -> CoreResult<()> {
269 if let Some(parent) = target.parent() {
270 ito_common::io::create_dir_all_std(parent)
271 .map_err(|e| CoreError::io(format!("creating directory {}", parent.display()), e))?;
272 }
273
274 if let Ok(text) = std::str::from_utf8(rendered_bytes)
276 && let Some(block) = ito_templates::extract_managed_block(text)
277 {
278 if target.exists() {
279 if mode == InstallMode::Init && !opts.force && !opts.update {
280 let existing = ito_common::io::read_to_string_or_default(target);
283 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
284 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
285 if !(has_start && has_end) {
286 return Err(CoreError::Validation(format!(
287 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
288 target.display()
289 )));
290 }
291 }
292
293 update_file_with_markers(
294 target,
295 block,
296 ito_templates::ITO_START_MARKER,
297 ito_templates::ITO_END_MARKER,
298 )
299 .map_err(|e| match e {
300 markers::FsEditError::Io(io_err) => {
301 CoreError::io(format!("updating markers in {}", target.display()), io_err)
302 }
303 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
304 "Failed to update markers in {}: {}",
305 target.display(),
306 marker_err
307 )),
308 })?;
309 } else {
310 ito_common::io::write_std(target, rendered_bytes)
312 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
313 }
314
315 return Ok(());
316 }
317
318 if mode == InstallMode::Init && target.exists() && !opts.force {
321 if opts.update {
322 return Ok(());
323 }
324 return Err(CoreError::Validation(format!(
325 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
326 target.display()
327 )));
328 }
329
330 ito_common::io::write_std(target, rendered_bytes)
331 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
332 Ok(())
333}
334
335fn install_adapter_files(
336 project_root: &Path,
337 _mode: InstallMode,
338 opts: &InitOptions,
339 worktree_ctx: Option<&WorktreeTemplateContext>,
340) -> CoreResult<()> {
341 for tool in &opts.tools {
342 match tool.as_str() {
343 TOOL_OPENCODE => {
344 let config_dir = project_root.join(".opencode");
345 let manifests = crate::distribution::opencode_manifests(&config_dir);
346 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
347 }
348 TOOL_CLAUDE => {
349 let manifests = crate::distribution::claude_manifests(project_root);
350 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
351 }
352 TOOL_CODEX => {
353 let manifests = crate::distribution::codex_manifests(project_root);
354 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
355 }
356 TOOL_GITHUB_COPILOT => {
357 let manifests = crate::distribution::github_manifests(project_root);
358 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
359 }
360 _ => {}
361 }
362 }
363
364 Ok(())
365}
366
367fn install_agent_templates(
369 project_root: &Path,
370 mode: InstallMode,
371 opts: &InitOptions,
372) -> CoreResult<()> {
373 use ito_templates::agents::{
374 AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
375 };
376
377 let configs = default_agent_configs();
378
379 let tool_harness_map = [
381 (TOOL_OPENCODE, Harness::OpenCode),
382 (TOOL_CLAUDE, Harness::ClaudeCode),
383 (TOOL_CODEX, Harness::Codex),
384 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
385 ];
386
387 for (tool_id, harness) in tool_harness_map {
388 if !opts.tools.contains(tool_id) {
389 continue;
390 }
391
392 let agent_dir = project_root.join(harness.project_agent_path());
393
394 let files = get_agent_files(harness);
396
397 for (rel_path, contents) in files {
398 let target = agent_dir.join(rel_path);
399
400 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
402 Some(AgentTier::Quick)
403 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
404 Some(AgentTier::General)
405 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
406 Some(AgentTier::Thinking)
407 } else {
408 None
409 };
410
411 let config = tier.and_then(|t| configs.get(&(harness, t)));
413
414 match mode {
415 InstallMode::Init => {
416 if target.exists() {
417 if opts.update {
418 if let Some(cfg) = config {
420 update_agent_model_field(&target, &cfg.model)?;
421 }
422 continue;
423 }
424 if !opts.force {
425 continue;
427 }
428 }
429
430 let rendered = if let Some(cfg) = config {
432 if let Ok(template_str) = std::str::from_utf8(contents) {
433 render_agent_template(template_str, cfg).into_bytes()
434 } else {
435 contents.to_vec()
436 }
437 } else {
438 contents.to_vec()
439 };
440
441 if let Some(parent) = target.parent() {
443 ito_common::io::create_dir_all_std(parent).map_err(|e| {
444 CoreError::io(format!("creating directory {}", parent.display()), e)
445 })?;
446 }
447
448 ito_common::io::write_std(&target, rendered)
449 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
450 }
451 InstallMode::Update => {
452 if !target.exists() {
454 let rendered = if let Some(cfg) = config {
456 if let Ok(template_str) = std::str::from_utf8(contents) {
457 render_agent_template(template_str, cfg).into_bytes()
458 } else {
459 contents.to_vec()
460 }
461 } else {
462 contents.to_vec()
463 };
464
465 if let Some(parent) = target.parent() {
466 ito_common::io::create_dir_all_std(parent).map_err(|e| {
467 CoreError::io(format!("creating directory {}", parent.display()), e)
468 })?;
469 }
470 ito_common::io::write_std(&target, rendered).map_err(|e| {
471 CoreError::io(format!("writing {}", target.display()), e)
472 })?;
473 } else if let Some(cfg) = config {
474 update_agent_model_field(&target, &cfg.model)?;
476 }
477 }
478 }
479 }
480 }
481
482 Ok(())
483}
484
485fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
487 let content = ito_common::io::read_to_string_or_default(path);
488
489 if !content.starts_with("---") {
491 return Ok(());
492 }
493
494 let rest = &content[3..];
496 let Some(end_idx) = rest.find("\n---") else {
497 return Ok(());
498 };
499
500 let frontmatter = &rest[..end_idx];
501 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
505
506 let updated = format!("---{}\n---{}", updated_frontmatter, body);
508 ito_common::io::write_std(path, updated)
509 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
510
511 Ok(())
512}
513
514fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
516 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
517 let mut found = false;
518
519 for line in &mut lines {
520 if line.trim_start().starts_with("model:") {
521 *line = format!("model: \"{}\"", new_model);
522 found = true;
523 break;
524 }
525 }
526
527 if !found {
529 lines.push(format!("model: \"{}\"", new_model));
530 }
531
532 lines.join("\n")
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn gitignore_created_when_missing() {
541 let td = tempfile::tempdir().unwrap();
542 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
543 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
544 assert_eq!(s, ".ito/session.json\n");
545 }
546
547 #[test]
548 fn gitignore_noop_when_already_present() {
549 let td = tempfile::tempdir().unwrap();
550 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
551 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
552 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
553 assert_eq!(s, ".ito/session.json\n");
554 }
555
556 #[test]
557 fn gitignore_does_not_duplicate_on_repeated_calls() {
558 let td = tempfile::tempdir().unwrap();
559 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
560 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
561 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
562 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
563 assert_eq!(s, "node_modules\n.ito/session.json\n");
564 }
565
566 #[test]
567 fn gitignore_audit_session_added() {
568 let td = tempfile::tempdir().unwrap();
569 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
570 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
571 assert!(s.contains(".ito/.state/audit/.session"));
572 }
573
574 #[test]
575 fn gitignore_both_session_entries() {
576 let td = tempfile::tempdir().unwrap();
577 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
578 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
579 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
580 assert!(s.contains(".ito/session.json"));
581 assert!(s.contains(".ito/.state/audit/.session"));
582 }
583
584 #[test]
585 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
586 let td = tempfile::tempdir().unwrap();
587 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
588 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
589 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
590 assert_eq!(s, "node_modules\n.ito/session.json\n");
591 }
592
593 #[test]
594 fn gitignore_audit_events_unignored() {
595 let td = tempfile::tempdir().unwrap();
596 ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
597 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
598 assert!(s.contains("!.ito/.state/audit/"));
599 }
600
601 #[test]
602 fn gitignore_full_audit_setup() {
603 let td = tempfile::tempdir().unwrap();
604 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
606 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
607 ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
608 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
609 assert!(s.contains(".ito/.state/audit/.session"));
610 assert!(s.contains("!.ito/.state/audit/"));
611 }
612}