@spec-wave/cli 0.1.0 → 0.2.1
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 +13 -0
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +50 -0
- package/src/commands/decompose.mjs +38 -20
- package/src/commands/implement.mjs +222 -0
- package/src/commands/info.mjs +1 -0
- package/src/commands/init.mjs +20 -4
- package/src/config.mjs +29 -0
- package/src/lib/claude.mjs +73 -4
- package/src/lib/issue-type.mjs +37 -0
- package/src/templates/workflows/decompose.yml +1 -0
- package/src/templates/workflows/generate-plan.yml +1 -0
- package/src/templates/workflows/generate-spec.yml +1 -0
- package/src/ui/wizard.mjs +27 -1
package/bin/spec-wave.mjs
CHANGED
|
@@ -22,6 +22,8 @@ program
|
|
|
22
22
|
.option('--skip-project', 'Pula a criação do GitHub Project (use se já foi criado)')
|
|
23
23
|
.option('--skip-labels', 'Pula a criação das labels')
|
|
24
24
|
.option('--skip-files', 'Pula a criação dos arquivos de workflow')
|
|
25
|
+
.option('--provider <provider>', 'Provider de IA dos workflows: anthropic ou openrouter')
|
|
26
|
+
.option('--model <model>', 'Modelo de IA usado pelos workflows (ex.: anthropic/claude-3.7-sonnet)')
|
|
25
27
|
.action(async (options) => {
|
|
26
28
|
const { init } = await import('../src/commands/init.mjs');
|
|
27
29
|
await init(options);
|
|
@@ -122,4 +124,15 @@ program
|
|
|
122
124
|
await decompose(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
123
125
|
});
|
|
124
126
|
|
|
127
|
+
program
|
|
128
|
+
.command('implement')
|
|
129
|
+
.description('Aciona o spec-kit implement para uma Story (todas as tasks) ou uma Task')
|
|
130
|
+
.argument('<issue>', 'Número da issue (Story ou Task), ex.: 12 ou #12')
|
|
131
|
+
.option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
|
|
132
|
+
.option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
|
|
133
|
+
.action(async (issue, options) => {
|
|
134
|
+
const { implement } = await import('../src/commands/implement.mjs');
|
|
135
|
+
await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
|
|
136
|
+
});
|
|
137
|
+
|
|
125
138
|
program.parse();
|
package/package.json
CHANGED
|
@@ -183,6 +183,56 @@ export async function addSubIssue(token, parentIssueId, childIssueId) {
|
|
|
183
183
|
`, { issueId: parentIssueId, subIssueId: childIssueId });
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// Lista as sub-issues de uma issue (pelo node id da issue pai). Retorna
|
|
187
|
+
// [{ number, title, body, nodeId, labels: [nome...] }]. Usado pelo comando
|
|
188
|
+
// `implement` para coletar as Tasks de uma Story.
|
|
189
|
+
export async function listSubIssues(token, issueNodeId) {
|
|
190
|
+
const client = makeClient(token);
|
|
191
|
+
const result = await client(`
|
|
192
|
+
query SubIssues($id: ID!) {
|
|
193
|
+
node(id: $id) {
|
|
194
|
+
... on Issue {
|
|
195
|
+
subIssues(first: 100) {
|
|
196
|
+
nodes {
|
|
197
|
+
id
|
|
198
|
+
number
|
|
199
|
+
title
|
|
200
|
+
body
|
|
201
|
+
labels(first: 20) { nodes { name } }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
`, { id: issueNodeId });
|
|
208
|
+
const nodes = result.node?.subIssues?.nodes || [];
|
|
209
|
+
return nodes.map(n => ({
|
|
210
|
+
number: n.number,
|
|
211
|
+
title: n.title,
|
|
212
|
+
body: n.body || '',
|
|
213
|
+
nodeId: n.id,
|
|
214
|
+
labels: (n.labels?.nodes || []).map(l => l.name),
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Lê o parent (issue pai) de uma sub-issue. Retorna { number, title, nodeId }
|
|
219
|
+
// ou null se a issue não tiver pai. Usado para subir a cadeia Task→Story→Feature.
|
|
220
|
+
export async function getIssueParent(token, issueNodeId) {
|
|
221
|
+
const client = makeClient(token);
|
|
222
|
+
const result = await client(`
|
|
223
|
+
query IssueParent($id: ID!) {
|
|
224
|
+
node(id: $id) {
|
|
225
|
+
... on Issue {
|
|
226
|
+
parent { id number title }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
`, { id: issueNodeId });
|
|
231
|
+
const parent = result.node?.parent;
|
|
232
|
+
if (!parent) return null;
|
|
233
|
+
return { number: parent.number, title: parent.title, nodeId: parent.id };
|
|
234
|
+
}
|
|
235
|
+
|
|
186
236
|
// Define o valor de um campo SINGLE_SELECT para um item do Project.
|
|
187
237
|
export async function setItemSingleSelect(token, projectId, itemId, fieldId, optionId) {
|
|
188
238
|
const client = makeClient(token);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
2
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
-
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
3
|
+
import { getIssue, createIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
4
|
+
import { addSubIssue } from '../api/github-graphql.mjs';
|
|
5
5
|
import { generateDocument } from '../lib/claude.mjs';
|
|
6
6
|
import { slugify } from '../lib/slugify.mjs';
|
|
7
7
|
|
|
@@ -12,11 +12,12 @@ Responda APENAS com JSON válido neste formato:
|
|
|
12
12
|
{
|
|
13
13
|
"stories": [
|
|
14
14
|
{
|
|
15
|
-
"title": "
|
|
16
|
-
"
|
|
15
|
+
"title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
|
|
16
|
+
"userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
|
|
17
|
+
"body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
|
|
17
18
|
"tasks": [
|
|
18
19
|
{
|
|
19
|
-
"title": "
|
|
20
|
+
"title": "Título técnico curto da task (sem prefixo)",
|
|
20
21
|
"body": "Descrição técnica detalhada"
|
|
21
22
|
}
|
|
22
23
|
]
|
|
@@ -25,9 +26,11 @@ Responda APENAS com JSON válido neste formato:
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
Regras:
|
|
29
|
+
- "title" deve ser CURTO (máx. ~60 caracteres): apenas a parte "quero" da user story, sem o "Como" nem o "para", e sem prefixo. Ex.: "visualizar meus repositórios em layout responsivo"
|
|
30
|
+
- "userStory" deve trazer a user story completa no formato "Como <perfil>, quero <objetivo>, para <benefício>"
|
|
31
|
+
- "body" é texto complementar (contexto, critérios de aceite); não repita o título
|
|
28
32
|
- Cada Story deve ter 2–5 Tasks associadas
|
|
29
|
-
-
|
|
30
|
-
- Tasks devem ser atividades técnicas concretas
|
|
33
|
+
- Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
|
|
31
34
|
- Gere entre 3 e 7 Stories por Feature`;
|
|
32
35
|
|
|
33
36
|
export async function decompose({ issueNumber }) {
|
|
@@ -75,26 +78,41 @@ export async function decompose({ issueNumber }) {
|
|
|
75
78
|
decomposition = JSON.parse(jsonMatch[0]);
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
// node id da Feature — necessário para vincular as stories como sub-issues.
|
|
82
|
+
const featureNodeId = issue.node_id;
|
|
83
|
+
|
|
78
84
|
const created = [];
|
|
79
85
|
|
|
80
86
|
for (const story of decomposition.stories) {
|
|
81
87
|
console.log(`Criando story: ${story.title}`);
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
const storyTitle = `[STORY] ${story.title}`;
|
|
89
|
+
// Corpo: user story completa (Como/quero/para) + texto complementar.
|
|
90
|
+
const storyBody = [story.userStory, story.body]
|
|
91
|
+
.map(s => (s || '').trim())
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join('\n\n') || '_(sem descrição)_';
|
|
94
|
+
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
95
|
+
created.push({ type: 'story', title: storyTitle, url: createdStory.url });
|
|
96
|
+
|
|
97
|
+
// Vincula a story como sub-issue da Feature (relação nativa do GitHub).
|
|
98
|
+
try {
|
|
99
|
+
await addSubIssue(token, featureNodeId, createdStory.nodeId);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
|
|
102
|
+
}
|
|
89
103
|
|
|
90
104
|
for (const task of story.tasks || []) {
|
|
91
105
|
console.log(` Criando task: ${task.title}`);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
const taskTitle = `[TASK] ${task.title}`;
|
|
107
|
+
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
108
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
109
|
+
|
|
110
|
+
// Vincula a task como sub-issue da Story.
|
|
111
|
+
try {
|
|
112
|
+
await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
|
|
115
|
+
}
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
118
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
7
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
8
|
+
import { getIssue } from '../api/github-rest.mjs';
|
|
9
|
+
import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
|
|
10
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
11
|
+
import { slugify } from '../lib/slugify.mjs';
|
|
12
|
+
|
|
13
|
+
// Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
|
|
14
|
+
const WORK_DIR = '.spec-wave';
|
|
15
|
+
|
|
16
|
+
// 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
|
+
let current = startNodeId;
|
|
21
|
+
for (let depth = 0; depth < 5 && current; depth++) {
|
|
22
|
+
const parent = await getIssueParent(token, current);
|
|
23
|
+
if (!parent) return null;
|
|
24
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') return parent.title;
|
|
25
|
+
current = parent.nodeId;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Lê spec.md/plan.md de um docs/features/<slug> se existirem.
|
|
31
|
+
function readSpecPlan(featureDir) {
|
|
32
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
33
|
+
const planPath = path.join(featureDir, 'plan.md');
|
|
34
|
+
return {
|
|
35
|
+
specPath,
|
|
36
|
+
planPath,
|
|
37
|
+
spec: existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null,
|
|
38
|
+
plan: existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Monta o markdown de contexto que será entregue ao spec-kit implement.
|
|
43
|
+
function buildContext({ type, issue, tasks, spec, plan, specPath, planPath }) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`# Contexto de implementação — ${type} #${issue.number}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push(`**${type}:** ${issue.title}`);
|
|
48
|
+
if (issue.body && issue.body.trim()) {
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push(issue.body.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push(`## Tasks a implementar (${tasks.length})`);
|
|
55
|
+
for (const t of tasks) {
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push(`### #${t.number} ${t.title}`);
|
|
58
|
+
if (t.body && t.body.trim()) lines.push(t.body.trim());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (spec) {
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push(`## spec.md (${specPath})`);
|
|
64
|
+
lines.push(spec.trim());
|
|
65
|
+
}
|
|
66
|
+
if (plan) {
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(`## plan.md (${planPath})`);
|
|
69
|
+
lines.push(plan.trim());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push('');
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Substitui os placeholders do template de comando do spec-kit.
|
|
77
|
+
function renderCommand(template, vars) {
|
|
78
|
+
return template.replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function implement({ issue: issueArg, featureDir: featureDirOpt, dryRun }) {
|
|
82
|
+
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
83
|
+
if (!Number.isInteger(issueNumber)) {
|
|
84
|
+
p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 1. Config local (.spec-wave.json) — owner/repo e bloco opcional specKit.
|
|
90
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
91
|
+
if (!existsSync(configPath)) {
|
|
92
|
+
p.log.error(`Repositório não inicializado (sem ${CONFIG_FILE}). Rode \`spec-wave init\` primeiro.`);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let config;
|
|
97
|
+
try {
|
|
98
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
99
|
+
} catch (err) {
|
|
100
|
+
p.log.error(`${CONFIG_FILE} corrompido: ${err.message}`);
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const { owner, repo } = config;
|
|
105
|
+
if (!owner || !repo) {
|
|
106
|
+
p.log.error(`${CONFIG_FILE} não contém owner/repo. Rode \`spec-wave init\` novamente.`);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let token;
|
|
112
|
+
try {
|
|
113
|
+
token = await resolveToken();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
p.log.error(err.message);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
p.intro(chalk.bold(`spec-wave implement #${issueNumber}`));
|
|
121
|
+
|
|
122
|
+
// 2. Lê a issue alvo.
|
|
123
|
+
let issue;
|
|
124
|
+
try {
|
|
125
|
+
issue = await getIssue(token, owner, repo, issueNumber);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
p.log.error(`Não foi possível ler a issue #${issueNumber}: ${err.message}`);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. Detecta o tipo e monta a lista de tasks.
|
|
133
|
+
const type = detectIssueType(issue);
|
|
134
|
+
let tasks;
|
|
135
|
+
if (type === 'Story') {
|
|
136
|
+
const subs = await listSubIssues(token, issue.node_id);
|
|
137
|
+
tasks = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Task');
|
|
138
|
+
if (tasks.length === 0) {
|
|
139
|
+
p.log.error(`Story #${issueNumber} não tem Tasks (sub-issues) para implementar.`);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
p.log.info(`Story com ${tasks.length} task(s): ${tasks.map(t => `#${t.number}`).join(', ')}`);
|
|
144
|
+
} else if (type === 'Task') {
|
|
145
|
+
tasks = [{ number: issue.number, title: issue.title, body: issue.body || '' }];
|
|
146
|
+
p.log.info(`Task única #${issueNumber}.`);
|
|
147
|
+
} else {
|
|
148
|
+
p.log.error(
|
|
149
|
+
`implement só aceita Story ou Task. Issue #${issueNumber} é do tipo ${type || 'desconhecido'}.`
|
|
150
|
+
);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Resolve spec.md/plan.md da Feature (enriquecimento opcional).
|
|
156
|
+
let featureDir = featureDirOpt;
|
|
157
|
+
if (!featureDir) {
|
|
158
|
+
const featureTitle = await resolveFeatureTitle(token, issue.node_id);
|
|
159
|
+
if (featureTitle) featureDir = path.join('docs', 'features', slugify(featureTitle));
|
|
160
|
+
}
|
|
161
|
+
let specPlan = { spec: null, plan: null, specPath: null, planPath: null };
|
|
162
|
+
if (featureDir && existsSync(featureDir)) {
|
|
163
|
+
specPlan = readSpecPlan(featureDir);
|
|
164
|
+
} else if (featureDir) {
|
|
165
|
+
p.log.warn(`Diretório da feature não encontrado (${featureDir}); seguindo só com as tasks.`);
|
|
166
|
+
} else {
|
|
167
|
+
p.log.warn('Não foi possível resolver a Feature; seguindo só com as tasks (use --feature-dir).');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 5. Monta e grava o arquivo de contexto.
|
|
171
|
+
const context = buildContext({ type, issue, tasks, ...specPlan });
|
|
172
|
+
mkdirSync(WORK_DIR, { recursive: true });
|
|
173
|
+
const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
|
|
174
|
+
writeFileSync(tasksFile, context);
|
|
175
|
+
p.log.success(`Contexto montado em ${chalk.cyan(tasksFile)}.`);
|
|
176
|
+
|
|
177
|
+
// 6. Aciona o spec-kit (comando configurável).
|
|
178
|
+
const template = process.env.SPEC_WAVE_IMPLEMENT_CMD || config.specKit?.command;
|
|
179
|
+
const vars = {
|
|
180
|
+
tasksFile,
|
|
181
|
+
specFile: specPlan.specPath || '',
|
|
182
|
+
planFile: specPlan.planPath || '',
|
|
183
|
+
issue: String(issueNumber),
|
|
184
|
+
type,
|
|
185
|
+
title: issue.title,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (!template) {
|
|
189
|
+
p.log.warn('Comando do spec-kit não configurado.');
|
|
190
|
+
p.note(
|
|
191
|
+
`Configure em ${CONFIG_FILE}:\n` +
|
|
192
|
+
` "specKit": { "command": "<comando do spec-kit com placeholders>" }\n` +
|
|
193
|
+
'ou defina a env SPEC_WAVE_IMPLEMENT_CMD.\n\n' +
|
|
194
|
+
'Placeholders: {tasksFile} {specFile} {planFile} {issue} {type} {title}',
|
|
195
|
+
'Como acionar o spec-kit'
|
|
196
|
+
);
|
|
197
|
+
p.outro(`Contexto pronto em ${tasksFile}. Acione o spec-kit manualmente com esse arquivo.`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const command = renderCommand(template, vars);
|
|
202
|
+
|
|
203
|
+
if (dryRun) {
|
|
204
|
+
p.note(command, 'Comando que seria executado (--dry-run)');
|
|
205
|
+
p.outro(`Dry-run: nada executado. Contexto em ${tasksFile}.`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
p.log.step(`Executando: ${chalk.dim(command)}`);
|
|
210
|
+
try {
|
|
211
|
+
execSync(command, { stdio: 'inherit' });
|
|
212
|
+
} catch (err) {
|
|
213
|
+
p.log.error(`spec-kit implement falhou: ${err.message}`);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
p.outro(
|
|
219
|
+
`${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
|
|
220
|
+
' Próximo: revise as mudanças, abra o PR e mova o card para 👀 Code Review.'
|
|
221
|
+
);
|
|
222
|
+
}
|
package/src/commands/info.mjs
CHANGED
|
@@ -45,6 +45,7 @@ export async function info(options = {}) {
|
|
|
45
45
|
`${chalk.dim('Repositório:')} ${config.owner ?? '?'}/${config.repo ?? '?'}\n` +
|
|
46
46
|
`${chalk.dim('Project:')} ${config.project?.title ?? '—'}\n` +
|
|
47
47
|
`${chalk.dim('URL:')} ${config.project?.url ? chalk.cyan(config.project.url) : '—'}\n` +
|
|
48
|
+
`${chalk.dim('IA:')} ${config.ai ? `${config.ai.provider} · ${config.ai.model}` : '—'}\n` +
|
|
48
49
|
`${chalk.dim('Versão CLI:')} ${config.version ?? '?'}\n` +
|
|
49
50
|
`${chalk.dim('Criado em:')} ${config.initializedAt ?? '?'}`,
|
|
50
51
|
'Configuração'
|
package/src/commands/init.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { setupProject } from '../setup/project.mjs';
|
|
|
9
9
|
import { setupLabels } from '../setup/labels.mjs';
|
|
10
10
|
import { setupFiles } from '../setup/files.mjs';
|
|
11
11
|
import { upsertFile } from '../api/github-rest.mjs';
|
|
12
|
-
import { CONFIG_FILE } from '../config.mjs';
|
|
12
|
+
import { CONFIG_FILE, AI_PROVIDERS, getProvider, DEFAULT_PROVIDER } 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'));
|
|
@@ -51,7 +51,7 @@ export async function init(options) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// --- Wizard ou flags ---
|
|
54
|
-
let owner, repo, projectTitle;
|
|
54
|
+
let owner, repo, projectTitle, provider, model;
|
|
55
55
|
if (options.repo) {
|
|
56
56
|
if (!options.repo.includes('/')) {
|
|
57
57
|
p.log.error('Formato inválido para --repo. Use: owner/repo');
|
|
@@ -59,16 +59,28 @@ export async function init(options) {
|
|
|
59
59
|
}
|
|
60
60
|
[owner, repo] = options.repo.split('/');
|
|
61
61
|
projectTitle = options.projectTitle ?? `${repo} — Spec Wave`;
|
|
62
|
+
provider = (options.provider ?? DEFAULT_PROVIDER).toLowerCase();
|
|
63
|
+
if (!getProvider(provider)) {
|
|
64
|
+
p.log.error(
|
|
65
|
+
`Provider inválido: ${provider}. Use um de: ${AI_PROVIDERS.map(pr => pr.value).join(', ')}`
|
|
66
|
+
);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
model = options.model ?? getProvider(provider).defaultModel;
|
|
62
70
|
p.log.info(`Repositório: ${owner}/${repo}`);
|
|
63
71
|
p.log.info(`Projeto: ${projectTitle}`);
|
|
72
|
+
p.log.info(`IA: ${getProvider(provider).label} · modelo ${model}`);
|
|
64
73
|
} else {
|
|
65
|
-
({ owner, repo, projectTitle } = await runWizard());
|
|
74
|
+
({ owner, repo, projectTitle, provider, model } = await runWizard());
|
|
66
75
|
}
|
|
67
76
|
|
|
77
|
+
const providerMeta = getProvider(provider);
|
|
78
|
+
|
|
68
79
|
if (options.dryRun) {
|
|
69
80
|
p.log.info(chalk.yellow('Modo dry-run: nenhuma alteração será feita.'));
|
|
70
81
|
p.log.info(` Repositório: ${owner}/${repo}`);
|
|
71
82
|
p.log.info(` Projeto: ${projectTitle}`);
|
|
83
|
+
p.log.info(` IA: ${providerMeta.label} · modelo ${model}`);
|
|
72
84
|
p.log.info(' Fases: project board → labels → workflow files');
|
|
73
85
|
p.outro('Dry-run concluído.');
|
|
74
86
|
return;
|
|
@@ -153,6 +165,10 @@ export async function init(options) {
|
|
|
153
165
|
number: projectNumber ?? null,
|
|
154
166
|
fields: projectFields ?? null,
|
|
155
167
|
},
|
|
168
|
+
ai: {
|
|
169
|
+
provider: providerMeta.value,
|
|
170
|
+
model,
|
|
171
|
+
},
|
|
156
172
|
initializedAt: new Date().toISOString(),
|
|
157
173
|
};
|
|
158
174
|
await upsertFile(
|
|
@@ -182,7 +198,7 @@ export async function init(options) {
|
|
|
182
198
|
`\n${chalk.green('✓')} spec-wave configurado com sucesso!\n\n` +
|
|
183
199
|
(projectUrl ? ` Projeto: ${chalk.cyan(projectUrl)}\n\n` : '') +
|
|
184
200
|
` Próximos passos:\n` +
|
|
185
|
-
` 1. Adicione
|
|
201
|
+
` 1. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
|
|
186
202
|
` 2. Configure o board view para agrupar por "Etapa"\n` +
|
|
187
203
|
` 3. Crie uma Feature com o prefixo [FEATURE] no título\n` +
|
|
188
204
|
` 4. Use a skill spec-wave para guiar o fluxo\n\n` +
|
package/src/config.mjs
CHANGED
|
@@ -4,6 +4,35 @@
|
|
|
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
|
+
// Providers de IA suportados pelos workflows (generate-plan/spec/decompose).
|
|
8
|
+
// O provider e o modelo escolhidos no `init` são persistidos em .spec-wave.json
|
|
9
|
+
// (bloco `ai`) e lidos em runtime por src/lib/claude.mjs. Cada provider declara
|
|
10
|
+
// o secret do GitHub Actions de onde a chave é lida.
|
|
11
|
+
export const AI_PROVIDERS = [
|
|
12
|
+
{
|
|
13
|
+
value: 'anthropic',
|
|
14
|
+
label: 'Anthropic (API direta)',
|
|
15
|
+
hint: 'Usa o secret ANTHROPIC_API_KEY',
|
|
16
|
+
secret: 'ANTHROPIC_API_KEY',
|
|
17
|
+
defaultModel: 'claude-sonnet-4-6',
|
|
18
|
+
modelHint: 'ex.: claude-sonnet-4-6, claude-opus-4-1',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
value: 'openrouter',
|
|
22
|
+
label: 'OpenRouter (multi-modelo)',
|
|
23
|
+
hint: 'Usa o secret OPENROUTER_API_KEY',
|
|
24
|
+
secret: 'OPENROUTER_API_KEY',
|
|
25
|
+
defaultModel: 'anthropic/claude-3.7-sonnet',
|
|
26
|
+
modelHint: 'ex.: anthropic/claude-3.7-sonnet, openai/gpt-4o — veja openrouter.ai/models',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_PROVIDER = 'anthropic';
|
|
31
|
+
|
|
32
|
+
export function getProvider(value) {
|
|
33
|
+
return AI_PROVIDERS.find(p => p.value === value);
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
export const STATUS_OPTIONS = [
|
|
8
37
|
{ name: '📥 Backlog', color: 'GRAY' },
|
|
9
38
|
{ name: '🎯 Priorizado', color: 'BLUE' },
|
package/src/lib/claude.mjs
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CONFIG_FILE, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
// Resolve o provider/modelo de IA a partir do .spec-wave.json (gravado pelo init
|
|
7
|
+
// e versionado no repo) com precedência para variáveis de ambiente — assim os
|
|
8
|
+
// workflows usam exatamente o que foi escolhido no init, sem depender de flags.
|
|
9
|
+
function resolveAi() {
|
|
10
|
+
let fileAi = {};
|
|
11
|
+
try {
|
|
12
|
+
const configPath = path.join(process.cwd(), CONFIG_FILE);
|
|
13
|
+
if (existsSync(configPath)) {
|
|
14
|
+
fileAi = JSON.parse(readFileSync(configPath, 'utf-8')).ai || {};
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// config ausente/corrompido → cai nos defaults/env
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const provider = (process.env.SPEC_WAVE_PROVIDER || fileAi.provider || DEFAULT_PROVIDER).toLowerCase();
|
|
21
|
+
const meta = getProvider(provider) || getProvider(DEFAULT_PROVIDER);
|
|
22
|
+
const model = process.env.SPEC_WAVE_MODEL || fileAi.model || meta.defaultModel;
|
|
23
|
+
return { provider: meta.value, model, secret: meta.secret };
|
|
24
|
+
}
|
|
4
25
|
|
|
5
26
|
export async function generateDocument(systemPrompt, userContent) {
|
|
27
|
+
const ai = resolveAi();
|
|
28
|
+
console.log(`Provider de IA: ${ai.provider} · modelo: ${ai.model}`);
|
|
29
|
+
|
|
30
|
+
if (ai.provider === 'openrouter') {
|
|
31
|
+
return generateWithOpenRouter(systemPrompt, userContent, ai);
|
|
32
|
+
}
|
|
33
|
+
return generateWithAnthropic(systemPrompt, userContent, ai);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function generateWithAnthropic(systemPrompt, userContent, ai) {
|
|
6
37
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
7
38
|
if (!apiKey) {
|
|
8
39
|
throw new Error(
|
|
@@ -12,10 +43,8 @@ export async function generateDocument(systemPrompt, userContent) {
|
|
|
12
43
|
}
|
|
13
44
|
|
|
14
45
|
const client = new Anthropic({ apiKey });
|
|
15
|
-
const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODEL;
|
|
16
|
-
|
|
17
46
|
const message = await client.messages.create({
|
|
18
|
-
model,
|
|
47
|
+
model: ai.model,
|
|
19
48
|
max_tokens: 4096,
|
|
20
49
|
messages: [{ role: 'user', content: userContent }],
|
|
21
50
|
system: systemPrompt,
|
|
@@ -23,3 +52,43 @@ export async function generateDocument(systemPrompt, userContent) {
|
|
|
23
52
|
|
|
24
53
|
return message.content[0].text;
|
|
25
54
|
}
|
|
55
|
+
|
|
56
|
+
async function generateWithOpenRouter(systemPrompt, userContent, ai) {
|
|
57
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
58
|
+
if (!apiKey) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'OPENROUTER_API_KEY not set.\n' +
|
|
61
|
+
'Add it as a GitHub Actions secret or set it in your environment.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${apiKey}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'HTTP-Referer': 'https://github.com/moacsjr/spec-wave',
|
|
71
|
+
'X-Title': 'spec-wave',
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model: ai.model,
|
|
75
|
+
max_tokens: 4096,
|
|
76
|
+
messages: [
|
|
77
|
+
{ role: 'system', content: systemPrompt },
|
|
78
|
+
{ role: 'user', content: userContent },
|
|
79
|
+
],
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const body = await res.text();
|
|
85
|
+
throw new Error(`OpenRouter API ${res.status}: ${body}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const data = await res.json();
|
|
89
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
90
|
+
if (!content) {
|
|
91
|
+
throw new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
|
|
92
|
+
}
|
|
93
|
+
return content;
|
|
94
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TYPE_LABELS } from '../config.mjs';
|
|
2
|
+
|
|
3
|
+
// Mapa de prefixo de label (ex.: "[STORY]") → nome canônico do tipo ("Story").
|
|
4
|
+
// Derivado de TYPE_LABELS para manter uma única fonte da verdade.
|
|
5
|
+
const TAG_TO_TYPE = Object.fromEntries(
|
|
6
|
+
TYPE_LABELS.map(l => [l.name.toUpperCase(), capitalize(l.name.replace(/[[\]]/g, ''))])
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function capitalize(s) {
|
|
10
|
+
const lower = s.toLowerCase();
|
|
11
|
+
// RFC fica em caixa alta; os demais tipos seguem Capitalizado (Story, Task...).
|
|
12
|
+
return lower === 'rfc' ? 'RFC' : lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Detecta o tipo canônico de uma issue ('Story', 'Task', 'Feature', ...) a partir
|
|
16
|
+
// do prefixo do título ([STORY], [TASK], ...) com fallback nas labels. Retorna
|
|
17
|
+
// null quando não é possível determinar.
|
|
18
|
+
export function detectIssueType(issue) {
|
|
19
|
+
if (!issue) return null;
|
|
20
|
+
|
|
21
|
+
// 1. Prefixo do título: "[STORY] ..." → "Story".
|
|
22
|
+
const titleMatch = String(issue.title || '').match(/^\s*(\[[^\]]+\])/);
|
|
23
|
+
if (titleMatch) {
|
|
24
|
+
const type = TAG_TO_TYPE[titleMatch[1].toUpperCase()];
|
|
25
|
+
if (type) return type;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. Fallback: varrer as labels da issue procurando um dos prefixos de tipo.
|
|
29
|
+
const labels = Array.isArray(issue.labels) ? issue.labels : [];
|
|
30
|
+
for (const label of labels) {
|
|
31
|
+
const name = (typeof label === 'string' ? label : label?.name) || '';
|
|
32
|
+
const type = TAG_TO_TYPE[name.toUpperCase()];
|
|
33
|
+
if (type) return type;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|
package/src/ui/wizard.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
|
+
import { AI_PROVIDERS, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
|
|
3
4
|
|
|
4
5
|
export async function runWizard() {
|
|
5
6
|
p.intro('spec-wave — configuração do fluxo spec-driven');
|
|
@@ -23,6 +24,25 @@ export async function runWizard() {
|
|
|
23
24
|
placeholder: 'Meu Projeto — Spec Wave',
|
|
24
25
|
}),
|
|
25
26
|
|
|
27
|
+
provider: () =>
|
|
28
|
+
p.select({
|
|
29
|
+
message: 'Qual provider de IA os workflows devem usar?',
|
|
30
|
+
options: AI_PROVIDERS.map(pr => ({ value: pr.value, label: pr.label, hint: pr.hint })),
|
|
31
|
+
initialValue: DEFAULT_PROVIDER,
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
model: ({ results }) => {
|
|
35
|
+
const pr = getProvider(results.provider);
|
|
36
|
+
return p.text({
|
|
37
|
+
message: `Qual modelo de IA os workflows devem usar? (${pr.modelHint})`,
|
|
38
|
+
defaultValue: pr.defaultModel,
|
|
39
|
+
placeholder: pr.defaultModel,
|
|
40
|
+
validate: v => {
|
|
41
|
+
if (!v || !v.trim()) return 'Informe o modelo a ser usado.';
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
|
|
26
46
|
triggerStrategy: () =>
|
|
27
47
|
p.select({
|
|
28
48
|
message: 'Como prefere acionar os workflows automáticos?',
|
|
@@ -67,7 +87,13 @@ export async function runWizard() {
|
|
|
67
87
|
}
|
|
68
88
|
|
|
69
89
|
const [owner, repo] = answers.repo.split('/');
|
|
70
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
owner,
|
|
92
|
+
repo,
|
|
93
|
+
projectTitle: answers.projectTitle,
|
|
94
|
+
provider: answers.provider,
|
|
95
|
+
model: answers.model.trim(),
|
|
96
|
+
};
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
function detectRepo() {
|