@guilhermefsousa/open-spec-kit 0.1.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.
Files changed (31) hide show
  1. package/README.md +57 -0
  2. package/bin/open-spec-kit.js +39 -0
  3. package/package.json +51 -0
  4. package/src/commands/doctor.js +324 -0
  5. package/src/commands/init.js +981 -0
  6. package/src/commands/update.js +168 -0
  7. package/src/commands/validate.js +599 -0
  8. package/src/parsers/markdown-sections.js +271 -0
  9. package/src/schemas/projects.schema.js +111 -0
  10. package/src/schemas/spec.schema.js +643 -0
  11. package/templates/agents/agents/spec-hub.agent.md +99 -0
  12. package/templates/agents/rules/hub_structure.instructions.md +49 -0
  13. package/templates/agents/rules/ownership.instructions.md +138 -0
  14. package/templates/agents/scripts/notify-gchat.ps1 +99 -0
  15. package/templates/agents/scripts/notify-gchat.sh +131 -0
  16. package/templates/agents/skills/dev-orchestrator/SKILL.md +573 -0
  17. package/templates/agents/skills/discovery/SKILL.md +406 -0
  18. package/templates/agents/skills/setup-project/SKILL.md +452 -0
  19. package/templates/agents/skills/specifying-features/SKILL.md +378 -0
  20. package/templates/github/agents/spec-hub.agent.md +75 -0
  21. package/templates/github/copilot-instructions.md +102 -0
  22. package/templates/github/instructions/hub_structure.instructions.md +33 -0
  23. package/templates/github/instructions/ownership.instructions.md +45 -0
  24. package/templates/github/prompts/dev.prompt.md +19 -0
  25. package/templates/github/prompts/discovery.prompt.md +20 -0
  26. package/templates/github/prompts/nova-feature.prompt.md +19 -0
  27. package/templates/github/prompts/setup.prompt.md +18 -0
  28. package/templates/github/skills/dev-orchestrator/SKILL.md +9 -0
  29. package/templates/github/skills/discovery/SKILL.md +9 -0
  30. package/templates/github/skills/setup-project/SKILL.md +9 -0
  31. package/templates/github/skills/specifying-features/SKILL.md +9 -0
