返回 CodeWhale
system.rs
根目录 / crates / tui / src / skills / system.rs
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
673 lines RUST