返回 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 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
649 lines RUST