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