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 {
280 ito_common::io::write_std(target, rendered_bytes)
281 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
282 return Ok(());
283 }
284
285 if mode == InstallMode::Init && !opts.force && !opts.update {
286 let existing = ito_common::io::read_to_string_or_default(target);
289 let has_start = existing.contains(ito_templates::ITO_START_MARKER);
290 let has_end = existing.contains(ito_templates::ITO_END_MARKER);
291 if !(has_start && has_end) {
292 return Err(CoreError::Validation(format!(
293 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
294 target.display()
295 )));
296 }
297 }
298
299 update_file_with_markers(
300 target,
301 block,
302 ito_templates::ITO_START_MARKER,
303 ito_templates::ITO_END_MARKER,
304 )
305 .map_err(|e| match e {
306 markers::FsEditError::Io(io_err) => {
307 CoreError::io(format!("updating markers in {}", target.display()), io_err)
308 }
309 markers::FsEditError::Marker(marker_err) => CoreError::Validation(format!(
310 "Failed to update markers in {}: {}",
311 target.display(),
312 marker_err
313 )),
314 })?;
315 } else {
316 ito_common::io::write_std(target, rendered_bytes)
318 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
319 }
320
321 return Ok(());
322 }
323
324 if mode == InstallMode::Init && target.exists() && !opts.force {
327 if opts.update {
328 return Ok(());
329 }
330 return Err(CoreError::Validation(format!(
331 "Refusing to overwrite existing file without markers: {} (re-run with --force)",
332 target.display()
333 )));
334 }
335
336 ito_common::io::write_std(target, rendered_bytes)
337 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
338 Ok(())
339}
340
341fn install_adapter_files(
342 project_root: &Path,
343 _mode: InstallMode,
344 opts: &InitOptions,
345 worktree_ctx: Option<&WorktreeTemplateContext>,
346) -> CoreResult<()> {
347 for tool in &opts.tools {
348 match tool.as_str() {
349 TOOL_OPENCODE => {
350 let config_dir = project_root.join(".opencode");
351 let manifests = crate::distribution::opencode_manifests(&config_dir);
352 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
353 }
354 TOOL_CLAUDE => {
355 let manifests = crate::distribution::claude_manifests(project_root);
356 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
357 }
358 TOOL_CODEX => {
359 let manifests = crate::distribution::codex_manifests(project_root);
360 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
361 }
362 TOOL_GITHUB_COPILOT => {
363 let manifests = crate::distribution::github_manifests(project_root);
364 crate::distribution::install_manifests(&manifests, worktree_ctx)?;
365 }
366 _ => {}
367 }
368 }
369
370 Ok(())
371}
372
373fn install_agent_templates(
375 project_root: &Path,
376 mode: InstallMode,
377 opts: &InitOptions,
378) -> CoreResult<()> {
379 use ito_templates::agents::{
380 AgentTier, Harness, default_agent_configs, get_agent_files, render_agent_template,
381 };
382
383 let configs = default_agent_configs();
384
385 let tool_harness_map = [
387 (TOOL_OPENCODE, Harness::OpenCode),
388 (TOOL_CLAUDE, Harness::ClaudeCode),
389 (TOOL_CODEX, Harness::Codex),
390 (TOOL_GITHUB_COPILOT, Harness::GitHubCopilot),
391 ];
392
393 for (tool_id, harness) in tool_harness_map {
394 if !opts.tools.contains(tool_id) {
395 continue;
396 }
397
398 let agent_dir = project_root.join(harness.project_agent_path());
399
400 let files = get_agent_files(harness);
402
403 for (rel_path, contents) in files {
404 let target = agent_dir.join(rel_path);
405
406 let tier = if rel_path.contains("ito-quick") || rel_path.contains("quick") {
408 Some(AgentTier::Quick)
409 } else if rel_path.contains("ito-general") || rel_path.contains("general") {
410 Some(AgentTier::General)
411 } else if rel_path.contains("ito-thinking") || rel_path.contains("thinking") {
412 Some(AgentTier::Thinking)
413 } else {
414 None
415 };
416
417 let config = tier.and_then(|t| configs.get(&(harness, t)));
419
420 match mode {
421 InstallMode::Init => {
422 if target.exists() {
423 if opts.update {
424 if let Some(cfg) = config {
426 update_agent_model_field(&target, &cfg.model)?;
427 }
428 continue;
429 }
430 if !opts.force {
431 continue;
433 }
434 }
435
436 let rendered = if let Some(cfg) = config {
438 if let Ok(template_str) = std::str::from_utf8(contents) {
439 render_agent_template(template_str, cfg).into_bytes()
440 } else {
441 contents.to_vec()
442 }
443 } else {
444 contents.to_vec()
445 };
446
447 if let Some(parent) = target.parent() {
449 ito_common::io::create_dir_all_std(parent).map_err(|e| {
450 CoreError::io(format!("creating directory {}", parent.display()), e)
451 })?;
452 }
453
454 ito_common::io::write_std(&target, rendered)
455 .map_err(|e| CoreError::io(format!("writing {}", target.display()), e))?;
456 }
457 InstallMode::Update => {
458 if !target.exists() {
460 let rendered = if let Some(cfg) = config {
462 if let Ok(template_str) = std::str::from_utf8(contents) {
463 render_agent_template(template_str, cfg).into_bytes()
464 } else {
465 contents.to_vec()
466 }
467 } else {
468 contents.to_vec()
469 };
470
471 if let Some(parent) = target.parent() {
472 ito_common::io::create_dir_all_std(parent).map_err(|e| {
473 CoreError::io(format!("creating directory {}", parent.display()), e)
474 })?;
475 }
476 ito_common::io::write_std(&target, rendered).map_err(|e| {
477 CoreError::io(format!("writing {}", target.display()), e)
478 })?;
479 } else if let Some(cfg) = config {
480 update_agent_model_field(&target, &cfg.model)?;
482 }
483 }
484 }
485 }
486 }
487
488 Ok(())
489}
490
491fn update_agent_model_field(path: &Path, new_model: &str) -> CoreResult<()> {
493 let content = ito_common::io::read_to_string_or_default(path);
494
495 if !content.starts_with("---") {
497 return Ok(());
498 }
499
500 let rest = &content[3..];
502 let Some(end_idx) = rest.find("\n---") else {
503 return Ok(());
504 };
505
506 let frontmatter = &rest[..end_idx];
507 let body = &rest[end_idx + 4..]; let updated_frontmatter = update_model_in_yaml(frontmatter, new_model);
511
512 let updated = format!("---{}\n---{}", updated_frontmatter, body);
514 ito_common::io::write_std(path, updated)
515 .map_err(|e| CoreError::io(format!("writing {}", path.display()), e))?;
516
517 Ok(())
518}
519
520fn update_model_in_yaml(yaml: &str, new_model: &str) -> String {
522 let mut lines: Vec<String> = yaml.lines().map(|l| l.to_string()).collect();
523 let mut found = false;
524
525 for line in &mut lines {
526 if line.trim_start().starts_with("model:") {
527 *line = format!("model: \"{}\"", new_model);
528 found = true;
529 break;
530 }
531 }
532
533 if !found {
535 lines.push(format!("model: \"{}\"", new_model));
536 }
537
538 lines.join("\n")
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn gitignore_created_when_missing() {
547 let td = tempfile::tempdir().unwrap();
548 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
549 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
550 assert_eq!(s, ".ito/session.json\n");
551 }
552
553 #[test]
554 fn gitignore_noop_when_already_present() {
555 let td = tempfile::tempdir().unwrap();
556 std::fs::write(td.path().join(".gitignore"), ".ito/session.json\n").unwrap();
557 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
558 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
559 assert_eq!(s, ".ito/session.json\n");
560 }
561
562 #[test]
563 fn gitignore_does_not_duplicate_on_repeated_calls() {
564 let td = tempfile::tempdir().unwrap();
565 std::fs::write(td.path().join(".gitignore"), "node_modules\n").unwrap();
566 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
567 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
568 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
569 assert_eq!(s, "node_modules\n.ito/session.json\n");
570 }
571
572 #[test]
573 fn gitignore_audit_session_added() {
574 let td = tempfile::tempdir().unwrap();
575 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
576 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
577 assert!(s.contains(".ito/.state/audit/.session"));
578 }
579
580 #[test]
581 fn gitignore_both_session_entries() {
582 let td = tempfile::tempdir().unwrap();
583 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
584 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
585 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
586 assert!(s.contains(".ito/session.json"));
587 assert!(s.contains(".ito/.state/audit/.session"));
588 }
589
590 #[test]
591 fn gitignore_preserves_existing_content_and_adds_newline_if_missing() {
592 let td = tempfile::tempdir().unwrap();
593 std::fs::write(td.path().join(".gitignore"), "node_modules").unwrap();
594 ensure_repo_gitignore_ignores_session_json(td.path(), ".ito").unwrap();
595 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
596 assert_eq!(s, "node_modules\n.ito/session.json\n");
597 }
598
599 #[test]
600 fn gitignore_audit_events_unignored() {
601 let td = tempfile::tempdir().unwrap();
602 ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
603 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
604 assert!(s.contains("!.ito/.state/audit/"));
605 }
606
607 #[test]
608 fn gitignore_full_audit_setup() {
609 let td = tempfile::tempdir().unwrap();
610 std::fs::write(td.path().join(".gitignore"), ".ito/.state/\n").unwrap();
612 ensure_repo_gitignore_ignores_audit_session(td.path(), ".ito").unwrap();
613 ensure_repo_gitignore_unignores_audit_events(td.path(), ".ito").unwrap();
614 let s = std::fs::read_to_string(td.path().join(".gitignore")).unwrap();
615 assert!(s.contains(".ito/.state/audit/.session"));
616 assert!(s.contains("!.ito/.state/audit/"));
617 }
618}