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., "ito-proposal/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., "ito-proposal/SKILL.md")
37/// Dest paths have ito- prefix added if not already present
38/// (e.g., "ito-proposal/SKILL.md" remains "ito-proposal/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., "ito-proposal/SKILL.md" -> "ito-proposal")
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/// Skill assets that explicitly use worktree Jinja variables are rendered with
226/// `worktree_ctx` before writing. Other skill files (which may contain `{{` as
227/// user-facing prompt placeholders) are written as-is.
228///
229/// Every `.md` file that contains an Ito managed block receives a version stamp
230/// immediately after `<!-- ITO:START -->` before being written to disk.
231pub fn install_manifests(
232    manifests: &[FileManifest],
233    worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
234    mode: crate::installers::InstallMode,
235    opts: &crate::installers::InitOptions,
236) -> CoreResult<()> {
237    use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
238
239    let default_ctx = WorktreeTemplateContext::default();
240    let ctx = worktree_ctx.unwrap_or(&default_ctx);
241
242    // Source the version once for all manifests in this batch.
243    let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
244
245    for manifest in manifests {
246        let raw_bytes = match manifest.asset_type {
247            AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
248                CoreError::NotFound(format!(
249                    "Skill file not found in embedded assets: {}",
250                    manifest.source
251                ))
252            })?,
253            AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
254                CoreError::NotFound(format!(
255                    "Adapter file not found in embedded assets: {}",
256                    manifest.source
257                ))
258            })?,
259            AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
260                CoreError::NotFound(format!(
261                    "Command file not found in embedded assets: {}",
262                    manifest.source
263                ))
264            })?,
265        };
266
267        // Render skill templates that opt into worktree Jinja2 variables. We
268        // intentionally avoid rendering arbitrary `{{ ... }}` placeholders used
269        // by non-template skills (e.g. research prompts).
270        let mut should_render_skill = false;
271        if manifest.asset_type == AssetType::Skill {
272            for line in raw_bytes.split(|b| *b == b'\n') {
273                let Ok(line) = std::str::from_utf8(line) else {
274                    continue;
275                };
276                if skill_line_uses_worktree_template_syntax(line) {
277                    should_render_skill = true;
278                    break;
279                }
280            }
281        }
282
283        let bytes = if should_render_skill {
284            render_project_template(raw_bytes, ctx).map_err(|e| {
285                CoreError::Validation(format!(
286                    "Failed to render skill template {}: {}",
287                    manifest.source, e
288                ))
289            })?
290        } else {
291            raw_bytes.to_vec()
292        };
293
294        // Stamp every managed-block markdown file with the current CLI version.
295        let bytes = stamp_managed_markdown(bytes, &manifest.source, version);
296
297        // Markdown manifest entries that contain an Ito-managed block AND
298        // belong to an asset type whose update contract is "user content
299        // outside the managed block survives" go through the marker-scoped
300        // writer. Today that contract applies to skills and commands. Adapter
301        // markdown (e.g. the codex bootstrap) is still wholesale-refreshed
302        // because adapter content is owned end-to-end by Ito; preserving
303        // out-of-marker user edits there is not part of the contract. Shell
304        // scripts and other non-markdown manifest entries also stay
305        // wholesale-write.
306        let asset_supports_marker_scope =
307            matches!(manifest.asset_type, AssetType::Skill | AssetType::Command);
308        let is_managed_md = asset_supports_marker_scope
309            && is_plain_markdown_path(&manifest.source)
310            && std::str::from_utf8(&bytes)
311                .map(|t| t.contains(ito_templates::ITO_START_MARKER))
312                .unwrap_or(false);
313        if is_managed_md {
314            crate::installers::write_marker_aware_markdown(&manifest.dest, &bytes, mode, opts)?;
315        } else {
316            if let Some(parent) = manifest.dest.parent() {
317                ito_common::io::create_dir_all_std(parent).map_err(|e| {
318                    CoreError::io(format!("creating directory {}", parent.display()), e)
319                })?;
320            }
321            ito_common::io::write_std(&manifest.dest, &bytes)
322                .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
323        }
324    }
325    Ok(())
326}
327
328/// True when `path` is a plain `.md` asset (excludes Jinja `.md.j2` templates
329/// which are rendered, not installed verbatim). Centralising this guard keeps
330/// the stamping and marker-scoping checks in one place.
331fn is_plain_markdown_path(path: &str) -> bool {
332    path.ends_with(".md") && !path.ends_with(".md.j2")
333}
334
335/// Inject a version stamp into `bytes` when the file is a managed-block markdown file.
336///
337/// Returns the (possibly modified) bytes.  The stamp is applied only when:
338/// - the relative path ends in `.md` (not `.md.j2`)
339/// - the bytes are valid UTF-8
340/// - the content contains `<!-- ITO:START -->`
341fn stamp_managed_markdown(bytes: Vec<u8>, rel_path: &str, version: &str) -> Vec<u8> {
342    if !is_plain_markdown_path(rel_path) {
343        return bytes;
344    }
345
346    let Ok(text) = std::str::from_utf8(&bytes) else {
347        return bytes;
348    };
349
350    if !text.contains(ito_templates::ITO_START_MARKER) {
351        return bytes;
352    }
353
354    ito_templates::stamp_version(text, version).into_bytes()
355}
356
357fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
358    if line.contains("{%") {
359        return true;
360    }
361
362    // Variable-only templates are supported for the worktree context keys.
363    const WORKTREE_VARS: &[&str] = &[
364        "{{ enabled",
365        "{{ strategy",
366        "{{ layout_dir_name",
367        "{{ integration_mode",
368        "{{ default_branch",
369    ];
370
371    for var in WORKTREE_VARS {
372        if line.contains(var) {
373            return true;
374        }
375    }
376    false
377}
378
379#[cfg(test)]
380#[path = "distribution_tests.rs"]
381mod distribution_tests;