@spec-wave/cli 0.5.7 → 0.5.9

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.
@@ -0,0 +1,309 @@
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
+ // Versão da CLI que gerou a skill instalada — carimbada no arquivo para detecção
13
+ // de desatualização (a skill é uma cópia estática; não acompanha o `npx` sozinha).
14
+ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
15
+
16
+ // Marcadores usados para gravar/atualizar a skill de forma idempotente em
17
+ // arquivos compartilhados (AGENTS.md) — permite reinstalar sem duplicar.
18
+ const BLOCK_START = '<!-- spec-wave:start -->';
19
+ const BLOCK_END = '<!-- spec-wave:end -->';
20
+
21
+ // Registro de agentes suportados. Cada alvo descreve como detectá-lo no
22
+ // diretório-base, onde gravar (projeto vs. global) e em que formato converter
23
+ // o SKILL.md. Caminhos conferidos na doc oficial de cada ferramenta.
24
+ const TARGETS = [
25
+ {
26
+ key: 'claude',
27
+ name: 'Claude Code',
28
+ format: 'skill',
29
+ detect: ['.claude'],
30
+ project: '.claude/skills/spec-wave/SKILL.md',
31
+ global: '.claude/skills/spec-wave/SKILL.md',
32
+ },
33
+ {
34
+ key: 'opencode',
35
+ name: 'opencode',
36
+ format: 'skill',
37
+ detect: ['.opencode'],
38
+ project: '.opencode/skills/spec-wave/SKILL.md',
39
+ global: '.config/opencode/skills/spec-wave/SKILL.md',
40
+ },
41
+ {
42
+ key: 'cursor',
43
+ name: 'Cursor',
44
+ format: 'mdc',
45
+ detect: ['.cursor'],
46
+ project: '.cursor/rules/spec-wave.mdc',
47
+ global: null, // Cursor user rules não são baseadas em arquivo.
48
+ },
49
+ {
50
+ key: 'cline',
51
+ name: 'Cline',
52
+ format: 'rules',
53
+ detect: ['.clinerules'],
54
+ project: '.clinerules/spec-wave.md',
55
+ global: null,
56
+ },
57
+ {
58
+ key: 'kilo',
59
+ name: 'Kilo Code',
60
+ format: 'rules',
61
+ detect: ['.kilocode'],
62
+ project: '.kilocode/rules/spec-wave.md',
63
+ global: '.kilocode/rules/spec-wave.md',
64
+ },
65
+ {
66
+ key: 'antigravity',
67
+ name: 'Antigravity',
68
+ format: 'rules',
69
+ detect: ['.agent', 'GEMINI.md'],
70
+ project: '.agent/rules/spec-wave.md',
71
+ global: '.gemini/AGENTS.md',
72
+ globalFormat: 'agents', // ~/.gemini/AGENTS.md é compartilhado → append.
73
+ },
74
+ {
75
+ key: 'agents',
76
+ name: 'AGENTS.md (genérico)',
77
+ format: 'agents',
78
+ detect: ['AGENTS.md'],
79
+ project: 'AGENTS.md',
80
+ global: '.config/opencode/AGENTS.md',
81
+ },
82
+ ];
83
+
84
+ const TARGET_BY_KEY = new Map(TARGETS.map((t) => [t.key, t]));
85
+
86
+ // Separa o frontmatter YAML do corpo do SKILL.md. Retorna { meta, body }.
87
+ function parseSkill(raw) {
88
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
89
+ if (!match) return { meta: {}, frontmatter: '', body: raw.trim() };
90
+ let meta = {};
91
+ try {
92
+ meta = yaml.load(match[1]) || {};
93
+ } catch {
94
+ meta = {};
95
+ }
96
+ return { meta, frontmatter: match[1], body: match[2].trim() };
97
+ }
98
+
99
+ // Banner de versão inserido no topo do corpo da skill instalada. O agente lê
100
+ // esta linha e, se `npx @spec-wave/cli --version` for maior, orienta reinstalar.
101
+ function versionBanner(version) {
102
+ return (
103
+ `> ⚙️ **spec-wave skill v${version}** — esta skill é uma cópia estática. ` +
104
+ 'Se `npx @spec-wave/cli --version` indicar uma versão maior, ela está ' +
105
+ 'desatualizada: rode `npx @spec-wave/cli install-skill --force` para atualizá-la.'
106
+ );
107
+ }
108
+
109
+ // Converte o SKILL.md para o formato exigido por cada agente, carimbando a versão.
110
+ function renderContent(format, parsed, version) {
111
+ const { meta, frontmatter, body } = parsed;
112
+ const description = meta.description ?? 'Skill spec-wave.';
113
+ const banner = versionBanner(version);
114
+ switch (format) {
115
+ case 'skill':
116
+ // Claude Code / opencode consomem o SKILL.md nativo. Preserva o frontmatter
117
+ // original (allowed-tools etc.) e insere o banner no topo do corpo.
118
+ return `---\n${frontmatter}\n---\n\n${banner}\n\n${body}\n`;
119
+ case 'mdc':
120
+ return (
121
+ `---\n` +
122
+ `description: ${JSON.stringify(description)}\n` +
123
+ `alwaysApply: false\n` +
124
+ `---\n\n` +
125
+ `${banner}\n\n${body}\n`
126
+ );
127
+ case 'rules':
128
+ return `# spec-wave\n\n${banner}\n\n${description}\n\n${body}\n`;
129
+ case 'agents':
130
+ return `${BLOCK_START}\n\n# spec-wave\n\n${banner}\n\n${description}\n\n${body}\n\n${BLOCK_END}\n`;
131
+ default:
132
+ return body;
133
+ }
134
+ }
135
+
136
+ // Insere/atualiza o bloco spec-wave num arquivo compartilhado (AGENTS.md),
137
+ // preservando o restante do conteúdo. Idempotente via marcadores.
138
+ function mergeAgentsFile(destPath, block) {
139
+ const existing = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
140
+ const blockRe = new RegExp(
141
+ `${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
142
+ );
143
+ if (blockRe.test(existing)) {
144
+ return existing.replace(blockRe, block);
145
+ }
146
+ if (existing.trim() === '') return block;
147
+ return `${existing.trimEnd()}\n\n${block}`;
148
+ }
149
+
150
+ function escapeRe(s) {
151
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
152
+ }
153
+
154
+ // Resolve o alvo do agente para um destino concreto no escopo escolhido.
155
+ // Retorna null quando o agente não suporta o escopo global.
156
+ function resolveDest(target, baseDir, isGlobal) {
157
+ const rel = isGlobal ? target.global : target.project;
158
+ if (!rel) return null;
159
+ const format = isGlobal && target.globalFormat ? target.globalFormat : target.format;
160
+ return { path: path.join(baseDir, rel), format };
161
+ }
162
+
163
+ // Retorna true se algum dos sinais de detecção existir em baseDir.
164
+ function isDetected(target, baseDir) {
165
+ return target.detect.some((sig) => existsSync(path.join(baseDir, sig)));
166
+ }
167
+
168
+ export async function installSkill(options = {}) {
169
+ p.intro(chalk.bold('spec-wave install-skill'));
170
+
171
+ if (!existsSync(SKILL_SOURCE)) {
172
+ p.log.error(`SKILL.md não encontrado no pacote (${SKILL_SOURCE}).`);
173
+ process.exitCode = 1;
174
+ return;
175
+ }
176
+ const raw = readFileSync(SKILL_SOURCE, 'utf-8');
177
+ const parsed = parseSkill(raw);
178
+
179
+ const isGlobal = !!options.global;
180
+ const baseDir = isGlobal ? homedir() : process.cwd();
181
+ const scopeLabel = isGlobal ? 'global (usuário)' : 'projeto (local)';
182
+
183
+ // 1) Determinar quais agentes receberão a skill.
184
+ let selectedKeys;
185
+ if (options.agent) {
186
+ const requested = String(options.agent)
187
+ .split(',')
188
+ .map((s) => s.trim().toLowerCase())
189
+ .filter(Boolean);
190
+ const invalid = requested.filter((k) => !TARGET_BY_KEY.has(k));
191
+ if (invalid.length) {
192
+ p.log.error(
193
+ `Agente(s) inválido(s): ${invalid.join(', ')}.\n` +
194
+ `Válidos: ${TARGETS.map((t) => t.key).join(', ')}.`,
195
+ );
196
+ process.exitCode = 1;
197
+ return;
198
+ }
199
+ selectedKeys = requested;
200
+ } else {
201
+ const detected = TARGETS.filter((t) => isDetected(t, baseDir)).map((t) => t.key);
202
+
203
+ if (options.all) {
204
+ if (!detected.length) {
205
+ p.log.error(
206
+ `Nenhum agente detectado em ${scopeLabel}. Use --agent <nome> para escolher manualmente.`,
207
+ );
208
+ process.exitCode = 1;
209
+ return;
210
+ }
211
+ selectedKeys = detected;
212
+ } else if (options.yes) {
213
+ if (!detected.length) {
214
+ p.log.error(
215
+ `Nenhum agente detectado em ${scopeLabel}. Use --agent <nome> em modo não-interativo.`,
216
+ );
217
+ process.exitCode = 1;
218
+ return;
219
+ }
220
+ selectedKeys = detected;
221
+ } else {
222
+ const answer = await p.multiselect({
223
+ message: `Onde instalar a skill? (escopo: ${scopeLabel})`,
224
+ options: TARGETS.map((t) => ({
225
+ value: t.key,
226
+ label: t.name,
227
+ hint: isDetected(t, baseDir) ? 'detectado' : undefined,
228
+ })),
229
+ initialValues: detected,
230
+ required: true,
231
+ });
232
+ if (p.isCancel(answer)) {
233
+ p.cancel('Instalação cancelada.');
234
+ return;
235
+ }
236
+ selectedKeys = answer;
237
+ }
238
+ }
239
+
240
+ // 2) Resolver destinos, avisando sobre escopos não suportados.
241
+ const jobs = [];
242
+ for (const key of selectedKeys) {
243
+ const target = TARGET_BY_KEY.get(key);
244
+ const dest = resolveDest(target, baseDir, isGlobal);
245
+ if (!dest) {
246
+ p.log.warn(`${target.name}: escopo global não suportado — pulado.`);
247
+ continue;
248
+ }
249
+ jobs.push({ target, dest });
250
+ }
251
+
252
+ if (!jobs.length) {
253
+ p.log.warn('Nenhum destino a instalar.');
254
+ p.outro('Nada foi feito.');
255
+ return;
256
+ }
257
+
258
+ // 3) Dry-run: apenas listar.
259
+ if (options.dryRun) {
260
+ p.note(
261
+ jobs
262
+ .map((j) => `${chalk.dim(j.target.name.padEnd(20))} ${j.dest.path} ${chalk.dim(`(${j.dest.format})`)}`)
263
+ .join('\n'),
264
+ `Dry-run — nada será gravado (escopo: ${scopeLabel})`,
265
+ );
266
+ p.outro('Dry-run concluído.');
267
+ return;
268
+ }
269
+
270
+ // 4) Gravar cada destino.
271
+ const written = [];
272
+ for (const { target, dest } of jobs) {
273
+ const content =
274
+ dest.format === 'agents'
275
+ ? mergeAgentsFile(dest.path, renderContent('agents', parsed, pkg.version))
276
+ : renderContent(dest.format, parsed, pkg.version);
277
+
278
+ // Confirmar sobrescrita de arquivos "próprios" (skill/rules/mdc). Para
279
+ // 'agents' o merge por marcadores já é seguro (não apaga conteúdo alheio).
280
+ if (existsSync(dest.path) && dest.format !== 'agents' && !options.force && !options.yes) {
281
+ const ok = await p.confirm({
282
+ message: `${target.name}: ${dest.path} já existe. Sobrescrever?`,
283
+ initialValue: true,
284
+ });
285
+ if (p.isCancel(ok) || !ok) {
286
+ p.log.info(`${target.name}: mantido (não sobrescrito).`);
287
+ continue;
288
+ }
289
+ }
290
+
291
+ mkdirSync(path.dirname(dest.path), { recursive: true });
292
+ writeFileSync(dest.path, content, 'utf-8');
293
+ written.push({ target, dest });
294
+ }
295
+
296
+ if (!written.length) {
297
+ p.outro('Nada foi gravado.');
298
+ return;
299
+ }
300
+
301
+ p.note(
302
+ written.map((w) => `${chalk.green('✓')} ${chalk.bold(w.target.name)}\n ${chalk.dim(w.dest.path)}`).join('\n'),
303
+ `Skill v${pkg.version} instalada (escopo: ${scopeLabel})`,
304
+ );
305
+ p.outro(
306
+ 'Reinicie/recarregue o agente para que ele detecte a skill. ' +
307
+ 'Ao atualizar a CLI, rode `install-skill --force` para atualizar a skill também.',
308
+ );
309
+ }
package/src/config.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  // pelo comando `info` (e pela skill) para detectar se o spec-wave já foi configurado.
5
5
  export const CONFIG_FILE = '.spec-wave.json';
6
6
 
7
+ // Portal Web da ferramenta — exibido ao final do `init` e no `info`.
8
+ export const PORTAL_URL = 'https://spec-wave.astratech.net.br';
9
+
7
10
  // Providers de IA suportados pelos workflows (generate-plan/spec/decompose).
8
11
  // O provider e o modelo escolhidos no `init` são persistidos em .spec-wave.json
9
12
  // (bloco `ai`) e lidos em runtime por src/lib/claude.mjs. Cada provider declara
@@ -48,6 +51,14 @@ export const STATUS_OPTIONS = [
48
51
  { name: '🎉 Done', color: 'GREEN' },
49
52
  ];
50
53
 
54
+ // Etapas usadas pelo fluxo de implementação (comando `implement`): cada task vai
55
+ // para "In Progress" ao INICIAR seu desenvolvimento e para "Done" ao concluir;
56
+ // ao final da Story, Feature + Story vão para "Code Review" (após commit + PR).
57
+ // Resolvidas por nome para não quebrar se a ordem/cor das opções mudar.
58
+ export const STAGE_IN_PROGRESS = STATUS_OPTIONS.find(s => s.name.includes('Desenvolvimento')).name;
59
+ export const STAGE_DONE = STATUS_OPTIONS.find(s => s.name.includes('Done')).name;
60
+ export const STAGE_CODE_REVIEW = STATUS_OPTIONS.find(s => s.name.includes('Code Review')).name;
61
+
51
62
  export const CUSTOM_FIELDS = [
52
63
  {
53
64
  name: 'Work Item Type',