@etus/seven-skill 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude/skills/seven/SKILL.md +162 -0
  2. package/.claude/skills/seven/reference/audit.md +120 -0
  3. package/.claude/skills/seven/reference/brand.md +24 -0
  4. package/.claude/skills/seven/reference/clarify.md +76 -0
  5. package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
  6. package/.claude/skills/seven/reference/motion-design.md +71 -0
  7. package/.claude/skills/seven/reference/polish.md +55 -0
  8. package/.claude/skills/seven/reference/product.md +45 -0
  9. package/.claude/skills/seven/reference/shape.md +85 -0
  10. package/.claude/skills/seven/reference/spatial-design.md +60 -0
  11. package/.claude/skills/seven/reference/typography.md +43 -0
  12. package/.claude/skills/seven/reference/ux-writing.md +47 -0
  13. package/.claude/skills/seven/scripts/load-context.mjs +84 -0
  14. package/.claude-plugin/marketplace.json +34 -0
  15. package/.claude-plugin/plugin.json +12 -0
  16. package/LICENSE +190 -0
  17. package/NOTICE.md +26 -0
  18. package/README.md +118 -0
  19. package/cli/bin/commands/skills.mjs +664 -0
  20. package/cli/bin/seven.mjs +68 -0
  21. package/cli/engine/browser/injected/index.mjs +84 -0
  22. package/cli/engine/cli/main.mjs +215 -0
  23. package/cli/engine/detect-antipatterns-browser.js +3014 -0
  24. package/cli/engine/detect-antipatterns.mjs +44 -0
  25. package/cli/engine/engines/browser/detect-url.mjs +108 -0
  26. package/cli/engine/engines/regex/detect-text.mjs +508 -0
  27. package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
  28. package/cli/engine/engines/static-html/detect-html.mjs +211 -0
  29. package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
  30. package/cli/engine/findings.mjs +28 -0
  31. package/cli/engine/node/file-system.mjs +212 -0
  32. package/cli/engine/profile/profiler.mjs +169 -0
  33. package/cli/engine/registry/seven-antipatterns.mjs +494 -0
  34. package/cli/engine/rules/checks.mjs +1518 -0
  35. package/cli/engine/shared/color.mjs +204 -0
  36. package/cli/engine/shared/constants.mjs +91 -0
  37. package/cli/engine/shared/page.mjs +9 -0
  38. package/cli/engine/shared/tokens.mjs +153 -0
  39. package/cli/engine/token-data.generated.mjs +691 -0
  40. package/docs/detector-rules.md +605 -0
  41. package/docs/figma-token-rule-exploration.md +185 -0
  42. package/package.json +76 -0
