| 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 | /// Generation 11 rewrites `mcp-discovery` from a Registry-first gate into a |
| 18 | /// missing-capability fallback, and corrects two stale facts in its body: |
| 19 | /// `registry_sync` requires a `query`, and the discovery tools are deferred |
| 20 | /// rather than always present in the active surface. Because the rewrite |
| 21 | /// changes an already-installed body, generation 10's exact body is retained in |
| 22 | /// `SUPERSEDED_BODIES` so unmodified copies upgrade and edited copies do not. |
| 23 | /// Generation 12 adds the everyday pack (comms, money, audio, health, |
| 24 | /// shopping/travel, media, github, goals, forget, feedback) and demotes |
| 25 | /// `contributor-onboarding` to a repo-local project skill: existing installed |
| 26 | /// copies are left in place, new installs do not receive it. |
| 27 | /// Generation 13 trims the pack: `social-media` and `health` ship to nobody |
| 28 | /// (phone-export workflows, not everyday), and `feedback` joins |
| 29 | /// `contributor-onboarding` as a repo-local project skill. |
| 30 | /// Generation 14 corrects account setup, Photos export, forgetting and plugin |
| 31 | /// lifecycle guidance; exact generation-13 bodies allow safe upgrades. |
| 32 | const BUNDLED_SKILL_VERSION: &str = "14"; |
| 33 | |
| 34 | // ── system & extension (meta) ─────────────────────────────────────────────── |
| 35 | const SKILL_CREATOR_BODY: &str = include_str!("../../assets/skills/skill-creator/SKILL.md"); |
| 36 | const DELEGATE_BODY: &str = include_str!("../../assets/skills/delegate/SKILL.md"); |
| 37 | const PLUGIN_CREATOR_BODY: &str = include_str!("../../assets/skills/plugin-creator/SKILL.md"); |
| 38 | const SKILL_INSTALLER_BODY: &str = include_str!("../../assets/skills/skill-installer/SKILL.md"); |
| 39 | const MCP_BUILDER_BODY: &str = include_str!("../../assets/skills/mcp-builder/SKILL.md"); |
| 40 | const FLEET_MANAGER_BODY: &str = include_str!("../../assets/skills/fleet-manager/SKILL.md"); |
| 41 | const HELP_BODY: &str = include_str!("../../assets/skills/help/SKILL.md"); |
| 42 | |
| 43 | // ── end-user workflows ────────────────────────────────────────────────────── |
| 44 | const HANDOFF_BODY: &str = include_str!("../../assets/skills/handoff/SKILL.md"); |
| 45 | const BEST_OF_N_BODY: &str = include_str!("../../assets/skills/best-of-n/SKILL.md"); |
| 46 | const INTERVIEW_BODY: &str = include_str!("../../assets/skills/interview/SKILL.md"); |
| 47 | const PLAN_BODY: &str = include_str!("../../assets/skills/plan/SKILL.md"); |
| 48 | const IMPLEMENT_BODY: &str = include_str!("../../assets/skills/implement/SKILL.md"); |
| 49 | const DEBUG_BODY: &str = include_str!("../../assets/skills/debug/SKILL.md"); |
| 50 | const TEST_BODY: &str = include_str!("../../assets/skills/test/SKILL.md"); |
| 51 | const REVIEW_BODY: &str = include_str!("../../assets/skills/review/SKILL.md"); |
| 52 | const SECURITY_REVIEW_BODY: &str = include_str!("../../assets/skills/security-review/SKILL.md"); |
| 53 | const SIMPLIFY_BODY: &str = include_str!("../../assets/skills/simplify/SKILL.md"); |
| 54 | const VERIFY_BODY: &str = include_str!("../../assets/skills/verify/SKILL.md"); |
| 55 | const RESEARCH_BODY: &str = include_str!("../../assets/skills/research/SKILL.md"); |
| 56 | const FRONTEND_DESIGN_BODY: &str = include_str!("../../assets/skills/frontend-design/SKILL.md"); |
| 57 | const WEBAPP_TESTING_BODY: &str = include_str!("../../assets/skills/webapp-testing/SKILL.md"); |
| 58 | const DOCUMENT_BODY: &str = include_str!("../../assets/skills/document/SKILL.md"); |
| 59 | const DATAVIZ_BODY: &str = include_str!("../../assets/skills/dataviz/SKILL.md"); |
| 60 | const DOCX_BODY: &str = include_str!("../../assets/skills/docx/SKILL.md"); |
| 61 | const PDF_BODY: &str = include_str!("../../assets/skills/pdf/SKILL.md"); |
| 62 | const PPTX_BODY: &str = include_str!("../../assets/skills/pptx/SKILL.md"); |
| 63 | const XLSX_BODY: &str = include_str!("../../assets/skills/xlsx/SKILL.md"); |
| 64 | const DOCUMENTS_ALIAS_BODY: &str = include_str!("../../assets/skills/documents/SKILL.md"); |
| 65 | const PRESENTATIONS_ALIAS_BODY: &str = include_str!("../../assets/skills/presentations/SKILL.md"); |
| 66 | const SPREADSHEETS_ALIAS_BODY: &str = include_str!("../../assets/skills/spreadsheets/SKILL.md"); |
| 67 | |
| 68 | // ── everyday ──────────────────────────────────────────────────────────────── |
| 69 | const GITHUB_BODY: &str = include_str!("../../assets/skills/github/SKILL.md"); |
| 70 | const GMAIL_BODY: &str = include_str!("../../assets/skills/gmail/SKILL.md"); |
| 71 | const GOOGLE_CALENDAR_BODY: &str = include_str!("../../assets/skills/google-calendar/SKILL.md"); |
| 72 | const MONEY_BODY: &str = include_str!("../../assets/skills/money/SKILL.md"); |
| 73 | const SPOTIFY_BODY: &str = include_str!("../../assets/skills/spotify/SKILL.md"); |
| 74 | const TTS_BODY: &str = include_str!("../../assets/skills/tts/SKILL.md"); |
| 75 | const PODCAST_BODY: &str = include_str!("../../assets/skills/podcast/SKILL.md"); |
| 76 | const SHOPPING_BODY: &str = include_str!("../../assets/skills/shopping/SKILL.md"); |
| 77 | const FLIGHTS_BODY: &str = include_str!("../../assets/skills/flights/SKILL.md"); |
| 78 | const PHOTOS_BODY: &str = include_str!("../../assets/skills/photos/SKILL.md"); |
| 79 | const IMAGE_SEARCH_BODY: &str = include_str!("../../assets/skills/image-search/SKILL.md"); |
| 80 | const GOALS_BODY: &str = include_str!("../../assets/skills/goals/SKILL.md"); |
| 81 | const FORGET_BODY: &str = include_str!("../../assets/skills/forget/SKILL.md"); |
| 82 | const FEEDBACK_BODY: &str = include_str!("../../assets/skills/feedback/SKILL.md"); |
| 83 | |
| 84 | // ── power / explicit-only ─────────────────────────────────────────────────── |
| 85 | const BATCH_BODY: &str = include_str!("../../assets/skills/batch/SKILL.md"); |
| 86 | const DEPENDENCY_UPDATE_BODY: &str = include_str!("../../assets/skills/dependency-update/SKILL.md"); |
| 87 | const RELEASE_BODY: &str = include_str!("../../assets/skills/release/SKILL.md"); |
| 88 | const CONTRIBUTOR_ONBOARDING_BODY: &str = |
| 89 | include_str!("../../assets/skills/contributor-onboarding/SKILL.md"); |
| 90 | |
| 91 | // Optional integration (not auto-installed for every user): Feishu body kept for |
| 92 | // digest/migration helpers only. |
| 93 | const FEISHU_BODY: &str = include_str!("../../assets/skills/feishu/SKILL.md"); |
| 94 | const MCP_DISCOVERY_BODY: &str = include_str!("../../assets/skills/mcp-discovery/SKILL.md"); |
| 95 | |
| 96 | // Legacy v4 body retained solely for digest-based safe retirement (#4691). |
| 97 | const V4_BEST_PRACTICES_BODY: &str = include_str!("../../assets/skills/v4-best-practices/SKILL.md"); |
| 98 | |
| 99 | // Generation-10 `mcp-discovery` body, retained solely so an unmodified copy of |
| 100 | // it can be recognized and refreshed on upgrade. Same digest discipline as the |
| 101 | // v4 retirement above: an exact byte match proves CodeWhale still owns the |
| 102 | // file, so replacing it loses no user work. |
| 103 | const MCP_DISCOVERY_GENERATION_10_BODY: &str = |
| 104 | include_str!("../../assets/skills/mcp-discovery/SKILL.generation-10.md"); |
| 105 | |
| 106 | /// Exact bodies a bundled skill shipped in an earlier generation. |
| 107 | /// |
| 108 | /// The installer refuses to overwrite an installed body it does not recognize, |
| 109 | /// which is what protects user edits. Without this table that same rule also |
| 110 | /// pins every unmodified older copy forever: the on-disk body no longer equals |
| 111 | /// the shipped one, so the skill never upgrades. Listing the previous body |
| 112 | /// restores the upgrade for exactly the copies CodeWhale wrote itself. |
| 113 | const SUPERSEDED_BODIES: &[(&str, &str)] = &[ |
| 114 | ("mcp-discovery", MCP_DISCOVERY_GENERATION_10_BODY), |
| 115 | ( |
| 116 | "gmail", |
| 117 | include_str!("../../assets/skills/gmail/SKILL.generation-13.md"), |
| 118 | ), |
| 119 | ( |
| 120 | "google-calendar", |
| 121 | include_str!("../../assets/skills/google-calendar/SKILL.generation-13.md"), |
| 122 | ), |
| 123 | ( |
| 124 | "photos", |
| 125 | include_str!("../../assets/skills/photos/SKILL.generation-13.md"), |
| 126 | ), |
| 127 | ( |
| 128 | "forget", |
| 129 | include_str!("../../assets/skills/forget/SKILL.generation-13.md"), |
| 130 | ), |
| 131 | ( |
| 132 | "spotify", |
| 133 | include_str!("../../assets/skills/spotify/SKILL.generation-13.md"), |
| 134 | ), |
| 135 | ( |
| 136 | "plugin-creator", |
| 137 | include_str!("../../assets/skills/plugin-creator/SKILL.generation-13.md"), |
| 138 | ), |
| 139 | ]; |
| 140 | |
| 141 | /// Whether `existing` is byte-for-byte a body CodeWhale previously shipped for |
| 142 | /// `name` (and therefore safe to replace on upgrade). |
| 143 | fn is_superseded_shipped_body(name: &str, existing: &str) -> bool { |
| 144 | SUPERSEDED_BODIES |
| 145 | .iter() |
| 146 | .any(|(skill, body)| *skill == name && *body == existing) |
| 147 | } |
| 148 | |
| 149 | struct BundledSkill { |
| 150 | name: &'static str, |
| 151 | body: &'static str, |
| 152 | introduced_in: u32, |
| 153 | } |
| 154 | |
| 155 | /// Skills auto-installed for every user on fresh install / upgrade. |
| 156 | const BUNDLED_SKILLS: &[BundledSkill] = &[ |
| 157 | // System & extension |
| 158 | BundledSkill { |
| 159 | name: "skill-creator", |
| 160 | body: SKILL_CREATOR_BODY, |
| 161 | introduced_in: 1, |
| 162 | }, |
| 163 | BundledSkill { |
| 164 | name: "delegate", |
| 165 | body: DELEGATE_BODY, |
| 166 | introduced_in: 2, |
| 167 | }, |
| 168 | BundledSkill { |
| 169 | name: "plugin-creator", |
| 170 | body: PLUGIN_CREATOR_BODY, |
| 171 | introduced_in: 3, |
| 172 | }, |
| 173 | BundledSkill { |
| 174 | name: "skill-installer", |
| 175 | body: SKILL_INSTALLER_BODY, |
| 176 | introduced_in: 3, |
| 177 | }, |
| 178 | BundledSkill { |
| 179 | name: "mcp-builder", |
| 180 | body: MCP_BUILDER_BODY, |
| 181 | introduced_in: 3, |
| 182 | }, |
| 183 | BundledSkill { |
| 184 | name: "fleet-manager", |
| 185 | body: FLEET_MANAGER_BODY, |
| 186 | introduced_in: 4, |
| 187 | }, |
| 188 | BundledSkill { |
| 189 | name: "help", |
| 190 | body: HELP_BODY, |
| 191 | introduced_in: 7, |
| 192 | }, |
| 193 | // End-user workflows |
| 194 | BundledSkill { |
| 195 | name: "handoff", |
| 196 | body: HANDOFF_BODY, |
| 197 | introduced_in: 9, |
| 198 | }, |
| 199 | BundledSkill { |
| 200 | name: "best-of-n", |
| 201 | body: BEST_OF_N_BODY, |
| 202 | introduced_in: 6, |
| 203 | }, |
| 204 | BundledSkill { |
| 205 | name: "interview", |
| 206 | body: INTERVIEW_BODY, |
| 207 | introduced_in: 5, |
| 208 | }, |
| 209 | BundledSkill { |
| 210 | name: "plan", |
| 211 | body: PLAN_BODY, |
| 212 | introduced_in: 5, |
| 213 | }, |
| 214 | BundledSkill { |
| 215 | name: "implement", |
| 216 | body: IMPLEMENT_BODY, |
| 217 | introduced_in: 5, |
| 218 | }, |
| 219 | BundledSkill { |
| 220 | name: "debug", |
| 221 | body: DEBUG_BODY, |
| 222 | introduced_in: 5, |
| 223 | }, |
| 224 | BundledSkill { |
| 225 | name: "test", |
| 226 | body: TEST_BODY, |
| 227 | introduced_in: 5, |
| 228 | }, |
| 229 | BundledSkill { |
| 230 | name: "review", |
| 231 | body: REVIEW_BODY, |
| 232 | introduced_in: 5, |
| 233 | }, |
| 234 | BundledSkill { |
| 235 | name: "security-review", |
| 236 | body: SECURITY_REVIEW_BODY, |
| 237 | introduced_in: 5, |
| 238 | }, |
| 239 | BundledSkill { |
| 240 | name: "simplify", |
| 241 | body: SIMPLIFY_BODY, |
| 242 | introduced_in: 5, |
| 243 | }, |
| 244 | BundledSkill { |
| 245 | name: "verify", |
| 246 | body: VERIFY_BODY, |
| 247 | introduced_in: 5, |
| 248 | }, |
| 249 | BundledSkill { |
| 250 | name: "research", |
| 251 | body: RESEARCH_BODY, |
| 252 | introduced_in: 5, |
| 253 | }, |
| 254 | BundledSkill { |
| 255 | name: "frontend-design", |
| 256 | body: FRONTEND_DESIGN_BODY, |
| 257 | introduced_in: 5, |
| 258 | }, |
| 259 | BundledSkill { |
| 260 | name: "webapp-testing", |
| 261 | body: WEBAPP_TESTING_BODY, |
| 262 | introduced_in: 5, |
| 263 | }, |
| 264 | BundledSkill { |
| 265 | name: "document", |
| 266 | body: DOCUMENT_BODY, |
| 267 | introduced_in: 5, |
| 268 | }, |
| 269 | BundledSkill { |
| 270 | name: "dataviz", |
| 271 | body: DATAVIZ_BODY, |
| 272 | introduced_in: 5, |
| 273 | }, |
| 274 | BundledSkill { |
| 275 | name: "docx", |
| 276 | body: DOCX_BODY, |
| 277 | introduced_in: 5, |
| 278 | }, |
| 279 | BundledSkill { |
| 280 | name: "pdf", |
| 281 | body: PDF_BODY, |
| 282 | introduced_in: 3, |
| 283 | }, |
| 284 | BundledSkill { |
| 285 | name: "pptx", |
| 286 | body: PPTX_BODY, |
| 287 | introduced_in: 5, |
| 288 | }, |
| 289 | BundledSkill { |
| 290 | name: "xlsx", |
| 291 | body: XLSX_BODY, |
| 292 | introduced_in: 5, |
| 293 | }, |
| 294 | // Compatibility aliases for pre-v5 artifact names |
| 295 | BundledSkill { |
| 296 | name: "documents", |
| 297 | body: DOCUMENTS_ALIAS_BODY, |
| 298 | introduced_in: 3, |
| 299 | }, |
| 300 | BundledSkill { |
| 301 | name: "presentations", |
| 302 | body: PRESENTATIONS_ALIAS_BODY, |
| 303 | introduced_in: 3, |
| 304 | }, |
| 305 | BundledSkill { |
| 306 | name: "spreadsheets", |
| 307 | body: SPREADSHEETS_ALIAS_BODY, |
| 308 | introduced_in: 3, |
| 309 | }, |
| 310 | // Power / explicit-only |
| 311 | BundledSkill { |
| 312 | name: "batch", |
| 313 | body: BATCH_BODY, |
| 314 | introduced_in: 5, |
| 315 | }, |
| 316 | BundledSkill { |
| 317 | name: "dependency-update", |
| 318 | body: DEPENDENCY_UPDATE_BODY, |
| 319 | introduced_in: 5, |
| 320 | }, |
| 321 | BundledSkill { |
| 322 | name: "release", |
| 323 | body: RELEASE_BODY, |
| 324 | introduced_in: 5, |
| 325 | }, |
| 326 | BundledSkill { |
| 327 | name: "mcp-discovery", |
| 328 | body: MCP_DISCOVERY_BODY, |
| 329 | introduced_in: 10, |
| 330 | }, |
| 331 | // Everyday (generation 12) |
| 332 | BundledSkill { |
| 333 | name: "github", |
| 334 | body: GITHUB_BODY, |
| 335 | introduced_in: 12, |
| 336 | }, |
| 337 | BundledSkill { |
| 338 | name: "gmail", |
| 339 | body: GMAIL_BODY, |
| 340 | introduced_in: 12, |
| 341 | }, |
| 342 | BundledSkill { |
| 343 | name: "google-calendar", |
| 344 | body: GOOGLE_CALENDAR_BODY, |
| 345 | introduced_in: 12, |
| 346 | }, |
| 347 | BundledSkill { |
| 348 | name: "money", |
| 349 | body: MONEY_BODY, |
| 350 | introduced_in: 12, |
| 351 | }, |
| 352 | BundledSkill { |
| 353 | name: "spotify", |
| 354 | body: SPOTIFY_BODY, |
| 355 | introduced_in: 12, |
| 356 | }, |
| 357 | BundledSkill { |
| 358 | name: "tts", |
| 359 | body: TTS_BODY, |
| 360 | introduced_in: 12, |
| 361 | }, |
| 362 | BundledSkill { |
| 363 | name: "podcast", |
| 364 | body: PODCAST_BODY, |
| 365 | introduced_in: 12, |
| 366 | }, |
| 367 | BundledSkill { |
| 368 | name: "shopping", |
| 369 | body: SHOPPING_BODY, |
| 370 | introduced_in: 12, |
| 371 | }, |
| 372 | BundledSkill { |
| 373 | name: "flights", |
| 374 | body: FLIGHTS_BODY, |
| 375 | introduced_in: 12, |
| 376 | }, |
| 377 | BundledSkill { |
| 378 | name: "photos", |
| 379 | body: PHOTOS_BODY, |
| 380 | introduced_in: 12, |
| 381 | }, |
| 382 | BundledSkill { |
| 383 | name: "image-search", |
| 384 | body: IMAGE_SEARCH_BODY, |
| 385 | introduced_in: 12, |
| 386 | }, |
| 387 | BundledSkill { |
| 388 | name: "goals", |
| 389 | body: GOALS_BODY, |
| 390 | introduced_in: 12, |
| 391 | }, |
| 392 | BundledSkill { |
| 393 | name: "forget", |
| 394 | body: FORGET_BODY, |
| 395 | introduced_in: 12, |
| 396 | }, |
| 397 | ]; |
| 398 | |
| 399 | /// Product-facing grouping for the bundled catalog. |
| 400 | /// |
| 401 | /// User and compatible skills remain outside these two buckets. The grouping |
| 402 | /// is deliberately attached to the shipped catalog instead of inferred from |
| 403 | /// arbitrary community metadata. |
| 404 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 405 | pub enum BundledSkillTier { |
| 406 | CoreAgentic, |
| 407 | FormatTooling, |
| 408 | } |
| 409 | |
| 410 | impl BundledSkillTier { |
| 411 | #[must_use] |
| 412 | pub const fn label(self) -> &'static str { |
| 413 | match self { |
| 414 | Self::CoreAgentic => "core", |
| 415 | Self::FormatTooling => "tools", |
| 416 | } |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | /// Return the curated tier for a bundled skill name. |
| 421 | #[must_use] |
| 422 | pub fn bundled_skill_tier(name: &str) -> Option<BundledSkillTier> { |
| 423 | if !is_bundled_skill_name(name) { |
| 424 | return None; |
| 425 | } |
| 426 | let tier = match name { |
| 427 | "skill-creator" | "plugin-creator" | "skill-installer" | "mcp-builder" | "help" |
| 428 | | "frontend-design" | "webapp-testing" | "document" | "dataviz" | "docx" | "pdf" |
| 429 | | "pptx" | "xlsx" | "documents" | "presentations" | "spreadsheets" => { |
| 430 | BundledSkillTier::FormatTooling |
| 431 | } |
| 432 | _ => BundledSkillTier::CoreAgentic, |
| 433 | }; |
| 434 | Some(tier) |
| 435 | } |
| 436 | |
| 437 | /// Canonical names of every skill in the shipped starter pack, in bundle order. |
| 438 | /// |
| 439 | /// Exposed so the catalog fixture matrix (#4698) can assert a *bijection* |
| 440 | /// between the checked-in fixture and the real bundle: a skill added or removed |
| 441 | /// without updating the fixture fails the build rather than silently changing |
| 442 | /// what every user gets installed. |
| 443 | #[must_use] |
| 444 | #[cfg(test)] |
| 445 | pub fn bundled_skill_names() -> Vec<&'static str> { |
| 446 | BUNDLED_SKILLS.iter().map(|skill| skill.name).collect() |
| 447 | } |
| 448 | |
| 449 | /// The shipped generation marker written to `.system-installed-version`. |
| 450 | #[must_use] |
| 451 | #[cfg(test)] |
| 452 | pub fn bundled_skill_generation() -> &'static str { |
| 453 | BUNDLED_SKILL_VERSION |
| 454 | } |
| 455 | |
| 456 | /// Legacy v4-best-practices body digest helper (not in BUNDLED_SKILLS). |
| 457 | fn v4_best_practices_body() -> &'static str { |
| 458 | V4_BEST_PRACTICES_BODY |
| 459 | } |
| 460 | |
| 461 | fn feishu_body() -> &'static str { |
| 462 | FEISHU_BODY |
| 463 | } |
| 464 | |
| 465 | /// Last shipped `contributor-onboarding` body (removed from the bundle in |
| 466 | /// generation 12; now a repo-local project skill). Retained so tests can pin |
| 467 | /// its load-bearing refusals and so an installed copy stays recognizable. |
| 468 | fn contributor_onboarding_body() -> &'static str { |
| 469 | CONTRIBUTOR_ONBOARDING_BODY |
| 470 | } |
| 471 | |
| 472 | /// Last shipped `feedback` body (removed from the bundle in generation 13; |
| 473 | /// now a repo-local project skill). Retained so an installed copy stays |
| 474 | /// recognizable and is left in place, never deleted by name. |
| 475 | fn feedback_body() -> &'static str { |
| 476 | FEEDBACK_BODY |
| 477 | } |
| 478 | |
| 479 | /// Whether a skill name matches one of the bundled first-party skills. |
| 480 | /// |
| 481 | /// Used by `/skills` to distinguish user-created skills (which should be |
| 482 | /// surfaced prominently) from the always-installed bundle (which can be |
| 483 | /// rendered compactly when many skills are present). |
| 484 | /// |
| 485 | /// Prefer [`is_exact_bundled_skill`] when classifying audit rows — name-only |
| 486 | /// matches can collide with user overrides of the same command name. |
| 487 | #[must_use] |
| 488 | pub fn is_bundled_skill_name(name: &str) -> bool { |
| 489 | BUNDLED_SKILLS.iter().any(|s| s.name == name) |
| 490 | } |
| 491 | |
| 492 | /// True when `name` is a bundled skill **and** `skill_md_content` exactly |
| 493 | /// matches the shipped asset body (byte-for-byte). |
| 494 | /// |
| 495 | /// Used by the skill audit inventory so a user-edited copy of a bundled name |
| 496 | /// is not misclassified as built-in. |
| 497 | #[must_use] |
| 498 | pub fn is_exact_bundled_skill(name: &str, skill_md_content: &str) -> bool { |
| 499 | BUNDLED_SKILLS |
| 500 | .iter() |
| 501 | .any(|s| s.name == name && s.body == skill_md_content) |
| 502 | } |
| 503 | |
| 504 | /// Attempt to install a single bundled skill into `skills_dir`. |
| 505 | /// |
| 506 | /// Returns `true` if installation occurred (fresh install or version bump). |
| 507 | fn install_one( |
| 508 | skills_dir: &Path, |
| 509 | skill: &BundledSkill, |
| 510 | installed_version: Option<&str>, |
| 511 | ) -> std::io::Result<bool> { |
| 512 | let target_dir = skills_dir.join(skill.name); |
| 513 | let target_file = target_dir.join("SKILL.md"); |
| 514 | let dir_exists = target_dir.exists(); |
| 515 | let installed_number = installed_version.and_then(|value| value.parse::<u32>().ok()); |
| 516 | |
| 517 | let should_install = match (installed_version, installed_number, dir_exists) { |
| 518 | // Fresh install: neither marker nor directory. |
| 519 | (None, _, false) => true, |
| 520 | // Newly bundled skill: add it for older system-skill installs. |
| 521 | (Some(_), Some(version), _) if version < skill.introduced_in => true, |
| 522 | // Version bump for an existing skill: refresh only if the user has not |
| 523 | // intentionally deleted that skill directory. |
| 524 | (Some(version), _, true) if version != BUNDLED_SKILL_VERSION => true, |
| 525 | // Every other case: current install, user-deleted dir, or pre-existing |
| 526 | // user-owned skill without our marker. |
| 527 | _ => false, |
| 528 | }; |
| 529 | |
| 530 | if should_install { |
| 531 | // Never overwrite a user-modified copy that no longer matches a known |
| 532 | // shipped body (#4691 non-destructive upgrade table). A body we shipped |
| 533 | // in an earlier generation is still a known shipped body, so it may be |
| 534 | // refreshed; anything else is the user's. |
| 535 | if target_file.exists() { |
| 536 | let existing = fs::read_to_string(&target_file)?; |
| 537 | if existing != skill.body && !is_superseded_shipped_body(skill.name, &existing) { |
| 538 | // Preserve user/compatible-root content; skip replace-by-name. |
| 539 | return Ok(false); |
| 540 | } |
| 541 | } |
| 542 | fs::create_dir_all(&target_dir)?; |
| 543 | fs::write(&target_file, skill.body)?; |
| 544 | } |
| 545 | Ok(should_install) |
| 546 | } |
| 547 | |
| 548 | /// Install bundled system skills into `skills_dir`. |
| 549 | /// |
| 550 | /// Behaviour: |
| 551 | /// - Fresh install (no marker, no dir): installs every bundled skill, then |
| 552 | /// writes the version marker. |
| 553 | /// - Version bump (marker present with older version): re-installs any existing |
| 554 | /// bundled skill whose body is still one CodeWhale shipped (current or a |
| 555 | /// [`SUPERSEDED_BODIES`] entry) and installs newly introduced bundled skills. |
| 556 | /// A user-edited body is never replaced. |
| 557 | /// - User deleted a skill dir while marker still present at same version: leaves |
| 558 | /// it gone. |
| 559 | /// - Idempotent: calling twice with no changes is a no-op. |
| 560 | /// |
| 561 | /// Errors are I/O errors from the filesystem; the caller should log them but not |
| 562 | /// abort startup. |
| 563 | pub fn install_system_skills(skills_dir: &Path) -> std::io::Result<()> { |
| 564 | let marker = skills_dir.join(".system-installed-version"); |
| 565 | |
| 566 | // A marker can be left behind as an invalid file (or even as a directory |
| 567 | // after an interrupted/manual install). Treat it as an untrusted marker, |
| 568 | // but still repair it after reconciling the bundled skills. This keeps |
| 569 | // user-edited skill bodies intact while allowing missing skills to be |
| 570 | // restored and future upgrades to be versioned again. |
| 571 | let (installed_version, repair_marker) = match fs::read_to_string(&marker) { |
| 572 | Ok(contents) => match contents.trim().parse::<u32>() { |
| 573 | Ok(_) => (Some(contents.trim().to_string()), false), |
| 574 | Err(_) => (None, true), |
| 575 | }, |
| 576 | Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, false), |
| 577 | Err(_) => (None, true), |
| 578 | }; |
| 579 | |
| 580 | let mut changed = false; |
| 581 | for skill in BUNDLED_SKILLS { |
| 582 | changed |= install_one(skills_dir, skill, installed_version.as_deref())?; |
| 583 | } |
| 584 | |
| 585 | // Safe retirement: remove only an unchanged CodeWhale-owned v4-best-practices. |
| 586 | changed |= retire_unchanged_v4_best_practices(skills_dir)?; |
| 587 | |
| 588 | // Feishu is optional: do not install for every user. If an older bundle |
| 589 | // installed an exact shipped copy, leave it; never delete by name alone. |
| 590 | let _ = feishu_body(); |
| 591 | |
| 592 | // Contributor-onboarding is repo-local since generation 12: do not install |
| 593 | // for new users. An older bundle's installed copy is left in place, same as |
| 594 | // Feishu above — never delete by name alone. |
| 595 | let _ = contributor_onboarding_body(); |
| 596 | |
| 597 | // Feedback is repo-local since generation 13: same leave-in-place rule. |
| 598 | let _ = feedback_body(); |
| 599 | |
| 600 | if changed || repair_marker { |
| 601 | fs::create_dir_all(skills_dir)?; |
| 602 | if marker.exists() && !marker.is_file() { |
| 603 | if marker.is_dir() { |
| 604 | fs::remove_dir_all(&marker)?; |
| 605 | } else { |
| 606 | fs::remove_file(&marker)?; |
| 607 | } |
| 608 | } |
| 609 | write_marker_atomically(&marker, BUNDLED_SKILL_VERSION)?; |
| 610 | } |
| 611 | Ok(()) |
| 612 | } |
| 613 | |
| 614 | /// Delete `v4-best-practices` only when the installed SKILL.md exactly matches |
| 615 | /// the last shipped bundled body (byte-for-byte). Modified or user-owned copies |
| 616 | /// are preserved. |
| 617 | fn retire_unchanged_v4_best_practices(skills_dir: &Path) -> std::io::Result<bool> { |
| 618 | let dir = skills_dir.join("v4-best-practices"); |
| 619 | let file = dir.join("SKILL.md"); |
| 620 | if !file.exists() { |
| 621 | return Ok(false); |
| 622 | } |
| 623 | let existing = fs::read_to_string(&file)?; |
| 624 | if existing != v4_best_practices_body() { |
| 625 | return Ok(false); |
| 626 | } |
| 627 | fs::remove_dir_all(&dir)?; |
| 628 | Ok(true) |
| 629 | } |
| 630 | |
| 631 | fn write_marker_atomically(marker: &Path, version: &str) -> std::io::Result<()> { |
| 632 | let parent = marker |
| 633 | .parent() |
| 634 | .expect("skill version marker should have a parent directory"); |
| 635 | let mut temporary = tempfile::NamedTempFile::new_in(parent)?; |
| 636 | temporary.write_all(version.as_bytes())?; |
| 637 | temporary.as_file().sync_all()?; |
| 638 | // `rename` atomically replaces a file on Unix. Windows refuses to replace |
| 639 | // an existing destination, so remove only this reserved marker first. |
| 640 | #[cfg(windows)] |
| 641 | if marker.exists() { |
| 642 | fs::remove_file(marker)?; |
| 643 | } |
| 644 | fs::rename(temporary.path(), marker) |
| 645 | } |
| 646 | |
| 647 | #[cfg(test)] |
| 648 | mod tests; |
| 649 |