@spec-wave/cli 0.5.5 → 0.5.8
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 +390 -0
- package/bin/spec-wave.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/code-review.mjs +4 -3
- package/src/commands/decompose.mjs +7 -4
- package/src/commands/init.mjs +1 -1
- package/src/commands/install-skill.mjs +292 -0
- package/src/commands/qa.mjs +4 -3
- package/src/templates/skill/SKILL.md +517 -0
- package/src/templates/workflows/code-review.yml +2 -1
- package/src/templates/workflows/decompose.yml +2 -1
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +2 -1
- package/src/templates/workflows/validate.yml +2 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
// Fonte única da skill, publicada via "files": ["src"] no package.json.
|
|
11
|
+
const SKILL_SOURCE = path.join(__dir, '..', 'templates', 'skill', 'SKILL.md');
|
|
12
|
+
|
|
13
|
+
// Marcadores usados para gravar/atualizar a skill de forma idempotente em
|
|
14
|
+
// arquivos compartilhados (AGENTS.md) — permite reinstalar sem duplicar.
|
|
15
|
+
const BLOCK_START = '<!-- spec-wave:start -->';
|
|
16
|
+
const BLOCK_END = '<!-- spec-wave:end -->';
|
|
17
|
+
|
|
18
|
+
// Registro de agentes suportados. Cada alvo descreve como detectá-lo no
|
|
19
|
+
// diretório-base, onde gravar (projeto vs. global) e em que formato converter
|
|
20
|
+
// o SKILL.md. Caminhos conferidos na doc oficial de cada ferramenta.
|
|
21
|
+
const TARGETS = [
|
|
22
|
+
{
|
|
23
|
+
key: 'claude',
|
|
24
|
+
name: 'Claude Code',
|
|
25
|
+
format: 'skill',
|
|
26
|
+
detect: ['.claude'],
|
|
27
|
+
project: '.claude/skills/spec-wave/SKILL.md',
|
|
28
|
+
global: '.claude/skills/spec-wave/SKILL.md',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: 'opencode',
|
|
32
|
+
name: 'opencode',
|
|
33
|
+
format: 'skill',
|
|
34
|
+
detect: ['.opencode'],
|
|
35
|
+
project: '.opencode/skills/spec-wave/SKILL.md',
|
|
36
|
+
global: '.config/opencode/skills/spec-wave/SKILL.md',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: 'cursor',
|
|
40
|
+
name: 'Cursor',
|
|
41
|
+
format: 'mdc',
|
|
42
|
+
detect: ['.cursor'],
|
|
43
|
+
project: '.cursor/rules/spec-wave.mdc',
|
|
44
|
+
global: null, // Cursor user rules não são baseadas em arquivo.
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'cline',
|
|
48
|
+
name: 'Cline',
|
|
49
|
+
format: 'rules',
|
|
50
|
+
detect: ['.clinerules'],
|
|
51
|
+
project: '.clinerules/spec-wave.md',
|
|
52
|
+
global: null,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: 'kilo',
|
|
56
|
+
name: 'Kilo Code',
|
|
57
|
+
format: 'rules',
|
|
58
|
+
detect: ['.kilocode'],
|
|
59
|
+
project: '.kilocode/rules/spec-wave.md',
|
|
60
|
+
global: '.kilocode/rules/spec-wave.md',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: 'antigravity',
|
|
64
|
+
name: 'Antigravity',
|
|
65
|
+
format: 'rules',
|
|
66
|
+
detect: ['.agent', 'GEMINI.md'],
|
|
67
|
+
project: '.agent/rules/spec-wave.md',
|
|
68
|
+
global: '.gemini/AGENTS.md',
|
|
69
|
+
globalFormat: 'agents', // ~/.gemini/AGENTS.md é compartilhado → append.
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: 'agents',
|
|
73
|
+
name: 'AGENTS.md (genérico)',
|
|
74
|
+
format: 'agents',
|
|
75
|
+
detect: ['AGENTS.md'],
|
|
76
|
+
project: 'AGENTS.md',
|
|
77
|
+
global: '.config/opencode/AGENTS.md',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const TARGET_BY_KEY = new Map(TARGETS.map((t) => [t.key, t]));
|
|
82
|
+
|
|
83
|
+
// Separa o frontmatter YAML do corpo do SKILL.md. Retorna { meta, body }.
|
|
84
|
+
function parseSkill(raw) {
|
|
85
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
86
|
+
if (!match) return { meta: {}, body: raw.trim() };
|
|
87
|
+
let meta = {};
|
|
88
|
+
try {
|
|
89
|
+
meta = yaml.load(match[1]) || {};
|
|
90
|
+
} catch {
|
|
91
|
+
meta = {};
|
|
92
|
+
}
|
|
93
|
+
return { meta, body: match[2].trim() };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Converte o SKILL.md para o formato exigido por cada agente.
|
|
97
|
+
function renderContent(format, raw, parsed) {
|
|
98
|
+
const { meta, body } = parsed;
|
|
99
|
+
const description = meta.description ?? 'Skill spec-wave.';
|
|
100
|
+
switch (format) {
|
|
101
|
+
case 'skill':
|
|
102
|
+
// Claude Code / opencode consomem o SKILL.md nativo. Campos extras do
|
|
103
|
+
// frontmatter são ignorados pelo opencode — sem problema.
|
|
104
|
+
return raw.trimEnd() + '\n';
|
|
105
|
+
case 'mdc':
|
|
106
|
+
return (
|
|
107
|
+
`---\n` +
|
|
108
|
+
`description: ${JSON.stringify(description)}\n` +
|
|
109
|
+
`alwaysApply: false\n` +
|
|
110
|
+
`---\n\n` +
|
|
111
|
+
`${body}\n`
|
|
112
|
+
);
|
|
113
|
+
case 'rules':
|
|
114
|
+
return `# spec-wave\n\n${description}\n\n${body}\n`;
|
|
115
|
+
case 'agents':
|
|
116
|
+
return `${BLOCK_START}\n\n# spec-wave\n\n${description}\n\n${body}\n\n${BLOCK_END}\n`;
|
|
117
|
+
default:
|
|
118
|
+
return raw;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Insere/atualiza o bloco spec-wave num arquivo compartilhado (AGENTS.md),
|
|
123
|
+
// preservando o restante do conteúdo. Idempotente via marcadores.
|
|
124
|
+
function mergeAgentsFile(destPath, block) {
|
|
125
|
+
const existing = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
|
|
126
|
+
const blockRe = new RegExp(
|
|
127
|
+
`${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
|
|
128
|
+
);
|
|
129
|
+
if (blockRe.test(existing)) {
|
|
130
|
+
return existing.replace(blockRe, block);
|
|
131
|
+
}
|
|
132
|
+
if (existing.trim() === '') return block;
|
|
133
|
+
return `${existing.trimEnd()}\n\n${block}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function escapeRe(s) {
|
|
137
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Resolve o alvo do agente para um destino concreto no escopo escolhido.
|
|
141
|
+
// Retorna null quando o agente não suporta o escopo global.
|
|
142
|
+
function resolveDest(target, baseDir, isGlobal) {
|
|
143
|
+
const rel = isGlobal ? target.global : target.project;
|
|
144
|
+
if (!rel) return null;
|
|
145
|
+
const format = isGlobal && target.globalFormat ? target.globalFormat : target.format;
|
|
146
|
+
return { path: path.join(baseDir, rel), format };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Retorna true se algum dos sinais de detecção existir em baseDir.
|
|
150
|
+
function isDetected(target, baseDir) {
|
|
151
|
+
return target.detect.some((sig) => existsSync(path.join(baseDir, sig)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function installSkill(options = {}) {
|
|
155
|
+
p.intro(chalk.bold('spec-wave install-skill'));
|
|
156
|
+
|
|
157
|
+
if (!existsSync(SKILL_SOURCE)) {
|
|
158
|
+
p.log.error(`SKILL.md não encontrado no pacote (${SKILL_SOURCE}).`);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const raw = readFileSync(SKILL_SOURCE, 'utf-8');
|
|
163
|
+
const parsed = parseSkill(raw);
|
|
164
|
+
|
|
165
|
+
const isGlobal = !!options.global;
|
|
166
|
+
const baseDir = isGlobal ? homedir() : process.cwd();
|
|
167
|
+
const scopeLabel = isGlobal ? 'global (usuário)' : 'projeto (local)';
|
|
168
|
+
|
|
169
|
+
// 1) Determinar quais agentes receberão a skill.
|
|
170
|
+
let selectedKeys;
|
|
171
|
+
if (options.agent) {
|
|
172
|
+
const requested = String(options.agent)
|
|
173
|
+
.split(',')
|
|
174
|
+
.map((s) => s.trim().toLowerCase())
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
const invalid = requested.filter((k) => !TARGET_BY_KEY.has(k));
|
|
177
|
+
if (invalid.length) {
|
|
178
|
+
p.log.error(
|
|
179
|
+
`Agente(s) inválido(s): ${invalid.join(', ')}.\n` +
|
|
180
|
+
`Válidos: ${TARGETS.map((t) => t.key).join(', ')}.`,
|
|
181
|
+
);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
selectedKeys = requested;
|
|
186
|
+
} else {
|
|
187
|
+
const detected = TARGETS.filter((t) => isDetected(t, baseDir)).map((t) => t.key);
|
|
188
|
+
|
|
189
|
+
if (options.all) {
|
|
190
|
+
if (!detected.length) {
|
|
191
|
+
p.log.error(
|
|
192
|
+
`Nenhum agente detectado em ${scopeLabel}. Use --agent <nome> para escolher manualmente.`,
|
|
193
|
+
);
|
|
194
|
+
process.exitCode = 1;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
selectedKeys = detected;
|
|
198
|
+
} else if (options.yes) {
|
|
199
|
+
if (!detected.length) {
|
|
200
|
+
p.log.error(
|
|
201
|
+
`Nenhum agente detectado em ${scopeLabel}. Use --agent <nome> em modo não-interativo.`,
|
|
202
|
+
);
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
selectedKeys = detected;
|
|
207
|
+
} else {
|
|
208
|
+
const answer = await p.multiselect({
|
|
209
|
+
message: `Onde instalar a skill? (escopo: ${scopeLabel})`,
|
|
210
|
+
options: TARGETS.map((t) => ({
|
|
211
|
+
value: t.key,
|
|
212
|
+
label: t.name,
|
|
213
|
+
hint: isDetected(t, baseDir) ? 'detectado' : undefined,
|
|
214
|
+
})),
|
|
215
|
+
initialValues: detected,
|
|
216
|
+
required: true,
|
|
217
|
+
});
|
|
218
|
+
if (p.isCancel(answer)) {
|
|
219
|
+
p.cancel('Instalação cancelada.');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
selectedKeys = answer;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 2) Resolver destinos, avisando sobre escopos não suportados.
|
|
227
|
+
const jobs = [];
|
|
228
|
+
for (const key of selectedKeys) {
|
|
229
|
+
const target = TARGET_BY_KEY.get(key);
|
|
230
|
+
const dest = resolveDest(target, baseDir, isGlobal);
|
|
231
|
+
if (!dest) {
|
|
232
|
+
p.log.warn(`${target.name}: escopo global não suportado — pulado.`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
jobs.push({ target, dest });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!jobs.length) {
|
|
239
|
+
p.log.warn('Nenhum destino a instalar.');
|
|
240
|
+
p.outro('Nada foi feito.');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 3) Dry-run: apenas listar.
|
|
245
|
+
if (options.dryRun) {
|
|
246
|
+
p.note(
|
|
247
|
+
jobs
|
|
248
|
+
.map((j) => `${chalk.dim(j.target.name.padEnd(20))} ${j.dest.path} ${chalk.dim(`(${j.dest.format})`)}`)
|
|
249
|
+
.join('\n'),
|
|
250
|
+
`Dry-run — nada será gravado (escopo: ${scopeLabel})`,
|
|
251
|
+
);
|
|
252
|
+
p.outro('Dry-run concluído.');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 4) Gravar cada destino.
|
|
257
|
+
const written = [];
|
|
258
|
+
for (const { target, dest } of jobs) {
|
|
259
|
+
const content =
|
|
260
|
+
dest.format === 'agents'
|
|
261
|
+
? mergeAgentsFile(dest.path, renderContent('agents', raw, parsed))
|
|
262
|
+
: renderContent(dest.format, raw, parsed);
|
|
263
|
+
|
|
264
|
+
// Confirmar sobrescrita de arquivos "próprios" (skill/rules/mdc). Para
|
|
265
|
+
// 'agents' o merge por marcadores já é seguro (não apaga conteúdo alheio).
|
|
266
|
+
if (existsSync(dest.path) && dest.format !== 'agents' && !options.force && !options.yes) {
|
|
267
|
+
const ok = await p.confirm({
|
|
268
|
+
message: `${target.name}: ${dest.path} já existe. Sobrescrever?`,
|
|
269
|
+
initialValue: true,
|
|
270
|
+
});
|
|
271
|
+
if (p.isCancel(ok) || !ok) {
|
|
272
|
+
p.log.info(`${target.name}: mantido (não sobrescrito).`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
mkdirSync(path.dirname(dest.path), { recursive: true });
|
|
278
|
+
writeFileSync(dest.path, content, 'utf-8');
|
|
279
|
+
written.push({ target, dest });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!written.length) {
|
|
283
|
+
p.outro('Nada foi gravado.');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
p.note(
|
|
288
|
+
written.map((w) => `${chalk.green('✓')} ${chalk.bold(w.target.name)}\n ${chalk.dim(w.dest.path)}`).join('\n'),
|
|
289
|
+
`Skill instalada (escopo: ${scopeLabel})`,
|
|
290
|
+
);
|
|
291
|
+
p.outro('Reinicie/recarregue o agente para que ele detecte a skill.');
|
|
292
|
+
}
|
package/src/commands/qa.mjs
CHANGED
|
@@ -64,6 +64,7 @@ async function setQA(token, project, etapaField, statusField, nodeId) {
|
|
|
64
64
|
|
|
65
65
|
export async function qa({ prNumber }) {
|
|
66
66
|
const token = await resolveToken();
|
|
67
|
+
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
67
68
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
68
69
|
const cfgPath = path.join(process.cwd(), CONFIG_FILE);
|
|
69
70
|
let cfg = {};
|
|
@@ -103,8 +104,8 @@ export async function qa({ prNumber }) {
|
|
|
103
104
|
return;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
const etapaField = await resolveField(
|
|
107
|
-
const statusField = await resolveField(
|
|
107
|
+
const etapaField = await resolveField(projectToken, project, 'Etapa').catch(() => null);
|
|
108
|
+
const statusField = await resolveField(projectToken, project, 'Status').catch(() => null);
|
|
108
109
|
|
|
109
110
|
const seen = new Set();
|
|
110
111
|
const updated = [];
|
|
@@ -114,7 +115,7 @@ export async function qa({ prNumber }) {
|
|
|
114
115
|
if (!feature || seen.has(feature.number)) continue;
|
|
115
116
|
seen.add(feature.number);
|
|
116
117
|
try {
|
|
117
|
-
await setQA(
|
|
118
|
+
await setQA(projectToken, project, etapaField, statusField, feature.node_id);
|
|
118
119
|
updated.push(`#${feature.number} ${feature.title}`);
|
|
119
120
|
console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
|
|
120
121
|
} catch (err) {
|