@spec-wave/cli 0.1.0 → 0.2.0
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 +2 -0
- package/package.json +1 -1
- 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/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);
|
package/package.json
CHANGED
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
|
+
}
|
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() {
|