@culeo/specx 0.1.0
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/README.md +154 -0
- package/package.json +30 -0
- package/skills/specx-archive/SKILL.md +235 -0
- package/skills/specx-clarify/SKILL.md +581 -0
- package/skills/specx-create-design-template/SKILL.md +262 -0
- package/skills/specx-create-rule/SKILL.md +174 -0
- package/skills/specx-demystify/SKILL.md +180 -0
- package/skills/specx-design/SKILL.md +126 -0
- package/skills/specx-docs-align/SKILL.md +225 -0
- package/skills/specx-executing-plans/SKILL.md +83 -0
- package/skills/specx-writing-plans/SKILL.md +183 -0
- package/specx.js +385 -0
package/specx.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import readline from "readline";
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
// ─── Paths ───────────────────────────────────────────────────────
|
|
14
|
+
const SPECX_HOME = process.env.SPECX_HOME || path.resolve(__dirname);
|
|
15
|
+
const SKILLS_DIR = path.join(SPECX_HOME, "skills");
|
|
16
|
+
|
|
17
|
+
// ─── Registered CLI Targets ──────────────────────────────────────
|
|
18
|
+
const CLI_TARGETS = [
|
|
19
|
+
{
|
|
20
|
+
id: "claude",
|
|
21
|
+
label: "Claude Code",
|
|
22
|
+
dir: () => path.join(process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude"), "skills"),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "codex",
|
|
26
|
+
label: "Codex",
|
|
27
|
+
dir: () => path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "skills"),
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// ─── Interactive Multi-Select ────────────────────────────────────
|
|
32
|
+
function isTTY() {
|
|
33
|
+
return process.stdin.isTTY;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function interactiveSelect(items, prompt, preSelected, showStatus) {
|
|
37
|
+
let cursor = 0;
|
|
38
|
+
const selected = new Set(preSelected);
|
|
39
|
+
|
|
40
|
+
const render = () => {
|
|
41
|
+
const lines = [];
|
|
42
|
+
lines.push(`\n${prompt}\n`);
|
|
43
|
+
for (let i = 0; i < items.length; i++) {
|
|
44
|
+
const isSel = selected.has(items[i].id);
|
|
45
|
+
const isCur = i === cursor;
|
|
46
|
+
const status = showStatus ? (items[i].detected ? "" : " (not detected)") : "";
|
|
47
|
+
lines.push(`${isCur ? "›" : " "} ${isSel ? "✓" : "○"} ${items[i].label}${status}`);
|
|
48
|
+
}
|
|
49
|
+
lines.push("\n ↑↓ navigate · space toggle · enter confirm\n");
|
|
50
|
+
return lines;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const clearScreen = (lines) => {
|
|
54
|
+
readline.cursorTo(process.stdout, 0);
|
|
55
|
+
readline.moveCursor(process.stdout, 0, -(lines.length));
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
readline.clearLine(process.stdout, 0);
|
|
58
|
+
readline.moveCursor(process.stdout, 0, 1);
|
|
59
|
+
}
|
|
60
|
+
readline.moveCursor(process.stdout, 0, -lines.length);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
64
|
+
process.stdout.write(render().join("\n"));
|
|
65
|
+
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const handler = (key) => {
|
|
68
|
+
const keyStr = key.toString();
|
|
69
|
+
const lines = render();
|
|
70
|
+
|
|
71
|
+
if (keyStr === "\u001b[A") {
|
|
72
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
73
|
+
clearScreen(lines);
|
|
74
|
+
process.stdout.write(lines.join("\n"));
|
|
75
|
+
} else if (keyStr === "\u001b[B") {
|
|
76
|
+
cursor = (cursor + 1) % items.length;
|
|
77
|
+
clearScreen(lines);
|
|
78
|
+
process.stdout.write(lines.join("\n"));
|
|
79
|
+
} else if (keyStr === " ") {
|
|
80
|
+
const item = items[cursor];
|
|
81
|
+
if (selected.has(item.id)) selected.delete(item.id);
|
|
82
|
+
else selected.add(item.id);
|
|
83
|
+
clearScreen(lines);
|
|
84
|
+
process.stdout.write(lines.join("\n"));
|
|
85
|
+
} else if (keyStr === "\r" || keyStr === "\n") {
|
|
86
|
+
process.stdin.removeListener("data", handler);
|
|
87
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
88
|
+
resolve(Array.from(selected));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
process.stdin.on("data", handler);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function interactiveSelectTargets(targets) {
|
|
96
|
+
const enriched = targets.map((t) => ({
|
|
97
|
+
...t,
|
|
98
|
+
detected: fs.existsSync(t.dir()),
|
|
99
|
+
}));
|
|
100
|
+
const detected = enriched.filter((t) => t.detected);
|
|
101
|
+
const notDetected = enriched.filter((t) => !t.detected);
|
|
102
|
+
const all = [...detected, ...notDetected];
|
|
103
|
+
|
|
104
|
+
if (all.length === 0) {
|
|
105
|
+
console.log("No supported CLI tools detected. Install one of:");
|
|
106
|
+
for (const t of targets) console.log(` - ${t.label}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return interactiveSelect(
|
|
111
|
+
all,
|
|
112
|
+
"📦 Select AI CLI tools to install specx skills into:",
|
|
113
|
+
detected.map((t) => t.id),
|
|
114
|
+
true
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function interactiveScopeSelect() {
|
|
119
|
+
const items = [
|
|
120
|
+
{ id: "user", label: "User (install to ~/.{tool}/skills/)" },
|
|
121
|
+
{ id: "project", label: `Project (install to ./.{tool}/skills/)` },
|
|
122
|
+
];
|
|
123
|
+
const result = await interactiveSelect(items, "📁 Select scope:", ["user"], false);
|
|
124
|
+
return result.includes("project") ? "project" : "user";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── CLI Setup ───────────────────────────────────────────────────
|
|
128
|
+
const program = new Command();
|
|
129
|
+
|
|
130
|
+
program
|
|
131
|
+
.name("specx")
|
|
132
|
+
.description("specx CLI — install specx skills to AI coding agents")
|
|
133
|
+
.version("0.1.0");
|
|
134
|
+
|
|
135
|
+
// ─── Resolve scope helper ────────────────────────────────────────
|
|
136
|
+
function resolveScope(options) {
|
|
137
|
+
if (options.project !== undefined) return { isProject: true, projectDir: typeof options.project === "string" ? options.project : process.cwd() };
|
|
138
|
+
if (options.user) return { isProject: false };
|
|
139
|
+
return null; // not specified
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getFinalDir(targetId, isProject, projectDir) {
|
|
143
|
+
if (isProject) return path.join(projectDir, `.${targetId}`, "skills");
|
|
144
|
+
const target = CLI_TARGETS.find((t) => t.id === targetId);
|
|
145
|
+
return target.dir();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Install Command ─────────────────────────────────────────────
|
|
149
|
+
program
|
|
150
|
+
.command("install")
|
|
151
|
+
.description("Install specx skills to AI CLI tools")
|
|
152
|
+
.option("--claude", "Install to Claude Code")
|
|
153
|
+
.option("--codex", "Install to Codex")
|
|
154
|
+
.option("--user", "Install user-wide (~/.claude/skills/ or ~/.codex/skills/)")
|
|
155
|
+
.option("--project [dir]", "Install project-wide (e.g. .claude/skills/ in project dir)")
|
|
156
|
+
.option("--all", "Install to all detected CLI tools (non-interactive)")
|
|
157
|
+
.action(async (options) => {
|
|
158
|
+
const explicitTargets = [];
|
|
159
|
+
if (options.claude) explicitTargets.push("claude");
|
|
160
|
+
if (options.codex) explicitTargets.push("codex");
|
|
161
|
+
|
|
162
|
+
// Resolve target list
|
|
163
|
+
let targetIds;
|
|
164
|
+
if (explicitTargets.length > 0) {
|
|
165
|
+
targetIds = explicitTargets;
|
|
166
|
+
} else if (options.all || !isTTY()) {
|
|
167
|
+
targetIds = CLI_TARGETS.filter((t) => fs.existsSync(t.dir())).map((t) => t.id);
|
|
168
|
+
if (targetIds.length === 0) {
|
|
169
|
+
console.error("✖ No supported CLI tools detected.");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Interactive: step 1 — select targets
|
|
174
|
+
targetIds = await interactiveSelectTargets(CLI_TARGETS);
|
|
175
|
+
if (targetIds.length === 0) {
|
|
176
|
+
console.log("No targets selected. Aborting.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Resolve scope
|
|
182
|
+
const scope = resolveScope(options);
|
|
183
|
+
let isProject, projectDir;
|
|
184
|
+
if (scope) {
|
|
185
|
+
isProject = scope.isProject;
|
|
186
|
+
projectDir = scope.projectDir;
|
|
187
|
+
} else if (isTTY() && explicitTargets.length === 0 && !options.all) {
|
|
188
|
+
// Interactive: step 2 — select scope
|
|
189
|
+
const scopeChoice = await interactiveScopeSelect();
|
|
190
|
+
isProject = scopeChoice === "project";
|
|
191
|
+
projectDir = process.cwd();
|
|
192
|
+
} else {
|
|
193
|
+
isProject = options.project !== undefined;
|
|
194
|
+
projectDir = typeof options.project === "string" ? options.project : process.cwd();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const skillsToInstall = getAvailableSkills();
|
|
198
|
+
if (skillsToInstall.length === 0) {
|
|
199
|
+
console.error("✖ No specx skills found.");
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const targetId of targetIds) {
|
|
204
|
+
const target = CLI_TARGETS.find((t) => t.id === targetId);
|
|
205
|
+
const finalDir = getFinalDir(targetId, isProject, projectDir);
|
|
206
|
+
|
|
207
|
+
let installCount = 0;
|
|
208
|
+
for (const skillName of skillsToInstall) {
|
|
209
|
+
const destDir = path.join(finalDir, skillName);
|
|
210
|
+
if (fs.existsSync(destDir)) {
|
|
211
|
+
console.log(` ○ ${target.label}: ${skillName} already installed`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const srcDir = path.join(SKILLS_DIR, skillName);
|
|
215
|
+
if (!fs.existsSync(srcDir)) {
|
|
216
|
+
console.log(` ✖ ${skillName}: source not found, skipping`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
fs.mkdirSync(finalDir, { recursive: true });
|
|
220
|
+
copyDirSync(srcDir, destDir);
|
|
221
|
+
console.log(` ✓ ${target.label}: ${skillName}`);
|
|
222
|
+
installCount++;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (installCount > 0) {
|
|
226
|
+
console.log(`\n✓ Installed ${installCount} skill(s) to ${target.label} (${isProject ? "project" : "user"} level)`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─── Uninstall Command ───────────────────────────────────────────
|
|
232
|
+
program
|
|
233
|
+
.command("uninstall")
|
|
234
|
+
.description("Remove installed specx skills from AI CLI tools")
|
|
235
|
+
.option("--claude", "Uninstall from Claude Code")
|
|
236
|
+
.option("--codex", "Uninstall from Codex")
|
|
237
|
+
.option("--user", "Target user-wide installation")
|
|
238
|
+
.option("--project [dir]", "Target project-wide installation")
|
|
239
|
+
.option("--all", "Uninstall from all targets (non-interactive)")
|
|
240
|
+
.action(async (options) => {
|
|
241
|
+
const explicitTargets = [];
|
|
242
|
+
if (options.claude) explicitTargets.push("claude");
|
|
243
|
+
if (options.codex) explicitTargets.push("codex");
|
|
244
|
+
|
|
245
|
+
let targetIds;
|
|
246
|
+
if (explicitTargets.length > 0) {
|
|
247
|
+
targetIds = explicitTargets;
|
|
248
|
+
} else if (options.all || !isTTY()) {
|
|
249
|
+
targetIds = CLI_TARGETS.filter((t) => fs.existsSync(t.dir())).map((t) => t.id);
|
|
250
|
+
if (targetIds.length === 0) {
|
|
251
|
+
console.error("✖ No supported CLI tools detected.");
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
targetIds = await interactiveSelectTargets(CLI_TARGETS);
|
|
256
|
+
if (targetIds.length === 0) {
|
|
257
|
+
console.log("No targets selected. Aborting.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const scope = resolveScope(options);
|
|
263
|
+
let isProject, projectDir;
|
|
264
|
+
if (scope) {
|
|
265
|
+
isProject = scope.isProject;
|
|
266
|
+
projectDir = scope.projectDir;
|
|
267
|
+
} else if (isTTY() && explicitTargets.length === 0 && !options.all) {
|
|
268
|
+
const scopeChoice = await interactiveScopeSelect();
|
|
269
|
+
isProject = scopeChoice === "project";
|
|
270
|
+
projectDir = process.cwd();
|
|
271
|
+
} else {
|
|
272
|
+
isProject = options.project !== undefined;
|
|
273
|
+
projectDir = typeof options.project === "string" ? options.project : process.cwd();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const targetId of targetIds) {
|
|
277
|
+
const target = CLI_TARGETS.find((t) => t.id === targetId);
|
|
278
|
+
const finalDir = getFinalDir(targetId, isProject, projectDir);
|
|
279
|
+
|
|
280
|
+
if (!fs.existsSync(finalDir)) {
|
|
281
|
+
console.log(`${target.label}: No skills installed.`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let removedCount = 0;
|
|
286
|
+
const entries = fs.readdirSync(finalDir);
|
|
287
|
+
for (const name of entries) {
|
|
288
|
+
const dir = path.join(finalDir, name);
|
|
289
|
+
if (fs.statSync(dir).isDirectory() && fs.existsSync(path.join(dir, "SKILL.md")) && name.startsWith("specx-")) {
|
|
290
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
291
|
+
console.log(` ✖ ${target.label}: ${name}`);
|
|
292
|
+
removedCount++;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (removedCount > 0) {
|
|
297
|
+
console.log(`\n✓ Removed ${removedCount} skill(s) from ${target.label} (${isProject ? "project" : "user"} level)`);
|
|
298
|
+
} else {
|
|
299
|
+
console.log(`${target.label}: No specx skills installed.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ─── List Command ────────────────────────────────────────────────
|
|
305
|
+
program
|
|
306
|
+
.command("list")
|
|
307
|
+
.description("List installed specx skills")
|
|
308
|
+
.action(() => {
|
|
309
|
+
for (const target of CLI_TARGETS) {
|
|
310
|
+
const targetDir = target.dir();
|
|
311
|
+
if (!fs.existsSync(targetDir)) {
|
|
312
|
+
console.log(`\n📦 ${target.label}: not installed or not detected`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const installed = fs.readdirSync(targetDir)
|
|
317
|
+
.filter((name) => {
|
|
318
|
+
const dir = path.join(targetDir, name);
|
|
319
|
+
return fs.statSync(dir).isDirectory() && fs.existsSync(path.join(dir, "SKILL.md")) && name.startsWith("specx-");
|
|
320
|
+
})
|
|
321
|
+
.sort();
|
|
322
|
+
|
|
323
|
+
if (installed.length === 0) {
|
|
324
|
+
console.log(`\n📦 ${target.label}: no specx skills installed`);
|
|
325
|
+
} else {
|
|
326
|
+
console.log(`\n📦 ${target.label}:`);
|
|
327
|
+
for (const s of installed) console.log(` ✓ ${s}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ─── Available Command ───────────────────────────────────────────
|
|
333
|
+
program
|
|
334
|
+
.command("available")
|
|
335
|
+
.description("List all available specx skills")
|
|
336
|
+
.action(() => {
|
|
337
|
+
listAvailableSkills();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ─── Core Logic ──────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
function getAvailableSkills() {
|
|
343
|
+
if (!fs.existsSync(SKILLS_DIR)) {
|
|
344
|
+
console.error(`✖ Skills directory not found: ${SKILLS_DIR}`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
return fs.readdirSync(SKILLS_DIR)
|
|
348
|
+
.filter((name) => {
|
|
349
|
+
const skillDir = path.join(SKILLS_DIR, name);
|
|
350
|
+
return fs.statSync(skillDir).isDirectory() && fs.existsSync(path.join(skillDir, "SKILL.md"));
|
|
351
|
+
})
|
|
352
|
+
.sort();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function listAvailableSkills() {
|
|
356
|
+
const skills = getAvailableSkills();
|
|
357
|
+
console.log("\n📦 Available specx skills:");
|
|
358
|
+
for (const s of skills) {
|
|
359
|
+
const skillMd = path.join(SKILLS_DIR, s, "SKILL.md");
|
|
360
|
+
let desc = "";
|
|
361
|
+
try {
|
|
362
|
+
const content = fs.readFileSync(skillMd, "utf-8");
|
|
363
|
+
const match = content.match(/description:\s*(.+)/);
|
|
364
|
+
if (match) desc = match[1];
|
|
365
|
+
} catch {}
|
|
366
|
+
console.log(` ${s.padEnd(35)} ${desc}`);
|
|
367
|
+
}
|
|
368
|
+
console.log("\nInstall with: specx install");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function copyDirSync(src, dest) {
|
|
372
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
373
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
374
|
+
const srcPath = path.join(src, entry.name);
|
|
375
|
+
const destPath = path.join(dest, entry.name);
|
|
376
|
+
if (entry.isDirectory()) {
|
|
377
|
+
copyDirSync(srcPath, destPath);
|
|
378
|
+
} else {
|
|
379
|
+
fs.copyFileSync(srcPath, destPath);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ─── Run ─────────────────────────────────────────────────────────
|
|
385
|
+
program.parse();
|