@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.
- package/.claude/skills/seven/SKILL.md +162 -0
- package/.claude/skills/seven/reference/audit.md +120 -0
- package/.claude/skills/seven/reference/brand.md +24 -0
- package/.claude/skills/seven/reference/clarify.md +76 -0
- package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
- package/.claude/skills/seven/reference/motion-design.md +71 -0
- package/.claude/skills/seven/reference/polish.md +55 -0
- package/.claude/skills/seven/reference/product.md +45 -0
- package/.claude/skills/seven/reference/shape.md +85 -0
- package/.claude/skills/seven/reference/spatial-design.md +60 -0
- package/.claude/skills/seven/reference/typography.md +43 -0
- package/.claude/skills/seven/reference/ux-writing.md +47 -0
- package/.claude/skills/seven/scripts/load-context.mjs +84 -0
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +190 -0
- package/NOTICE.md +26 -0
- package/README.md +118 -0
- package/cli/bin/commands/skills.mjs +664 -0
- package/cli/bin/seven.mjs +68 -0
- package/cli/engine/browser/injected/index.mjs +84 -0
- package/cli/engine/cli/main.mjs +215 -0
- package/cli/engine/detect-antipatterns-browser.js +3014 -0
- package/cli/engine/detect-antipatterns.mjs +44 -0
- package/cli/engine/engines/browser/detect-url.mjs +108 -0
- package/cli/engine/engines/regex/detect-text.mjs +508 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
- package/cli/engine/engines/static-html/detect-html.mjs +211 -0
- package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
- package/cli/engine/findings.mjs +28 -0
- package/cli/engine/node/file-system.mjs +212 -0
- package/cli/engine/profile/profiler.mjs +169 -0
- package/cli/engine/registry/seven-antipatterns.mjs +494 -0
- package/cli/engine/rules/checks.mjs +1518 -0
- package/cli/engine/shared/color.mjs +204 -0
- package/cli/engine/shared/constants.mjs +91 -0
- package/cli/engine/shared/page.mjs +9 -0
- package/cli/engine/shared/tokens.mjs +153 -0
- package/cli/engine/token-data.generated.mjs +691 -0
- package/docs/detector-rules.md +605 -0
- package/docs/figma-token-rule-exploration.md +185 -0
- 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
|
+
}
|