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#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15
16#[derive(Debug, Clone)]
17/// One file to be installed from embedded assets.
18pub struct FileManifest {
19    /// Source path relative to embedded assets (e.g., "brainstorming/SKILL.md" for skills)
20    pub source: String,
21    /// Destination path on disk
22    pub dest: PathBuf,
23    /// Asset type determines which embedded directory to read from
24    pub asset_type: AssetType,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28/// Category of embedded asset.
29pub enum AssetType {
30    /// A skill markdown file.
31    Skill,
32    /// A tool-specific adapter/bootstrap file.
33    Adapter,
34    /// A command/prompt template.
35    Command,
36}
37
38/// Returns manifest entries for all ito-skills.
39/// Source paths are relative to assets/skills/ (e.g., "brainstorming/SKILL.md")
40/// Dest paths have ito- prefix added if not already present
41/// (e.g., "brainstorming/SKILL.md" -> "ito-brainstorming/SKILL.md")
42/// (e.g., "ito/SKILL.md" -> "ito/SKILL.md" - no double prefix)
43fn ito_skills_manifests(skills_dir: &Path) -> Vec<FileManifest> {
44    let mut manifests = Vec::new();
45
46    // Get all skill files from embedded assets
47    for file in skills_files() {
48        let rel_path = file.relative_path;
49        // Extract skill name from path (e.g., "brainstorming/SKILL.md" -> "brainstorming")
50        let parts: Vec<&str> = rel_path.split('/').collect();
51        if parts.is_empty() {
52            continue;
53        }
54        let skill_name = parts[0];
55
56        // Build destination path, adding ito- prefix only if not already present
57        let dest_skill_name = if skill_name.starts_with("ito") {
58            skill_name.to_string()
59        } else {
60            format!("ito-{}", skill_name)
61        };
62
63        let rest = if parts.len() > 1 {
64            parts[1..].join("/")
65        } else {
66            rel_path.to_string()
67        };
68        let dest = skills_dir.join(format!("{}/{}", dest_skill_name, rest));
69
70        manifests.push(FileManifest {
71            source: rel_path.to_string(),
72            dest,
73            asset_type: AssetType::Skill,
74        });
75    }
76
77    manifests
78}
79
80/// Returns manifest entries for all ito commands.
81/// Commands are copied directly to the commands directory with their original names.
82fn ito_commands_manifests(commands_dir: &Path) -> Vec<FileManifest> {
83    let mut manifests = Vec::new();
84
85    for file in commands_files() {
86        let rel_path = file.relative_path;
87        manifests.push(FileManifest {
88            source: rel_path.to_string(),
89            dest: commands_dir.join(rel_path),
90            asset_type: AssetType::Command,
91        });
92    }
93
94    manifests
95}
96
97/// Return manifest entries for OpenCode template installation.
98///
99/// OpenCode stores its configuration under a single directory (typically
100/// `~/.config/opencode/`). We install an Ito plugin along with a flat list of
101/// skills and commands.
102pub fn opencode_manifests(config_dir: &Path) -> Vec<FileManifest> {
103    let mut out = Vec::new();
104
105    out.push(FileManifest {
106        source: "opencode/ito-skills.js".to_string(),
107        dest: config_dir.join("plugins").join("ito-skills.js"),
108        asset_type: AssetType::Adapter,
109    });
110
111    // Skills go directly under skills/ (flat structure with ito- prefix)
112    let skills_dir = config_dir.join("skills");
113    out.extend(ito_skills_manifests(&skills_dir));
114
115    // Commands go under commands/
116    let commands_dir = config_dir.join("commands");
117    out.extend(ito_commands_manifests(&commands_dir));
118
119    out
120}
121
122/// Return manifest entries for Claude Code template installation.
123pub fn claude_manifests(project_root: &Path) -> Vec<FileManifest> {
124    let mut out = vec![
125        FileManifest {
126            source: "claude/session-start.sh".to_string(),
127            dest: project_root.join(".claude").join("session-start.sh"),
128            asset_type: AssetType::Adapter,
129        },
130        FileManifest {
131            source: "claude/hooks/ito-audit.sh".to_string(),
132            dest: project_root
133                .join(".claude")
134                .join("hooks")
135                .join("ito-audit.sh"),
136            asset_type: AssetType::Adapter,
137        },
138    ];
139
140    // Skills go directly under .claude/skills/ (flat structure with ito- prefix)
141    let skills_dir = project_root.join(".claude").join("skills");
142    out.extend(ito_skills_manifests(&skills_dir));
143
144    // Commands go under .claude/commands/
145    let commands_dir = project_root.join(".claude").join("commands");
146    out.extend(ito_commands_manifests(&commands_dir));
147
148    out
149}
150
151/// Return manifest entries for Codex template installation.
152pub fn codex_manifests(project_root: &Path) -> Vec<FileManifest> {
153    let mut out = vec![FileManifest {
154        source: "codex/ito-skills-bootstrap.md".to_string(),
155        dest: project_root
156            .join(".codex")
157            .join("instructions")
158            .join("ito-skills-bootstrap.md"),
159        asset_type: AssetType::Adapter,
160    }];
161
162    // Skills go directly under .codex/skills/ (flat structure with ito- prefix)
163    let skills_dir = project_root.join(".codex").join("skills");
164    out.extend(ito_skills_manifests(&skills_dir));
165
166    // Commands go under .codex/prompts/ (Codex uses "prompts" terminology)
167    let commands_dir = project_root.join(".codex").join("prompts");
168    out.extend(ito_commands_manifests(&commands_dir));
169
170    out
171}
172
173/// Return manifest entries for Pi coding agent template installation.
174///
175/// Pi gets its own copy of skills and commands under `.pi/` so it is fully
176/// self-contained — users can install Pi without OpenCode. The skills and
177/// commands are read from the same shared embedded assets used by every harness.
178pub fn pi_manifests(project_root: &Path) -> Vec<FileManifest> {
179    let mut out = vec![FileManifest {
180        source: "pi/ito-skills.ts".to_string(),
181        dest: project_root
182            .join(".pi")
183            .join("extensions")
184            .join("ito-skills.ts"),
185        asset_type: AssetType::Adapter,
186    }];
187
188    // Skills go under .pi/skills/ (flat structure with ito- prefix)
189    let skills_dir = project_root.join(".pi").join("skills");
190    out.extend(ito_skills_manifests(&skills_dir));
191
192    // Commands go under .pi/commands/
193    let commands_dir = project_root.join(".pi").join("commands");
194    out.extend(ito_commands_manifests(&commands_dir));
195
196    out
197}
198
199/// Return manifest entries for GitHub Copilot template installation.
200pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
201    // Skills go directly under .github/skills/ (flat structure with ito- prefix)
202    let skills_dir = project_root.join(".github").join("skills");
203    let mut out = ito_skills_manifests(&skills_dir);
204
205    // Commands go under .github/prompts/ (GitHub uses "prompts" terminology)
206    // Note: GitHub Copilot uses .prompt.md suffix convention
207    let prompts_dir = project_root.join(".github").join("prompts");
208    for file in commands_files() {
209        let rel_path = file.relative_path;
210        // Convert ito-apply.md -> ito-apply.prompt.md for GitHub
211        let dest_name = if let Some(stripped) = rel_path.strip_suffix(".md") {
212            format!("{stripped}.prompt.md")
213        } else {
214            rel_path.to_string()
215        };
216        out.push(FileManifest {
217            source: rel_path.to_string(),
218            dest: prompts_dir.join(dest_name),
219            asset_type: AssetType::Command,
220        });
221    }
222
223    out
224}
225
226/// Install manifests from embedded assets to disk.
227///
228/// When `worktree_ctx` is `Some`, the `using-git-worktrees` skill template is
229/// rendered with the given worktree configuration before writing. Other skill
230/// files (which may contain `{{` as user-facing prompt placeholders) are written
231/// as-is.
232///
233/// Every `.md` file that contains an Ito managed block receives a version stamp
234/// immediately after `<!-- ITO:START -->` before being written to disk.
235pub fn install_manifests(
236    manifests: &[FileManifest],
237    worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
238    mode: crate::installers::InstallMode,
239    opts: &crate::installers::InitOptions,
240) -> CoreResult<()> {
241    use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
242
243    let default_ctx = WorktreeTemplateContext::default();
244    let ctx = worktree_ctx.unwrap_or(&default_ctx);
245
246    // Source the version once for all manifests in this batch.
247    let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
248
249    for manifest in manifests {
250        let raw_bytes = match manifest.asset_type {
251            AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
252                CoreError::NotFound(format!(
253                    "Skill file not found in embedded assets: {}",
254                    manifest.source
255                ))
256            })?,
257            AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
258                CoreError::NotFound(format!(
259                    "Adapter file not found in embedded assets: {}",
260                    manifest.source
261                ))
262            })?,
263            AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
264                CoreError::NotFound(format!(
265                    "Command file not found in embedded assets: {}",
266                    manifest.source
267                ))
268            })?,
269        };
270
271        // Render skill templates that opt into worktree Jinja2 variables. We
272        // intentionally avoid rendering arbitrary `{{ ... }}` placeholders used
273        // by non-template skills (e.g. research prompts).
274        let mut should_render_skill = false;
275        if manifest.asset_type == AssetType::Skill {
276            for line in raw_bytes.split(|b| *b == b'\n') {
277                let Ok(line) = std::str::from_utf8(line) else {
278                    continue;
279                };
280                if skill_line_uses_worktree_template_syntax(line) {
281                    should_render_skill = true;
282                    break;
283                }
284            }
285        }
286
287        let bytes = if should_render_skill {
288            render_project_template(raw_bytes, ctx).map_err(|e| {
289                CoreError::Validation(format!(
290                    "Failed to render skill template {}: {}",
291                    manifest.source, e
292                ))
293            })?
294        } else {
295            raw_bytes.to_vec()
296        };
297
298        // Stamp every managed-block markdown file with the current CLI version.
299        let bytes = stamp_managed_markdown(bytes, &manifest.source, version);
300
301        // Markdown manifest entries that contain an Ito-managed block AND
302        // belong to an asset type whose update contract is "user content
303        // outside the managed block survives" go through the marker-scoped
304        // writer. Today that contract applies to skills and commands. Adapter
305        // markdown (e.g. the codex bootstrap) is still wholesale-refreshed
306        // because adapter content is owned end-to-end by Ito; preserving
307        // out-of-marker user edits there is not part of the contract. Shell
308        // scripts and other non-markdown manifest entries also stay
309        // wholesale-write.
310        let asset_supports_marker_scope =
311            matches!(manifest.asset_type, AssetType::Skill | AssetType::Command);
312        let is_managed_md = asset_supports_marker_scope
313            && is_plain_markdown_path(&manifest.source)
314            && std::str::from_utf8(&bytes)
315                .map(|t| t.contains(ito_templates::ITO_START_MARKER))
316                .unwrap_or(false);
317        if is_managed_md {
318            crate::installers::write_marker_aware_markdown(&manifest.dest, &bytes, mode, opts)?;
319        } else {
320            if let Some(parent) = manifest.dest.parent() {
321                ito_common::io::create_dir_all_std(parent).map_err(|e| {
322                    CoreError::io(format!("creating directory {}", parent.display()), e)
323                })?;
324            }
325            ito_common::io::write_std(&manifest.dest, &bytes)
326                .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
327        }
328        ensure_manifest_script_is_executable(manifest)?;
329    }
330    Ok(())
331}
332
333/// True when `path` is a plain `.md` asset (excludes Jinja `.md.j2` templates
334/// which are rendered, not installed verbatim). Centralising this guard keeps
335/// the stamping and marker-scoping checks in one place.
336fn is_plain_markdown_path(path: &str) -> bool {
337    path.ends_with(".md") && !path.ends_with(".md.j2")
338}
339
340/// Inject a version stamp into `bytes` when the file is a managed-block markdown file.
341///
342/// Returns the (possibly modified) bytes.  The stamp is applied only when:
343/// - the relative path ends in `.md` (not `.md.j2`)
344/// - the bytes are valid UTF-8
345/// - the content contains `<!-- ITO:START -->`
346fn stamp_managed_markdown(bytes: Vec<u8>, rel_path: &str, version: &str) -> Vec<u8> {
347    if !is_plain_markdown_path(rel_path) {
348        return bytes;
349    }
350
351    let Ok(text) = std::str::from_utf8(&bytes) else {
352        return bytes;
353    };
354
355    if !text.contains(ito_templates::ITO_START_MARKER) {
356        return bytes;
357    }
358
359    ito_templates::stamp_version(text, version).into_bytes()
360}
361
362fn ensure_manifest_script_is_executable(manifest: &FileManifest) -> CoreResult<()> {
363    #[cfg(unix)]
364    {
365        let is_skill_script = manifest.asset_type == AssetType::Skill
366            && manifest.source.ends_with(".sh")
367            && manifest.source.contains("/scripts/");
368
369        if is_skill_script {
370            let metadata = std::fs::metadata(&manifest.dest).map_err(|e| {
371                CoreError::io(
372                    format!("reading metadata for {}", manifest.dest.display()),
373                    e,
374                )
375            })?;
376            let mut permissions = metadata.permissions();
377            permissions.set_mode(permissions.mode() | 0o111);
378            std::fs::set_permissions(&manifest.dest, permissions).map_err(|e| {
379                CoreError::io(
380                    format!(
381                        "setting executable permissions on {}",
382                        manifest.dest.display()
383                    ),
384                    e,
385                )
386            })?;
387        }
388    }
389
390    Ok(())
391}
392
393fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
394    if line.contains("{%") {
395        return true;
396    }
397
398    // Variable-only templates are supported for the worktree context keys.
399    const WORKTREE_VARS: &[&str] = &[
400        "{{ enabled",
401        "{{ strategy",
402        "{{ layout_dir_name",
403        "{{ integration_mode",
404        "{{ default_branch",
405    ];
406
407    for var in WORKTREE_VARS {
408        if line.contains(var) {
409            return true;
410        }
411    }
412    false
413}
414
415#[cfg(test)]
416#[path = "distribution_tests.rs"]
417mod distribution_tests;