@@ -0,0 +1,981 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { writeFile, mkdir, access, cp } from 'fs/promises';
5
+ import { join, dirname } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { execSync } from 'child_process';
8
+ import https from 'https';
9
+ import http from 'http';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates');
13
+
14
+ const AGENTS = {
15
+ claude: { name: 'Claude Code' },
16
+ copilot: { name: 'GitHub Copilot' },
17
+ };
18
+
19
+ const FIGMA_MCP_PACKAGE = 'figma-developer-mcp';
20
+
21
+ const PRESETS = {
22
+ lean: {
23
+ name: 'Lean',
24
+ description: 'Bugfix ou feature trivial (brief + tasks)',
25
+ artifacts: ['brief.md', 'tasks.md'],
26
+ },
27
+ standard: {
28
+ name: 'Standard',
29
+ description: 'Feature média (brief + scenarios + contracts + tasks + links)',
30
+ artifacts: ['brief.md', 'scenarios.md', 'contracts.md', 'tasks.md', 'links.md'],
31
+ },
32
+ enterprise: {
33
+ name: 'Enterprise',
34
+ description: 'Feature crítica (standard + ADR + security review + runbook)',
35
+ artifacts: ['brief.md', 'scenarios.md', 'contracts.md', 'tasks.md', 'links.md'],
36
+ extras: ['adr', 'security-review', 'runbook'],
37
+ }
38
+ };
39
+
40
+ const KNOWN_TECH_KEYWORDS = [
41
+ '.NET', 'Node.js', 'React', 'PostgreSQL', 'RabbitMQ', 'Kafka', 'Docker',
42
+ 'Redis', 'TypeScript', 'Java', 'Kotlin', 'Python', 'Spring Boot', 'Angular',
43
+ 'Vue', 'MongoDB', 'MySQL', 'xUnit', 'Jest', 'MassTransit', 'C#', 'Go',
44
+ 'Rust', 'PHP', 'Laravel', 'Django', 'Flask', 'Express', 'NestJS', 'Next.js',
45
+ 'Nuxt', 'Svelte', 'Terraform', 'Kubernetes', 'AWS', 'Azure', 'GCP',
46
+ 'SQL Server', 'Oracle', 'Elasticsearch', 'GraphQL', 'gRPC', 'REST',
47
+ 'SQS', 'SNS', 'DynamoDB', 'Cassandra', 'Nginx', 'Apache',
48
+ ];
49
+
50
+ // ──────────────────────────────────────────────────────
51
+ // HTTP helper using Node.js built-in modules
52
+ // ──────────────────────────────────────────────────────
53
+ const SSL_ERROR_CODES = new Set([
54
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
55
+ 'SELF_SIGNED_CERT_IN_CHAIN',
56
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
57
+ 'CERT_HAS_EXPIRED',
58
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
59
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
60
+ ]);
61
+
62
+ function isSslError(err) {
63
+ return err.status === 0 && (
64
+ SSL_ERROR_CODES.has(err.code) ||
65
+ SSL_ERROR_CODES.has(err.message) ||
66
+ (err.message && SSL_ERROR_CODES.has(err.message.split(':').pop()?.trim()))
67
+ );
68
+ }
69
+
70
+ function confluenceRequest(baseUrl, path, user, token, timeoutMs = 15000, allowInsecure = false) {
71
+ return new Promise((resolve, reject) => {
72
+ try {
73
+ const fullUrl = new URL(baseUrl.replace(/\/$/, '') + path);
74
+ const mod = fullUrl.protocol === 'https:' ? https : http;
75
+ const auth = Buffer.from(`${user}:${token}`).toString('base64');
76
+ const options = {
77
+ headers: {
78
+ 'Authorization': `Basic ${auth}`,
79
+ 'Accept': 'application/json',
80
+ },
81
+ timeout: timeoutMs,
82
+ ...(allowInsecure && fullUrl.protocol === 'https:'
83
+ ? { agent: new https.Agent({ rejectUnauthorized: false }) }
84
+ : {}),
85
+ };
86
+ const req = mod.get(fullUrl, options, (res) => {
87
+ let data = '';
88
+ res.on('data', (chunk) => { data += chunk; });
89
+ res.on('end', () => {
90
+ if (res.statusCode >= 200 && res.statusCode < 300) {
91
+ try {
92
+ resolve({ status: res.statusCode, data: JSON.parse(data) });
93
+ } catch {
94
+ resolve({ status: res.statusCode, data });
95
+ }
96
+ } else {
97
+ reject({ status: res.statusCode, message: `HTTP ${res.statusCode}` });
98
+ }
99
+ });
100
+ });
101
+ req.on('error', (err) => reject({ status: 0, message: err.message, code: err.code }));
102
+ req.on('timeout', () => {
103
+ req.destroy();
104
+ reject({ status: 0, message: 'Timeout (15s)' });
105
+ });
106
+ } catch (err) {
107
+ reject({ status: 0, message: err.message, code: err.code });
108
+ }
109
+ });
110
+ }
111
+
112
+ function stripHtml(html) {
113
+ if (!html) return '';
114
+ return html
115
+ .replace(/<[^>]+>/g, ' ')
116
+ .replace(/&nbsp;/g, ' ')
117
+ .replace(/&amp;/g, '&')
118
+ .replace(/&lt;/g, '<')
119
+ .replace(/&gt;/g, '>')
120
+ .replace(/&quot;/g, '"')
121
+ .replace(/&#39;/g, "'")
122
+ .replace(/\s+/g, ' ')
123
+ .trim();
124
+ }
125
+
126
+ function detectTechInText(text) {
127
+ if (!text) return [];
128
+ const found = new Set();
129
+ for (const tech of KNOWN_TECH_KEYWORDS) {
130
+ const escaped = tech.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ const regex = new RegExp('\\b' + escaped + '\\b', 'i');
132
+ if (regex.test(text)) {
133
+ found.add(tech);
134
+ }
135
+ }
136
+ const tablePatterns = /(?:tecnologia|stack|framework|linguagem|banco|database|message.?broker|cache|container)[\s:|\\-]+([^\n|]+)/gi;
137
+ let match;
138
+ while ((match = tablePatterns.exec(text)) !== null) {
139
+ const value = match[1].trim();
140
+ for (const tech of KNOWN_TECH_KEYWORDS) {
141
+ if (value.toLowerCase().includes(tech.toLowerCase())) {
142
+ found.add(tech);
143
+ }
144
+ }
145
+ }
146
+ return [...found];
147
+ }
148
+
149
+ function detectRepoNames(text) {
150
+ if (!text) return [];
151
+ const repos = new Set();
152
+ const repoPatterns = /(?:reposit[óo]rio|repo|github|gitlab)[:\s]+([a-zA-Z][\w.-]+(?:\/[\w.-]+)?)/gi;
153
+ let match;
154
+ while ((match = repoPatterns.exec(text)) !== null) {
155
+ repos.add(match[1].trim());
156
+ }
157
+ return [...repos];
158
+ }
159
+
160
+ function deriveSigla(projectName) {
161
+ if (!projectName) return '';
162
+ const words = projectName.trim().split(/\s+/).filter(w => w.length > 0);
163
+ const skip = new Set(['de', 'do', 'da', 'dos', 'das', 'e', 'o', 'a', 'os', 'as', 'em', 'no', 'na', 'para', 'com']);
164
+ const letters = words
165
+ .filter(w => !skip.has(w.toLowerCase()))
166
+ .map(w => w[0].toUpperCase())
167
+ .join('');
168
+ return letters.slice(0, 4) || projectName.slice(0, 3).toUpperCase();
169
+ }
170
+
171
+ // ──────────────────────────────────────────────────────
172
+ // Auto-detection from Confluence
173
+ // ──────────────────────────────────────────────────────
174
+ async function autoDetectFromConfluence(confluenceUrl, confluenceRef, user, token, spinner, allowInsecure = false) {
175
+ const result = {
176
+ projectName: null,
177
+ stack: [],
178
+ repos: [],
179
+ spaceKey: null,
180
+ success: false,
181
+ };
182
+
183
+ try {
184
+ spinner.text = 'Lendo página raiz do Confluence...';
185
+ const rootPage = await confluenceRequest(
186
+ confluenceUrl,
187
+ `/rest/api/content/${confluenceRef}?expand=body.storage,space`,
188
+ user, token, 15000, allowInsecure
189
+ );
190
+ result.projectName = rootPage.data.title || null;
191
+ if (rootPage.data.space && rootPage.data.space.key) {
192
+ result.spaceKey = rootPage.data.space.key;
193
+ }
194
+
195
+ let allText = '';
196
+ if (rootPage.data.body && rootPage.data.body.storage) {
197
+ allText += stripHtml(rootPage.data.body.storage.value) + ' ';
198
+ }
199
+
200
+ spinner.text = 'Buscando páginas filhas...';
201
+ const children = await confluenceRequest(
202
+ confluenceUrl,
203
+ `/rest/api/content/${confluenceRef}/child/page?limit=50`,
204
+ user, token, 15000, allowInsecure
205
+ );
206
+ const childPages = children.data.results || [];
207
+
208
+ let demandasPage = childPages.find(p =>
209
+ p.title && p.title.toLowerCase().includes('demandas')
210
+ );
211
+
212
+ // If "Demandas" not found among children, look UP the ancestors tree.
213
+ // The configured page may itself live INSIDE a "Demandas" folder.
214
+ if (!demandasPage) {
215
+ spinner.text = 'Buscando "Demandas" nos ancestrais...';
216
+ try {
217
+ const ancestorRes = await confluenceRequest(
218
+ confluenceUrl,
219
+ `/rest/api/content/${confluenceRef}?expand=ancestors`,
220
+ user, token, 15000, allowInsecure
221
+ );
222
+ const ancestors = ancestorRes.data.ancestors || [];
223
+ const demandasAncestor = ancestors.find(a =>
224
+ a.title && a.title.toLowerCase().includes('demandas')
225
+ );
226
+ if (demandasAncestor) {
227
+ demandasPage = demandasAncestor;
228
+ }
229
+ } catch { /* ignore ancestor lookup failure */ }
230
+ }
231
+
232
+ if (demandasPage) {
233
+ spinner.text = 'Pasta "Demandas" encontrada — lendo todas as páginas...';
234
+ const demandasChildren = await confluenceRequest(
235
+ confluenceUrl,
236
+ `/rest/api/content/${demandasPage.id}/child/page?limit=50`,
237
+ user, token, 15000, allowInsecure
238
+ );
239
+ const demandaPages = demandasChildren.data.results || [];
240
+ for (const page of demandaPages) {
241
+ if (page.id === confluenceRef) continue;
242
+ try {
243
+ spinner.text = `Lendo: ${page.title}...`;
244
+ const pageData = await confluenceRequest(
245
+ confluenceUrl,
246
+ `/rest/api/content/${page.id}?expand=body.storage`,
247
+ user, token, 15000, allowInsecure
248
+ );
249
+ if (pageData.data.body && pageData.data.body.storage) {
250
+ allText += stripHtml(pageData.data.body.storage.value) + ' ';
251
+ }
252
+ } catch { /* skip pages that fail to load */ }
253
+ }
254
+ }
255
+
256
+ for (const page of childPages) {
257
+ if (demandasPage && page.id === demandasPage.id) continue;
258
+ try {
259
+ const pageData = await confluenceRequest(
260
+ confluenceUrl,
261
+ `/rest/api/content/${page.id}?expand=body.storage`,
262
+ user, token, 15000, allowInsecure
263
+ );
264
+ if (pageData.data.body && pageData.data.body.storage) {
265
+ allText += stripHtml(pageData.data.body.storage.value) + ' ';
266
+ }
267
+ } catch { /* skip pages that fail */ }
268
+ }
269
+
270
+ result.stack = detectTechInText(allText);
271
+ result.repos = detectRepoNames(allText);
272
+ result.success = true;
273
+ } catch {
274
+ result.success = false;
275
+ }
276
+
277
+ return result;
278
+ }
279
+
280
+ // ──────────────────────────────────────────────────────
281
+ // Existing setup check
282
+ // ──────────────────────────────────────────────────────
283
+ async function checkGitExists() {
284
+ try {
285
+ await access(join(process.cwd(), '.git'));
286
+ return true;
287
+ } catch {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ async function checkExistingSetup() {
293
+ const cwd = process.cwd();
294
+ const check = ['.agents', '.github/agents', 'projects.yml', '.mcp.json', '.vscode/mcp.json', '.env'];
295
+ const found = [];
296
+ for (const f of check) {
297
+ try {
298
+ await access(join(cwd, f));
299
+ found.push(f);
300
+ } catch { /* not found */ }
301
+ }
302
+ return found;
303
+ }
304
+
305
+ /**
306
+ * Copy a template directory tree to the target.
307
+ * Templates are bundled with the npm package under cli/templates/.
308
+ */
309
+ async function copyTemplateDir(templateName, targetRoot) {
310
+ const sourceDir = join(TEMPLATES_DIR, templateName);
311
+ try {
312
+ await access(sourceDir);
313
+ } catch {
314
+ throw new Error(`Template directory not found: ${sourceDir}`);
315
+ }
316
+ const targetMap = {
317
+ agents: '.agents',
318
+ github: '.github',
319
+ };
320
+ const targetDir = join(targetRoot, targetMap[templateName] || templateName);
321
+ await cp(sourceDir, targetDir, { recursive: true, force: true });
322
+ }
323
+
324
+ // ──────────────────────────────────────────────────────
325
+ // File generators
326
+ // ──────────────────────────────────────────────────────
327
+ function generateProjectsYml(config) {
328
+ const isNumeric = /^\d+$/.test(config.confluenceRef);
329
+ const stackYaml = config.stack.map(s => ` - ${s}`).join('\n');
330
+ const agentsYaml = config.agents.map(a => ` - ${a}`).join('\n');
331
+
332
+ let yml = `project:
333
+ name: ${config.projectName}
334
+ sigla: ${config.sigla}
335
+ domain: # Domínio de negócio
336
+ status: inception
337
+ team_size: ${config.teamSize}
338
+ preset: ${config.preset}
339
+ stack:
340
+ ${stackYaml}
341
+ agents:
342
+ ${agentsYaml}
343
+
344
+ ${config.vcs !== 'none'
345
+ ? `vcs: ${config.vcs}\nvcs_org: ${config.vcsOrg}`
346
+ : `# vcs: github # Descomente e configure depois\n# vcs_org: sua-org`}
347
+
348
+ repos:
349
+ # Liste os repositórios de código do projeto.
350
+ # - name: MeuProjeto.Api
351
+ # type: api
352
+ # purpose: Endpoints REST
353
+ # status: planned
354
+ # url: null
355
+
356
+ # Agent configuration
357
+ agents:
358
+ coding:
359
+ dotnet: dotnet-engineer
360
+ nodejs: nodejs-engineer
361
+ java: java-engineer
362
+ python: labs-python-engineer
363
+ react: frontend-expert
364
+ mobile: mobile-engineer
365
+ security: labs-secops-agent
366
+ code_review: labs-code-reviewer
367
+ design_doc: design-doc
368
+ principal: principal-engineer
369
+
370
+ external_dependencies:
371
+ # - name: SistemaX
372
+ # type: event-producer
373
+ # description: Publica EventoY via RabbitMQ
374
+
375
+ confluence:
376
+ ${isNumeric ? 'root_page_id' : 'space'}: ${config.confluenceRef}`;
377
+
378
+ if (config.confluenceLayout === 'page' && config.spaceKey) {
379
+ yml += `\n space_key: ${config.spaceKey}`;
380
+ }
381
+
382
+ if (config.hasFigma && config.figmaFileUrl) {
383
+ yml += `\n\nfigma:\n file_url: ${config.figmaFileUrl}`;
384
+ }
385
+
386
+ yml += '\n';
387
+ return yml;
388
+ }
389
+
390
+ function generateGitignore(config) {
391
+ let content = `# Secrets (nunca comitar)
392
+ .env
393
+ .mcp.json
394
+
395
+ # Claude Code local files
396
+ .claude/
397
+
398
+ # OS files
399
+ Thumbs.db
400
+ .DS_Store
401
+
402
+ # IDE
403
+ .idea/
404
+ *.swp
405
+ `;
406
+
407
+ if (config.agents.includes('copilot')) {
408
+ content += `
409
+ # VS Code (exceto MCP config compartilhada)
410
+ .vscode/*
411
+ !.vscode/mcp.json
412
+ `;
413
+ } else {
414
+ content += '\n.vscode/\n';
415
+ }
416
+
417
+ return content;
418
+ }
419
+
420
+ function generateEnvFile(config) {
421
+ let content = `CONFLUENCE_URL=${config.confluenceUrl}
422
+ CONFLUENCE_USER=${config.confluenceUser}
423
+ CONFLUENCE_API_TOKEN=${config.confluenceToken}
424
+ GCHAT_WEBHOOK_URL=${config.gchatWebhookUrl || ''}
425
+ `;
426
+
427
+ if (config.hasFigma && config.figmaFileUrl) {
428
+ content += 'FIGMA_API_KEY=\n';
429
+ }
430
+
431
+ return content;
432
+ }
433
+
434
+ function generateEnvExample(config) {
435
+ let content = `# Confluence credentials
436
+ # Obtenha o token em: https://id.atlassian.com/manage-profile/security/api-tokens
437
+ CONFLUENCE_URL=https://seu-dominio.atlassian.net/wiki
438
+ CONFLUENCE_USER=seu-email@empresa.com
439
+ CONFLUENCE_API_TOKEN=seu-token-aqui
440
+
441
+ # Google Chat webhook (opcional)
442
+ GCHAT_WEBHOOK_URL=
443
+ `;
444
+
445
+ if (config.hasFigma && config.figmaFileUrl) {
446
+ content += `
447
+ # Figma API key
448
+ # Obtenha em: https://www.figma.com/settings
449
+ FIGMA_API_KEY=seu-figma-api-key
450
+ `;
451
+ }
452
+
453
+ return content;
454
+ }
455
+
456
+ function generateClaudeMcp(config) {
457
+ const servers = {
458
+ confluence: {
459
+ command: 'mcp-atlassian',
460
+ args: [
461
+ '--confluence-url', '${CONFLUENCE_URL}',
462
+ '--confluence-username', '${CONFLUENCE_USER}',
463
+ '--confluence-token', '${CONFLUENCE_API_TOKEN}'
464
+ ]
465
+ }
466
+ };
467
+ if (config.hasFigma && config.figmaFileUrl) {
468
+ servers.figma = {
469
+ command: 'npx',
470
+ args: ['-y', FIGMA_MCP_PACKAGE, '--figma-api-key', '${FIGMA_API_KEY}']
471
+ };
472
+ }
473
+ return JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
474
+ }
475
+
476
+ function generateCopilotMcp(config) {
477
+ const servers = {
478
+ confluence: {
479
+ type: 'stdio',
480
+ command: 'mcp-atlassian',
481
+ args: [
482
+ '--env-file', '.env',
483
+ '--no-confluence-ssl-verify'
484
+ ]
485
+ }
486
+ };
487
+ if (config.hasFigma && config.figmaFileUrl) {
488
+ servers.figma = {
489
+ type: 'stdio',
490
+ command: 'npx',
491
+ args: ['-y', FIGMA_MCP_PACKAGE, '--figma-api-key', '${input:figma-api-key}']
492
+ };
493
+ }
494
+ return JSON.stringify({ servers }, null, 2) + '\n';
495
+ }
496
+
497
+ function generateArchitectureSkeleton(config) {
498
+ return `# Arquitetura — ${config.projectName}
499
+
500
+ ## Stack
501
+
502
+ ${config.stack.map(s => `- ${s}`).join('\n')}
503
+
504
+ ## Repositórios
505
+
506
+ (Preenchido pelo /setup)
507
+
508
+ ## Decisões em Aberto
509
+
510
+ | # | Decisão | Impacto | Bloqueia | Status |
511
+ |---|---------|---------|----------|--------|
512
+
513
+ ## Diagrama
514
+
515
+ (Gerado pelo /spec ou /dev)
516
+ `;
517
+ }
518
+
519
+ // ──────────────────────────────────────────────────────
520
+ // Main command
521
+ // ──────────────────────────────────────────────────────
522
+ export async function initCommand() {
523
+ console.log(chalk.bold('\n open-spec-kit init\n'));
524
+
525
+ // Pre-flight: check existing setup
526
+ const existing = await checkExistingSetup();
527
+ if (existing.length > 0) {
528
+ console.log(chalk.yellow(' Estrutura existente detectada:'));
529
+ existing.forEach(f => console.log(chalk.dim(` - ${f}`)));
530
+ const { proceed } = await inquirer.prompt([{
531
+ type: 'confirm',
532
+ name: 'proceed',
533
+ message: 'Deseja sobrescrever? (arquivos existentes serão substituídos)',
534
+ default: false
535
+ }]);
536
+ if (!proceed) {
537
+ console.log(chalk.dim(' Cancelado.'));
538
+ return;
539
+ }
540
+ }
541
+
542
+ // ════════════════════════════════════════════
543
+ // Phase 1 — Conexão
544
+ // ════════════════════════════════════════════
545
+ console.log(chalk.bold.blue('\n ── Fase 1: Conexão ──\n'));
546
+
547
+ const phase1 = await inquirer.prompt([
548
+ {
549
+ type: 'checkbox',
550
+ name: 'agents',
551
+ message: 'Quais AI tools você usa?',
552
+ choices: [
553
+ { name: 'Claude Code', value: 'claude', checked: true },
554
+ { name: 'GitHub Copilot', value: 'copilot', checked: true },
555
+ ],
556
+ validate: v => v.length > 0 || 'Selecione pelo menos uma tool'
557
+ },
558
+ {
559
+ type: 'list',
560
+ name: 'preset',
561
+ message: 'Preset padrão do projeto:',
562
+ choices: Object.entries(PRESETS).map(([key, p]) => ({
563
+ name: `${p.name} — ${p.description}`,
564
+ value: key,
565
+ })),
566
+ default: 'standard'
567
+ },
568
+ {
569
+ type: 'list',
570
+ name: 'confluenceLayout',
571
+ message: 'Layout do Confluence:',
572
+ choices: [
573
+ { name: '1 space = 1 projeto (space key)', value: 'space' },
574
+ { name: '1 space = N projetos (page ID)', value: 'page' },
575
+ ]
576
+ },
577
+ {
578
+ type: 'input',
579
+ name: 'confluenceRef',
580
+ message: 'Space key ou page ID (ex: TT ou 1234567890):',
581
+ validate: v => v.trim().length > 0 || 'Obrigatório'
582
+ },
583
+ {
584
+ type: 'input',
585
+ name: 'confluenceUrl',
586
+ message: 'Confluence URL (ex: https://seu-dominio.atlassian.net/wiki):',
587
+ validate: v => {
588
+ const trimmed = v.trim();
589
+ if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
590
+ return 'URL deve começar com http:// ou https://';
591
+ }
592
+ return true;
593
+ }
594
+ },
595
+ {
596
+ type: 'input',
597
+ name: 'confluenceUser',
598
+ message: 'Confluence username/email (ex: voce@empresa.com):',
599
+ validate: v => v.trim().length > 0 || 'Obrigatório'
600
+ },
601
+ {
602
+ type: 'password',
603
+ name: 'confluenceToken',
604
+ message: 'Confluence API token:',
605
+ mask: '*',
606
+ validate: v => v.trim().length > 0 || 'Obrigatório'
607
+ }
608
+ ]);
609
+
610
+ // Validate credentials
611
+ const spinner = ora('Validando credenciais do Confluence...').start();
612
+ let credentialsValid = false;
613
+ let allowInsecure = false;
614
+ const confluenceUrl = phase1.confluenceUrl.trim().replace(/\/$/, '');
615
+
616
+ try {
617
+ await confluenceRequest(
618
+ confluenceUrl,
619
+ '/rest/api/space?limit=1',
620
+ phase1.confluenceUser.trim(),
621
+ phase1.confluenceToken.trim()
622
+ );
623
+ spinner.succeed('Credenciais validadas com sucesso!');
624
+ credentialsValid = true;
625
+ } catch (err) {
626
+ if (err.status === 401 || err.status === 403) {
627
+ spinner.fail(`Autenticação falhou (HTTP ${err.status}). Verifique suas credenciais.`);
628
+ console.log(chalk.red(' Não é possível continuar sem acesso ao Confluence.'));
629
+ console.log(chalk.dim(' Gere um token em: https://id.atlassian.com/manage-profile/security/api-tokens'));
630
+ return;
631
+ }
632
+ if (isSslError(err)) {
633
+ spinner.text = 'Certificado SSL corporativo detectado — tentando sem verificação...';
634
+ try {
635
+ await confluenceRequest(
636
+ confluenceUrl,
637
+ '/rest/api/space?limit=1',
638
+ phase1.confluenceUser.trim(),
639
+ phase1.confluenceToken.trim(),
640
+ 15000,
641
+ true
642
+ );
643
+ allowInsecure = true;
644
+ spinner.succeed('Credenciais validadas (SSL corporativo ignorado — ambiente interno).');
645
+ credentialsValid = true;
646
+ } catch (retryErr) {
647
+ if (retryErr.status === 401 || retryErr.status === 403) {
648
+ spinner.fail(`Autenticação falhou (HTTP ${retryErr.status}). Verifique suas credenciais.`);
649
+ console.log(chalk.red(' Não é possível continuar sem acesso ao Confluence.'));
650
+ console.log(chalk.dim(' Gere um token em: https://id.atlassian.com/manage-profile/security/api-tokens'));
651
+ return;
652
+ }
653
+ spinner.warn(`Não foi possível conectar ao Confluence: ${retryErr.message}`);
654
+ console.log(chalk.yellow(' Continuando sem auto-detecção...\n'));
655
+ }
656
+ } else {
657
+ spinner.warn(`Não foi possível conectar ao Confluence: ${err.message}`);
658
+ console.log(chalk.yellow(' Continuando sem auto-detecção...\n'));
659
+ }
660
+ }
661
+
662
+ // ════════════════════════════════════════════
663
+ // Phase 2 — Auto-detecção
664
+ // ════════════════════════════════════════════
665
+ let detected = { projectName: null, stack: [], repos: [], spaceKey: null, success: false };
666
+ const isNumericRef = /^\d+$/.test(phase1.confluenceRef.trim());
667
+
668
+ if (credentialsValid && isNumericRef) {
669
+ console.log(chalk.bold.blue('\n ── Fase 2: Auto-detecção ──\n'));
670
+ const detectSpinner = ora('Analisando páginas do Confluence...').start();
671
+
672
+ try {
673
+ detected = await autoDetectFromConfluence(
674
+ confluenceUrl,
675
+ phase1.confluenceRef.trim(),
676
+ phase1.confluenceUser.trim(),
677
+ phase1.confluenceToken.trim(),
678
+ detectSpinner,
679
+ allowInsecure
680
+ );
681
+
682
+ if (detected.success && detected.stack.length > 0) {
683
+ detectSpinner.succeed('Auto-detecção concluída!');
684
+ console.log(chalk.green(` Stack detectada: ${detected.stack.join(', ')}`));
685
+ if (detected.repos.length > 0) {
686
+ console.log(chalk.green(` Repositórios: ${detected.repos.join(', ')}`));
687
+ }
688
+ if (detected.projectName) {
689
+ console.log(chalk.green(` Projeto: ${detected.projectName}`));
690
+ }
691
+ } else {
692
+ detectSpinner.info('Nenhuma stack detectada automaticamente — preencha manualmente.');
693
+ }
694
+ } catch {
695
+ detectSpinner.info('Auto-detecção não disponível — preencha manualmente.');
696
+ }
697
+ } else if (credentialsValid && !isNumericRef) {
698
+ console.log(chalk.dim('\n Auto-detecção disponível apenas com page ID numérico.\n'));
699
+ }
700
+
701
+ // ════════════════════════════════════════════
702
+ // Phase 3 — Dados do projeto
703
+ // ════════════════════════════════════════════
704
+ console.log(chalk.bold.blue('\n ── Fase 3: Dados do projeto ──\n'));
705
+
706
+ // B3: verificar se .git já existe antes de oferecer git init
707
+ const gitAlreadyExists = await checkGitExists();
708
+ if (gitAlreadyExists) {
709
+ console.log(chalk.dim(' ℹ .git já existe neste diretório — etapa de git init será pulada automaticamente.\n'));
710
+ }
711
+
712
+ const defaultStack = detected.stack.length > 0 ? detected.stack.join(', ') : '';
713
+
714
+ const phase3 = await inquirer.prompt([
715
+ {
716
+ type: 'input',
717
+ name: 'projectName',
718
+ message: 'Nome do projeto:',
719
+ default: detected.projectName || undefined,
720
+ validate: v => v.trim().length > 0 || 'Nome obrigatório'
721
+ },
722
+ {
723
+ type: 'input',
724
+ name: 'sigla',
725
+ message: 'Sigla (2-4 letras maiúsculas, usada como prefixo):',
726
+ default: (answers) => deriveSigla(answers.projectName || detected.projectName || ''),
727
+ filter: v => v.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4),
728
+ validate: v => {
729
+ const clean = v.toUpperCase().replace(/[^A-Z]/g, '');
730
+ if (clean.length < 2 || clean.length > 4) return 'Sigla deve ter 2 a 4 letras';
731
+ return true;
732
+ }
733
+ },
734
+ {
735
+ type: 'input',
736
+ name: 'stack',
737
+ message: 'Stack principal (separado por vírgula, ex: Node.js, React, PostgreSQL):',
738
+ default: defaultStack || undefined,
739
+ filter: v => v.split(',').map(s => s.trim()).filter(Boolean),
740
+ when: () => detected.stack.length === 0
741
+ },
742
+ {
743
+ type: 'list',
744
+ name: 'vcs',
745
+ message: 'Plataforma de versionamento:',
746
+ choices: [
747
+ { name: 'GitHub', value: 'github' },
748
+ { name: 'GitLab', value: 'gitlab' },
749
+ { name: 'Nenhum / configurar depois', value: 'none' },
750
+ ]
751
+ },
752
+ {
753
+ type: 'input',
754
+ name: 'vcsOrg',
755
+ message: 'Organização/grupo no VCS (ex: minha-empresa ou meu-usuario):',
756
+ when: (answers) => answers.vcs !== 'none',
757
+ validate: v => v.trim().length > 0 || 'Obrigatório'
758
+ },
759
+ {
760
+ type: 'input',
761
+ name: 'gchatWebhookUrl',
762
+ message: 'Google Chat Webhook URL (opcional — deixe vazio para pular notificações):',
763
+ default: ''
764
+ },
765
+ {
766
+ type: 'confirm',
767
+ name: 'hasFigma',
768
+ message: 'O projeto tem designs no Figma?',
769
+ default: false
770
+ },
771
+ {
772
+ type: 'input',
773
+ name: 'figmaFileUrl',
774
+ message: 'URL do arquivo Figma (deixe vazio para pular):',
775
+ when: (answers) => answers.hasFigma
776
+ },
777
+ {
778
+ type: 'confirm',
779
+ name: 'initGit',
780
+ message: 'Inicializar repositório Git agora?',
781
+ default: true,
782
+ when: () => !gitAlreadyExists
783
+ }
784
+ ]);
785
+
786
+ // If Figma URL is empty, treat as no Figma (BUG-026 fix)
787
+ if (phase3.hasFigma && (!phase3.figmaFileUrl || !phase3.figmaFileUrl.trim())) {
788
+ phase3.hasFigma = false;
789
+ phase3.figmaFileUrl = '';
790
+ }
791
+
792
+ // B3: se .git já existe, git init é false independente do prompt (que foi pulado)
793
+ const initGitFinal = gitAlreadyExists ? false : (phase3.initGit ?? false);
794
+
795
+ // Build unified config object
796
+ const config = {
797
+ agents: phase1.agents,
798
+ preset: phase1.preset,
799
+ confluenceLayout: phase1.confluenceLayout,
800
+ confluenceRef: phase1.confluenceRef.trim(),
801
+ confluenceUrl,
802
+ confluenceUser: phase1.confluenceUser.trim(),
803
+ confluenceToken: phase1.confluenceToken.trim(),
804
+ spaceKey: detected.spaceKey || null,
805
+ projectName: phase3.projectName.trim(),
806
+ sigla: phase3.sigla,
807
+ stack: phase3.stack ?? detected.stack,
808
+ vcs: phase3.vcs,
809
+ // B2: vcsOrg é undefined quando vcs='none' (prompt foi pulado)
810
+ vcsOrg: phase3.vcs !== 'none' ? (phase3.vcsOrg || '').trim() : '',
811
+ teamSize: '5',
812
+ gchatWebhookUrl: (phase3.gchatWebhookUrl || '').trim(),
813
+ hasFigma: phase3.hasFigma,
814
+ figmaFileUrl: (phase3.figmaFileUrl || '').trim(),
815
+ initGit: initGitFinal,
816
+ };
817
+
818
+ // ════════════════════════════════════════════
819
+ // Phase 4 — Revisão & Confirmação (B4)
820
+ // ════════════════════════════════════════════
821
+ console.log(chalk.bold.blue('\n ── Fase 4: Revisão ──\n'));
822
+ console.log(chalk.bold(' Confira todos os dados antes de gerar os arquivos:\n'));
823
+
824
+ const vcsDisplay = config.vcs === 'none'
825
+ ? chalk.dim('Nenhum (configurar depois)')
826
+ : `${chalk.cyan(config.vcs)} — ${chalk.cyan(config.vcsOrg)}`;
827
+
828
+ console.log(` Projeto: ${chalk.cyan(config.projectName)}`);
829
+ console.log(` Sigla: ${chalk.cyan(config.sigla)}`);
830
+ console.log(` Stack: ${chalk.cyan(config.stack.length ? config.stack.join(', ') : '(não definida)')}`);
831
+ console.log(` Preset: ${chalk.cyan(PRESETS[config.preset].name)}`);
832
+ console.log(` AI tools: ${chalk.cyan(config.agents.map(a => AGENTS[a].name).join(', '))}`);
833
+ console.log(` VCS: ${vcsDisplay}`);
834
+ console.log(` Confluence: ${chalk.cyan(config.confluenceRef)} (${config.confluenceLayout === 'space' ? 'space key' : 'page ID'}) @ ${chalk.cyan(config.confluenceUrl)}`);
835
+ console.log(` GChat: ${chalk.cyan(config.gchatWebhookUrl ? '(webhook configurado)' : chalk.dim('Não configurado'))}`);
836
+ console.log(` Figma: ${chalk.cyan(config.hasFigma ? (config.figmaFileUrl || 'Sim (URL não informada)') : 'Não')}`);
837
+ if (gitAlreadyExists) {
838
+ console.log(` Git init: ${chalk.dim('Pulado (.git já existe)')}`);
839
+ } else {
840
+ console.log(` Git init: ${chalk.cyan(config.initGit ? 'Sim' : 'Não')}`);
841
+ }
842
+ console.log('');
843
+
844
+ const { confirmed } = await inquirer.prompt([{
845
+ type: 'confirm',
846
+ name: 'confirmed',
847
+ message: 'Confirmar e gerar arquivos?',
848
+ default: true
849
+ }]);
850
+
851
+ if (!confirmed) {
852
+ console.log(chalk.yellow('\n Cancelado. Execute open-spec-kit init novamente para recomeçar.\n'));
853
+ return;
854
+ }
855
+
856
+ // ════════════════════════════════════════════
857
+ // File generation
858
+ // ════════════════════════════════════════════
859
+ const genSpinner = ora('Gerando estrutura...').start();
860
+ const cwd = process.cwd();
861
+
862
+ try {
863
+ // Create common directories
864
+ const commonDirs = ['specs', 'docs/decisions', 'docs/lessons', 'scripts'];
865
+ for (const dir of commonDirs) {
866
+ await mkdir(join(cwd, dir), { recursive: true });
867
+ }
868
+
869
+ // Generate .env with real credentials (GAP-03 fix)
870
+ genSpinner.text = '.env criado';
871
+ await writeFile(join(cwd, '.env'), generateEnvFile(config));
872
+
873
+ // Generate .env.example
874
+ genSpinner.text = '.env.example criado';
875
+ await writeFile(join(cwd, '.env.example'), generateEnvExample(config));
876
+
877
+ // Generate projects.yml
878
+ genSpinner.text = 'projects.yml criado';
879
+ await writeFile(join(cwd, 'projects.yml'), generateProjectsYml(config));
880
+
881
+ // Generate .gitignore
882
+ genSpinner.text = '.gitignore criado';
883
+ await writeFile(join(cwd, '.gitignore'), generateGitignore(config));
884
+
885
+ // Copy shared templates (agents source of truth)
886
+ genSpinner.text = 'Copiando skills e rules...';
887
+ await copyTemplateDir('agents', cwd);
888
+
889
+ // Copy notify scripts to scripts/ (cross-platform)
890
+ genSpinner.text = 'Copiando scripts de notificação...';
891
+ const scriptsSource = join(TEMPLATES_DIR, 'agents', 'scripts');
892
+ const scriptsTarget = join(cwd, 'scripts');
893
+ try {
894
+ await access(scriptsSource);
895
+ await cp(scriptsSource, scriptsTarget, { recursive: true, force: true });
896
+ } catch { /* scripts dir may not exist in older templates */ }
897
+
898
+ // Generate per-agent files
899
+ if (config.agents.includes('claude')) {
900
+ genSpinner.text = 'Configurando Claude Code...';
901
+ await writeFile(join(cwd, '.mcp.json'), generateClaudeMcp(config));
902
+ }
903
+
904
+ if (config.agents.includes('copilot')) {
905
+ genSpinner.text = 'Configurando GitHub Copilot...';
906
+ await copyTemplateDir('github', cwd);
907
+ await mkdir(join(cwd, '.vscode'), { recursive: true });
908
+ await writeFile(join(cwd, '.vscode/mcp.json'), generateCopilotMcp(config));
909
+ }
910
+
911
+ // Generate docs/architecture.md skeleton
912
+ await writeFile(join(cwd, 'docs/architecture.md'), generateArchitectureSkeleton(config));
913
+
914
+ genSpinner.succeed('Estrutura gerada com sucesso!');
915
+
916
+ // Install mcp-atlassian (BUG-024 fix)
917
+ const pipSpinner = ora('Verificando mcp-atlassian...').start();
918
+ try {
919
+ const pipList = execSync('pip list', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
920
+ if (!pipList.toLowerCase().includes('mcp-atlassian')) {
921
+ pipSpinner.text = 'Instalando mcp-atlassian...';
922
+ execSync('pip install mcp-atlassian', { stdio: 'pipe', timeout: 120000 });
923
+ pipSpinner.succeed('mcp-atlassian instalado com sucesso!');
924
+ } else {
925
+ pipSpinner.succeed('mcp-atlassian já está instalado.');
926
+ }
927
+ } catch {
928
+ pipSpinner.warn('Não foi possível instalar mcp-atlassian. Instale manualmente: pip install mcp-atlassian');
929
+ }
930
+
931
+ // Git init
932
+ if (config.initGit) {
933
+ try {
934
+ execSync('git init', { cwd, stdio: 'pipe' });
935
+ execSync('git add .', { cwd, stdio: 'pipe' });
936
+ execSync('git commit -m "chore: init spec repo via open-spec-kit"', { cwd, stdio: 'pipe' });
937
+ console.log(chalk.green('\n ✓ Repositório Git inicializado e commit inicial criado'));
938
+ } catch {
939
+ console.log(chalk.yellow('\n ⚠ Git init falhou — inicialize manualmente:'));
940
+ console.log(chalk.dim(' git init && git add . && git commit -m "chore: init spec repo"'));
941
+ }
942
+ }
943
+
944
+ // Summary
945
+ console.log(chalk.bold('\n Resumo:\n'));
946
+ console.log(` Projeto: ${chalk.cyan(config.projectName)}`);
947
+ console.log(` Sigla: ${chalk.cyan(config.sigla)}`);
948
+ console.log(` Stack: ${chalk.cyan(config.stack.join(', ') || '(não definida)')}`);
949
+ console.log(` Preset: ${chalk.cyan(PRESETS[config.preset].name)}`);
950
+ console.log(` AI tools: ${chalk.cyan(config.agents.map(a => AGENTS[a].name).join(', '))}`);
951
+ const vcsSummary = config.vcs === 'none'
952
+ ? chalk.dim('Nenhum (configurar depois)')
953
+ : `${chalk.cyan(config.vcs)} (${config.vcsOrg})`;
954
+ console.log(` VCS: ${vcsSummary}`);
955
+ console.log(` Confluence: ${chalk.cyan(config.confluenceRef)} (${config.confluenceLayout})`);
956
+ if (config.hasFigma && config.figmaFileUrl) {
957
+ console.log(` Figma: ${chalk.cyan(config.figmaFileUrl)}`);
958
+ }
959
+
960
+ console.log(chalk.bold('\n Arquivos gerados:\n'));
961
+ console.log(chalk.dim(' .env — credenciais reais (NÃO comitar)'));
962
+ console.log(chalk.dim(' .env.example — template para o time'));
963
+ console.log(chalk.dim(' projects.yml — configuração do projeto'));
964
+ console.log(chalk.dim(' .gitignore — ignora .env e .mcp.json'));
965
+ if (config.agents.includes('claude')) {
966
+ console.log(chalk.dim(' .mcp.json — MCP config para Claude Code'));
967
+ }
968
+ if (config.agents.includes('copilot')) {
969
+ console.log(chalk.dim(' .vscode/mcp.json — MCP config para GitHub Copilot'));
970
+ }
971
+
972
+ console.log(chalk.bold('\n Próximos passos:\n'));
973
+ console.log(' → Execute /setup para fazer o bootstrap no Confluence');
974
+ console.log(' → Execute /discovery para analisar a primeira demanda');
975
+ console.log('');
976
+
977
+ } catch (err) {
978
+ genSpinner.fail(`Erro: ${err.message}`);
979
+ process.exit(1);
980
+ }
981
+ }