use crate::errors::{CoreError, CoreResult};
use ito_templates::{
commands_files, get_adapter_file, get_command_file, get_skill_file, skills_files,
};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[derive(Debug, Clone)]
pub struct FileManifest {
pub source: String,
pub dest: PathBuf,
pub asset_type: AssetType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetType {
Skill,
Adapter,
Command,
}
fn ito_skills_manifests(skills_dir: &Path) -> Vec<FileManifest> {
let mut manifests = Vec::new();
for file in skills_files() {
let rel_path = file.relative_path;
let parts: Vec<&str> = rel_path.split('/').collect();
if parts.is_empty() {
continue;
}
let skill_name = parts[0];
let dest_skill_name = if skill_name.starts_with("ito") {
skill_name.to_string()
} else {
format!("ito-{}", skill_name)
};
let rest = if parts.len() > 1 {
parts[1..].join("/")
} else {
rel_path.to_string()
};
let dest = skills_dir.join(format!("{}/{}", dest_skill_name, rest));
manifests.push(FileManifest {
source: rel_path.to_string(),
dest,
asset_type: AssetType::Skill,
});
}
manifests
}
fn ito_commands_manifests(commands_dir: &Path) -> Vec<FileManifest> {
let mut manifests = Vec::new();
for file in commands_files() {
let rel_path = file.relative_path;
manifests.push(FileManifest {
source: rel_path.to_string(),
dest: commands_dir.join(rel_path),
asset_type: AssetType::Command,
});
}
manifests
}
pub fn opencode_manifests(config_dir: &Path) -> Vec<FileManifest> {
let mut out = Vec::new();
out.push(FileManifest {
source: "opencode/ito-skills.js".to_string(),
dest: config_dir.join("plugins").join("ito-skills.js"),
asset_type: AssetType::Adapter,
});
let skills_dir = config_dir.join("skills");
out.extend(ito_skills_manifests(&skills_dir));
let commands_dir = config_dir.join("commands");
out.extend(ito_commands_manifests(&commands_dir));
out
}
pub fn claude_manifests(project_root: &Path) -> Vec<FileManifest> {
let mut out = vec![
FileManifest {
source: "claude/session-start.sh".to_string(),
dest: project_root.join(".claude").join("session-start.sh"),
asset_type: AssetType::Adapter,
},
FileManifest {
source: "claude/hooks/ito-audit.sh".to_string(),
dest: project_root
.join(".claude")
.join("hooks")
.join("ito-audit.sh"),
asset_type: AssetType::Adapter,
},
];
let skills_dir = project_root.join(".claude").join("skills");
out.extend(ito_skills_manifests(&skills_dir));
let commands_dir = project_root.join(".claude").join("commands");
out.extend(ito_commands_manifests(&commands_dir));
out
}
pub fn codex_manifests(project_root: &Path) -> Vec<FileManifest> {
let mut out = vec![FileManifest {
source: "codex/ito-skills-bootstrap.md".to_string(),
dest: project_root
.join(".codex")
.join("instructions")
.join("ito-skills-bootstrap.md"),
asset_type: AssetType::Adapter,
}];
let skills_dir = project_root.join(".codex").join("skills");
out.extend(ito_skills_manifests(&skills_dir));
let commands_dir = project_root.join(".codex").join("prompts");
out.extend(ito_commands_manifests(&commands_dir));
out
}
pub fn pi_manifests(project_root: &Path) -> Vec<FileManifest> {
let mut out = vec![FileManifest {
source: "pi/ito-skills.ts".to_string(),
dest: project_root
.join(".pi")
.join("extensions")
.join("ito-skills.ts"),
asset_type: AssetType::Adapter,
}];
let skills_dir = project_root.join(".pi").join("skills");
out.extend(ito_skills_manifests(&skills_dir));
let commands_dir = project_root.join(".pi").join("commands");
out.extend(ito_commands_manifests(&commands_dir));
out
}
pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
let skills_dir = project_root.join(".github").join("skills");
let mut out = ito_skills_manifests(&skills_dir);
let prompts_dir = project_root.join(".github").join("prompts");
for file in commands_files() {
let rel_path = file.relative_path;
let dest_name = if let Some(stripped) = rel_path.strip_suffix(".md") {
format!("{stripped}.prompt.md")
} else {
rel_path.to_string()
};
out.push(FileManifest {
source: rel_path.to_string(),
dest: prompts_dir.join(dest_name),
asset_type: AssetType::Command,
});
}
out
}
pub fn install_manifests(
manifests: &[FileManifest],
worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
) -> CoreResult<()> {
use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};
let default_ctx = WorktreeTemplateContext::default();
let ctx = worktree_ctx.unwrap_or(&default_ctx);
for manifest in manifests {
let raw_bytes = match manifest.asset_type {
AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
CoreError::NotFound(format!(
"Skill file not found in embedded assets: {}",
manifest.source
))
})?,
AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
CoreError::NotFound(format!(
"Adapter file not found in embedded assets: {}",
manifest.source
))
})?,
AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
CoreError::NotFound(format!(
"Command file not found in embedded assets: {}",
manifest.source
))
})?,
};
let mut should_render_skill = false;
if manifest.asset_type == AssetType::Skill {
for line in raw_bytes.split(|b| *b == b'\n') {
let Ok(line) = std::str::from_utf8(line) else {
continue;
};
if skill_line_uses_worktree_template_syntax(line) {
should_render_skill = true;
break;
}
}
}
let bytes = if should_render_skill {
render_project_template(raw_bytes, ctx).map_err(|e| {
CoreError::Validation(format!(
"Failed to render skill template {}: {}",
manifest.source, e
))
})?
} else {
raw_bytes.to_vec()
};
if let Some(parent) = manifest.dest.parent() {
ito_common::io::create_dir_all_std(parent).map_err(|e| {
CoreError::io(format!("creating directory {}", parent.display()), e)
})?;
}
ito_common::io::write_std(&manifest.dest, &bytes)
.map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
ensure_manifest_script_is_executable(manifest)?;
}
Ok(())
}
fn ensure_manifest_script_is_executable(manifest: &FileManifest) -> CoreResult<()> {
#[cfg(unix)]
{
let is_skill_script = manifest.asset_type == AssetType::Skill
&& manifest.source.ends_with(".sh")
&& manifest.source.contains("/scripts/");
if is_skill_script {
let metadata = std::fs::metadata(&manifest.dest).map_err(|e| {
CoreError::io(
format!("reading metadata for {}", manifest.dest.display()),
e,
)
})?;
let mut permissions = metadata.permissions();
permissions.set_mode(permissions.mode() | 0o111);
std::fs::set_permissions(&manifest.dest, permissions).map_err(|e| {
CoreError::io(
format!(
"setting executable permissions on {}",
manifest.dest.display()
),
e,
)
})?;
}
}
Ok(())
}
fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
if line.contains("{%") {
return true;
}
const WORKTREE_VARS: &[&str] = &[
"{{ enabled",
"{{ strategy",
"{{ layout_dir_name",
"{{ integration_mode",
"{{ default_branch",
];
for var in WORKTREE_VARS {
if line.contains(var) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pi_manifests_includes_adapter_skills_and_commands() {
let root = Path::new("/tmp/project");
let manifests = pi_manifests(root);
let adapter = manifests
.iter()
.find(|m| m.asset_type == AssetType::Adapter);
assert!(adapter.is_some(), "pi_manifests must include the adapter");
let adapter = adapter.unwrap();
assert_eq!(adapter.source, "pi/ito-skills.ts");
assert!(
adapter.dest.ends_with(".pi/extensions/ito-skills.ts"),
"adapter dest should end with .pi/extensions/ito-skills.ts, got {:?}",
adapter.dest
);
let skills: Vec<_> = manifests
.iter()
.filter(|m| m.asset_type == AssetType::Skill)
.collect();
assert!(
!skills.is_empty(),
"pi_manifests must include skill entries"
);
for skill in &skills {
let dest_str = skill.dest.to_string_lossy();
assert!(
dest_str.contains(".pi/skills/"),
"skill dest should be under .pi/skills/, got: {}",
dest_str
);
}
let commands: Vec<_> = manifests
.iter()
.filter(|m| m.asset_type == AssetType::Command)
.collect();
assert!(
!commands.is_empty(),
"pi_manifests must include command entries"
);
for cmd in &commands {
let dest_str = cmd.dest.to_string_lossy();
assert!(
dest_str.contains(".pi/commands/"),
"command dest should be under .pi/commands/, got: {}",
dest_str
);
}
}
#[test]
fn pi_adapter_asset_exists_in_embedded_templates() {
let contents = ito_templates::get_adapter_file("pi/ito-skills.ts");
assert!(
contents.is_some(),
"pi/ito-skills.ts must be present in embedded adapter assets"
);
let bytes = contents.unwrap();
assert!(!bytes.is_empty());
let text = std::str::from_utf8(bytes).expect("adapter should be valid UTF-8");
assert!(
text.contains("ExtensionAPI"),
"Pi adapter should import the Pi ExtensionAPI type"
);
assert!(
text.contains(r#""--tool", "pi""#),
"Pi adapter must request bootstrap with --tool pi (not opencode or other)"
);
assert!(
!text.contains(r#""--tool", "opencode""#),
"Pi adapter must not reference opencode tool type"
);
assert!(
!text.contains("import path from"),
"Pi adapter should not have unused path import"
);
assert!(
!text.contains("import fs from"),
"Pi adapter should not have unused fs import"
);
}
#[test]
fn pi_manifests_skills_match_opencode_skills() {
let root = Path::new("/home/user/myproject");
let pi = pi_manifests(root);
let oc_dir = root.join(".opencode");
let oc = opencode_manifests(&oc_dir);
let pi_skill_sources: std::collections::BTreeSet<_> = pi
.iter()
.filter(|m| m.asset_type == AssetType::Skill)
.map(|m| m.source.clone())
.collect();
let oc_skill_sources: std::collections::BTreeSet<_> = oc
.iter()
.filter(|m| m.asset_type == AssetType::Skill)
.map(|m| m.source.clone())
.collect();
assert_eq!(
pi_skill_sources, oc_skill_sources,
"Pi and OpenCode should install identical skill sources"
);
}
#[test]
fn pi_agent_templates_discoverable() {
use ito_templates::agents::{Harness, get_agent_files};
let files = get_agent_files(Harness::Pi);
let names: Vec<_> = files.iter().map(|(name, _)| *name).collect();
assert!(
names.contains(&"ito-quick.md"),
"Pi agent templates must include ito-quick.md, got: {:?}",
names
);
assert!(
names.contains(&"ito-general.md"),
"Pi agent templates must include ito-general.md, got: {:?}",
names
);
assert!(
names.contains(&"ito-thinking.md"),
"Pi agent templates must include ito-thinking.md, got: {:?}",
names
);
}
#[test]
fn pi_manifests_commands_match_opencode_commands() {
let root = Path::new("/home/user/myproject");
let pi = pi_manifests(root);
let oc_dir = root.join(".opencode");
let oc = opencode_manifests(&oc_dir);
let pi_cmd_sources: std::collections::BTreeSet<_> = pi
.iter()
.filter(|m| m.asset_type == AssetType::Command)
.map(|m| m.source.clone())
.collect();
let oc_cmd_sources: std::collections::BTreeSet<_> = oc
.iter()
.filter(|m| m.asset_type == AssetType::Command)
.map(|m| m.source.clone())
.collect();
assert_eq!(
pi_cmd_sources, oc_cmd_sources,
"Pi and OpenCode should install identical command sources"
);
}
#[cfg(unix)]
#[test]
fn ensure_manifest_script_is_executable_only_adds_execute_bits() {
let td = tempfile::tempdir().unwrap();
let dest = td.path().join("skills/demo/scripts/run.sh");
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
std::fs::write(&dest, "#!/usr/bin/env bash\n").unwrap();
let mut permissions = std::fs::metadata(&dest).unwrap().permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(&dest, permissions).unwrap();
let manifest = FileManifest {
source: "demo/scripts/run.sh".to_string(),
dest: dest.clone(),
asset_type: AssetType::Skill,
};
ensure_manifest_script_is_executable(&manifest).unwrap();
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o711);
}
}