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![FileManifest {
122        source: "claude/session-start.sh".to_string(),
123        dest: project_root.join(".claude").join("session-start.sh"),
124        asset_type: AssetType::Adapter,
125    }];
126
127    // Skills go directly under .claude/skills/ (flat structure with ito- prefix)
128    let skills_dir = project_root.join(".claude").join("skills");
129    out.extend(ito_skills_manifests(&skills_dir));
130
131    // Commands go under .claude/commands/
132    let commands_dir = project_root.join(".claude").join("commands");
133    out.extend(ito_commands_manifests(&commands_dir));
134
135    out
136}
137
138/// Return manifest entries for Codex template installation.
139pub fn codex_manifests(project_root: &Path) -> Vec<FileManifest> {
140    let mut out = vec![FileManifest {
141        source: "codex/ito-skills-bootstrap.md".to_string(),
142        dest: project_root
143            .join(".codex")
144            .join("instructions")
145            .join("ito-skills-bootstrap.md"),
146        asset_type: AssetType::Adapter,
147    }];
148
149    // Skills go directly under .codex/skills/ (flat structure with ito- prefix)
150    let skills_dir = project_root.join(".codex").join("skills");
151    out.extend(ito_skills_manifests(&skills_dir));
152
153    // Commands go under .codex/prompts/ (Codex uses "prompts" terminology)
154    let commands_dir = project_root.join(".codex").join("prompts");
155    out.extend(ito_commands_manifests(&commands_dir));
156
157    out
158}
159
160/// Return manifest entries for GitHub Copilot template installation.
161pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
162    // Skills go directly under .github/skills/ (flat structure with ito- prefix)
163    let skills_dir = project_root.join(".github").join("skills");
164    let mut out = ito_skills_manifests(&skills_dir);
165
166    // Commands go under .github/prompts/ (GitHub uses "prompts" terminology)
167    // Note: GitHub Copilot uses .prompt.md suffix convention
168    let prompts_dir = project_root.join(".github").join("prompts");
169    for file in commands_files() {
170        let rel_path = file.relative_path;
171        // Convert ito-apply.md -> ito-apply.prompt.md for GitHub
172        let dest_name = if let Some(stripped) = rel_path.strip_suffix(".md") {
173            format!("{stripped}.prompt.md")
174        } else {
175            rel_path.to_string()
176        };
177        out.push(FileManifest {
178            source: rel_path.to_string(),
179            dest: prompts_dir.join(dest_name),
180            asset_type: AssetType::Command,
181        });
182    }
183
184    out
185}
186
187/// Install manifests from embedded assets to disk.
188///
189/// When `worktree_ctx` is `Some`, the `using-git-worktrees` skill template is
190/// rendered with the given worktree configuration before writing. Other skill
191/// files (which may contain `{{` as user-facing prompt placeholders) are written
192/// as-is.
193pub fn install_manifests(
194    manifests: &[FileManifest],
195    worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
196) -> CoreResult<()> {
197    use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
198
199    let default_ctx = WorktreeTemplateContext::default();
200    let ctx = worktree_ctx.unwrap_or(&default_ctx);
201
202    for manifest in manifests {
203        let raw_bytes = match manifest.asset_type {
204            AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
205                CoreError::NotFound(format!(
206                    "Skill file not found in embedded assets: {}",
207                    manifest.source
208                ))
209            })?,
210            AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
211                CoreError::NotFound(format!(
212                    "Adapter file not found in embedded assets: {}",
213                    manifest.source
214                ))
215            })?,
216            AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
217                CoreError::NotFound(format!(
218                    "Command file not found in embedded assets: {}",
219                    manifest.source
220                ))
221            })?,
222        };
223
224        // Render worktree-aware skill templates with worktree config. Only
225        // the using-git-worktrees skill uses Jinja2 for worktree rendering;
226        // other skills (e.g., research/) may contain `{{` as user-facing
227        // prompt placeholders that must NOT be processed by minijinja.
228        let is_worktree_skill = manifest.source.starts_with("using-git-worktrees/");
229        let bytes = if manifest.asset_type == AssetType::Skill && is_worktree_skill {
230            render_project_template(raw_bytes, ctx).map_err(|e| {
231                CoreError::Validation(format!(
232                    "Failed to render skill template {}: {}",
233                    manifest.source, e
234                ))
235            })?
236        } else {
237            raw_bytes.to_vec()
238        };
239
240        if let Some(parent) = manifest.dest.parent() {
241            ito_common::io::create_dir_all_std(parent).map_err(|e| {
242                CoreError::io(format!("creating directory {}", parent.display()), e)
243            })?;
244        }
245        ito_common::io::write_std(&manifest.dest, &bytes)
246            .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
247    }
248    Ok(())
249}