1use 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)]
14pub struct FileManifest {
16 pub source: String,
18 pub dest: PathBuf,
20 pub asset_type: AssetType,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AssetType {
27 Skill,
29 Adapter,
31 Command,
33}
34
35fn ito_skills_manifests(skills_dir: &Path) -> Vec<FileManifest> {
41 let mut manifests = Vec::new();
42
43 for file in skills_files() {
45 let rel_path = file.relative_path;
46 let parts: Vec<&str> = rel_path.split('/').collect();
48 if parts.is_empty() {
49 continue;
50 }
51 let skill_name = parts[0];
52
53 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
77fn 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
94pub 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 let skills_dir = config_dir.join("skills");
110 out.extend(ito_skills_manifests(&skills_dir));
111
112 let commands_dir = config_dir.join("commands");
114 out.extend(ito_commands_manifests(&commands_dir));
115
116 out
117}
118
119pub 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 let skills_dir = project_root.join(".claude").join("skills");
139 out.extend(ito_skills_manifests(&skills_dir));
140
141 let commands_dir = project_root.join(".claude").join("commands");
143 out.extend(ito_commands_manifests(&commands_dir));
144
145 out
146}
147
148pub 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 let skills_dir = project_root.join(".codex").join("skills");
161 out.extend(ito_skills_manifests(&skills_dir));
162
163 let commands_dir = project_root.join(".codex").join("prompts");
165 out.extend(ito_commands_manifests(&commands_dir));
166
167 out
168}
169
170pub 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 let skills_dir = project_root.join(".pi").join("skills");
187 out.extend(ito_skills_manifests(&skills_dir));
188
189 let commands_dir = project_root.join(".pi").join("commands");
191 out.extend(ito_commands_manifests(&commands_dir));
192
193 out
194}
195
196pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
198 let skills_dir = project_root.join(".github").join("skills");
200 let mut out = ito_skills_manifests(&skills_dir);
201
202 let prompts_dir = project_root.join(".github").join("prompts");
205 for file in commands_files() {
206 let rel_path = file.relative_path;
207 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
223pub 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 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 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 let bytes = stamp_managed_markdown(bytes, &manifest.source, version);
296
297 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
328fn is_plain_markdown_path(path: &str) -> bool {
332 path.ends_with(".md") && !path.ends_with(".md.j2")
333}
334
335fn 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 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;