@spec-wave/cli 0.5.8 → 0.5.10

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/bin/spec-wave.mjs CHANGED
@@ -86,6 +86,20 @@ program
86
86
  await feature(options).catch(err => { console.error(err.message); process.exit(1); });
87
87
  });
88
88
 
89
+ program
90
+ .command('update')
91
+ .description('Detecta o que está desatualizado (skill, .spec-wave.json, workflows/labels do repo) e atualiza só o que mudou')
92
+ .option('--global', 'Verifica a skill no escopo do usuário (padrão: projeto)')
93
+ .option('--skip-skill', 'Não verifica/atualiza a skill instalada')
94
+ .option('--skip-config', 'Não verifica/atualiza o .spec-wave.json local')
95
+ .option('--skip-repo', 'Não verifica/atualiza workflows e labels do repo')
96
+ .option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
97
+ .option('--yes', 'Aplica sem pedir confirmação')
98
+ .action(async (options) => {
99
+ const { update } = await import('../src/commands/update.mjs');
100
+ await update(options).catch(err => { console.error(err.message); process.exit(1); });
101
+ });
102
+
89
103
  program
90
104
  .command('install-skill')
91
105
  .description('Instala a skill spec-wave no(s) agente(s) detectado(s): Claude Code, Cursor, opencode, Cline, Kilo, Antigravity, AGENTS.md')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -233,6 +233,31 @@ export async function getIssueParent(token, issueNodeId) {
233
233
  return { number: parent.number, title: parent.title, nodeId: parent.id };
234
234
  }
235
235
 
236
+ // Lê o valor atual (nome da opção) de um campo SINGLE_SELECT para um item do
237
+ // Project. Usado para garantir que a Etapa só avança (nunca retrocede).
238
+ export async function getItemSingleSelectValue(token, itemId, fieldId) {
239
+ const client = makeClient(token);
240
+ const result = await client(`
241
+ query ItemValue($itemId: ID!) {
242
+ node(id: $itemId) {
243
+ ... on ProjectV2Item {
244
+ fieldValues(first: 50) {
245
+ nodes {
246
+ ... on ProjectV2ItemFieldSingleSelectValue {
247
+ name
248
+ field { ... on ProjectV2SingleSelectField { id } }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }
254
+ }
255
+ `, { itemId });
256
+ const nodes = result.node?.fieldValues?.nodes || [];
257
+ const value = nodes.find(n => n && n.field?.id === fieldId);
258
+ return value?.name ?? null;
259
+ }
260
+
236
261
  // Define o valor de um campo SINGLE_SELECT para um item do Project.
237
262
  export async function setItemSingleSelect(token, projectId, itemId, fieldId, optionId) {
238
263
  const client = makeClient(token);
@@ -43,6 +43,31 @@ export async function createLabel(token, owner, repo, label) {
43
43
  }
44
44
  }
45
45
 
46
+ // Lista todas as labels do repo: [{ name, color, description }]. color é hex
47
+ // sem "#" (minúsculo, como a API retorna). Usado pelo `update` para detectar
48
+ // labels ausentes ou com cor/descrição divergente.
49
+ export async function listLabels(token, owner, repo) {
50
+ const octokit = makeOctokit(token);
51
+ const labels = await octokit.paginate(octokit.rest.issues.listLabelsForRepo, {
52
+ owner,
53
+ repo,
54
+ per_page: 100,
55
+ });
56
+ return labels.map(l => ({ name: l.name, color: l.color, description: l.description || '' }));
57
+ }
58
+
59
+ // Atualiza cor/descrição de uma label existente (mantém o nome).
60
+ export async function updateLabel(token, owner, repo, label) {
61
+ const octokit = makeOctokit(token);
62
+ await octokit.rest.issues.updateLabel({
63
+ owner,
64
+ repo,
65
+ name: label.name,
66
+ color: label.color,
67
+ description: label.description,
68
+ });
69
+ }
70
+
46
71
  export async function upsertFile(token, owner, repo, path, content, message) {
47
72
  const octokit = makeOctokit(token);
48
73
  let sha;
@@ -2,13 +2,13 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
5
- import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent } from '../api/github-graphql.mjs';
5
+ import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
6
6
  import { detectIssueType } from '../lib/issue-type.mjs';
7
- import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
7
+ import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, PROGRESS_TODO } from '../config.mjs';
8
8
 
9
9
  // Campo "Etapa" (custom) → "👀 Code Review". Campo "Status" (nativo) → "Todo".
