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(
233 manifests: &[FileManifest],
234 worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
235) -> CoreResult<()> {
236 use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
237
238 let default_ctx = WorktreeTemplateContext::default();
239 let ctx = worktree_ctx.unwrap_or(&default_ctx);
240
241 for manifest in manifests {
242 let raw_bytes = match manifest.asset_type {
243 AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
244 CoreError::NotFound(format!(
245 "Skill file not found in embedded assets: {}",
246 manifest.source
247 ))
248 })?,
249 AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
250 CoreError::NotFound(format!(
251 "Adapter file not found in embedded assets: {}",
252 manifest.source
253 ))
254 })?,
255 AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
256 CoreError::NotFound(format!(
257 "Command file not found in embedded assets: {}",
258 manifest.source
259 ))
260 })?,
261 };
262
263 let mut should_render_skill = false;
267 if manifest.asset_type == AssetType::Skill {
268 for line in raw_bytes.split(|b| *b == b'\n') {
269 let Ok(line) = std::str::from_utf8(line) else {
270 continue;
271 };
272 if skill_line_uses_worktree_template_syntax(line) {
273 should_render_skill = true;
274 break;
275 }
276 }
277 }
278
279 let bytes = if should_render_skill {
280 render_project_template(raw_bytes, ctx).map_err(|e| {
281 CoreError::Validation(format!(
282 "Failed to render skill template {}: {}",
283 manifest.source, e
284 ))
285 })?
286 } else {
287 raw_bytes.to_vec()
288 };
289
290 if let Some(parent) = manifest.dest.parent() {
291 ito_common::io::create_dir_all_std(parent).map_err(|e| {
292 CoreError::io(format!("creating directory {}", parent.display()), e)
293 })?;
294 }
295 ito_common::io::write_std(&manifest.dest, &bytes)
296 .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
297 ensure_manifest_script_is_executable(manifest)?;
298 }
299 Ok(())
300}
301
302fn ensure_manifest_script_is_executable(manifest: &FileManifest) -> CoreResult<()> {
303 #[cfg(unix)]
304 {
305 let is_skill_script = manifest.asset_type == AssetType::Skill
306 && manifest.source.ends_with(".sh")
307 && manifest.source.contains("/scripts/");
308
309 if is_skill_script {
310 let metadata = std::fs::metadata(&manifest.dest).map_err(|e| {
311 CoreError::io(
312 format!("reading metadata for {}", manifest.dest.display()),
313 e,
314 )
315 })?;
316 let mut permissions = metadata.permissions();
317 permissions.set_mode(permissions.mode() | 0o111);
318 std::fs::set_permissions(&manifest.dest, permissions).map_err(|e| {
319 CoreError::io(
320 format!(
321 "setting executable permissions on {}",
322 manifest.dest.display()
323 ),
324 e,
325 )
326 })?;
327 }
328 }
329
330 Ok(())
331}
332
333fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
334 if line.contains("{%") {
335 return true;
336 }
337
338 const WORKTREE_VARS: &[&str] = &[
340 "{{ enabled",
341 "{{ strategy",
342 "{{ layout_dir_name",
343 "{{ integration_mode",
344 "{{ default_branch",
345 ];
346
347 for var in WORKTREE_VARS {
348 if line.contains(var) {
349 return true;
350 }
351 }
352 false
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn pi_manifests_includes_adapter_skills_and_commands() {
361 let root = Path::new("/tmp/project");
362 let manifests = pi_manifests(root);
363
364 let adapter = manifests
366 .iter()
367 .find(|m| m.asset_type == AssetType::Adapter);
368 assert!(adapter.is_some(), "pi_manifests must include the adapter");
369 let adapter = adapter.unwrap();
370 assert_eq!(adapter.source, "pi/ito-skills.ts");
371 assert!(
372 adapter.dest.ends_with(".pi/extensions/ito-skills.ts"),
373 "adapter dest should end with .pi/extensions/ito-skills.ts, got {:?}",
374 adapter.dest
375 );
376
377 let skills: Vec<_> = manifests
379 .iter()
380 .filter(|m| m.asset_type == AssetType::Skill)
381 .collect();
382 assert!(
383 !skills.is_empty(),
384 "pi_manifests must include skill entries"
385 );
386 for skill in &skills {
387 let dest_str = skill.dest.to_string_lossy();
388 assert!(
389 dest_str.contains(".pi/skills/"),
390 "skill dest should be under .pi/skills/, got: {}",
391 dest_str
392 );
393 }
394
395 let commands: Vec<_> = manifests
397 .iter()
398 .filter(|m| m.asset_type == AssetType::Command)
399 .collect();
400 assert!(
401 !commands.is_empty(),
402 "pi_manifests must include command entries"
403 );
404 for cmd in &commands {
405 let dest_str = cmd.dest.to_string_lossy();
406 assert!(
407 dest_str.contains(".pi/commands/"),
408 "command dest should be under .pi/commands/, got: {}",
409 dest_str
410 );
411 }
412 }
413
414 #[test]
415 fn pi_adapter_asset_exists_in_embedded_templates() {
416 let contents = ito_templates::get_adapter_file("pi/ito-skills.ts");
417 assert!(
418 contents.is_some(),
419 "pi/ito-skills.ts must be present in embedded adapter assets"
420 );
421 let bytes = contents.unwrap();
422 assert!(!bytes.is_empty());
423 let text = std::str::from_utf8(bytes).expect("adapter should be valid UTF-8");
424 assert!(
425 text.contains("ExtensionAPI"),
426 "Pi adapter should import the Pi ExtensionAPI type"
427 );
428 assert!(
429 text.contains(r#""--tool", "pi""#),
430 "Pi adapter must request bootstrap with --tool pi (not opencode or other)"
431 );
432 assert!(
433 !text.contains(r#""--tool", "opencode""#),
434 "Pi adapter must not reference opencode tool type"
435 );
436 assert!(
438 !text.contains("import path from"),
439 "Pi adapter should not have unused path import"
440 );
441 assert!(
442 !text.contains("import fs from"),
443 "Pi adapter should not have unused fs import"
444 );
445 }
446
447 #[test]
448 fn pi_manifests_skills_match_opencode_skills() {
449 let root = Path::new("/home/user/myproject");
452 let pi = pi_manifests(root);
453 let oc_dir = root.join(".opencode");
454 let oc = opencode_manifests(&oc_dir);
455
456 let pi_skill_sources: std::collections::BTreeSet<_> = pi
457 .iter()
458 .filter(|m| m.asset_type == AssetType::Skill)
459 .map(|m| m.source.clone())
460 .collect();
461 let oc_skill_sources: std::collections::BTreeSet<_> = oc
462 .iter()
463 .filter(|m| m.asset_type == AssetType::Skill)
464 .map(|m| m.source.clone())
465 .collect();
466
467 assert_eq!(
468 pi_skill_sources, oc_skill_sources,
469 "Pi and OpenCode should install identical skill sources"
470 );
471 }
472
473 #[test]
474 fn pi_agent_templates_discoverable() {
475 use ito_templates::agents::{Harness, get_agent_files};
476 let files = get_agent_files(Harness::Pi);
477 let names: Vec<_> = files.iter().map(|(name, _)| *name).collect();
478 assert!(
479 names.contains(&"ito-quick.md"),
480 "Pi agent templates must include ito-quick.md, got: {:?}",
481 names
482 );
483 assert!(
484 names.contains(&"ito-general.md"),
485 "Pi agent templates must include ito-general.md, got: {:?}",
486 names
487 );
488 assert!(
489 names.contains(&"ito-thinking.md"),
490 "Pi agent templates must include ito-thinking.md, got: {:?}",
491 names
492 );
493 }
494
495 #[test]
496 fn pi_manifests_commands_match_opencode_commands() {
497 let root = Path::new("/home/user/myproject");
500 let pi = pi_manifests(root);
501 let oc_dir = root.join(".opencode");
502 let oc = opencode_manifests(&oc_dir);
503
504 let pi_cmd_sources: std::collections::BTreeSet<_> = pi
505 .iter()
506 .filter(|m| m.asset_type == AssetType::Command)
507 .map(|m| m.source.clone())
508 .collect();
509 let oc_cmd_sources: std::collections::BTreeSet<_> = oc
510 .iter()
511 .filter(|m| m.asset_type == AssetType::Command)
512 .map(|m| m.source.clone())
513 .collect();
514
515 assert_eq!(
516 pi_cmd_sources, oc_cmd_sources,
517 "Pi and OpenCode should install identical command sources"
518 );
519 }
520
521 #[cfg(unix)]
522 #[test]
523 fn ensure_manifest_script_is_executable_only_adds_execute_bits() {
524 let td = tempfile::tempdir().unwrap();
525 let dest = td.path().join("skills/demo/scripts/run.sh");
526 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
527 std::fs::write(&dest, "#!/usr/bin/env bash\n").unwrap();
528
529 let mut permissions = std::fs::metadata(&dest).unwrap().permissions();
530 permissions.set_mode(0o600);
531 std::fs::set_permissions(&dest, permissions).unwrap();
532
533 let manifest = FileManifest {
534 source: "demo/scripts/run.sh".to_string(),
535 dest: dest.clone(),
536 asset_type: AssetType::Skill,
537 };
538
539 ensure_manifest_script_is_executable(&manifest).unwrap();
540
541 let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
542 assert_eq!(mode, 0o711);
543 }
544}