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)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn pi_manifests_includes_adapter_skills_and_commands() {
421        let root = Path::new("/tmp/project");
422        let manifests = pi_manifests(root);
423
424        // Must contain the adapter extension.
425        let adapter = manifests
426            .iter()
427            .find(|m| m.asset_type == AssetType::Adapter);
428        assert!(adapter.is_some(), "pi_manifests must include the adapter");
429        let adapter = adapter.unwrap();
430        assert_eq!(adapter.source, "pi/ito-skills.ts");
431        assert!(
432            adapter.dest.ends_with(".pi/extensions/ito-skills.ts"),
433            "adapter dest should end with .pi/extensions/ito-skills.ts, got {:?}",
434            adapter.dest
435        );
436
437        // Must contain skill entries under .pi/skills/.
438        let skills: Vec<_> = manifests
439            .iter()
440            .filter(|m| m.asset_type == AssetType::Skill)
441            .collect();
442        assert!(
443            !skills.is_empty(),
444            "pi_manifests must include skill entries"
445        );
446        for skill in &skills {
447            let dest_str = skill.dest.to_string_lossy();
448            assert!(
449                dest_str.contains(".pi/skills/"),
450                "skill dest should be under .pi/skills/, got: {}",
451                dest_str
452            );
453        }
454
455        // Must contain command entries under .pi/commands/.
456        let commands: Vec<_> = manifests
457            .iter()
458            .filter(|m| m.asset_type == AssetType::Command)
459            .collect();
460        assert!(
461            !commands.is_empty(),
462            "pi_manifests must include command entries"
463        );
464        for cmd in &commands {
465            let dest_str = cmd.dest.to_string_lossy();
466            assert!(
467                dest_str.contains(".pi/commands/"),
468                "command dest should be under .pi/commands/, got: {}",
469                dest_str
470            );
471        }
472    }
473
474    #[test]
475    fn pi_adapter_asset_exists_in_embedded_templates() {
476        let contents = ito_templates::get_adapter_file("pi/ito-skills.ts");
477        assert!(
478            contents.is_some(),
479            "pi/ito-skills.ts must be present in embedded adapter assets"
480        );
481        let bytes = contents.unwrap();
482        assert!(!bytes.is_empty());
483        let text = std::str::from_utf8(bytes).expect("adapter should be valid UTF-8");
484        assert!(
485            text.contains("ExtensionAPI"),
486            "Pi adapter should import the Pi ExtensionAPI type"
487        );
488        assert!(
489            text.contains(r#""--tool", "pi""#),
490            "Pi adapter must request bootstrap with --tool pi (not opencode or other)"
491        );
492        assert!(
493            !text.contains(r#""--tool", "opencode""#),
494            "Pi adapter must not reference opencode tool type"
495        );
496        // Verify unused imports are not present.
497        assert!(
498            !text.contains("import path from"),
499            "Pi adapter should not have unused path import"
500        );
501        assert!(
502            !text.contains("import fs from"),
503            "Pi adapter should not have unused fs import"
504        );
505    }
506
507    #[test]
508    fn pi_manifests_skills_match_opencode_skills() {
509        // Pi and OpenCode should install the same set of skills from the
510        // shared embedded source — only the destination directory differs.
511        let root = Path::new("/home/user/myproject");
512        let pi = pi_manifests(root);
513        let oc_dir = root.join(".opencode");
514        let oc = opencode_manifests(&oc_dir);
515
516        let pi_skill_sources: std::collections::BTreeSet<_> = pi
517            .iter()
518            .filter(|m| m.asset_type == AssetType::Skill)
519            .map(|m| m.source.clone())
520            .collect();
521        let oc_skill_sources: std::collections::BTreeSet<_> = oc
522            .iter()
523            .filter(|m| m.asset_type == AssetType::Skill)
524            .map(|m| m.source.clone())
525            .collect();
526
527        assert_eq!(
528            pi_skill_sources, oc_skill_sources,
529            "Pi and OpenCode should install identical skill sources"
530        );
531    }
532
533    #[test]
534    fn pi_agent_templates_discoverable() {
535        use ito_templates::agents::{Harness, get_agent_files};
536        let files = get_agent_files(Harness::Pi);
537        let names: Vec<_> = files.iter().map(|(name, _)| *name).collect();
538        assert!(
539            names.contains(&"ito-quick.md"),
540            "Pi agent templates must include ito-quick.md, got: {:?}",
541            names
542        );
543        assert!(
544            names.contains(&"ito-general.md"),
545            "Pi agent templates must include ito-general.md, got: {:?}",
546            names
547        );
548        assert!(
549            names.contains(&"ito-thinking.md"),
550            "Pi agent templates must include ito-thinking.md, got: {:?}",
551            names
552        );
553    }
554
555    #[test]
556    fn pi_manifests_commands_match_opencode_commands() {
557        // Pi and OpenCode should install the same set of commands from the
558        // shared embedded source — only the destination directory differs.
559        let root = Path::new("/home/user/myproject");
560        let pi = pi_manifests(root);
561        let oc_dir = root.join(".opencode");
562        let oc = opencode_manifests(&oc_dir);
563
564        let pi_cmd_sources: std::collections::BTreeSet<_> = pi
565            .iter()
566            .filter(|m| m.asset_type == AssetType::Command)
567            .map(|m| m.source.clone())
568            .collect();
569        let oc_cmd_sources: std::collections::BTreeSet<_> = oc
570            .iter()
571            .filter(|m| m.asset_type == AssetType::Command)
572            .map(|m| m.source.clone())
573            .collect();
574
575        assert_eq!(
576            pi_cmd_sources, oc_cmd_sources,
577            "Pi and OpenCode should install identical command sources"
578        );
579    }
580
581    #[cfg(unix)]
582    #[test]
583    fn ensure_manifest_script_is_executable_only_adds_execute_bits() {
584        let td = tempfile::tempdir().unwrap();
585        let dest = td.path().join("skills/demo/scripts/run.sh");
586        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
587        std::fs::write(&dest, "#!/usr/bin/env bash\n").unwrap();
588
589        let mut permissions = std::fs::metadata(&dest).unwrap().permissions();
590        permissions.set_mode(0o600);
591        std::fs::set_permissions(&dest, permissions).unwrap();
592
593        let manifest = FileManifest {
594            source: "demo/scripts/run.sh".to_string(),
595            dest: dest.clone(),
596            asset_type: AssetType::Skill,
597        };
598
599        ensure_manifest_script_is_executable(&manifest).unwrap();
600
601        let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
602        assert_eq!(mode, 0o711);
603    }
604}