10
10
  const CODE_REVIEW_STAGE = STATUS_OPTIONS.find(s => s.name.includes('Code Review'))?.name;
11
- const TODO_STATUS = 'Todo';
11
+ const TODO_STATUS = PROGRESS_TODO;
12
12
 
13
13
  // Extrai números de issues referenciadas no corpo do PR (Closes #N, Fixes #N, #N solto).
14
14
  function extractIssueNumbers(body) {
@@ -54,17 +54,69 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
54
54
  return null;
55
55
  }
56
56
 
57
- // Move um item do board para Etapa "👀 Code Review" e Status "Todo".
57
+ // Coleta a "unidade de review" de uma issue referenciada no PR: a Feature
58
+ // ancestral + a Story + as Tasks dessa Story — tudo anda junto com a Feature.
59
+ // Retorna Map number -> { nodeId, title }.
60
+ async function collectReviewUnit(token, owner, repo, issueNumber) {
61
+ const unit = new Map();
62
+ const add = (n, nodeId, title) => { if (n && nodeId && !unit.has(n)) unit.set(n, { nodeId, title }); };
63
+
64
+ let issue;
65
+ try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return unit; }
66
+ const type = detectIssueType(issue);
67
+ if (type !== 'Feature' && type !== 'Story' && type !== 'Task') return unit;
68
+
69
+ // Feature ancestral (ou a própria).
70
+ const feature = await resolveFeatureIssue(token, owner, repo, issueNumber);
71
+ if (feature) add(feature.number, feature.node_id, feature.title);
72
+
73
+ if (type === 'Feature') {
74
+ // Toda a subárvore da Feature: Stories + Tasks.
75
+ const stories = await listSubIssues(token, issue.node_id).catch(() => []);
76
+ for (const st of stories) {
77
+ add(st.number, st.nodeId, st.title);
78
+ const tasks = await listSubIssues(token, st.nodeId).catch(() => []);
79
+ for (const t of tasks) add(t.number, t.nodeId, t.title);
80
+ }
81
+ } else if (type === 'Story') {
82
+ add(issue.number, issue.node_id, issue.title);
83
+ const tasks = await listSubIssues(token, issue.node_id).catch(() => []);
84
+ for (const t of tasks) add(t.number, t.nodeId, t.title);
85
+ } else { // Task → inclui a Story pai e as Tasks irmãs.
86
+ add(issue.number, issue.node_id, issue.title);
87
+ const parent = await getIssueParent(token, issue.node_id).catch(() => null);
88
+ if (parent && detectIssueType({ title: parent.title }) === 'Story') {
89
+ add(parent.number, parent.nodeId, parent.title);
90
+ const tasks = await listSubIssues(token, parent.nodeId).catch(() => []);
91
+ for (const t of tasks) add(t.number, t.nodeId, t.title);
92
+ }
93
+ }
94
+ return unit;
95
+ }
96
+
97
+ // Avança um item do board para a Etapa "👀 Code Review" e reinicia o Status
98
+ // (nativo) para "Todo". Uma issue só AVANÇA: se já estiver em Code Review ou em
99
+ // uma etapa posterior, não é tocada (retorna false). Retorna true se avançou.
58
100
  async function setCodeReview(token, project, etapaField, statusField, nodeId) {
59
101
  const itemId = await addProjectItem(token, project.id, nodeId);
102
+
60
103
  if (etapaField?.id && CODE_REVIEW_STAGE) {
104
+ // Nunca retroceder: compara a etapa atual com a de destino na ordem canônica.
105
+ const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
106
+ const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
107
+ const tgtIdx = STAGE_ORDER.indexOf(CODE_REVIEW_STAGE);
108
+ if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
109
+ return false; // já está em Code Review ou adiante — não retrocede
110
+ }
61
111
  const optionId = etapaField.options?.[CODE_REVIEW_STAGE];
62
112
  if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
63
113
  }
114
+ // Ao avançar de etapa, o Status reinicia em "Todo".
64
115
  if (statusField?.id) {
65
116
  const optionId = statusField.options?.[TODO_STATUS];
66
117
  if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
67
118
  }
119
+ return true;
68
120
  }
69
121
 
