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#[derive(Debug, Clone)]
14pub struct FileManifest {
16 pub source: String,
18 pub dest: PathBuf,
20 pub asset_type: AssetType,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AssetType {
27 Skill,
29 Adapter,
31 Command,
33}
34
35fn ito_skills_manifests(skills_dir: &Path) -> Vec<FileManifest> {
41 let mut manifests = Vec::new();
42
43 for file in skills_files() {
45 let rel_path = file.relative_path;
46 let parts: Vec<&str> = rel_path.split('/').collect();
48 if parts.is_empty() {
49 continue;
50 }
51 let skill_name = parts[0];
52
53 let dest_skill_name = if skill_name.starts_with("ito") {
55 skill_name.to_string()
56 } else {
57 format!("ito-{}", skill_name)
58 };
59
60 let rest = if parts.len() > 1 {
61 parts[1..].join("/")
62 } else {
63 rel_path.to_string()
64 };
65 let dest = skills_dir.join(format!("{}/{}", dest_skill_name, rest));
66
67 manifests.push(FileManifest {
68 source: rel_path.to_string(),
69 dest,
70 asset_type: AssetType::Skill,
71 });
72 }
73
74 manifests
75}
76
77fn ito_commands_manifests(commands_dir: &Path) -> Vec<FileManifest> {
80 let mut manifests = Vec::new();
81
82 for file in commands_files() {
83 let rel_path = file.relative_path;
84 manifests.push(FileManifest {
85 source: rel_path.to_string(),
86 dest: commands_dir.join(rel_path),
87 asset_type: AssetType::Command,
88 });
89 }
90
91 manifests
92}
93
94pub fn opencode_manifests(config_dir: &Path) -> Vec<FileManifest> {
100 let mut out = Vec::new();
101
102 out.push(FileManifest {
103 source: "opencode/ito-skills.js".to_string(),
104 dest: config_dir.join("plugins").join("ito-skills.js"),
105 asset_type: AssetType::Adapter,
106 });
107
108 let skills_dir = config_dir.join("skills");
110 out.extend(ito_skills_manifests(&skills_dir));
111
112 let commands_dir = config_dir.join("commands");
114 out.extend(ito_commands_manifests(&commands_dir));
115
116 out
117}
118
119pub fn claude_manifests(project_root: &Path) -> Vec<FileManifest> {
121 let mut out = vec![
122 FileManifest {
123 source: "claude/session-start.sh".to_string(),
124 dest: project_root.join(".claude").join("session-start.sh"),
125 asset_type: AssetType::Adapter,
126 },
127 FileManifest {
128 source: "claude/hooks/ito-audit.sh".to_string(),
129 dest: project_root
130 .join(".claude")
131 .join("hooks")
132 .join("ito-audit.sh"),
133 asset_type: AssetType::Adapter,
134 },
135 ];
136
137 let skills_dir = project_root.join(".claude").join("skills");
139 out.extend(ito_skills_manifests(&skills_dir));
140
141 let commands_dir = project_root.join(".claude").join("commands");
143 out.extend(ito_commands_manifests(&commands_dir));
144
145 out
146}
147
148pub fn codex_manifests(project_root: &Path) -> Vec<FileManifest> {
150 let mut out = vec![FileManifest {
151 source: "codex/ito-skills-bootstrap.md".to_string(),
152 dest: project_root
153 .join(".codex")
154 .join("instructions")
155 .join("ito-skills-bootstrap.md"),
156 asset_type: AssetType::Adapter,
157 }];
158
159 let skills_dir = project_root.join(".codex").join("skills");
161 out.extend(ito_skills_manifests(&skills_dir));
162
163 let commands_dir = project_root.join(".codex").join("prompts");
165 out.extend(ito_commands_manifests(&commands_dir));
166
167 out
168}
169
170pub fn pi_manifests(project_root: &Path) -> Vec<FileManifest> {
176 let mut out = vec![FileManifest {
177 source: "pi/ito-skills.ts".to_string(),
178 dest: project_root
179 .join(".pi")
180 .join("extensions")
181 .join("ito-skills.ts"),
182 asset_type: AssetType::Adapter,
183 }];
184
185 let skills_dir = project_root.join(".pi").join("skills");
187 out.extend(ito_skills_manifests(&skills_dir));
188
189 let commands_dir = project_root.join(".pi").join("commands");
191 out.extend(ito_commands_manifests(&commands_dir));
192
193 out
194}
195
196pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
198 let skills_dir = project_root.join(".github").join("skills");
200 let mut out = ito_skills_manifests(&skills_dir);
201
202 let prompts_dir = project_root.join(".github").join("prompts");
205 for file in commands_files() {
206 let rel_path = file.relative_path;
207 let dest_name = if let Some(stripped) = rel_path.strip_suffix(".md") {
209 format!("{stripped}.prompt.md")
210 } else {
211 rel_path.to_string()
212 };
213 out.push(FileManifest {
214 source: rel_path.to_string(),
215 dest: prompts_dir.join(dest_name),
216 asset_type: AssetType::Command,
217 });
218 }
219
220 out
221}
222
223pub fn install_manifests(
230 manifests: &[FileManifest],
231 worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
232) -> CoreResult<()> {
233 use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
234
235 let default_ctx = WorktreeTemplateContext::default();
236 let ctx = worktree_ctx.unwrap_or(&default_ctx);
237
238 for manifest in manifests {
239 let raw_bytes = match manifest.asset_type {
240 AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
241 CoreError::NotFound(format!(
242 "Skill file not found in embedded assets: {}",
243 manifest.source
244 ))
245 })?,
246 AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
247 CoreError::NotFound(format!(
248 "Adapter file not found in embedded assets: {}",
249 manifest.source
250 ))
251 })?,
252 AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
253 CoreError::NotFound(format!(
254 "Command file not found in embedded assets: {}",
255 manifest.source
256 ))
257 })?,
258 };
259
260 let mut should_render_skill = false;
264 if manifest.asset_type == AssetType::Skill {
265 for line in raw_bytes.split(|b| *b == b'\n') {
266 let Ok(line) = std::str::from_utf8(line) else {
267 continue;
268 };
269 if skill_line_uses_worktree_template_syntax(line) {
270 should_render_skill = true;
271 break;
272 }
273 }
274 }
275
276 let bytes = if should_render_skill {
277 render_project_template(raw_bytes, ctx).map_err(|e| {
278 CoreError::Validation(format!(
279 "Failed to render skill template {}: {}",
280 manifest.source, e
281 ))
282 })?
283 } else {
284 raw_bytes.to_vec()
285 };
286
287 if let Some(parent) = manifest.dest.parent() {
288 ito_common::io::create_dir_all_std(parent).map_err(|e| {
289 CoreError::io(format!("creating directory {}", parent.display()), e)
290 })?;
291 }
292 ito_common::io::write_std(&manifest.dest, &bytes)
293 .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
294 }
295 Ok(())
296}
297
298fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
299 if line.contains("{%") {
300 return true;
301 }
302
303 const WORKTREE_VARS: &[&str] = &[
305 "{{ enabled",
306 "{{ strategy",
307 "{{ layout_dir_name",
308 "{{ integration_mode",
309 "{{ default_branch",
310 ];
311
312 for var in WORKTREE_VARS {
313 if line.contains(var) {
314 return true;
315 }
316 }
317 false
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn pi_manifests_includes_adapter_skills_and_commands() {
326 let root = Path::new("/tmp/project");
327 let manifests = pi_manifests(root);
328
329 let adapter = manifests
331 .iter()
332 .find(|m| m.asset_type == AssetType::Adapter);
333 assert!(adapter.is_some(), "pi_manifests must include the adapter");
334 let adapter = adapter.unwrap();
335 assert_eq!(adapter.source, "pi/ito-skills.ts");
336 assert!(
337 adapter.dest.ends_with(".pi/extensions/ito-skills.ts"),
338 "adapter dest should end with .pi/extensions/ito-skills.ts, got {:?}",
339 adapter.dest
340 );
341
342 let skills: Vec<_> = manifests
344 .iter()
345 .filter(|m| m.asset_type == AssetType::Skill)
346 .collect();
347 assert!(
348 !skills.is_empty(),
349 "pi_manifests must include skill entries"
350 );
351 for skill in &skills {
352 let dest_str = skill.dest.to_string_lossy();
353 assert!(
354 dest_str.contains(".pi/skills/"),
355 "skill dest should be under .pi/skills/, got: {}",
356 dest_str
357 );
358 }
359
360 let commands: Vec<_> = manifests
362 .iter()
363 .filter(|m| m.asset_type == AssetType::Command)
364 .collect();
365 assert!(
366 !commands.is_empty(),
367 "pi_manifests must include command entries"
368 );
369 for cmd in &commands {
370 let dest_str = cmd.dest.to_string_lossy();
371 assert!(
372 dest_str.contains(".pi/commands/"),
373 "command dest should be under .pi/commands/, got: {}",
374 dest_str
375 );
376 }
377 }
378
379 #[test]
380 fn pi_adapter_asset_exists_in_embedded_templates() {
381 let contents = ito_templates::get_adapter_file("pi/ito-skills.ts");
382 assert!(
383 contents.is_some(),
384 "pi/ito-skills.ts must be present in embedded adapter assets"
385 );
386 let bytes = contents.unwrap();
387 assert!(!bytes.is_empty());
388 let text = std::str::from_utf8(bytes).expect("adapter should be valid UTF-8");
389 assert!(
390 text.contains("ExtensionAPI"),
391 "Pi adapter should import the Pi ExtensionAPI type"
392 );
393 assert!(
394 text.contains(r#""--tool", "pi""#),
395 "Pi adapter must request bootstrap with --tool pi (not opencode or other)"
396 );
397 assert!(
398 !text.contains(r#""--tool", "opencode""#),
399 "Pi adapter must not reference opencode tool type"
400 );
401 assert!(
403 !text.contains("import path from"),
404 "Pi adapter should not have unused path import"
405 );
406 assert!(
407 !text.contains("import fs from"),
408 "Pi adapter should not have unused fs import"
409 );
410 }
411
412 #[test]
413 fn pi_manifests_skills_match_opencode_skills() {
414 let root = Path::new("/home/user/myproject");
417 let pi = pi_manifests(root);
418 let oc_dir = root.join(".opencode");
419 let oc = opencode_manifests(&oc_dir);
420
421 let pi_skill_sources: std::collections::BTreeSet<_> = pi
422 .iter()
423 .filter(|m| m.asset_type == AssetType::Skill)
424 .map(|m| m.source.clone())
425 .collect();
426 let oc_skill_sources: std::collections::BTreeSet<_> = oc
427 .iter()
428 .filter(|m| m.asset_type == AssetType::Skill)
429 .map(|m| m.source.clone())
430 .collect();
431
432 assert_eq!(
433 pi_skill_sources, oc_skill_sources,
434 "Pi and OpenCode should install identical skill sources"
435 );
436 }
437
438 #[test]
439 fn pi_agent_templates_discoverable() {
440 use ito_templates::agents::{Harness, get_agent_files};
441 let files = get_agent_files(Harness::Pi);
442 let names: Vec<_> = files.iter().map(|(name, _)| *name).collect();
443 assert!(
444 names.contains(&"ito-quick.md"),
445 "Pi agent templates must include ito-quick.md, got: {:?}",
446 names
447 );
448 assert!(
449 names.contains(&"ito-general.md"),
450 "Pi agent templates must include ito-general.md, got: {:?}",
451 names
452 );
453 assert!(
454 names.contains(&"ito-thinking.md"),
455 "Pi agent templates must include ito-thinking.md, got: {:?}",
456 names
457 );
458 }
459
460 #[test]
461 fn pi_manifests_commands_match_opencode_commands() {
462 let root = Path::new("/home/user/myproject");
465 let pi = pi_manifests(root);
466 let oc_dir = root.join(".opencode");
467 let oc = opencode_manifests(&oc_dir);
468
469 let pi_cmd_sources: std::collections::BTreeSet<_> = pi
470 .iter()
471 .filter(|m| m.asset_type == AssetType::Command)
472 .map(|m| m.source.clone())
473 .collect();
474 let oc_cmd_sources: std::collections::BTreeSet<_> = oc
475 .iter()
476 .filter(|m| m.asset_type == AssetType::Command)
477 .map(|m| m.source.clone())
478 .collect();
479
480 assert_eq!(
481 pi_cmd_sources, oc_cmd_sources,
482 "Pi and OpenCode should install identical command sources"
483 );
484 }
485}