Skip to main content

ito_core/
distribution.rs

1//! Embedded asset distribution helpers.
2//!
3//! This module builds install manifests for the various harnesses Ito supports.
4//! The manifests map a file embedded in `ito-templates` to a destination path on
5//! disk.
6
7use 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)]
14/// One file to be installed from embedded assets.
15pub struct FileManifest {
16    /// Source path relative to embedded assets (e.g., "brainstorming/SKILL.md" for skills)
17    pub source: String,
18    /// Destination path on disk
19    pub dest: PathBuf,
20    /// Asset type determines which embedded directory to read from
21    pub asset_type: AssetType,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25/// Category of embedded asset.
26pub enum AssetType {
27    /// A skill markdown file.
28    Skill,
29    /// A tool-specific adapter/bootstrap file.
30    Adapter,
31    /// A command/prompt template.
32    Command,
33}
34
35/// Returns manifest entries for all ito-skills.
36/// Source paths are relative to assets/skills/ (e.g., "brainstorming/SKILL.md")
37/// Dest paths have ito- prefix added if not already present
38/// (e.g., "brainstorming/SKILL.md" -> "ito-brainstorming/SKILL.md")
39/// (e.g., "ito/SKILL.md" -> "ito/SKILL.md" - no double prefix)
40fn ito_skills_manifests(skills_dir: &Path) -> Vec<FileManifest> {
41    let mut manifests = Vec::new();
42
43    // Get all skill files from embedded assets
44    for file in skills_files() {
45        let rel_path = file.relative_path;
46        // Extract skill name from path (e.g., "brainstorming/SKILL.md" -> "brainstorming")
47        let parts: Vec<&str> = rel_path.split('/').collect();
48        if parts.is_empty() {
49            continue;
50        }
51        let skill_name = parts[0];
52
53        // Build destination path, adding ito- prefix only if not already present
54        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
77/// Returns manifest entries for all ito commands.
78/// Commands are copied directly to the commands directory with their original names.
79fn 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
94/// Return manifest entries for OpenCode template installation.
95///
96/// OpenCode stores its configuration under a single directory (typically
97/// `~/.config/opencode/`). We install an Ito plugin along with a flat list of
98/// skills and commands.
99pub 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    // Skills go directly under skills/ (flat structure with ito- prefix)
109    let skills_dir = config_dir.join("skills");
110    out.extend(ito_skills_manifests(&skills_dir));
111
112    // Commands go under commands/
113    let commands_dir = config_dir.join("commands");
114    out.extend(ito_commands_manifests(&commands_dir));
115
116    out
117}
118
119/// Return manifest entries for Claude Code template installation.
120pub 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    // Skills go directly under .claude/skills/ (flat structure with ito- prefix)
138    let skills_dir = project_root.join(".claude").join("skills");
139    out.extend(ito_skills_manifests(&skills_dir));
140
141    // Commands go under .claude/commands/
142    let commands_dir = project_root.join(".claude").join("commands");
143    out.extend(ito_commands_manifests(&commands_dir));
144
145    out
146}
147
148/// Return manifest entries for Codex template installation.
149pub 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    // Skills go directly under .codex/skills/ (flat structure with ito- prefix)
160    let skills_dir = project_root.join(".codex").join("skills");
161    out.extend(ito_skills_manifests(&skills_dir));
162
163    // Commands go under .codex/prompts/ (Codex uses "prompts" terminology)
164    let commands_dir = project_root.join(".codex").join("prompts");
165    out.extend(ito_commands_manifests(&commands_dir));
166
167    out
168}
169
170/// Return manifest entries for Pi coding agent template installation.
171///
172/// Pi gets its own copy of skills and commands under `.pi/` so it is fully
173/// self-contained — users can install Pi without OpenCode. The skills and
174/// commands are read from the same shared embedded assets used by every harness.
175pub 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    // Skills go under .pi/skills/ (flat structure with ito- prefix)
186    let skills_dir = project_root.join(".pi").join("skills");
187    out.extend(ito_skills_manifests(&skills_dir));
188
189    // Commands go under .pi/commands/
190    let commands_dir = project_root.join(".pi").join("commands");
191    out.extend(ito_commands_manifests(&commands_dir));
192
193    out
194}
195
196/// Return manifest entries for GitHub Copilot template installation.
197pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
198    // Skills go directly under .github/skills/ (flat structure with ito- prefix)
199    let skills_dir = project_root.join(".github").join("skills");
200    let mut out = ito_skills_manifests(&skills_dir);
201
202    // Commands go under .github/prompts/ (GitHub uses "prompts" terminology)
203    // Note: GitHub Copilot uses .prompt.md suffix convention
204    let prompts_dir = project_root.join(".github").join("prompts");
205    for file in commands_files() {
206        let rel_path = file.relative_path;
207        // Convert ito-apply.md -> ito-apply.prompt.md for GitHub
208        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
223/// Install manifests from embedded assets to disk.
224///
225/// When `worktree_ctx` is `Some`, the `using-git-worktrees` skill template is
226/// rendered with the given worktree configuration before writing. Other skill
227/// files (which may contain `{{` as user-facing prompt placeholders) are written
228/// as-is.
229pub 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        // Render skill templates that opt into worktree Jinja2 variables. We
261        // intentionally avoid rendering arbitrary `{{ ... }}` placeholders used
262        // by non-template skills (e.g. research prompts).
263        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    // Variable-only templates are supported for the worktree context keys.
304    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        // Must contain the adapter extension.
330        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        // Must contain skill entries under .pi/skills/.
343        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        // Must contain command entries under .pi/commands/.
361        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        // Verify unused imports are not present.
402        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        // Pi and OpenCode should install the same set of skills from the
415        // shared embedded source — only the destination directory differs.
416        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        // Pi and OpenCode should install the same set of commands from the
463        // shared embedded source — only the destination directory differs.
464        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}