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 GitHub Copilot template installation.
171pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
172    // Skills go directly under .github/skills/ (flat structure with ito- prefix)
173    let skills_dir = project_root.join(".github").join("skills");
174    let mut out = ito_skills_manifests(&skills_dir);
175
176    // Commands go under .github/prompts/ (GitHub uses "prompts" terminology)
177    // Note: GitHub Copilot uses .prompt.md suffix convention
178    let prompts_dir = project_root.join(".github").join("prompts");
179    for file in commands_files() {
180        let rel_path = file.relative_path;
181        // Convert ito-apply.md -> ito-apply.prompt.md for GitHub
182        let dest_name = if let Some(stripped) = rel_path.strip_suffix(".md") {
183            format!("{stripped}.prompt.md")
184        } else {
185            rel_path.to_string()
186        };
187        out.push(FileManifest {
188            source: rel_path.to_string(),
189            dest: prompts_dir.join(dest_name),
190            asset_type: AssetType::Command,
191        });
192    }
193
194    out
195}
196
197/// Install manifests from embedded assets to disk.
198///
199/// When `worktree_ctx` is `Some`, the `using-git-worktrees` skill template is
200/// rendered with the given worktree configuration before writing. Other skill
201/// files (which may contain `{{` as user-facing prompt placeholders) are written
202/// as-is.
203pub fn install_manifests(
204    manifests: &[FileManifest],
205    worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
206) -> CoreResult<()> {
207    use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
208
209    let default_ctx = WorktreeTemplateContext::default();
210    let ctx = worktree_ctx.unwrap_or(&default_ctx);
211
212    for manifest in manifests {
213        let raw_bytes = match manifest.asset_type {
214            AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
215                CoreError::NotFound(format!(
216                    "Skill file not found in embedded assets: {}",
217                    manifest.source
218                ))
219            })?,
220            AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
221                CoreError::NotFound(format!(
222                    "Adapter file not found in embedded assets: {}",
223                    manifest.source
224                ))
225            })?,
226            AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
227                CoreError::NotFound(format!(
228                    "Command file not found in embedded assets: {}",
229                    manifest.source
230                ))
231            })?,
232        };
233
234        // Render skill templates that opt into worktree Jinja2 variables. We
235        // intentionally avoid rendering arbitrary `{{ ... }}` placeholders used
236        // by non-template skills (e.g. research prompts).
237        let mut should_render_skill = false;
238        if manifest.asset_type == AssetType::Skill {
239            for line in raw_bytes.split(|b| *b == b'\n') {
240                let Ok(line) = std::str::from_utf8(line) else {
241                    continue;
242                };
243                if skill_line_uses_worktree_template_syntax(line) {
244                    should_render_skill = true;
245                    break;
246                }
247            }
248        }
249
250        let bytes = if should_render_skill {
251            render_project_template(raw_bytes, ctx).map_err(|e| {
252                CoreError::Validation(format!(
253                    "Failed to render skill template {}: {}",
254                    manifest.source, e
255                ))
256            })?
257        } else {
258            raw_bytes.to_vec()
259        };
260
261        if let Some(parent) = manifest.dest.parent() {
262            ito_common::io::create_dir_all_std(parent).map_err(|e| {
263                CoreError::io(format!("creating directory {}", parent.display()), e)
264            })?;
265        }
266        ito_common::io::write_std(&manifest.dest, &bytes)
267            .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
268    }
269    Ok(())
270}
271
272fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
273    if line.contains("{%") {
274        return true;
275    }
276
277    // Variable-only templates are supported for the worktree context keys.
278    const WORKTREE_VARS: &[&str] = &[
279        "{{ enabled",
280        "{{ strategy",
281        "{{ layout_dir_name",
282        "{{ integration_mode",
283        "{{ default_branch",
284    ];
285
286    for var in WORKTREE_VARS {
287        if line.contains(var) {
288            return true;
289        }
290    }
291    false
292}