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