70
122
  export async function codeReview({ prNumber }) {
@@ -117,16 +169,24 @@ export async function codeReview({ prNumber }) {
117
169
  const seen = new Set();
118
170
  const updated = [];
119
171
 
172
+ // Para cada issue referenciada, move a unidade inteira (Feature + Story +
173
+ // Tasks) para Code Review — tudo anda junto com a Feature.
120
174
  for (const num of issueNums) {
121
- const feature = await resolveFeatureIssue(token, owner, repo, num);
122
- if (!feature || seen.has(feature.number)) continue;
123
- seen.add(feature.number);
124
- try {
125
- await setCodeReview(projectToken, project, etapaField, statusField, feature.node_id);
126
- updated.push(`#${feature.number} ${feature.title}`);
127
- console.log(`Feature #${feature.number} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
128
- } catch (err) {
129
- console.warn(`Falha ao atualizar Feature #${feature.number}: ${err.message}`);
175
+ const unit = await collectReviewUnit(token, owner, repo, num);
176
+ for (const [n, info] of unit) {
177
+ if (seen.has(n)) continue;
178
+ seen.add(n);
179
+ try {
180
+ const moved = await setCodeReview(projectToken, project, etapaField, statusField, info.nodeId);
181
+ if (moved) {
182
+ updated.push(`#${n} ${info.title}`);
183
+ console.log(`#${n} "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
184
+ } else {
185
+ console.log(`#${n} já está em "${CODE_REVIEW_STAGE}" ou etapa posterior — mantido (não retrocede).`);
186
+ }
187
+ } catch (err) {
188
+ console.warn(`Falha ao atualizar #${n}: ${err.message}`);
189
+ }
130
190
  }
131
191
  }
132
192
 
@@ -134,10 +194,10 @@ export async function codeReview({ prNumber }) {
134
194
  await commentOnIssue(
135
195
  token, owner, repo, parseInt(prNumber, 10),
136
196
  `🔍 **Code Review iniciado**\n\n` +
137
- `Feature(s) movida(s) para **${CODE_REVIEW_STAGE}**:\n\n` +
197
+ `Movidos para **${CODE_REVIEW_STAGE}** (Feature + Story + Tasks):\n\n` +
138
198
  updated.map(f => `- ${f}`).join('\n')
139
199
  ).catch(() => {});
140
200
  }
141
201
 
142
- console.log(`code-review: ${updated.length} feature(s) atualizada(s).`);
202
+ console.log(`code-review: ${updated.length} item(ns) movido(s) para "${CODE_REVIEW_STAGE}".`);
143
203
  }
@@ -4,7 +4,10 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { execSync } from 'node:child_process';
5
5
  import path from 'node:path';
6
6
  import { resolveToken } from '../api/auth.mjs';
7
- import { CONFIG_FILE } from '../config.mjs';
7
+ import {
8
+ CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW,
9
+ PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
10
+ } from '../config.mjs';
8
11
  import { getIssue } from '../api/github-rest.mjs';
9
12
  import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
10
13
  import { detectIssueType } from '../lib/issue-type.mjs';
@@ -14,14 +17,17 @@ import { slugify } from '../lib/slugify.mjs';
14
17
  const WORK_DIR = '.spec-wave';
15
18
 
16
19
  // Sobe a cadeia de pais (Task → Story → Feature) até achar uma issue do tipo
17
- // "Feature" e devolve seu título (para resolver docs/features/<slug>). Limita a
18
- // profundidade para evitar loops em dados inconsistentes.
19
- async function resolveFeatureTitle(token, startNodeId) {
20
+ // "Feature" e devolve { number, title } — usado para resolver docs/features/<slug>
21
+ // e para as instruções de fim de Story (mover a Feature para Code Review). Limita
22
+ // a profundidade para evitar loops em dados inconsistentes.
23
+ async function resolveFeature(token, startNodeId) {
20
24
  let current = startNodeId;
21
25
  for (let depth = 0; depth < 5 && current; depth++) {
22
26
  const parent = await getIssueParent(token, current);
23
27
  if (!parent) return null;
24
- if (detectIssueType({ title: parent.title }) === 'Feature') return parent.title;
28
+ if (detectIssueType({ title: parent.title }) === 'Feature') {
29
+ return { number: parent.number, title: parent.title };
30
+ }
25
31
  current = parent.nodeId;
26
32
  }
27
33
  return null;
@@ -40,7 +46,7 @@ function readSpecPlan(featureDir) {
40
46
  }
41
47
 
42
48
  // Monta o markdown de contexto que será entregue ao spec-kit implement.
43
- function buildContext({ type, issue, tasks, spec, plan, specPath, planPath }) {
49
+ function buildContext({ type, issue, tasks, feature, spec, plan, specPath, planPath }) {
44
50
  const lines = [];
45
51
  lines.push(`# Contexto de implementação — ${type} #${issue.number}`);
46
52
  lines.push('');
@@ -50,32 +56,66 @@ function buildContext({ type, issue, tasks, spec, plan, specPath, planPath }) {
50
56
  lines.push(issue.body.trim());
51
57
  }
52
58
 
53
- // Issues cujo status deve ir para "in progress" ao iniciar: a issue alvo
54
- // (Story ou Task) e, no caso de Story, todas as suas Tasks.
55
- const inProgress = [issue.number, ...tasks.map(t => t.number)]
56
- .filter((n, i, arr) => arr.indexOf(n) === i);
59
+ // Modelo do board: "Etapa" (coluna do kanban) = DIREÇÃO, avança; "Status"
60
+ // (Todo/In Progress/Done) = PROGRESSO dentro da etapa, reinicia a cada avanço.
61
+ // O desenvolvimento acontece na Etapa 🚧 Desenvolvimento; o progresso por task
62
+ // é registrado no STATUS. Ao final, a Etapa avança para Code Review.
57
63
  lines.push('');
58
- lines.push('## Instruções para o agente');
64
+ lines.push('## Instruções de execução (uma task por vez, sequencial)');
59
65
  lines.push('');
60
66
  lines.push(
61
- 'Antes de começar a implementação, atualize o status no GitHub Project para ' +
62
- '**🚧 Desenvolvimento** (in progress) ' +
63
- (type === 'Story'
64
- ? `da Story #${issue.number} e de cada Task: ${inProgress.filter(n => n !== issue.number).map(n => `#${n}`).join(', ')}.`
65
- : `da Task #${issue.number}.`)
67
+ ' **dois campos** no board com papéis diferentes não os confunda:\n' +
68
+ `- **Etapa** (Backlog → … → ${STAGE_DEVELOPMENT} → ${STAGE_CODE_REVIEW} → …): a DIREÇÃO no kanban. Uma issue só **avança**, **nunca** volta para uma etapa anterior.\n` +
69
+ `- **Status** (${PROGRESS_TODO} ${PROGRESS_IN_PROGRESS} → ${PROGRESS_DONE}): o **progresso dentro da etapa atual**. Ao avançar de etapa, o Status **reinicia em ${PROGRESS_TODO}**.`
66
70
  );
71
+ lines.push('');
72
+ if (type === 'Story') {
73
+ lines.push(
74
+ `Nesta fase, a Story #${issue.number} e suas Tasks estão na Etapa **${STAGE_DEVELOPMENT}**. ` +
75
+ 'Durante a implementação, mexa **apenas no Status** das Tasks — **não** mude a Etapa delas.'
76
+ );
77
+ lines.push('');
78
+ lines.push(`1. Garanta que a Story #${issue.number} está na Etapa **${STAGE_DEVELOPMENT}** com Status **${PROGRESS_IN_PROGRESS}**, e que as Tasks estão nessa Etapa com Status **${PROGRESS_TODO}**.`);
79
+ lines.push(`2. Implemente as ${tasks.length} task(s) **uma de cada vez, na ordem abaixo**. É PROIBIDO ter mais de uma Task com Status **${PROGRESS_IN_PROGRESS}** ao mesmo tempo. Para **cada Task**, na ordem:`);
80
+ lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
81
+ lines.push(' 2. **Implemente** a Task por completo.');
82
+ lines.push(` 3. **Ao concluir:** Status da Task → **${PROGRESS_DONE}** (ainda na Etapa ${STAGE_DEVELOPMENT}).`);
83
+ lines.push(' 4. Só então avance para a próxima Task.');
84
+ lines.push('');
85
+ lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks com Status ${PROGRESS_DONE}):`);
86
+ lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
87
+ lines.push(` 2. Abra o **Pull Request** da Story #${issue.number}.`);
88
+ lines.push(
89
+ ` 3. **Avance a Etapa — em conjunto — ${feature ? `da Feature #${feature.number}` : 'da Feature (issue pai da Story)'}, ` +
90
+ `a Story #${issue.number} e todas as ${tasks.length} Task(s) (${tasks.map(t => `#${t.number}`).join(', ')}) ` +
91
+ `para ${STAGE_CODE_REVIEW}**, e **reinicie o Status de cada um para ${PROGRESS_TODO}**. ` +
92
+ 'Tudo anda junto com a Feature e sempre para frente.'
93
+ );
94
+ } else {
95
+ lines.push(
96
+ `Esta Task #${issue.number} está na Etapa **${STAGE_DEVELOPMENT}**. Acompanhe o progresso pelo ` +
97
+ '**Status** — **não** mude a Etapa aqui:'
98
+ );
99
+ lines.push('');
100
+ lines.push(`1. **Ao começar:** Status da Task #${issue.number} → **${PROGRESS_IN_PROGRESS}**.`);
101
+ lines.push('2. **Implemente** a Task por completo.');
102
+ lines.push(`3. **Ao concluir:** Status da Task #${issue.number} → **${PROGRESS_DONE}**.`);
103
+ lines.push('');
104
+ lines.push(`> A Etapa avança para **${STAGE_CODE_REVIEW}** (Status reinicia em ${PROGRESS_TODO}) junto com a Story/Feature quando o PR é aberto — nunca volta para uma etapa anterior.`);
105
+ }
106
+ lines.push('');
67
107
  lines.push(
68
- '(Atualize o campo "Etapa"/Status do item no board; mantenha o status coerente ' +
69
- 'conforme o progresso da implementação.)'
108
+ `> **Regra do board:** a **Etapa** avança (nunca retrocede); o **Status** (${PROGRESS_TODO}/${PROGRESS_IN_PROGRESS}/${PROGRESS_DONE}) ` +
109
+ ' mede o progresso dentro da etapa atual e reinicia a cada avanço de etapa.'
70
110
  );
71
111
 
72
112
  lines.push('');
73
- lines.push(`## Tasks a implementar (${tasks.length})`);
74
- for (const t of tasks) {
113
+ lines.push(`## Tasks a implementar — NESTA ORDEM (${tasks.length})`);
114
+ tasks.forEach((t, i) => {
75
115
  lines.push('');
76
- lines.push(`### #${t.number} ${t.title}`);
116
+ lines.push(`### ${i + 1}. #${t.number} ${t.title}`);
77
117
  if (t.body && t.body.trim()) lines.push(t.body.trim());
78
- }
118
+ });
79
119
 
80
120
  if (spec) {
81
121
  lines.push('');
@@ -171,11 +211,12 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
171
211
  return;
172
212
  }
173
213
 
174
- // 4. Resolve spec.md/plan.md da Feature (enriquecimento opcional).
214
+ // 4. Resolve a Feature (pai na cadeia) — para spec.md/plan.md e para as
215
+ // instruções de fim de Story (mover Feature + Story para Code Review).
216
+ const feature = await resolveFeature(token, issue.node_id);
175
217
  let featureDir = featureDirOpt;
176
- if (!featureDir) {
177
- const featureTitle = await resolveFeatureTitle(token, issue.node_id);
178
- if (featureTitle) featureDir = path.join('docs', 'features', slugify(featureTitle));
218
+ if (!featureDir && feature?.title) {
219
+ featureDir = path.join('docs', 'features', slugify(feature.title));
179
220
  }
180
221
  let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
181
222
  if (featureDir && existsSync(featureDir)) {
@@ -187,7 +228,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
187
228
  }
188
229
 
189
230
  // 5. Monta e grava o arquivo de contexto.
190
- const context = buildContext({ type, issue, tasks, ...specPlan });
231
+ const context = buildContext({ type, issue, tasks, feature, ...specPlan });
191
232
  mkdirSync(WORK_DIR, { recursive: true });
192
233
  const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
193
234
  writeFileSync(tasksFile, context);
@@ -2,7 +2,7 @@ import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
3
  import { readFileSync, existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { CONFIG_FILE } from '../config.mjs';
5
+ import { CONFIG_FILE, PORTAL_URL } from '../config.mjs';
6
6
 
7
7
  // Lê o marcador .spec-wave.json do repositório atual (cwd) e reporta se o
8
8
  // spec-wave já foi inicializado. Usado pela skill para decidir entre mostrar
@@ -17,6 +17,7 @@ export async function info(options = {}) {
17
17
  }
18
18
  p.intro(chalk.bold('spec-wave info'));
19
19
  p.log.warn(`Este repositório ${chalk.bold('não foi inicializado')} (sem ${CONFIG_FILE}).`);
20
+ p.log.info(`🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`);
20
21
  p.outro('Execute `npx @spec-wave/cli init` para configurar.');
21
22
  return;
22
23
  }
@@ -50,5 +51,6 @@ export async function info(options = {}) {
50
51
  `${chalk.dim('Criado em:')} ${config.initializedAt ?? '?'}`,
51
52
  'Configuração'
52
53
  );
54
+ p.log.info(`🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`);
53
55
  p.outro('Use `/spec-wave feature <descrição>` para criar uma Feature.');
54
56
  }
@@ -1,6 +1,6 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import path from 'node:path';
6
6
  import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
@@ -8,8 +8,8 @@ import { runWizard } from '../ui/wizard.mjs';
8
8
  import { setupProject } from '../setup/project.mjs';
9
9
  import { setupLabels } from '../setup/labels.mjs';
10
10
  import { setupFiles } from '../setup/files.mjs';
11
- import { upsertFile, getFileContent } from '../api/github-rest.mjs';
12
- import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
11
+ import { getFileContent } from '../api/github-rest.mjs';
12
+ import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER, PORTAL_URL } from '../config.mjs';
13
13
 
14
14
  const __dir = path.dirname(fileURLToPath(import.meta.url));
15
15
  const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
@@ -91,8 +91,13 @@ export async function init(options) {
91
91
  if (options.skipProject) {
92
92
  p.log.info('Pulando criação do GitHub Project (--skip-project).');
93
93
  // Preserva o bloco project do .spec-wave.json existente (se houver).
94
+ // Prefere o arquivo local; recorre ao remoto para repos configurados por
95
+ // versões antigas (que commitavam o config direto no repo).
94
96
  try {
95
- const raw = await getFileContent(token, owner, repo, CONFIG_FILE);
97
+ const localConfigPath = path.join(process.cwd(), CONFIG_FILE);
98
+ const raw = existsSync(localConfigPath)
99
+ ? readFileSync(localConfigPath, 'utf-8')
100
+ : await getFileContent(token, owner, repo, CONFIG_FILE);
96
101
  if (raw) {
97
102
  const existing = JSON.parse(raw);
98
103
  if (existing.project) {
@@ -165,10 +170,13 @@ export async function init(options) {
165
170
  }
166
171
 
167
172
  // --- Marcador de configuração (.spec-wave.json) ---
168
- // Commitado no repo-alvo para que a skill detecte, em sessões futuras, que o
169
- // init rodou e qual project/versão foi usado. É a fonte de estado persistente.
173
+ // Gravado LOCALMENTE no diretório atual (não commitado direto no repo): é a
174
+ // fonte de estado persistente lida por info/refresh/uninstall/skill a partir
175
+ // do cwd. O usuário revisa e commita quando quiser.
176
+ const localConfigPath = path.join(process.cwd(), CONFIG_FILE);
177
+ let configWritten = false;
170
178
  const configSpinner = p.spinner();
171
- configSpinner.start(`Gravando ${CONFIG_FILE}...`);
179
+ configSpinner.start(`Gravando ${CONFIG_FILE} local...`);
172
180
  try {
173
181
  const config = {
174
182
  version: pkg.version,
@@ -187,15 +195,9 @@ export async function init(options) {
187
195
  },
188
196
  initializedAt: new Date().toISOString(),
189
197
  };
190
- await upsertFile(
191
- token,
192
- owner,
193
- repo,
194
- CONFIG_FILE,
195
- JSON.stringify(config, null, 2) + '\n',
196
- 'chore: record spec-wave config [spec-wave]'
197
- );
198
- configSpinner.stop(`${CONFIG_FILE} gravado (spec-wave v${pkg.version})`);
198
+ writeFileSync(localConfigPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
199
+ configWritten = true;
200
+ configSpinner.stop(`${CONFIG_FILE} gravado local (spec-wave v${pkg.version})`);
199
201
  } catch (err) {
200
202
  configSpinner.stop('');
201
203
  p.log.warn(`Não foi possível gravar ${CONFIG_FILE}: ${err.message}`);
@@ -214,10 +216,14 @@ export async function init(options) {
214
216
  `\n${chalk.green('✓')} spec-wave configurado com sucesso!\n\n` +
215
217
  (projectUrl ? ` Projeto: ${chalk.cyan(projectUrl)}\n\n` : '') +
216
218
  ` Próximos passos:\n` +
217
- ` 1. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
218
- ` 2. Configure o board view para agrupar por "Etapa"\n` +
219
- ` 3. Crie uma Feature com o prefixo [FEATURE] no título\n` +
220
- ` 4. Use a skill spec-wave para guiar o fluxo\n\n` +
221
- ` ${chalk.dim('Para instalar a skill no seu agente: npx @spec-wave/cli install-skill')}`
219
+ (configWritten
220
+ ? ` 1. Commite o ${CONFIG_FILE} quando quiser (git add ${CONFIG_FILE} && git commit)\n`
221
+ : '') +
222
+ ` ${configWritten ? '2' : '1'}. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
223
+ ` ${configWritten ? '3' : '2'}. Configure o board view para agrupar por "Etapa"\n` +
224
+ ` ${configWritten ? '4' : '3'}. Crie uma Feature com o prefixo [FEATURE] no título\n` +
225
+ ` ${configWritten ? '5' : '4'}. Use a skill spec-wave para guiar o fluxo\n\n` +
226
+ ` ${chalk.dim('Para instalar a skill no seu agente: npx @spec-wave/cli install-skill')}\n\n` +
227
+ ` 🌐 Acesse o Portal Web da ferramenta em ${chalk.cyan(PORTAL_URL)}`
222
228
  );
223
229
  }
@@ -9,16 +9,22 @@ import path from 'node:path';
9
9
  const __dir = path.dirname(fileURLToPath(import.meta.url));
10
10
  // Fonte única da skill, publicada via "files": ["src"] no package.json.
11
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
+ // Reexportados para o comando `update` (detecção de skill desatualizada).
16
+ export { SKILL_SOURCE };
17
+ export const CLI_VERSION = pkg.version;
12
18
 
13
19
  // Marcadores usados para gravar/atualizar a skill de forma idempotente em
14
20
  // arquivos compartilhados (AGENTS.md) — permite reinstalar sem duplicar.
15
- const BLOCK_START = '<!-- spec-wave:start -->';
16
- const BLOCK_END = '<!-- spec-wave:end -->';
21
+ export const BLOCK_START = '<!-- spec-wave:start -->';
22
+ export const BLOCK_END = '<!-- spec-wave:end -->';
17
23
 
18
24
  // Registro de agentes suportados. Cada alvo descreve como detectá-lo no
19
25
  // diretório-base, onde gravar (projeto vs. global) e em que formato converter
20
26
  // o SKILL.md. Caminhos conferidos na doc oficial de cada ferramenta.
21
- const TARGETS = [
27
+ export const TARGETS = [
22
28
  {
23
29
  key: 'claude',
24
30
  name: 'Claude Code',
@@ -81,47 +87,59 @@ const TARGETS = [
81
87
  const TARGET_BY_KEY = new Map(TARGETS.map((t) => [t.key, t]));
82
88
 
83
89
  // Separa o frontmatter YAML do corpo do SKILL.md. Retorna { meta, body }.
84
- function parseSkill(raw) {
90
+ export function parseSkill(raw) {
85
91
  const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
86
- if (!match) return { meta: {}, body: raw.trim() };
92
+ if (!match) return { meta: {}, frontmatter: '', body: raw.trim() };
87
93
  let meta = {};
88
94
  try {
89
95
  meta = yaml.load(match[1]) || {};
90
96
  } catch {
91
97
  meta = {};
92
98
  }
93
- return { meta, body: match[2].trim() };
99
+ return { meta, frontmatter: match[1], body: match[2].trim() };
94
100
  }
95
101
 
96
- // Converte o SKILL.md para o formato exigido por cada agente.
97
- function renderContent(format, raw, parsed) {
98
- const { meta, body } = parsed;
102
+ // Banner de versão inserido no topo do corpo da skill instalada. O agente
103
+ // esta linha e, se `npx @spec-wave/cli --version` for maior, orienta reinstalar.
104
+ function versionBanner(version) {
105
+ return (
106
+ `> ⚙️ **spec-wave skill v${version}** — esta skill é uma cópia estática. ` +
107
+ 'Se `npx @spec-wave/cli --version` indicar uma versão maior, ela está ' +
108
+ 'desatualizada: rode `npx @spec-wave/cli update` (atualiza só o que mudou) ' +
109
+ 'ou `npx @spec-wave/cli install-skill --force` (só a skill).'
110
+ );
111
+ }
112
+
113
+ // Converte o SKILL.md para o formato exigido por cada agente, carimbando a versão.
114
+ export function renderContent(format, parsed, version) {
115
+ const { meta, frontmatter, body } = parsed;
99
116
  const description = meta.description ?? 'Skill spec-wave.';
117
+ const banner = versionBanner(version);
100
118
  switch (format) {
101
119
  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';
120
+ // Claude Code / opencode consomem o SKILL.md nativo. Preserva o frontmatter
121
+ // original (allowed-tools etc.) e insere o banner no topo do corpo.
122
+ return `---\n${frontmatter}\n---\n\n${banner}\n\n${body}\n`;
105
123
  case 'mdc':
106
124
  return (
107
125
  `---\n` +
108
126
  `description: ${JSON.stringify(description)}\n` +
109
127
  `alwaysApply: false\n` +
110
128
  `---\n\n` +
111
- `${body}\n`
129
+ `${banner}\n\n${body}\n`
112
130
  );
113
131
  case 'rules':
114
- return `# spec-wave\n\n${description}\n\n${body}\n`;
132
+ return `# spec-wave\n\n${banner}\n\n${description}\n\n${body}\n`;
115
133
  case 'agents':
116
- return `${BLOCK_START}\n\n# spec-wave\n\n${description}\n\n${body}\n\n${BLOCK_END}\n`;
134
+ return `${BLOCK_START}\n\n# spec-wave\n\n${banner}\n\n${description}\n\n${body}\n\n${BLOCK_END}\n`;
117
135
  default:
118
- return raw;
136
+ return body;
119
137
  }
120
138
  }
121
139
 
122
140
  // Insere/atualiza o bloco spec-wave num arquivo compartilhado (AGENTS.md),
123
141
  // preservando o restante do conteúdo. Idempotente via marcadores.
124
- function mergeAgentsFile(destPath, block) {
142
+ export function mergeAgentsFile(destPath, block) {
125
143
  const existing = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
126
144
  const blockRe = new RegExp(
127
145
  `${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
@@ -137,9 +155,18 @@ function escapeRe(s) {
137
155
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
138
156
  }
139
157
 
158
+ // Extrai o bloco spec-wave (entre marcadores) de um arquivo compartilhado
159
+ // (AGENTS.md). Retorna o texto do bloco incluindo os marcadores, ou null.
160
+ export function extractAgentsBlock(fileContent) {
161
+ const m = fileContent.match(
162
+ new RegExp(`${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}`),
163
+ );
164
+ return m ? m[0] : null;
165
+ }
166
+
140
167
  // Resolve o alvo do agente para um destino concreto no escopo escolhido.
141
168
  // Retorna null quando o agente não suporta o escopo global.
142
- function resolveDest(target, baseDir, isGlobal) {
169
+ export function resolveDest(target, baseDir, isGlobal) {
143
170
  const rel = isGlobal ? target.global : target.project;
144
171
  if (!rel) return null;
145
172
  const format = isGlobal && target.globalFormat ? target.globalFormat : target.format;
@@ -147,7 +174,7 @@ function resolveDest(target, baseDir, isGlobal) {
147
174
  }
148
175
 
149
176
  // Retorna true se algum dos sinais de detecção existir em baseDir.
150
- function isDetected(target, baseDir) {
177
+ export function isDetected(target, baseDir) {
151
178
  return target.detect.some((sig) => existsSync(path.join(baseDir, sig)));
152
179
  }
153
180
 
@@ -258,8 +285,8 @@ export async function installSkill(options = {}) {
258
285
  for (const { target, dest } of jobs) {
259
286
  const content =
260
287
  dest.format === 'agents'
261
- ? mergeAgentsFile(dest.path, renderContent('agents', raw, parsed))
262
- : renderContent(dest.format, raw, parsed);
288
+ ? mergeAgentsFile(dest.path, renderContent('agents', parsed, pkg.version))
289
+ : renderContent(dest.format, parsed, pkg.version);
263
290
 
264
291
  // Confirmar sobrescrita de arquivos "próprios" (skill/rules/mdc). Para
265
292
  // 'agents' o merge por marcadores já é seguro (não apaga conteúdo alheio).
@@ -286,7 +313,10 @@ export async function installSkill(options = {}) {
286
313
 
287
314
  p.note(
288
315
  written.map((w) => `${chalk.green('✓')} ${chalk.bold(w.target.name)}\n ${chalk.dim(w.dest.path)}`).join('\n'),
289
- `Skill instalada (escopo: ${scopeLabel})`,
316
+ `Skill v${pkg.version} instalada (escopo: ${scopeLabel})`,
317
+ );
318
+ p.outro(
319
+ 'Reinicie/recarregue o agente para que ele detecte a skill. ' +
320
+ 'Ao atualizar a CLI, rode `install-skill --force` para atualizar a skill também.',
290
321
  );
291
- p.outro('Reinicie/recarregue o agente para que ele detecte a skill.');
292
322
  }