| 1 | //! System-skill installer: bundles first-party skills and auto-installs them |
| 2 | //! on first launch. |
| 3 | |
| 4 | use std::fs; |
| 5 | use std::io::Write; |
| 6 | use std::path::Path; |
| 7 | |
| 8 | /// Bundled catalog generation for the default CodeWhale skill pack (#4691). |
| 9 | /// |
| 10 | /// Generation 7 adds the explicit-only `help` router (#4698 parity slice). |
| 11 | /// Generation 8 adds the explicit-only `contributor-onboarding` path |
| 12 | /// requested by @JayBeest (#4227). |
| 13 | /// Generation 9 adds the `handoff` workflow skill (baton-pass for |
| 14 | /// continuous operate-mode operations). |
| 15 | /// Generation 10 adds the bundled `mcp-discovery` skill (Registry-first |
| 16 | /// tool selection). |
| 17 | const BUNDLED_SKILL_VERSION: &str = "10"; |
| 18 | |
| 19 | // ── system & extension (meta) ─────────────────────────────────────────────── |
| 20 | const SKILL_CREATOR_BODY: &str = include_str!("../../assets/skills/skill-creator/SKILL.md"); |
| 21 | const DELEGATE_BODY: &str = include_str!("../../assets/skills/delegate/SKILL.md"); |
| 22 | const PLUGIN_CREATOR_BODY: &str = include_str!("../../assets/skills/plugin-creator/SKILL.md"); |
| 23 | const SKILL_INSTALLER_BODY: &str = include_str!("../../assets/skills/skill-installer/SKILL.md"); |
| 24 | const MCP_BUILDER_BODY: &str = include_str!("../../assets/skills/mcp-builder/SKILL.md"); |
| 25 | const FLEET_MANAGER_BODY: &str = include_str!("../../assets/skills/fleet-manager/SKILL.md"); |
| 26 | const HELP_BODY: &str = include_str!("../../assets/skills/help/SKILL.md"); |
| 27 | |
| 28 | // ── end-user workflows ────────────────────────────────────────────────────── |
| 29 | const HANDOFF_BODY: &str = include_str!("../../assets/skills/handoff/SKILL.md"); |
| 30 | const BEST_OF_N_BODY: &str = include_str!("../../assets/skills/best-of-n/SKILL.md"); |
| 31 | const INTERVIEW_BODY: &str = include_str!("../../assets/skills/interview/SKILL.md"); |
| 32 | const PLAN_BODY: &str = include_str!("../../assets/skills/plan/SKILL.md"); |
| 33 | const IMPLEMENT_BODY: &str = include_str!("../../assets/skills/implement/SKILL.md"); |
| 34 | const DEBUG_BODY: &str = include_str!("../../assets/skills/debug/SKILL.md"); |
| 35 | const TEST_BODY: &str = include_str!("../../assets/skills/test/SKILL.md"); |
| 36 | const REVIEW_BODY: &str = include_str!("../../assets/skills/review/SKILL.md"); |
| 37 | const SECURITY_REVIEW_BODY: &str = include_str!("../../assets/skills/security-review/SKILL.md"); |
| 38 | const SIMPLIFY_BODY: &str = include_str!("../../assets/skills/simplify/SKILL.md"); |
| 39 | const VERIFY_BODY: &str = include_str!("../../assets/skills/verify/SKILL.md"); |
| 40 | const RESEARCH_BODY: &str = include_str!("../../assets/skills/research/SKILL.md"); |
| 41 | const FRONTEND_DESIGN_BODY: &str = include_str!("../../assets/skills/frontend-design/SKILL.md"); |
| 42 | const WEBAPP_TESTING_BODY: &str = include_str!("../../assets/skills/webapp-testing/SKILL.md"); |
| 43 | const DOCUMENT_BODY: &str = include_str!("../../assets/skills/document/SKILL.md"); |
| 44 | const DATAVIZ_BODY: &str = include_str!("../../assets/skills/dataviz/SKILL.md"); |
| 45 | const DOCX_BODY: &str = include_str!("../../assets/skills/docx/SKILL.md"); |
| 46 | const PDF_BODY: &str = include_str!("../../assets/skills/pdf/SKILL.md"); |
| 47 | const PPTX_BODY: &str = include_str!("../../assets/skills/pptx/SKILL.md"); |
| 48 | const XLSX_BODY: &str = include_str!("../../assets/skills/xlsx/SKILL.md"); |
| 49 | const DOCUMENTS_ALIAS_BODY: &str = include_str!("../../assets/skills/documents/SKILL.md"); |
| 50 | const PRESENTATIONS_ALIAS_BODY: &str = include_str!("../../assets/skills/presentations/SKILL.md"); |
| 51 | const SPREADSHEETS_ALIAS_BODY: &str = include_str!("../../assets/skills/spreadsheets/SKILL.md"); |
| 52 | |
| 53 | // ── power / explicit-only ─────────────────────────────────────────────────── |
| 54 | const BATCH_BODY: &str = include_str!("../../assets/skills/batch/SKILL.md"); |
| 55 | const DEPENDENCY_UPDATE_BODY: &str = include_str!("../../assets/skills/dependency-update/SKILL.md"); |
| 56 | const RELEASE_BODY: &str = include_str!("../../assets/skills/release/SKILL.md"); |
| 57 | const CONTRIBUTOR_ONBOARDING_BODY: &str = |
| 58 | include_str!("../../assets/skills/contributor-onboarding/SKILL.md"); |
| 59 | |
| 60 | // Optional integration (not auto-installed for every user): Feishu body kept for |
| 61 | // digest/migration helpers only. |
| 62 | const FEISHU_BODY: &str = include_str!("../../assets/skills/feishu/SKILL.md"); |
| 63 | const MCP_DISCOVERY_BODY: &str = include_str!("../../assets/skills/mcp-discovery/SKILL.md"); |
| 64 | |
| 65 | // Legacy v4 body retained solely for digest-based safe retirement (#4691). |
| 66 | const V4_BEST_PRACTICES_BODY: &str = include_str!("../../assets/skills/v4-best-practices/SKILL.md"); |
| 67 | |
| 68 | struct BundledSkill { |
| 69 | name: &'static str, |
| 70 | body: &'static str, |
| 71 | introduced_in: u32, |
| 72 | } |
| 73 | |
| 74 | /// Skills auto-installed for every user on fresh install / upgrade. |
| 75 | const BUNDLED_SKILLS: &[BundledSkill] = &[ |
| 76 | // System & extension |
| 77 | BundledSkill { |
| 78 | name: "skill-creator", |
| 79 | body: SKILL_CREATOR_BODY, |
| 80 | introduced_in: 1, |
| 81 | }, |
| 82 | BundledSkill { |
| 83 | name: "delegate", |
| 84 | body: DELEGATE_BODY, |
| 85 | introduced_in: 2, |
| 86 | }, |
| 87 | BundledSkill { |
| 88 | name: "plugin-creator", |
| 89 | body: PLUGIN_CREATOR_BODY, |
| 90 | introduced_in: 3, |
| 91 | }, |
| 92 | BundledSkill { |
| 93 | name: "skill-installer", |
| 94 | body: SKILL_INSTALLER_BODY, |
| 95 | introduced_in: 3, |
| 96 | }, |
| 97 | BundledSkill { |
| 98 | name: "mcp-builder", |
| 99 | body: MCP_BUILDER_BODY, |
| 100 | introduced_in: 3, |
| 101 | }, |
| 102 | BundledSkill { |
| 103 | name: "fleet-manager", |
| 104 | body: FLEET_MANAGER_BODY, |
| 105 | introduced_in: 4, |
| 106 | }, |
| 107 | BundledSkill { |
| 108 | name: "help", |
| 109 | body: HELP_BODY, |
| 110 | introduced_in: 7, |
| 111 | }, |
| 112 | // End-user workflows |
| 113 | BundledSkill { |
| 114 | name: "handoff", |
| 115 | body: HANDOFF_BODY, |
| 116 | introduced_in: 9, |
| 117 | }, |
| 118 | BundledSkill { |
| 119 | name: "best-of-n", |
| 120 | body: BEST_OF_N_BODY, |
| 121 | introduced_in: 6, |
| 122 | }, |
| 123 | BundledSkill { |
| 124 | name: "interview", |
| 125 | body: INTERVIEW_BODY, |
| 126 | introduced_in: 5, |
| 127 | }, |
| 128 | BundledSkill { |
| 129 | name: "plan", |
| 130 | body: PLAN_BODY, |
| 131 | introduced_in: 5, |
| 132 | }, |
| 133 | BundledSkill { |
| 134 | name: "implement", |
| 135 | body: IMPLEMENT_BODY, |
| 136 | introduced_in: 5, |
| 137 | }, |
| 138 | BundledSkill { |
| 139 | name: "debug", |
| 140 | body: DEBUG_BODY, |
| 141 | introduced_in: 5, |
| 142 | }, |
| 143 | BundledSkill { |
| 144 | name: "test", |
| 145 | body: TEST_BODY, |
| 146 | introduced_in: 5, |
| 147 | }, |
| 148 | BundledSkill { |
| 149 | name: "review", |
| 150 | body: REVIEW_BODY, |
| 151 | introduced_in: 5, |
| 152 | }, |
| 153 | BundledSkill { |
| 154 | name: "security-review", |
| 155 | body: SECURITY_REVIEW_BODY, |
| 156 | introduced_in: 5, |
| 157 | }, |
| 158 | BundledSkill { |
| 159 | name: "simplify", |
| 160 | body: SIMPLIFY_BODY, |
| 161 | introduced_in: 5, |
| 162 | }, |
| 163 | BundledSkill { |
| 164 | name: "verify", |
| 165 | body: VERIFY_BODY, |
| 166 | introduced_in: 5, |
| 167 | }, |
| 168 | BundledSkill { |
| 169 | name: "research", |
| 170 | body: RESEARCH_BODY, |
| 171 | introduced_in: 5, |
| 172 | }, |
| 173 | BundledSkill { |
| 174 | name: "frontend-design", |
| 175 | body: FRONTEND_DESIGN_BODY, |
| 176 | introduced_in: 5, |
| 177 | }, |
| 178 | BundledSkill { |
| 179 | name: "webapp-testing", |
| 180 | body: WEBAPP_TESTING_BODY, |
| 181 | introduced_in: 5, |
| 182 | }, |
| 183 | BundledSkill { |
| 184 | name: "document", |
| 185 | body: DOCUMENT_BODY, |
| 186 | introduced_in: 5, |
| 187 | }, |
| 188 | BundledSkill { |
| 189 | name: "dataviz", |
| 190 | body: DATAVIZ_BODY, |
| 191 | introduced_in: 5, |
| 192 | }, |
| 193 | BundledSkill { |
| 194 | name: "docx", |
| 195 | body: DOCX_BODY, |
| 196 | introduced_in: 5, |
| 197 | }, |
| 198 | BundledSkill { |
| 199 | name: "pdf", |
| 200 | body: PDF_BODY, |
| 201 | introduced_in: 3, |
| 202 | }, |
| 203 | BundledSkill { |
| 204 | name: "pptx", |
| 205 | body: PPTX_BODY, |
| 206 | introduced_in: 5, |
| 207 | }, |
| 208 | BundledSkill { |
| 209 | name: "xlsx", |
| 210 | body: XLSX_BODY, |
| 211 | introduced_in: 5, |
| 212 | }, |
| 213 | // Compatibility aliases for pre-v5 artifact names |
| 214 | BundledSkill { |
| 215 | name: "documents", |
| 216 | body: DOCUMENTS_ALIAS_BODY, |
| 217 | introduced_in: 3, |
| 218 | }, |
| 219 | BundledSkill { |
| 220 | name: "presentations", |
| 221 | body: PRESENTATIONS_ALIAS_BODY, |
| 222 | introduced_in: 3, |
| 223 | }, |
| 224 | BundledSkill { |
| 225 | name: "spreadsheets", |
| 226 | body: SPREADSHEETS_ALIAS_BODY, |
| 227 | introduced_in: 3, |
| 228 | }, |
| 229 | // Power / explicit-only |
| 230 | BundledSkill { |
| 231 | name: "batch", |
| 232 | body: BATCH_BODY, |
| 233 | introduced_in: 5, |
| 234 | }, |
| 235 | BundledSkill { |
| 236 | name: "dependency-update", |
| 237 | body: DEPENDENCY_UPDATE_BODY, |
| 238 | introduced_in: 5, |
| 239 | }, |
| 240 | BundledSkill { |
| 241 | name: "release", |
| 242 | body: RELEASE_BODY, |
| 243 | introduced_in: 5, |
| 244 | }, |
| 245 | BundledSkill { |
| 246 | name: "contributor-onboarding", |
| 247 | body: CONTRIBUTOR_ONBOARDING_BODY, |
| 248 | introduced_in: 8, |
| 249 | }, |
| 250 | BundledSkill { |
| 251 | name: "mcp-discovery", |
| 252 | body: MCP_DISCOVERY_BODY, |
| 253 | introduced_in: 10, |
| 254 | }, |
| 255 | ]; |
| 256 | |
| 257 | /// Product-facing grouping for the bundled catalog. |
| 258 | /// |
| 259 | /// User and compatible skills remain outside these two buckets. The grouping |
| 260 | /// is deliberately attached to the shipped catalog instead of inferred from |
| 261 | /// arbitrary community metadata. |
| 262 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 263 | pub enum BundledSkillTier { |
| 264 | CoreAgentic, |
| 265 | FormatTooling, |
| 266 | } |
| 267 | |
| 268 | impl BundledSkillTier { |
| 269 | #[must_use] |
| 270 | pub const fn label(self) -> &'static str { |
| 271 | match self { |
| 272 | Self::CoreAgentic => "core", |
| 273 | Self::FormatTooling => "tools", |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | #[must_use] |
| 278 | pub const fn heading(self) -> &'static str { |
| 279 | match self { |
| 280 | Self::CoreAgentic => "Core agentic", |
| 281 | Self::FormatTooling => "Format & tooling", |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /// Return the curated tier for a bundled skill name. |
| 287 | #[must_use] |
| 288 | pub fn bundled_skill_tier(name: &str) -> Option<BundledSkillTier> { |
| 289 | if !is_bundled_skill_name(name) { |
| 290 | return None; |
| 291 | } |
| 292 | let tier = match name { |
| 293 | "skill-creator" | "plugin-creator" | "skill-installer" | "mcp-builder" | "help" |
| 294 | | "frontend-design" | "webapp-testing" | "document" | "dataviz" | "docx" | "pdf" |
| 295 | | "pptx" | "xlsx" | "documents" | "presentations" | "spreadsheets" => { |
| 296 | BundledSkillTier::FormatTooling |
| 297 | } |
| 298 | _ => BundledSkillTier::CoreAgentic, |
| 299 | }; |
| 300 | Some(tier) |
| 301 | } |
| 302 | |
| 303 | /// Canonical names of every skill in the shipped starter pack, in bundle order. |
| 304 | /// |
| 305 | /// Exposed so the catalog fixture matrix (#4698) can assert a *bijection* |
| 306 | /// between the checked-in fixture and the real bundle: a skill added or removed |
| 307 | /// without updating the fixture fails the build rather than silently changing |
| 308 | /// what every user gets installed. |
| 309 | #[must_use] |
| 310 | #[cfg(test)] |
| 311 | pub fn bundled_skill_names() -> Vec<&'static str> { |
| 312 | BUNDLED_SKILLS.iter().map(|skill| skill.name).collect() |
| 313 | } |
| 314 | |
| 315 | /// The shipped generation marker written to `.system-installed-version`. |
| 316 | #[must_use] |
| 317 | #[cfg(test)] |
| 318 | pub fn bundled_skill_generation() -> &'static str { |
| 319 | BUNDLED_SKILL_VERSION |
| 320 | } |
| 321 | |
| 322 | /// Legacy v4-best-practices body digest helper (not in BUNDLED_SKILLS). |
| 323 | fn v4_best_practices_body() -> &'static str { |
| 324 | V4_BEST_PRACTICES_BODY |
| 325 | } |
| 326 | |
| 327 | fn feishu_body() -> &'static str { |
| 328 | FEISHU_BODY |
| 329 | } |
| 330 | |
| 331 | /// Whether a skill name matches one of the bundled first-party skills. |
| 332 | /// |
| 333 | /// Used by `/skills` to distinguish user-created skills (which should be |
| 334 | /// surfaced prominently) from the always-installed bundle (which can be |
| 335 | /// rendered compactly when many skills are present). |
| 336 | /// |
| 337 | /// Prefer [`is_exact_bundled_skill`] when classifying audit rows — name-only |
| 338 | /// matches can collide with user overrides of the same command name. |
| 339 | #[must_use] |
| 340 | pub fn is_bundled_skill_name(name: &str) -> bool { |
| 341 | BUNDLED_SKILLS.iter().any(|s| s.name == name) |
| 342 | } |
| 343 | |
| 344 | /// True when `name` is a bundled skill **and** `skill_md_content` exactly |
| 345 | /// matches the shipped asset body (byte-for-byte). |
| 346 | /// |
| 347 | /// Used by the skill audit inventory so a user-edited copy of a bundled name |
| 348 | /// is not misclassified as built-in. |
| 349 | #[must_use] |
| 350 | pub fn is_exact_bundled_skill(name: &str, skill_md_content: &str) -> bool { |
| 351 | BUNDLED_SKILLS |
| 352 | .iter() |
| 353 | .any(|s| s.name == name && s.body == skill_md_content) |
| 354 | } |
| 355 | |
| 356 | /// Attempt to install a single bundled skill into `skills_dir`. |
| 357 | /// |
| 358 | /// Returns `true` if installation occurred (fresh install or version bump). |
| 359 | fn install_one( |
| 360 | skills_dir: &Path, |
| 361 | skill: &BundledSkill, |
| 362 | installed_version: Option<&str>, |
| 363 | ) -> std::io::Result<bool> { |
| 364 | let target_dir = skills_dir.join(skill.name); |
| 365 | let target_file = target_dir.join("SKILL.md"); |
| 366 | let dir_exists = target_dir.exists(); |
| 367 | let installed_number = installed_version.and_then(|value| value.parse::<u32>().ok()); |
| 368 | |
| 369 | let should_install = match (installed_version, installed_number, dir_exists) { |
| 370 | // Fresh install: neither marker nor directory. |
| 371 | (None, _, false) => true, |
| 372 | // Newly bundled skill: add it for older system-skill installs. |
| 373 | (Some(_), Some(version), _) if version < skill.introduced_in => true, |
| 374 | // Version bump for an existing skill: refresh only if the user has not |
| 375 | // intentionally deleted that skill directory. |
| 376 | (Some(version), _, true) if version != BUNDLED_SKILL_VERSION => true, |
| 377 | // Every other case: current install, user-deleted dir, or pre-existing |
| 378 | // user-owned skill without our marker. |
| 379 | _ => false, |
| 380 | }; |
| 381 | |
| 382 | if should_install { |
| 383 | // Never overwrite a user-modified copy that no longer matches a known |
| 384 | // shipped body (#4691 non-destructive upgrade table). |
| 385 | if target_file.exists() { |
| 386 | let existing = fs::read_to_string(&target_file).unwrap_or_default(); |
| 387 | if !existing.is_empty() && existing != skill.body { |
| 388 | // Preserve user/compatible-root content; skip replace-by-name. |
| 389 | return Ok(false); |
| 390 | } |
| 391 | } |
| 392 | fs::create_dir_all(&target_dir)?; |
| 393 | fs::write(&target_file, skill.body)?; |
| 394 | } |
| 395 | Ok(should_install) |
| 396 | } |
| 397 | |
| 398 | /// Install bundled system skills into `skills_dir`. |
| 399 | /// |
| 400 | /// Behaviour: |
| 401 | /// - Fresh install (no marker, no dir): installs every bundled skill, then |
| 402 | /// writes the version marker. |
| 403 | /// - Version bump (marker present with older version): re-installs any existing |
| 404 | /// bundled skill and installs newly introduced bundled skills. |
| 405 | /// - User deleted a skill dir while marker still present at same version: leaves |
| 406 | /// it gone. |
| 407 | /// - Idempotent: calling twice with no changes is a no-op. |
| 408 | /// |
| 409 | /// Errors are I/O errors from the filesystem; the caller should log them but not |
| 410 | /// abort startup. |
| 411 | pub fn install_system_skills(skills_dir: &Path) -> std::io::Result<()> { |
| 412 | let marker = skills_dir.join(".system-installed-version"); |
| 413 | |
| 414 | // A marker can be left behind as an invalid file (or even as a directory |
| 415 | // after an interrupted/manual install). Treat it as an untrusted marker, |
| 416 | // but still repair it after reconciling the bundled skills. This keeps |
| 417 | // user-edited skill bodies intact while allowing missing skills to be |
| 418 | // restored and future upgrades to be versioned again. |
| 419 | let (installed_version, repair_marker) = match fs::read_to_string(&marker) { |
| 420 | Ok(contents) => match contents.trim().parse::<u32>() { |
| 421 | Ok(_) => (Some(contents.trim().to_string()), false), |
| 422 | Err(_) => (None, true), |
| 423 | }, |
| 424 | Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, false), |
| 425 | Err(_) => (None, true), |
| 426 | }; |
| 427 | |
| 428 | let mut changed = false; |
| 429 | for skill in BUNDLED_SKILLS { |
| 430 | changed |= install_one(skills_dir, skill, installed_version.as_deref())?; |
| 431 | } |
| 432 | |
| 433 | // Safe retirement: remove only an unchanged CodeWhale-owned v4-best-practices. |
| 434 | changed |= retire_unchanged_v4_best_practices(skills_dir)?; |
| 435 | |
| 436 | // Feishu is optional: do not install for every user. If an older bundle |
| 437 | // installed an exact shipped copy, leave it; never delete by name alone. |
| 438 | let _ = feishu_body(); |
| 439 | |
| 440 | if changed || repair_marker { |
| 441 | fs::create_dir_all(skills_dir)?; |
| 442 | if marker.exists() && !marker.is_file() { |
| 443 | if marker.is_dir() { |
| 444 | fs::remove_dir_all(&marker)?; |
| 445 | } else { |
| 446 | fs::remove_file(&marker)?; |
| 447 | } |
| 448 | } |
| 449 | write_marker_atomically(&marker, BUNDLED_SKILL_VERSION)?; |
| 450 | } |
| 451 | Ok(()) |
| 452 | } |
| 453 | |
| 454 | /// Delete `v4-best-practices` only when the installed SKILL.md exactly matches |
| 455 | /// the last shipped bundled body (byte-for-byte). Modified or user-owned copies |
| 456 | /// are preserved. |
| 457 | fn retire_unchanged_v4_best_practices(skills_dir: &Path) -> std::io::Result<bool> { |
| 458 | let dir = skills_dir.join("v4-best-practices"); |
| 459 | let file = dir.join("SKILL.md"); |
| 460 | if !file.exists() { |
| 461 | return Ok(false); |
| 462 | } |
| 463 | let existing = fs::read_to_string(&file)?; |
| 464 | if existing != v4_best_practices_body() { |
| 465 | return Ok(false); |
| 466 | } |
| 467 | fs::remove_dir_all(&dir)?; |
| 468 | Ok(true) |
| 469 | } |
| 470 | |
| 471 | fn write_marker_atomically(marker: &Path, version: &str) -> std::io::Result<()> { |
| 472 | let parent = marker |
| 473 | .parent() |
| 474 | .expect("skill version marker should have a parent directory"); |
| 475 | let mut temporary = tempfile::NamedTempFile::new_in(parent)?; |
| 476 | temporary.write_all(version.as_bytes())?; |
| 477 | temporary.as_file().sync_all()?; |
| 478 | // `rename` atomically replaces a file on Unix. Windows refuses to replace |
| 479 | // an existing destination, so remove only this reserved marker first. |
| 480 | #[cfg(windows)] |
| 481 | if marker.exists() { |
| 482 | fs::remove_file(marker)?; |
| 483 | } |
| 484 | fs::rename(temporary.path(), marker) |
| 485 | } |
| 486 | |
| 487 | #[cfg(test)] |
| 488 | mod tests; |
| 489 |