@@ -0,0 +1,664 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ /**
6
+ * `seven skills` subcommand
7
+ *
8
+ * Usage:
9
+ * seven skills help Show all available skills and commands
10
+ * seven skills install Install the Seven skill into provider folders
11
+ * seven skills update Re-sync the Seven skill to the bundled version
12
+ * seven skills check Report whether the installed skill is current
13
+ *
14
+ * Seven ships its built skill INSIDE this npm package (`@etus/seven-skill`). The
15
+ * Claude Code skill lives at the package root in `.claude/skills/seven/`.
16
+ * There is no remote download — `install`/`update` copy that local source
17
+ * into the consumer project's provider directories.
18
+ */
19
+
20
+ import { execSync } from 'node:child_process';
21
+ import { existsSync, readFileSync, readdirSync, statSync, lstatSync, symlinkSync, readlinkSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, realpathSync } from 'node:fs';
22
+ import { join, dirname } from 'node:path';
23
+ import { createInterface } from 'node:readline';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { createHash } from 'node:crypto';
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ // cli/bin/commands -> up 3 -> package root, where the built skill is tracked.
29
+ const PACKAGE_ROOT = join(__dirname, '..', '..', '..');
30
+
31
+ // Provider folder names in project roots
32
+ const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.github', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn'];
33
+
34
+ function ask(question) {
35
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
36
+ return new Promise(r => rl.question(question, ans => { rl.close(); r(ans.trim().toLowerCase()); }));
37
+ }
38
+
39
+ /** Read the `version` field from the package's own package.json. */
40
+ function getPackageVersion() {
41
+ try {
42
+ return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
43
+ } catch {
44
+ return '0.0.0';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Provider directories that the package actually ships a built skill in.
50
+ * Seven currently only builds the `.claude` skill; the others are skipped
51
+ * gracefully until Phase 2 targets those harnesses.
52
+ */
53
+ function getBundledProviders() {
54
+ const found = [];
55
+ for (const d of PROVIDER_DIRS) {
56
+ if (existsSync(join(PACKAGE_ROOT, d, 'skills'))) found.push(d);
57
+ }
58
+ return found;
59
+ }
60
+
61
+ // ─── skills help ──────────────────────────────────────────────────────────────
62
+
63
+ function showHelp() {
64
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
65
+ const version = getPackageVersion();
66
+
67
+ // Seven ships one user-invocable skill (`seven`) with four sub-commands.
68
+ const commands = [
69
+ { id: 'seven shape', description: 'Plan the UX shape before writing component code' },
70
+ { id: 'seven audit', description: 'Five-dimension scan: a11y, perf, theming, responsive, anti-patterns' },
71
+ { id: 'seven polish', description: 'Final-pass cleanup aligned to Seven tokens' },
72
+ { id: 'seven clarify', description: 'Rewrite UX copy in Brazilian Portuguese' },
73
+ ];
74
+
75
+ console.log('\n Seven Skills & Commands\n');
76
+ console.log(' Install: npx seven skills install');
77
+ console.log(' Update: npx seven skills update');
78
+ console.log(' Plugin: claude plugin install @etus/seven-skill\n');
79
+ console.log(` ${pad('Command', 22)} Description`);
80
+ console.log(` ${'-'.repeat(22)} ${'-'.repeat(52)}`);
81
+
82
+ for (const cmd of commands) {
83
+ const desc = cmd.description.length > 72
84
+ ? cmd.description.substring(0, 69) + '...'
85
+ : cmd.description;
86
+ console.log(` ${pad('/' + cmd.id, 22)} ${desc}`);
87
+ }
88
+ console.log(`\n ${commands.length} commands available (skill v${version}). Run /<command> in your AI harness.\n`);
89
+ }
90
+
91
+ // ─── version helpers ─────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Read the installed skills version from the Seven SKILL.md frontmatter
95
+ * inside the consumer project (NOT the package's package.json — that's the
96
+ * available version, this is what's actually on disk).
97
+ */
98
+ function getSkillsVersion(root) {
99
+ for (const d of PROVIDER_DIRS) {
100
+ const skillMd = join(root, d, 'skills', 'seven', 'SKILL.md');
101
+ if (!existsSync(skillMd)) continue;
102
+ const content = readFileSync(skillMd, 'utf-8');
103
+ const match = content.match(/^version:\s*(.+)$/m);
104
+ if (match) return match[1].trim().replace(/^["']|["']$/g, '');
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Hash all SKILL.md files in a directory tree for comparison.
111
+ * Returns a sorted string of "name:hash" pairs.
112
+ */
113
+ function hashSkillsDir(skillsDir) {
114
+ if (!existsSync(skillsDir)) return '';
115
+ const entries = [];
116
+ for (const name of readdirSync(skillsDir).sort()) {
117
+ const skillMd = join(skillsDir, name, 'SKILL.md');
118
+ if (!existsSync(skillMd)) continue;
119
+ const hash = createHash('sha256').update(readFileSync(skillMd)).digest('hex').slice(0, 12);
120
+ entries.push(`${name}:${hash}`);
121
+ }
122
+ return entries.join(',');
123
+ }
124
+
125
+ /**
126
+ * Normalize a SKILL.md's content for comparison by stripping
127
+ * provider-specific paths. Different install targets resolve
128
+ * {{scripts_path}} to different provider dirs (e.g. .agents vs .claude),
129
+ * so we strip those differences.
130
+ */
131
+ function normalizeForHash(content) {
132
+ return content
133
+ .replace(/\.(claude|cursor|agents|github|gemini|kiro|opencode|pi|qoder|trae|trae-cn)\/skills\//g, '.PROVIDER/skills/')
134
+ .replace(/^version:\s*.+$/m, 'version: NORMALIZED');
135
+ }
136
+
137
+ /**
138
+ * Deduplicate providers by resolved path. When .claude/skills is a
139
+ * symlink to ../.agents/skills, both resolve to the same directory.
140
+ * Returns an array of { provider, localSkillsDir } with one entry
141
+ * per unique real path. The first provider that maps to a real path
142
+ * wins (so the comparison uses that provider's build).
143
+ */
144
+ function deduplicateProviders(root, providers) {
145
+ const seen = new Map(); // realPath -> { provider, localSkillsDir }
146
+ for (const provider of providers) {
147
+ const skillsDir = join(root, provider, 'skills');
148
+ if (!existsSync(skillsDir)) continue;
149
+ const real = realpathSync(skillsDir);
150
+ if (!seen.has(real)) {
151
+ seen.set(real, { provider, localSkillsDir: skillsDir });
152
+ }
153
+ }
154
+ return [...seen.values()];
155
+ }
156
+
157
+ /**
158
+ * Compare the consumer's installed skills against the package's bundled
159
+ * skills. Only checks skills the package ships (ignores the user's custom
160
+ * skills). Deduplicates providers that share the same real path (symlinks).
161
+ * Normalizes provider-specific paths and version fields before comparing.
162
+ * Returns true if every bundled skill matches the local copy.
163
+ */
164
+ function isUpToDate(root, providers) {
165
+ const unique = deduplicateProviders(root, providers);
166
+ if (unique.length === 0) return false;
167
+
168
+ for (const { provider, localSkillsDir } of unique) {
169
+ // Fall back to .claude if this provider isn't bundled (Seven only
170
+ // builds .claude today, but consumers may have installed it elsewhere).
171
+ let bundleSkillsDir = join(PACKAGE_ROOT, provider, 'skills');
172
+ if (!existsSync(bundleSkillsDir)) {
173
+ bundleSkillsDir = join(PACKAGE_ROOT, '.claude', 'skills');
174
+ }
175
+ if (!existsSync(bundleSkillsDir)) continue;
176
+
177
+ for (const name of readdirSync(bundleSkillsDir)) {
178
+ const bundleMd = join(bundleSkillsDir, name, 'SKILL.md');
179
+ const localMd = join(localSkillsDir, name, 'SKILL.md');
180
+ if (!existsSync(bundleMd)) continue;
181
+ if (!existsSync(localMd)) return false;
182
+
183
+ const bundleHash = createHash('sha256').update(normalizeForHash(readFileSync(bundleMd, 'utf-8'))).digest('hex');
184
+ const localHash = createHash('sha256').update(normalizeForHash(readFileSync(localMd, 'utf-8'))).digest('hex');
185
+ if (bundleHash !== localHash) return false;
186
+ }
187
+ }
188
+ return true;
189
+ }
190
+
191
+ // ─── skills check ────────────────────────────────────────────────────────────
192
+
193
+ async function check() {
194
+ const root = findProjectRoot();
195
+ const installed = isAlreadyInstalled(root);
196
+
197
+ if (!installed) {
198
+ console.log('Seven is not installed in this project.');
199
+ console.log('Run `npx seven skills install` to install.');
200
+ process.exit(0);
201
+ }
202
+
203
+ const providers = findInstalledProviders(root);
204
+
205
+ console.log('Checking for updates...\n');
206
+ try {
207
+ const upToDate = isUpToDate(root, providers);
208
+
209
+ if (upToDate) {
210
+ const v = getSkillsVersion(root);
211
+ console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
212
+ } else {
213
+ console.log(`Updates available (package ships v${getPackageVersion()}).`);
214
+ console.log('Run `npx seven skills update` to update.');
215
+ }
216
+ } catch (e) {
217
+ console.error(`Could not check for updates: ${e.message}`);
218
+ process.exit(1);
219
+ }
220
+ }
221
+
222
+ // ─── skills install ───────────────────────────────────────────────────────────
223
+
224
+ // Check if Seven skills are already present in any provider folder
225
+ function isAlreadyInstalled(root) {
226
+ for (const d of PROVIDER_DIRS) {
227
+ const skillsDir = join(root, d, 'skills');
228
+ if (!existsSync(skillsDir)) continue;
229
+ try {
230
+ const entries = readdirSync(skillsDir);
231
+ // Look for the 'seven' skill (or a prefixed variant)
232
+ if (entries.some(e => e === 'seven' || e.endsWith('-seven'))) {
233
+ return d;
234
+ }
235
+ } catch {}
236
+ }
237
+ return null;
238
+ }
239
+
240
+ function escapeRegex(str) {
241
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
242
+ }
243
+
244
+ function prefixSkillContent(content, prefix, allSkillNames) {
245
+ // Prefix the name in frontmatter
246
+ let result = content.replace(/^name:\s*(.+)$/m, (_, name) => `name: ${prefix}${name.trim()}`);
247
+
248
+ // Prefix cross-references: /skillname -> /prefix-skillname
249
+ const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
250
+ for (const name of sorted) {
251
+ // Command invocations: /skillname
252
+ result = result.replace(
253
+ new RegExp(`/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
254
+ `/${prefix}`
255
+ );
256
+ // Prose references: "the skillname skill"
257
+ result = result.replace(
258
+ new RegExp(`(the) ${escapeRegex(name)} skill`, 'gi'),
259
+ (_, article) => `${article} ${prefix}${name} skill`
260
+ );
261
+ }
262
+ return result;
263
+ }
264
+
265
+ function isSkillDir(skillsDir, name) {
266
+ // Skill entries can be real directories or symlinks to directories
267
+ const full = join(skillsDir, name);
268
+ try {
269
+ return statSync(full).isDirectory() && existsSync(join(full, 'SKILL.md'));
270
+ } catch { return false; }
271
+ }
272
+
273
+ function isRealSkillDir(skillsDir, name) {
274
+ // Only real directories, not symlinks -- renaming the real dir renames the symlink targets too
275
+ const full = join(skillsDir, name);
276
+ try {
277
+ const lstat = lstatSync(full);
278
+ return lstat.isDirectory() && !lstat.isSymbolicLink() && existsSync(join(full, 'SKILL.md'));
279
+ } catch { return false; }
280
+ }
281
+
282
+ function renameSkillsWithPrefix(root, prefix) {
283
+ // First pass: collect all skill names across all providers (use first provider found)
284
+ let allSkillNames = [];
285
+ for (const d of PROVIDER_DIRS) {
286
+ const skillsDir = join(root, d, 'skills');
287
+ if (!existsSync(skillsDir)) continue;
288
+ const entries = readdirSync(skillsDir);
289
+ allSkillNames = entries.filter(name => isSkillDir(skillsDir, name));
290
+ if (allSkillNames.length > 0) break;
291
+ }
292
+
293
+ // Second pass: rename real dirs and update their content
294
+ let count = 0;
295
+ for (const d of PROVIDER_DIRS) {
296
+ const skillsDir = join(root, d, 'skills');
297
+ if (!existsSync(skillsDir)) continue;
298
+ try {
299
+ const entries = readdirSync(skillsDir);
300
+ for (const name of entries) {
301
+ if (name.startsWith(prefix)) continue;
302
+ if (!isRealSkillDir(skillsDir, name)) continue;
303
+
304
+ const src = join(skillsDir, name);
305
+ const dest = join(skillsDir, prefix + name);
306
+
307
+ renameSync(src, dest);
308
+
309
+ // Prefix frontmatter name + all cross-references in SKILL.md
310
+ let content = readFileSync(join(dest, 'SKILL.md'), 'utf8');
311
+ content = prefixSkillContent(content, prefix, allSkillNames);
312
+ writeFileSync(join(dest, 'SKILL.md'), content);
313
+ count++;
314
+ }
315
+ } catch {}
316
+ }
317
+
318
+ // Third pass: fix symlinks that now point to renamed targets
319
+ for (const d of PROVIDER_DIRS) {
320
+ const skillsDir = join(root, d, 'skills');
321
+ if (!existsSync(skillsDir)) continue;
322
+ try {
323
+ const entries = readdirSync(skillsDir);
324
+ for (const name of entries) {
325
+ if (name.startsWith(prefix)) continue;
326
+ const full = join(skillsDir, name);
327
+ try {
328
+ if (!lstatSync(full).isSymbolicLink()) continue;
329
+ const target = readlinkSync(full);
330
+ const newTarget = target.replace(new RegExp(`/${escapeRegex(name)}$`), `/${prefix}${name}`);
331
+ unlinkSync(full);
332
+ symlinkSync(newTarget, join(skillsDir, prefix + name));
333
+ } catch {}
334
+ }
335
+ } catch {}
336
+ }
337
+
338
+ return count;
339
+ }
340
+
341
+ /**
342
+ * Copy the package's bundled skills into a consumer provider directory.
343
+ * Seven only builds the `.claude` skill today; providers without a bundled
344
+ * build are skipped gracefully. Returns the number of skills installed.
345
+ */
346
+ function installBundledSkills(root) {
347
+ const bundledProviders = getBundledProviders();
348
+ if (bundledProviders.length === 0) {
349
+ console.error('No built Seven skill found in this package.');
350
+ console.error('Run `pnpm --filter @etus/seven-skill build` first.');
351
+ process.exit(1);
352
+ }
353
+
354
+ let installed = 0;
355
+ for (const provider of bundledProviders) {
356
+ const srcDir = join(PACKAGE_ROOT, provider, 'skills');
357
+ const destDir = join(root, provider, 'skills');
358
+ const skills = readdirSync(srcDir, { withFileTypes: true });
359
+ for (const skill of skills) {
360
+ if (!skill.isDirectory()) continue;
361
+ const src = join(srcDir, skill.name);
362
+ if (!existsSync(join(src, 'SKILL.md'))) continue;
363
+ const dest = join(destDir, skill.name);
364
+ if (existsSync(dest)) rmSync(dest, { recursive: true });
365
+ copyDirSync(src, dest);
366
+ installed++;
367
+ }
368
+ }
369
+ return installed;
370
+ }
371
+
372
+ async function install(flags) {
373
+ const force = flags.includes('--force');
374
+ const yes = flags.includes('-y') || flags.includes('--yes');
375
+ const prefixFlag = flags.find(f => f.startsWith('--prefix='));
376
+ const root = findProjectRoot();
377
+ const existing = isAlreadyInstalled(root);
378
+
379
+ if (existing && !force) {
380
+ console.log(`Seven skills are already installed (found in ${existing}/).`);
381
+ console.log('Run with --force to reinstall.\n');
382
+ process.exit(0);
383
+ }
384
+
385
+ console.log('Installing Seven skills...\n');
386
+ let installed;
387
+ try {
388
+ installed = installBundledSkills(root);
389
+ } catch (e) {
390
+ console.error(`Install failed: ${e.message}`);
391
+ process.exit(1);
392
+ }
393
+
394
+ console.log(`Installed ${installed} skill(s) (v${getPackageVersion()}).`);
395
+
396
+ // Ask about prefixing (skip in CI mode unless --prefix= is set)
397
+ let prefix = '';
398
+ if (prefixFlag) {
399
+ prefix = prefixFlag.split('=')[1] || 's-';
400
+ } else if (!yes) {
401
+ console.log();
402
+ const wantPrefix = await ask('Prefix commands to avoid conflicts? e.g. /s-audit instead of /audit (y/N) ');
403
+ if (wantPrefix === 'y' || wantPrefix === 'yes') {
404
+ const custom = await ask('Prefix (default: s-): ');
405
+ prefix = custom || 's-';
406
+ }
407
+ }
408
+
409
+ if (prefix) {
410
+ const count = renameSkillsWithPrefix(root, prefix);
411
+ if (count > 0) {
412
+ console.log(`\nRenamed ${count} skills with "${prefix}" prefix.`);
413
+ console.log(`Commands are now available as /${prefix}<command> (e.g. /${prefix}audit).`);
414
+ }
415
+ }
416
+
417
+ // Clean up deprecated skills from previous versions
418
+ try {
419
+ const { cleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
420
+ const result = cleanup(root);
421
+ const total = result.deletedPaths.length + result.removedLockEntries.length;
422
+ if (total > 0) {
423
+ console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.`);
424
+ }
425
+ } catch {
426
+ // Cleanup script not available -- skip
427
+ }
428
+
429
+ console.log(`\nDone! Run /${prefix}seven shape in your AI harness to plan your first component.\n`);
430
+ }
431
+
432
+ /** Detect prefix by looking for the 'seven' skill */
433
+ function detectPrefix(root) {
434
+ for (const d of PROVIDER_DIRS) {
435
+ const skillsDir = join(root, d, 'skills');
436
+ if (!existsSync(skillsDir)) continue;
437
+ for (const name of readdirSync(skillsDir)) {
438
+ if (name === 'seven') return '';
439
+ if (name.endsWith('-seven')) return name.slice(0, -'seven'.length);
440
+ }
441
+ }
442
+ return '';
443
+ }
444
+
445
+ /** Undo prefixing: rename folders back and strip prefix from SKILL.md content */
446
+ function undoPrefix(root, prefix) {
447
+ if (!prefix) return;
448
+ // Collect the unprefixed names (strip our prefix)
449
+ let allPrefixedNames = [];
450
+ for (const d of PROVIDER_DIRS) {
451
+ const skillsDir = join(root, d, 'skills');
452
+ if (!existsSync(skillsDir)) continue;
453
+ allPrefixedNames = readdirSync(skillsDir).filter(n => n.startsWith(prefix) && isRealSkillDir(skillsDir, n));
454
+ if (allPrefixedNames.length > 0) break;
455
+ }
456
+ const unprefixedNames = allPrefixedNames.map(n => n.slice(prefix.length));
457
+
458
+ for (const d of PROVIDER_DIRS) {
459
+ const skillsDir = join(root, d, 'skills');
460
+ if (!existsSync(skillsDir)) continue;
461
+ for (const name of readdirSync(skillsDir)) {
462
+ if (!name.startsWith(prefix)) continue;
463
+ const unprefixed = name.slice(prefix.length);
464
+ const src = join(skillsDir, name);
465
+ const dest = join(skillsDir, unprefixed);
466
+
467
+ if (lstatSync(src).isSymbolicLink()) {
468
+ const target = readlinkSync(src);
469
+ const newTarget = target.replace(`/${name}`, `/${unprefixed}`);
470
+ unlinkSync(src);
471
+ symlinkSync(newTarget, dest);
472
+ } else {
473
+ renameSync(src, dest);
474
+ // Strip prefix from SKILL.md content
475
+ const skillMd = join(dest, 'SKILL.md');
476
+ if (existsSync(skillMd)) {
477
+ let content = readFileSync(skillMd, 'utf8');
478
+ // Reverse the prefixing: replace prefixed names with unprefixed
479
+ content = content.replace(new RegExp(`^name:\\s*${escapeRegex(prefix)}`, 'm'), 'name: ');
480
+ const sorted = [...allPrefixedNames].sort((a, b) => b.length - a.length);
481
+ for (const pName of sorted) {
482
+ const uName = pName.slice(prefix.length);
483
+ content = content.replace(new RegExp(`/${escapeRegex(pName)}(?=[^a-zA-Z0-9_-]|$)`, 'g'), `/${uName}`);
484
+ content = content.replace(new RegExp(`(the) ${escapeRegex(pName)} skill`, 'gi'), `$1 ${uName} skill`);
485
+ }
486
+ writeFileSync(skillMd, content);
487
+ }
488
+ }
489
+ }
490
+ }
491
+ }
492
+
493
+ // ─── skills update ────────────────────────────────────────────────────────────
494
+
495
+ function findProjectRoot() {
496
+ let dir = process.cwd();
497
+ while (dir !== dirname(dir)) {
498
+ if (existsSync(join(dir, '.git'))) return dir;
499
+ dir = dirname(dir);
500
+ }
501
+ return process.cwd();
502
+ }
503
+
504
+ function findInstalledProviders(root) {
505
+ const found = [];
506
+ for (const d of PROVIDER_DIRS) {
507
+ const skillsDir = join(root, d, 'skills');
508
+ if (!existsSync(skillsDir)) continue;
509
+ try {
510
+ const entries = readdirSync(skillsDir);
511
+ if (entries.some(name => isSkillDir(skillsDir, name))) found.push(d);
512
+ } catch {}
513
+ }
514
+ return found;
515
+ }
516
+
517
+ function getModifiedSkillFiles(root, providerDirs) {
518
+ // Use git to check if any skill files have local modifications
519
+ const modified = [];
520
+ try {
521
+ const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf8' });
522
+ for (const line of status.split('\n')) {
523
+ if (!line.trim()) continue;
524
+ const file = line.substring(3);
525
+ for (const d of providerDirs) {
526
+ if (file.startsWith(`${d}/skills/`)) {
527
+ const flag = line.substring(0, 2).trim();
528
+ modified.push({ file, flag });
529
+ }
530
+ }
531
+ }
532
+ } catch {
533
+ // Not a git repo or git not available
534
+ }
535
+ return modified;
536
+ }
537
+
538
+ async function update(flags = []) {
539
+ const yes = flags.includes('-y') || flags.includes('--yes');
540
+
541
+ // Clean up deprecated skills from previous versions.
542
+ try {
543
+ const { cleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
544
+ const root = findProjectRoot();
545
+ const result = cleanup(root);
546
+ const total = result.deletedPaths.length + result.removedLockEntries.length;
547
+ if (total > 0) {
548
+ console.log(`Cleaned up ${total} deprecated skill(s) from previous versions.\n`);
549
+ }
550
+ } catch {
551
+ // Cleanup script not available (e.g. script not part of this package) -- skip
552
+ }
553
+
554
+ // Re-sync the skill from the package's own bundled copy. There is no
555
+ // remote download — Seven ships the built skill inside @etus/seven-skill.
556
+ const root = findProjectRoot();
557
+ const providers = findInstalledProviders(root);
558
+
559
+ if (providers.length === 0) {
560
+ console.log('No Seven skill folders found in this project.');
561
+ console.log('Run `npx seven skills install` to install first.');
562
+ process.exit(1);
563
+ }
564
+
565
+ console.log('Checking for updates...');
566
+
567
+ // Compare local vs bundled -- skip if already up to date
568
+ if (isUpToDate(root, providers)) {
569
+ const v = getSkillsVersion(root);
570
+ console.log(`Skills are up to date${v ? ` (v${v})` : ''}. Nothing to do.`);
571
+ process.exit(0);
572
+ }
573
+
574
+ console.log(`Found skills in: ${providers.join(', ')}`);
575
+
576
+ if (!yes) {
577
+ const ans = await ask(`Update skills in ${providers.length} provider folder(s)? (Y/n) `);
578
+ if (ans === 'n' || ans === 'no') {
579
+ console.log('Aborted.');
580
+ process.exit(0);
581
+ }
582
+ }
583
+
584
+ try {
585
+ // Copy from the package's bundled skill to each unique provider folder.
586
+ // Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills)
587
+ // are only written once with the correct provider's content.
588
+ const unique = deduplicateProviders(root, providers);
589
+ let updated = 0;
590
+ for (const { provider, localSkillsDir } of unique) {
591
+ // Use the provider's own bundled build if it exists; otherwise fall
592
+ // back to .claude (the only build Seven currently ships).
593
+ let srcDir = join(PACKAGE_ROOT, provider, 'skills');
594
+ if (!existsSync(srcDir)) srcDir = join(PACKAGE_ROOT, '.claude', 'skills');
595
+ if (!existsSync(srcDir)) continue;
596
+
597
+ const skills = readdirSync(srcDir, { withFileTypes: true });
598
+ for (const skill of skills) {
599
+ if (!skill.isDirectory()) continue;
600
+ const src = join(srcDir, skill.name);
601
+ if (!existsSync(join(src, 'SKILL.md'))) continue;
602
+ const dest = join(localSkillsDir, skill.name);
603
+ if (existsSync(dest)) rmSync(dest, { recursive: true });
604
+ copyDirSync(src, dest);
605
+ updated++;
606
+ }
607
+ }
608
+
609
+ // Re-apply prefix if detected
610
+ const prefix = detectPrefix(root);
611
+ if (prefix) {
612
+ const count = renameSkillsWithPrefix(root, prefix);
613
+ if (count > 0) console.log(`Re-applied "${prefix}" prefix to ${count} skills.`);
614
+ }
615
+
616
+ // Run cleanup to remove deprecated stubs from the fresh copy
617
+ try {
618
+ const { cleanup: postCleanup } = await import('../../../skill/scripts/cleanup-deprecated.mjs');
619
+ postCleanup(root);
620
+ } catch {
621
+ // Not available -- skip
622
+ }
623
+
624
+ const v = getSkillsVersion(root);
625
+ console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
626
+ console.log('Done!\n');
627
+ } catch (e) {
628
+ console.error(`Update failed: ${e.message}`);
629
+ process.exit(1);
630
+ }
631
+ }
632
+
633
+ function copyDirSync(src, dest) {
634
+ mkdirSync(dest, { recursive: true });
635
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
636
+ const s = join(src, entry.name);
637
+ const d = join(dest, entry.name);
638
+ if (entry.isDirectory()) {
639
+ copyDirSync(s, d);
640
+ } else {
641
+ writeFileSync(d, readFileSync(s));
642
+ }
643
+ }
644
+ }
645
+
646
+ // ─── Router ───────────────────────────────────────────────────────────────────
647
+
648
+ export async function run(args) {
649
+ const sub = args[0];
650
+
651
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
652
+ showHelp();
653
+ } else if (sub === 'install') {
654
+ await install(args.slice(1));
655
+ } else if (sub === 'update') {
656
+ await update(args.slice(1));
657
+ } else if (sub === 'check') {
658
+ await check();
659
+ } else {
660
+ console.error(`Unknown skills command: ${sub}`);
661
+ console.error(`Run 'seven skills --help' for available commands.`);
662
+ process.exit(1);
663
+ }
664
+ }