1use crate::errors::{CoreError, CoreResult};
8use ito_templates::{
9 commands_files, get_adapter_file, get_command_file, get_skill_file, skills_files,
10};
11use std::path::{Path, PathBuf};
12
13#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15
16#[derive(Debug, Clone)]
17pub struct FileManifest {
19 pub source: String,
21 pub dest: PathBuf,
23 pub asset_type: AssetType,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum AssetType {
30 Skill,
32 Adapter,
34 Command,
36}
37
38fn ito_skills_manifests(skills_dir: &Path) -> Vec<FileManifest> {
44 let mut manifests = Vec::new();
45
46 for file in skills_files() {
48 let rel_path = file.relative_path;
49 let parts: Vec<&str> = rel_path.split('/').collect();
51 if parts.is_empty() {
52 continue;
53 }
54 let skill_name = parts[0];
55
56 let dest_skill_name = if skill_name.starts_with("ito") {
58 skill_name.to_string()
59 } else {
60 format!("ito-{}", skill_name)
61 };
62
63 let rest = if parts.len() > 1 {
64 parts[1..].join("/")
65 } else {
66 rel_path.to_string()
67 };
68 let dest = skills_dir.join(format!("{}/{}", dest_skill_name, rest));
69
70 manifests.push(FileManifest {
71 source: rel_path.to_string(),
72 dest,
73 asset_type: AssetType::Skill,
74 });
75 }
76
77 manifests
78}
79
80fn ito_commands_manifests(commands_dir: &Path) -> Vec<FileManifest> {
83 let mut manifests = Vec::new();
84
85 for file in commands_files() {
86 let rel_path = file.relative_path;
87 manifests.push(FileManifest {
88 source: rel_path.to_string(),
89 dest: commands_dir.join(rel_path),
90 asset_type: AssetType::Command,
91 });
92 }
93
94 manifests
95}
96
97pub fn opencode_manifests(config_dir: &Path) -> Vec<FileManifest> {
103 let mut out = Vec::new();
104
105 out.push(FileManifest {
106 source: "opencode/ito-skills.js".to_string(),
107 dest: config_dir.join("plugins").join("ito-skills.js"),
108 asset_type: AssetType::Adapter,
109 });
110
111 let skills_dir = config_dir.join("skills");
113 out.extend(ito_skills_manifests(&skills_dir));
114
115 let commands_dir = config_dir.join("commands");
117 out.extend(ito_commands_manifests(&commands_dir));
118
119 out
120}
121
122pub fn claude_manifests(project_root: &Path) -> Vec<FileManifest> {
124 let mut out = vec![
125 FileManifest {
126 source: "claude/session-start.sh".to_string(),
127 dest: project_root.join(".claude").join("session-start.sh"),
128 asset_type: AssetType::Adapter,
129 },
130 FileManifest {
131 source: "claude/hooks/ito-audit.sh".to_string(),
132 dest: project_root
133 .join(".claude")
134 .join("hooks")
135 .join("ito-audit.sh"),
136 asset_type: AssetType::Adapter,
137 },
138 ];
139
140 let skills_dir = project_root.join(".claude").join("skills");
142 out.extend(ito_skills_manifests(&skills_dir));
143
144 let commands_dir = project_root.join(".claude").join("commands");
146 out.extend(ito_commands_manifests(&commands_dir));
147
148 out
149}
150
151pub fn codex_manifests(project_root: &Path) -> Vec<FileManifest> {
153 let mut out = vec![FileManifest {
154 source: "codex/ito-skills-bootstrap.md".to_string(),
155 dest: project_root
156 .join(".codex")
157 .join("instructions")
158 .join("ito-skills-bootstrap.md"),
159 asset_type: AssetType::Adapter,
160 }];
161
162 let skills_dir = project_root.join(".codex").join("skills");
164 out.extend(ito_skills_manifests(&skills_dir));
165
166 let commands_dir = project_root.join(".codex").join("prompts");
168 out.extend(ito_commands_manifests(&commands_dir));
169
170 out
171}
172
173pub fn pi_manifests(project_root: &Path) -> Vec<FileManifest> {
179 let mut out = vec![FileManifest {
180 source: "pi/ito-skills.ts".to_string(),
181 dest: project_root
182 .join(".pi")
183 .join("extensions")
184 .join("ito-skills.ts"),
185 asset_type: AssetType::Adapter,
186 }];
187
188 let skills_dir = project_root.join(".pi").join("skills");
190 out.extend(ito_skills_manifests(&skills_dir));
191
192 let commands_dir = project_root.join(".pi").join("commands");
194 out.extend(ito_commands_manifests(&commands_dir));
195
196 out
197}
198
199pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
201 let skills_dir = project_root.join(".github").join("skills");
203 let mut out = ito_skills_manifests(&skills_dir);
204
205 let prompts_dir = project_root.join(".github").join("prompts");
208 for file in commands_files() {
209 let rel_path = file.relative_path;
210 let dest_name = if let Some(stripped) = rel_path.strip_suffix(".md") {
212 format!("{stripped}.prompt.md")
213 } else {
214 rel_path.to_string()
215 };
216 out.push(FileManifest {
217 source: rel_path.to_string(),
218 dest: prompts_dir.join(dest_name),
219 asset_type: AssetType::Command,
220 });
221 }
222
223 out
224}
225
226pub fn install_manifests(
236 manifests: &[FileManifest],
237 worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
238 mode: crate::installers::InstallMode,
239 opts: &crate::installers::InitOptions,
240) -> CoreResult<()> {
241 use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
242
243 let default_ctx = WorktreeTemplateContext::default();
244 let ctx = worktree_ctx.unwrap_or(&default_ctx);
245
246 let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
248
249 for manifest in manifests {
250 let raw_bytes = match manifest.asset_type {
251 AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
252 CoreError::NotFound(format!(
253 "Skill file not found in embedded assets: {}",
254 manifest.source
255 ))
256 })?,
257 AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
258 CoreError::NotFound(format!(
259 "Adapter file not found in embedded assets: {}",
260 manifest.source
261 ))
262 })?,
263 AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
264 CoreError::NotFound(format!(
265 "Command file not found in embedded assets: {}",
266 manifest.source
267 ))
268 })?,
269 };
270
271 let mut should_render_skill = false;
275 if manifest.asset_type == AssetType::Skill {
276 for line in raw_bytes.split(|b| *b == b'\n') {
277 let Ok(line) = std::str::from_utf8(line) else {
278 continue;
279 };
280 if skill_line_uses_worktree_template_syntax(line) {
281 should_render_skill = true;
282 break;
283 }
284 }
285 }
286
287 let bytes = if should_render_skill {
288 render_project_template(raw_bytes, ctx).map_err(|e| {
289 CoreError::Validation(format!(
290 "Failed to render skill template {}: {}",
291 manifest.source, e
292 ))
293 })?
294 } else {
295 raw_bytes.to_vec()
296 };
297
298 let bytes = stamp_managed_markdown(bytes, &manifest.source, version);
300
301 let asset_supports_marker_scope =
311 matches!(manifest.asset_type, AssetType::Skill | AssetType::Command);
312 let is_managed_md = asset_supports_marker_scope
313 && is_plain_markdown_path(&manifest.source)
314 && std::str::from_utf8(&bytes)
315 .map(|t| t.contains(ito_templates::ITO_START_MARKER))
316 .unwrap_or(false);
317 if is_managed_md {
318 crate::installers::write_marker_aware_markdown(&manifest.dest, &bytes, mode, opts)?;
319 } else {
320 if let Some(parent) = manifest.dest.parent() {
321 ito_common::io::create_dir_all_std(parent).map_err(|e| {
322 CoreError::io(format!("creating directory {}", parent.display()), e)
323 })?;
324 }
325 ito_common::io::write_std(&manifest.dest, &bytes)
326 .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
327 }
328 ensure_manifest_script_is_executable(manifest)?;
329 }
330 Ok(())
331}
332
333fn is_plain_markdown_path(path: &str) -> bool {
337 path.ends_with(".md") && !path.ends_with(".md.j2")
338}
339
340fn stamp_managed_markdown(bytes: Vec<u8>, rel_path: &str, version: &str) -> Vec<u8> {
347 if !is_plain_markdown_path(rel_path) {
348 return bytes;
349 }
350
351 let Ok(text) = std::str::from_utf8(&bytes) else {
352 return bytes;
353 };
354
355 if !text.contains(ito_templates::ITO_START_MARKER) {
356 return bytes;
357 }
358
359 ito_templates::stamp_version(text, version).into_bytes()
360}
361
362fn ensure_manifest_script_is_executable(manifest: &FileManifest) -> CoreResult<()> {
363 #[cfg(unix)]
364 {
365 let is_skill_script = manifest.asset_type == AssetType::Skill
366 && manifest.source.ends_with(".sh")
367 && manifest.source.contains("/scripts/");
368
369 if is_skill_script {
370 let metadata = std::fs::metadata(&manifest.dest).map_err(|e| {
371 CoreError::io(
372 format!("reading metadata for {}", manifest.dest.display()),
373 e,
374 )
375 })?;
376 let mut permissions = metadata.permissions();
377 permissions.set_mode(permissions.mode() | 0o111);
378 std::fs::set_permissions(&manifest.dest, permissions).map_err(|e| {
379 CoreError::io(
380 format!(
381 "setting executable permissions on {}",
382 manifest.dest.display()
383 ),
384 e,
385 )
386 })?;
387 }
388 }
389
390 Ok(())
391}
392
393fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
394 if line.contains("{%") {
395 return true;
396 }
397
398 const WORKTREE_VARS: &[&str] = &[
400 "{{ enabled",
401 "{{ strategy",
402 "{{ layout_dir_name",
403 "{{ integration_mode",
404 "{{ default_branch",
405 ];
406
407 for var in WORKTREE_VARS {
408 if line.contains(var) {
409 return true;
410 }
411 }
412 false
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn pi_manifests_includes_adapter_skills_and_commands() {
421 let root = Path::new("/tmp/project");
422 let manifests = pi_manifests(root);
423
424 let adapter = manifests
426 .iter()
427 .find(|m| m.asset_type == AssetType::Adapter);
428 assert!(adapter.is_some(), "pi_manifests must include the adapter");
429 let adapter = adapter.unwrap();
430 assert_eq!(adapter.source, "pi/ito-skills.ts");
431 assert!(
432 adapter.dest.ends_with(".pi/extensions/ito-skills.ts"),
433 "adapter dest should end with .pi/extensions/ito-skills.ts, got {:?}",
434 adapter.dest
435 );
436
437 let skills: Vec<_> = manifests
439 .iter()
440 .filter(|m| m.asset_type == AssetType::Skill)
441 .collect();
442 assert!(
443 !skills.is_empty(),
444 "pi_manifests must include skill entries"
445 );
446 for skill in &skills {
447 let dest_str = skill.dest.to_string_lossy();
448 assert!(
449 dest_str.contains(".pi/skills/"),
450 "skill dest should be under .pi/skills/, got: {}",
451 dest_str
452 );
453 }
454
455 let commands: Vec<_> = manifests
457 .iter()
458 .filter(|m| m.asset_type == AssetType::Command)
459 .collect();
460 assert!(
461 !commands.is_empty(),
462 "pi_manifests must include command entries"
463 );
464 for cmd in &commands {
465 let dest_str = cmd.dest.to_string_lossy();
466 assert!(
467 dest_str.contains(".pi/commands/"),
468 "command dest should be under .pi/commands/, got: {}",
469 dest_str
470 );
471 }
472 }
473
474 #[test]
475 fn pi_adapter_asset_exists_in_embedded_templates() {
476 let contents = ito_templates::get_adapter_file("pi/ito-skills.ts");
477 assert!(
478 contents.is_some(),
479 "pi/ito-skills.ts must be present in embedded adapter assets"
480 );
481 let bytes = contents.unwrap();
482 assert!(!bytes.is_empty());
483 let text = std::str::from_utf8(bytes).expect("adapter should be valid UTF-8");
484 assert!(
485 text.contains("ExtensionAPI"),
486 "Pi adapter should import the Pi ExtensionAPI type"
487 );
488 assert!(
489 text.contains(r#""--tool", "pi""#),
490 "Pi adapter must request bootstrap with --tool pi (not opencode or other)"
491 );
492 assert!(
493 !text.contains(r#""--tool", "opencode""#),
494 "Pi adapter must not reference opencode tool type"
495 );
496 assert!(
498 !text.contains("import path from"),
499 "Pi adapter should not have unused path import"
500 );
501 assert!(
502 !text.contains("import fs from"),
503 "Pi adapter should not have unused fs import"
504 );
505 }
506
507 #[test]
508 fn pi_manifests_skills_match_opencode_skills() {
509 let root = Path::new("/home/user/myproject");
512 let pi = pi_manifests(root);
513 let oc_dir = root.join(".opencode");
514 let oc = opencode_manifests(&oc_dir);
515
516 let pi_skill_sources: std::collections::BTreeSet<_> = pi
517 .iter()
518 .filter(|m| m.asset_type == AssetType::Skill)
519 .map(|m| m.source.clone())
520 .collect();
521 let oc_skill_sources: std::collections::BTreeSet<_> = oc
522 .iter()
523 .filter(|m| m.asset_type == AssetType::Skill)
524 .map(|m| m.source.clone())
525 .collect();
526
527 assert_eq!(
528 pi_skill_sources, oc_skill_sources,
529 "Pi and OpenCode should install identical skill sources"
530 );
531 }
532
533 #[test]
534 fn pi_agent_templates_discoverable() {
535 use ito_templates::agents::{Harness, get_agent_files};
536 let files = get_agent_files(Harness::Pi);
537 let names: Vec<_> = files.iter().map(|(name, _)| *name).collect();
538 assert!(
539 names.contains(&"ito-quick.md"),
540 "Pi agent templates must include ito-quick.md, got: {:?}",
541 names
542 );
543 assert!(
544 names.contains(&"ito-general.md"),
545 "Pi agent templates must include ito-general.md, got: {:?}",
546 names
547 );
548 assert!(
549 names.contains(&"ito-thinking.md"),
550 "Pi agent templates must include ito-thinking.md, got: {:?}",
551 names
552 );
553 }
554
555 #[test]
556 fn pi_manifests_commands_match_opencode_commands() {
557 let root = Path::new("/home/user/myproject");
560 let pi = pi_manifests(root);
561 let oc_dir = root.join(".opencode");
562 let oc = opencode_manifests(&oc_dir);
563
564 let pi_cmd_sources: std::collections::BTreeSet<_> = pi
565 .iter()
566 .filter(|m| m.asset_type == AssetType::Command)
567 .map(|m| m.source.clone())
568 .collect();
569 let oc_cmd_sources: std::collections::BTreeSet<_> = oc
570 .iter()
571 .filter(|m| m.asset_type == AssetType::Command)
572 .map(|m| m.source.clone())
573 .collect();
574
575 assert_eq!(
576 pi_cmd_sources, oc_cmd_sources,
577 "Pi and OpenCode should install identical command sources"
578 );
579 }
580
581 #[cfg(unix)]
582 #[test]
583 fn ensure_manifest_script_is_executable_only_adds_execute_bits() {
584 let td = tempfile::tempdir().unwrap();
585 let dest = td.path().join("skills/demo/scripts/run.sh");
586 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
587 std::fs::write(&dest, "#!/usr/bin/env bash\n").unwrap();
588
589 let mut permissions = std::fs::metadata(&dest).unwrap().permissions();
590 permissions.set_mode(0o600);
591 std::fs::set_permissions(&dest, permissions).unwrap();
592
593 let manifest = FileManifest {
594 source: "demo/scripts/run.sh".to_string(),
595 dest: dest.clone(),
596 asset_type: AssetType::Skill,
597 };
598
599 ensure_manifest_script_is_executable(&manifest).unwrap();
600
601 let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
602 assert_eq!(mode, 0o711);
603 }
604}