@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.
@@ -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
+ }
@@ -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(token, project, 'Etapa').catch(() => null);
107
- const statusField = await resolveField(token, project, 'Status').catch(() => null);
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(token, project, etapaField, statusField, feature.node_id);
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) {