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.
232pub fn install_manifests(
233    manifests: &[FileManifest],
234    worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
235) -> CoreResult<()> {
236    use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
237
238    let default_ctx = WorktreeTemplateContext::default();
239    let ctx = worktree_ctx.unwrap_or(&default_ctx);
240
241    for manifest in manifests {
242        let raw_bytes = match manifest.asset_type {
243            AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
244                CoreError::NotFound(format!(
245                    "Skill file not found in embedded assets: {}",
246                    manifest.source
247                ))
248            })?,
249            AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
250                CoreError::NotFound(format!(
251                    "Adapter file not found in embedded assets: {}",
252                    manifest.source
253                ))
254            })?,
255            AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
256                CoreError::NotFound(format!(
257                    "Command file not found in embedded assets: {}",
258                    manifest.source
259                ))
260            })?,
261        };
262
263        // Render skill templates that opt into worktree Jinja2 variables. We
264        // intentionally avoid rendering arbitrary `{{ ... }}` placeholders used
265        // by non-template skills (e.g. research prompts).
266        let mut should_render_skill = false;
267        if manifest.asset_type == AssetType::Skill {
268            for line in raw_bytes.split(|b| *b == b'\n') {
269                let Ok(line) = std::str::from_utf8(line) else {
270                    continue;
271                };
272                if skill_line_uses_worktree_template_syntax(line) {
273                    should_render_skill = true;
274                    break;
275                }
276            }
277        }
278
279        let bytes = if should_render_skill {
280            render_project_template(raw_bytes, ctx).map_err(|e| {
281                CoreError::Validation(format!(
282                    "Failed to render skill template {}: {}",
283                    manifest.source, e
284                ))
285            })?
286        } else {
287            raw_bytes.to_vec()
288        };
289
290        if let Some(parent) = manifest.dest.parent() {
291            ito_common::io::create_dir_all_std(parent).map_err(|e| {
292                CoreError::io(format!("creating directory {}", parent.display()), e)
293            })?;
294        }
295        ito_common::io::write_std(&manifest.dest, &bytes)
296            .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
297        ensure_manifest_script_is_executable(manifest)?;
298    }
299    Ok(())
300}
301
302fn ensure_manifest_script_is_executable(manifest: &FileManifest) -> CoreResult<()> {
303    #[cfg(unix)]
304    {
305        let is_skill_script = manifest.asset_type == AssetType::Skill
306            && manifest.source.ends_with(".sh")
307            && manifest.source.contains("/scripts/");
308
309        if is_skill_script {
310            let metadata = std::fs::metadata(&manifest.dest).map_err(|e| {
311                CoreError::io(
312                    format!("reading metadata for {}", manifest.dest.display()),
313                    e,
314                )
315            })?;
316            let mut permissions = metadata.permissions();
317            permissions.set_mode(permissions.mode() | 0o111);
318            std::fs::set_permissions(&manifest.dest, permissions).map_err(|e| {
319                CoreError::io(
320                    format!(
321                        "setting executable permissions on {}",
322                        manifest.dest.display()
323                    ),
324                    e,
325                )
326            })?;
327        }
328    }
329
330    Ok(())
331}
332
333fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
334    if line.contains("{%") {
335        return true;
336    }
337
338    // Variable-only templates are supported for the worktree context keys.
339    const WORKTREE_VARS: &[&str] = &[
340        "{{ enabled",
341        "{{ strategy",
342        "{{ layout_dir_name",
343        "{{ integration_mode",
344        "{{ default_branch",
345    ];
346
347    for var in WORKTREE_VARS {
348        if line.contains(var) {
349            return true;
350        }
351    }
352    false
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn pi_manifests_includes_adapter_skills_and_commands() {
361        let root = Path::new("/tmp/project");
362        let manifests = pi_manifests(root);
363
364        // Must contain the adapter extension.
365        let adapter = manifests
366            .iter()
367            .find(|m| m.asset_type == AssetType::Adapter);
368        assert!(adapter.is_some(), "pi_manifests must include the adapter");
369        let adapter = adapter.unwrap();
370        assert_eq!(adapter.source, "pi/ito-skills.ts");
371        assert!(
372            adapter.dest.ends_with(".pi/extensions/ito-skills.ts"),
373            "adapter dest should end with .pi/extensions/ito-skills.ts, got {:?}",
374            adapter.dest
375        );
376
377        // Must contain skill entries under .pi/skills/.
378        let skills: Vec<_> = manifests
379            .iter()
380            .filter(|m| m.asset_type == AssetType::Skill)
381            .collect();
382        assert!(
383            !skills.is_empty(),
384            "pi_manifests must include skill entries"
385        );
386        for skill in &skills {
387            let dest_str = skill.dest.to_string_lossy();
388            assert!(
389                dest_str.contains(".pi/skills/"),
390                "skill dest should be under .pi/skills/, got: {}",
391                dest_str
392            );
393        }
394
395        // Must contain command entries under .pi/commands/.
396        let commands: Vec<_> = manifests
397            .iter()
398            .filter(|m| m.asset_type == AssetType::Command)
399            .collect();
400        assert!(
401            !commands.is_empty(),
402            "pi_manifests must include command entries"
403        );
404        for cmd in &commands {
405            let dest_str = cmd.dest.to_string_lossy();
406            assert!(
407                dest_str.contains(".pi/commands/"),
408                "command dest should be under .pi/commands/, got: {}",
409                dest_str
410            );
411        }
412    }
413
414    #[test]
415    fn pi_adapter_asset_exists_in_embedded_templates() {
416        let contents = ito_templates::get_adapter_file("pi/ito-skills.ts");
417        assert!(
418            contents.is_some(),
419            "pi/ito-skills.ts must be present in embedded adapter assets"
420        );
421        let bytes = contents.unwrap();
422        assert!(!bytes.is_empty());
423        let text = std::str::from_utf8(bytes).expect("adapter should be valid UTF-8");
424        assert!(
425            text.contains("ExtensionAPI"),
426            "Pi adapter should import the Pi ExtensionAPI type"
427        );
428        assert!(
429            text.contains(r#""--tool", "pi""#),
430            "Pi adapter must request bootstrap with --tool pi (not opencode or other)"
431        );
432        assert!(
433            !text.contains(r#""--tool", "opencode""#),
434            "Pi adapter must not reference opencode tool type"
435        );
436        // Verify unused imports are not present.
437        assert!(
438            !text.contains("import path from"),
439            "Pi adapter should not have unused path import"
440        );
441        assert!(
442            !text.contains("import fs from"),
443            "Pi adapter should not have unused fs import"
444        );
445    }
446
447    #[test]
448    fn pi_manifests_skills_match_opencode_skills() {
449        // Pi and OpenCode should install the same set of skills from the
450        // shared embedded source — only the destination directory differs.
451        let root = Path::new("/home/user/myproject");
452        let pi = pi_manifests(root);
453        let oc_dir = root.join(".opencode");
454        let oc = opencode_manifests(&oc_dir);
455
456        let pi_skill_sources: std::collections::BTreeSet<_> = pi
457            .iter()
458            .filter(|m| m.asset_type == AssetType::Skill)
459            .map(|m| m.source.clone())
460            .collect();
461        let oc_skill_sources: std::collections::BTreeSet<_> = oc
462            .iter()
463            .filter(|m| m.asset_type == AssetType::Skill)
464            .map(|m| m.source.clone())
465            .collect();
466
467        assert_eq!(
468            pi_skill_sources, oc_skill_sources,
469            "Pi and OpenCode should install identical skill sources"
470        );
471    }
472
473    #[test]
474    fn pi_agent_templates_discoverable() {
475        use ito_templates::agents::{Harness, get_agent_files};
476        let files = get_agent_files(Harness::Pi);
477        let names: Vec<_> = files.iter().map(|(name, _)| *name).collect();
478        assert!(
479            names.contains(&"ito-quick.md"),
480            "Pi agent templates must include ito-quick.md, got: {:?}",
481            names
482        );
483        assert!(
484            names.contains(&"ito-general.md"),
485            "Pi agent templates must include ito-general.md, got: {:?}",
486            names
487        );
488        assert!(
489            names.contains(&"ito-thinking.md"),
490            "Pi agent templates must include ito-thinking.md, got: {:?}",
491            names
492        );
493    }
494
495    #[test]
496    fn pi_manifests_commands_match_opencode_commands() {
497        // Pi and OpenCode should install the same set of commands from the
498        // shared embedded source — only the destination directory differs.
499        let root = Path::new("/home/user/myproject");
500        let pi = pi_manifests(root);
501        let oc_dir = root.join(".opencode");
502        let oc = opencode_manifests(&oc_dir);
503
504        let pi_cmd_sources: std::collections::BTreeSet<_> = pi
505            .iter()
506            .filter(|m| m.asset_type == AssetType::Command)
507            .map(|m| m.source.clone())
508            .collect();
509        let oc_cmd_sources: std::collections::BTreeSet<_> = oc
510            .iter()
511            .filter(|m| m.asset_type == AssetType::Command)
512            .map(|m| m.source.clone())
513            .collect();
514
515        assert_eq!(
516            pi_cmd_sources, oc_cmd_sources,
517            "Pi and OpenCode should install identical command sources"
518        );
519    }
520
521    #[cfg(unix)]
522    #[test]
523    fn ensure_manifest_script_is_executable_only_adds_execute_bits() {
524        let td = tempfile::tempdir().unwrap();
525        let dest = td.path().join("skills/demo/scripts/run.sh");
526        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
527        std::fs::write(&dest, "#!/usr/bin/env bash\n").unwrap();
528
529        let mut permissions = std::fs::metadata(&dest).unwrap().permissions();
530        permissions.set_mode(0o600);
531        std::fs::set_permissions(&dest, permissions).unwrap();
532
533        let manifest = FileManifest {
534            source: "demo/scripts/run.sh".to_string(),
535            dest: dest.clone(),
536            asset_type: AssetType::Skill,
537        };
538
539        ensure_manifest_script_is_executable(&manifest).unwrap();
540
541        let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
542        assert_eq!(mode, 0o711);
543    }
544}