@guilhermefsousa/open-spec-kit 0.0.2 → 0.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guilhermefsousa/open-spec-kit",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "CLI para spec-driven development com suporte a Claude Code e GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,15 +16,18 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "start": "node bin/open-spec-kit.js",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "test:coverage": "vitest run --coverage",
19
22
  "prepublishOnly": "node bin/open-spec-kit.js --version"
20
23
  },
21
24
  "dependencies": {
25
+ "chalk": "^5.4.0",
22
26
  "commander": "^13.0.0",
23
27
  "inquirer": "^12.0.0",
24
- "chalk": "^5.4.0",
25
28
  "ora": "^8.0.0",
26
- "zod": "^3.24.0",
27
- "yaml": "^2.7.0"
29
+ "yaml": "^2.7.0",
30
+ "zod": "^3.24.0"
28
31
  },
29
32
  "engines": {
30
33
  "node": ">=18.0.0"
@@ -47,5 +50,9 @@
47
50
  "homepage": "https://github.com/guilhermefsousa/open-spec-kit#readme",
48
51
  "bugs": {
49
52
  "url": "https://github.com/guilhermefsousa/open-spec-kit/issues"
53
+ },
54
+ "devDependencies": {
55
+ "@vitest/coverage-v8": "^4.1.4",
56
+ "vitest": "^4.1.4"
50
57
  }
51
58
  }
@@ -3,6 +3,8 @@ import { readFile, access } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { execSync } from 'child_process';
5
5
  import yaml from 'yaml';
6
+ import { detectMcpRunner, getInstallInstructions } from '../utils/mcp-detect.js';
7
+ import { confluenceRequest, figmaRequest } from '../utils/http.js';
6
8
 
7
9
  export async function doctorCommand() {
8
10
  console.log(chalk.bold('\n open-spec-kit doctor\n'));
@@ -159,22 +161,29 @@ export async function doctorCommand() {
159
161
  fail++;
160
162
  }
161
163
 
162
- // Check 7: MCP CLI available — verify Python version too
163
- try {
164
- const whereCmd = process.platform === 'win32' ? 'where mcp-atlassian' : 'which mcp-atlassian';
165
- execSync(whereCmd, { stdio: 'pipe', timeout: 5000 });
166
- console.log(chalk.green(' ✓ mcp-atlassian disponivel no PATH'));
164
+ // Check 7: MCP CLI available — detect best runner (direct, uvx, pipx)
165
+ const mcpRunner = detectMcpRunner();
166
+ if (mcpRunner.method !== 'pip-fallback') {
167
+ const via = mcpRunner.method === 'direct' ? 'PATH' : mcpRunner.method;
168
+ console.log(chalk.green(` ✓ mcp-atlassian disponível via ${via} (${mcpRunner.command}${mcpRunner.prefix.length > 0 ? ' ' + mcpRunner.prefix.join(' ') : ''})`));
167
169
  pass++;
168
- } catch {
169
- console.log(chalk.yellow(' ⚠ mcp-atlassian nao encontrado'));
170
- console.log(chalk.dim(' Instale com: pip install mcp-atlassian (requer Python >= 3.10)'));
170
+ } else {
171
+ console.log(chalk.yellow(' ⚠ mcp-atlassian não encontrado (nem direto, nem via uvx/pipx)'));
172
+ for (const line of getInstallInstructions()) {
173
+ console.log(chalk.dim(` ${line}`));
174
+ }
171
175
  fail++;
172
176
  }
173
177
 
174
178
  // Check 7b: Python version >= 3.10 (required by mcp-atlassian)
179
+ // Try python3 first (Linux/macOS default), fall back to python (Windows default)
175
180
  try {
176
- const pyCmd = process.platform === 'win32' ? 'python --version' : 'python3 --version';
177
- const pyVersion = execSync(pyCmd, { stdio: 'pipe', timeout: 5000 }).toString().trim();
181
+ let pyVersion;
182
+ try {
183
+ pyVersion = execSync('python3 --version', { stdio: 'pipe', timeout: 5000 }).toString().trim();
184
+ } catch {
185
+ pyVersion = execSync('python --version', { stdio: 'pipe', timeout: 5000 }).toString().trim();
186
+ }
178
187
  const match = pyVersion.match(/(\d+)\.(\d+)/);
179
188
  if (match) {
180
189
  const major = Number(match[1]);
@@ -229,13 +238,8 @@ export async function doctorCommand() {
229
238
  if (hasFigma) {
230
239
  if (claudeMcpContent) {
231
240
  if (claudeMcpContent.includes('"figma"')) {
232
- if (claudeMcpContent.includes('SEU-FIGMA-API-KEY')) {
233
- console.log(chalk.yellow(' ⚠ .mcp.json tem servidor Figma mas com placeholder'));
234
- fail++;
235
- } else {
236
- console.log(chalk.green(' ✓ .mcp.json (Claude) tem servidor Figma configurado'));
237
- pass++;
238
- }
241
+ console.log(chalk.green(' ✓ .mcp.json (Claude) tem servidor Figma configurado'));
242
+ pass++;
239
243
  } else {
240
244
  console.log(chalk.yellow(' ⚠ projects.yml tem figma: mas .mcp.json não tem servidor figma'));
241
245
  fail++;
@@ -252,62 +256,75 @@ export async function doctorCommand() {
252
256
  }
253
257
  }
254
258
 
255
- // Check 11: Confluence credentials make real HTTP call to validate token
259
+ // Parse .env once for credential checks (11 + 12)
260
+ let envVars = {};
256
261
  try {
257
262
  const envContent = await readFile(join(cwd, '.env'), 'utf-8');
258
- const envVars = Object.fromEntries(
263
+ envVars = Object.fromEntries(
259
264
  envContent.split('\n')
260
265
  .filter(l => l.includes('=') && !l.startsWith('#'))
261
266
  .map(l => { const idx = l.indexOf('='); return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()]; }),
262
267
  );
268
+ } catch {
269
+ // .env not found — already warned by MCP config check
270
+ }
271
+
272
+ const isPlaceholder = (v) => !v || v.includes('seu-') || v.includes('SEU-') || v === 'seu-api-token-aqui' || v === 'seu-figma-api-key';
263
273
 
274
+ // Check 11: Confluence credentials — make real HTTP call to validate token
275
+ {
264
276
  const confUrl = envVars.CONFLUENCE_URL;
265
- const confUser = envVars.CONFLUENCE_USER;
277
+ const confUser = envVars.CONFLUENCE_USERNAME;
266
278
  const confToken = envVars.CONFLUENCE_API_TOKEN;
267
279
 
268
- const isPlaceholder = (v) => !v || v.includes('seu-') || v.includes('SEU-') || v === 'seu-api-token-aqui';
269
-
270
280
  if (isPlaceholder(confUrl) || isPlaceholder(confUser) || isPlaceholder(confToken)) {
271
281
  console.log(chalk.yellow(' ⚠ .env não configurado — pular validação de credenciais Confluence'));
272
- console.log(chalk.dim(' Configure CONFLUENCE_URL, CONFLUENCE_USER e CONFLUENCE_API_TOKEN no .env'));
282
+ console.log(chalk.dim(' Configure CONFLUENCE_URL, CONFLUENCE_USERNAME e CONFLUENCE_API_TOKEN no .env'));
273
283
  fail++;
274
284
  } else {
275
- const basicAuth = Buffer.from(`${confUser}:${confToken}`).toString('base64');
276
- const controller = new AbortController();
277
- const timeout = setTimeout(() => controller.abort(), 10000);
278
-
279
285
  try {
280
- const res = await fetch(`${confUrl}/rest/api/space?limit=1`, {
281
- headers: { Authorization: `Basic ${basicAuth}`, Accept: 'application/json' },
282
- signal: controller.signal,
283
- });
284
- clearTimeout(timeout);
285
-
286
- if (res.ok) {
287
- console.log(chalk.green(' ✓ Credenciais Confluence válidas (token ativo)'));
288
- pass++;
289
- } else if (res.status === 401 || res.status === 403) {
290
- console.log(chalk.red(' ✗ Token Confluence inválido ou expirado (HTTP ' + res.status + ')'));
286
+ await confluenceRequest(confUrl, '/rest/api/space?limit=1', confUser, confToken, 10000);
287
+ console.log(chalk.green(' ✓ Credenciais Confluence válidas (token ativo)'));
288
+ pass++;
289
+ } catch (err) {
290
+ if (err.status === 401 || err.status === 403) {
291
+ console.log(chalk.red(` ✗ Token Confluence inválido ou expirado (HTTP ${err.status})`));
291
292
  console.log(chalk.dim(' Renove em: https://id.atlassian.com/manage-profile/security/api-tokens'));
292
293
  console.log(chalk.dim(' Atualize CONFLUENCE_API_TOKEN no .env'));
293
294
  fail++;
294
295
  } else {
295
- console.log(chalk.yellow(` ⚠ Confluence respondeu HTTP ${res.status} — verifique CONFLUENCE_URL`));
296
+ console.log(chalk.yellow(` ⚠ Não foi possível verificar credenciais Confluence: ${err.message}`));
296
297
  fail++;
297
298
  }
298
- } catch (fetchErr) {
299
- clearTimeout(timeout);
300
- if (fetchErr.name === 'AbortError') {
301
- console.log(chalk.yellow(' ⚠ Confluence não respondeu em 10s — sem rede ou URL incorreta'));
299
+ }
300
+ }
301
+ }
302
+
303
+ // Check 12: Figma credentials — make real HTTP call to validate token
304
+ if (hasFigma) {
305
+ const figmaKey = envVars.FIGMA_API_KEY;
306
+
307
+ if (isPlaceholder(figmaKey)) {
308
+ console.log(chalk.yellow(' ⚠ FIGMA_API_KEY não configurado no .env'));
309
+ console.log(chalk.dim(' Gere em: Figma > Account Settings > Personal Access Tokens'));
310
+ fail++;
311
+ } else {
312
+ try {
313
+ const result = await figmaRequest(figmaKey, 10000);
314
+ console.log(chalk.green(` ✓ Token Figma válido (usuário: ${result.data.handle || result.data.email || 'OK'})`));
315
+ pass++;
316
+ } catch (err) {
317
+ if (err.status === 401 || err.status === 403) {
318
+ console.log(chalk.red(` ✗ Token Figma inválido ou expirado (HTTP ${err.status})`));
319
+ console.log(chalk.dim(' Renove em: Figma > Account Settings > Personal Access Tokens'));
320
+ console.log(chalk.dim(' Atualize FIGMA_API_KEY no .env'));
321
+ fail++;
302
322
  } else {
303
- console.log(chalk.yellow(` ⚠ Não foi possível verificar credenciais Confluence: ${fetchErr.message}`));
323
+ console.log(chalk.yellow(` ⚠ Não foi possível verificar token Figma: ${err.message}`));
324
+ fail++;
304
325
  }
305
- // Network errors are WARN not FAIL — offline dev should not be blocked
306
- fail++;
307
326
  }
308
327
  }
309
- } catch {
310
- // .env not found — already warned by MCP config check
311
328
  }
312
329
 
313
330
  // Summary
@@ -5,8 +5,8 @@ import { writeFile, mkdir, access, cp } from 'fs/promises';
5
5
  import { join, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
- import https from 'https';
9
- import http from 'http';
8
+ import { detectMcpRunner, detectNpxRunner, tryInstallMcpAtlassian, getInstallInstructions } from '../utils/mcp-detect.js';
9
+ import { confluenceRequest, figmaRequest } from '../utils/http.js';
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates');
@@ -48,7 +48,7 @@ const KNOWN_TECH_KEYWORDS = [
48
48
  ];
49
49
 
50
50
  // ──────────────────────────────────────────────────────
51
- // HTTP helper using Node.js built-in modules
51
+ // SSL error detection (corporate proxy workaround)
52
52
  // ──────────────────────────────────────────────────────
53
53
  const SSL_ERROR_CODES = new Set([
54
54
  'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
@@ -67,48 +67,6 @@ function isSslError(err) {
67
67
  );
68
68
  }
69
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
70
  function stripHtml(html) {
113
71
  if (!html) return '';
114
72
  return html
@@ -419,13 +377,13 @@ Thumbs.db
419
377
 
420
378
  function generateEnvFile(config) {
421
379
  let content = `CONFLUENCE_URL=${config.confluenceUrl}
422
- CONFLUENCE_USER=${config.confluenceUser}
380
+ CONFLUENCE_USERNAME=${config.confluenceUser}
423
381
  CONFLUENCE_API_TOKEN=${config.confluenceToken}
424
382
  GCHAT_WEBHOOK_URL=${config.gchatWebhookUrl || ''}
425
383
  `;
426
384
 
427
385
  if (config.hasFigma && config.figmaFileUrl) {
428
- content += 'FIGMA_API_KEY=\n';
386
+ content += `FIGMA_API_KEY=${config.figmaToken}\n`;
429
387
  }
430
388
 
431
389
  return content;
@@ -435,7 +393,7 @@ function generateEnvExample(config) {
435
393
  let content = `# Confluence credentials
436
394
  # Obtenha o token em: https://id.atlassian.com/manage-profile/security/api-tokens
437
395
  CONFLUENCE_URL=https://seu-dominio.atlassian.net/wiki
438
- CONFLUENCE_USER=seu-email@empresa.com
396
+ CONFLUENCE_USERNAME=seu-email@empresa.com
439
397
  CONFLUENCE_API_TOKEN=seu-token-aqui
440
398
 
441
399
  # Google Chat webhook (opcional)
@@ -454,41 +412,49 @@ FIGMA_API_KEY=seu-figma-api-key
454
412
  }
455
413
 
456
414
  function generateClaudeMcp(config) {
415
+ const runner = detectMcpRunner();
416
+ const confluenceArgs = [
417
+ ...runner.prefix,
418
+ '--confluence-url', '${CONFLUENCE_URL}',
419
+ '--confluence-username', '${CONFLUENCE_USERNAME}',
420
+ '--confluence-token', '${CONFLUENCE_API_TOKEN}'
421
+ ];
457
422
  const servers = {
458
423
  confluence: {
459
- command: 'mcp-atlassian',
460
- args: [
461
- '--confluence-url', '${CONFLUENCE_URL}',
462
- '--confluence-username', '${CONFLUENCE_USER}',
463
- '--confluence-token', '${CONFLUENCE_API_TOKEN}'
464
- ]
424
+ command: runner.command,
425
+ args: confluenceArgs,
465
426
  }
466
427
  };
467
428
  if (config.hasFigma && config.figmaFileUrl) {
429
+ const npx = detectNpxRunner();
468
430
  servers.figma = {
469
- command: 'npx',
470
- args: ['-y', FIGMA_MCP_PACKAGE, '--figma-api-key', '${FIGMA_API_KEY}']
431
+ command: npx.command,
432
+ args: [...npx.prefix, FIGMA_MCP_PACKAGE, '--figma-api-key', '${FIGMA_API_KEY}']
471
433
  };
472
434
  }
473
435
  return JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
474
436
  }
475
437
 
476
438
  function generateCopilotMcp(config) {
439
+ const runner = detectMcpRunner();
440
+ const confluenceArgs = [
441
+ ...runner.prefix,
442
+ '--env-file', '.env',
443
+ '--no-confluence-ssl-verify'
444
+ ];
477
445
  const servers = {
478
446
  confluence: {
479
447
  type: 'stdio',
480
- command: 'mcp-atlassian',
481
- args: [
482
- '--env-file', '.env',
483
- '--no-confluence-ssl-verify'
484
- ]
448
+ command: runner.command,
449
+ args: confluenceArgs,
485
450
  }
486
451
  };
487
452
  if (config.hasFigma && config.figmaFileUrl) {
453
+ const npx = detectNpxRunner();
488
454
  servers.figma = {
489
455
  type: 'stdio',
490
- command: 'npx',
491
- args: ['-y', FIGMA_MCP_PACKAGE, '--figma-api-key', '${input:figma-api-key}']
456
+ command: npx.command,
457
+ args: [...npx.prefix, FIGMA_MCP_PACKAGE, '--figma-api-key', '${input:figma-api-key}']
492
458
  };
493
459
  }
494
460
  return JSON.stringify({ servers }, null, 2) + '\n';
@@ -771,8 +737,22 @@ export async function initCommand() {
771
737
  {
772
738
  type: 'input',
773
739
  name: 'figmaFileUrl',
774
- message: 'URL do arquivo Figma (deixe vazio para pular):',
775
- when: (answers) => answers.hasFigma
740
+ message: 'URL do arquivo Figma (ex: https://www.figma.com/design/ABC123/Nome):',
741
+ when: (answers) => answers.hasFigma,
742
+ validate: v => {
743
+ const trimmed = v.trim();
744
+ if (!trimmed) return 'Obrigatório — se não tem URL, responda "Não" na pergunta anterior';
745
+ if (!trimmed.includes('figma.com')) return 'URL deve ser do Figma (figma.com)';
746
+ return true;
747
+ }
748
+ },
749
+ {
750
+ type: 'password',
751
+ name: 'figmaToken',
752
+ message: 'Figma API token (Personal Access Token):',
753
+ mask: '*',
754
+ when: (answers) => answers.hasFigma && answers.figmaFileUrl?.trim(),
755
+ validate: v => v.trim().length > 0 || 'Obrigatório — gere em: Figma > Account Settings > Personal Access Tokens'
776
756
  },
777
757
  {
778
758
  type: 'confirm',
@@ -789,6 +769,29 @@ export async function initCommand() {
789
769
  phase3.figmaFileUrl = '';
790
770
  }
791
771
 
772
+ // Validate Figma token
773
+ let figmaTokenValidated = false;
774
+ if (phase3.hasFigma && phase3.figmaToken) {
775
+ const figmaSpinner = ora('Validando token do Figma...').start();
776
+ try {
777
+ const result = await figmaRequest(phase3.figmaToken.trim());
778
+ figmaSpinner.succeed(`Token Figma validado — usuário: ${result.data.handle || result.data.email || 'OK'}`);
779
+ figmaTokenValidated = true;
780
+ } catch (err) {
781
+ if (err.status === 401 || err.status === 403) {
782
+ figmaSpinner.fail(`Token Figma inválido (HTTP ${err.status}). Verifique o Personal Access Token.`);
783
+ console.log(chalk.dim(' Gere em: Figma > Account Settings > Personal Access Tokens'));
784
+ console.log(chalk.yellow(' Continuando sem Figma...\n'));
785
+ phase3.hasFigma = false;
786
+ phase3.figmaFileUrl = '';
787
+ phase3.figmaToken = '';
788
+ } else {
789
+ figmaSpinner.warn(`Não foi possível validar o token Figma: ${err.message}`);
790
+ console.log(chalk.yellow(' Token será salvo, mas verifique com "open-spec-kit doctor".\n'));
791
+ }
792
+ }
793
+ }
794
+
792
795
  // B3: se .git já existe, git init é false independente do prompt (que foi pulado)
793
796
  const initGitFinal = gitAlreadyExists ? false : (phase3.initGit ?? false);
794
797
 
@@ -812,6 +815,7 @@ export async function initCommand() {
812
815
  gchatWebhookUrl: (phase3.gchatWebhookUrl || '').trim(),
813
816
  hasFigma: phase3.hasFigma,
814
817
  figmaFileUrl: (phase3.figmaFileUrl || '').trim(),
818
+ figmaToken: (phase3.figmaToken || '').trim(),
815
819
  initGit: initGitFinal,
816
820
  };
817
821
 
@@ -833,7 +837,10 @@ export async function initCommand() {
833
837
  console.log(` VCS: ${vcsDisplay}`);
834
838
  console.log(` Confluence: ${chalk.cyan(config.confluenceRef)} (${config.confluenceLayout === 'space' ? 'space key' : 'page ID'}) @ ${chalk.cyan(config.confluenceUrl)}`);
835
839
  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')}`);
840
+ const figmaStatus = config.hasFigma
841
+ ? `${config.figmaFileUrl} (${figmaTokenValidated ? 'token validado' : 'token salvo, não validado'})`
842
+ : 'Não';
843
+ console.log(` Figma: ${chalk.cyan(figmaStatus)}`);
837
844
  if (gitAlreadyExists) {
838
845
  console.log(` Git init: ${chalk.dim('Pulado (.git já existe)')}`);
839
846
  } else {
@@ -913,19 +920,16 @@ export async function initCommand() {
913
920
 
914
921
  genSpinner.succeed('Estrutura gerada com sucesso!');
915
922
 
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.');
923
+ // Install mcp-atlassian — cross-platform detection (uvx > pipx > pip)
924
+ const mcpSpinner = ora('Verificando mcp-atlassian...').start();
925
+ const installResult = tryInstallMcpAtlassian();
926
+ if (installResult.installed) {
927
+ mcpSpinner.succeed(`mcp-atlassian disponível via ${installResult.method}`);
928
+ } else {
929
+ mcpSpinner.warn(`${installResult.message}. Instale manualmente:`);
930
+ for (const line of getInstallInstructions()) {
931
+ console.log(chalk.dim(` ${line}`));
926
932
  }
927
- } catch {
928
- pipSpinner.warn('Não foi possível instalar mcp-atlassian. Instale manualmente: pip install mcp-atlassian');
929
933
  }
930
934
 
931
935
  // Git init
@@ -27,7 +27,7 @@ function parseBrief(content) {
27
27
  lineCount: content.split('\n').length,
28
28
  hasProblema: !!findSection(sections, /problema|contexto|visão\s*geral|overview|background|objetivo\s+do\s+produto|situação\s*atual|descri[çc][aã]o|identifica[çc][aã]o/i),
29
29
  hasForaDeEscopo: !!findSection(sections, /fora\s+de\s+escopo/i),
30
- reqIds: extractUniqueMatches(content, /REQ-\w+/g),
30
+ reqIds: content.match(/REQ-\w+/g) || [],
31
31
  rawContent: content,
32
32
  };
33
33
  }
@@ -35,7 +35,8 @@ function parseBrief(content) {
35
35
  function parseScenarios(content) {
36
36
  const sections = parseSections(content);
37
37
  const CT_HEADING_RE = /^CT-(\d{3}-\d{2}):\s*(.+?)(?:\s*\(REQ-(\d{3})\))?$/;
38
- const CT_ID_RE = /CT-\d{3}-\d{2}/g;
38
+ // Broad regex: capture all CT-like IDs (including malformed) so rule07 can flag them
39
+ const CT_ID_RE = /CT-\d+-\d+/g;
39
40
  const REQ_REF_RE = /REQ-\d{3}/g;
40
41
 
41
42
  const allCtIds = [];
@@ -429,12 +430,7 @@ export async function validateCommand(options = {}) {
429
430
  const contracts = contractsRaw ? parseContracts(contractsRaw) : null;
430
431
  const tasks = tasksRaw ? parseTasks(tasksRaw) : null;
431
432
 
432
- // Enrich brief with REQs from scenarios (union) covers specs where brief doesn't list REQs
433
- if (brief && scenarios) {
434
- brief.reqIds = [...new Set([...brief.reqIds, ...scenarios.allReqRefs])];
435
- }
436
-
437
- // Rules 2-6: Brief
433
+ // Rules 2-6: Brief (run BEFORE enrich so rule06 sees raw duplicates)
438
434
  if (brief) {
439
435
  results.push(rules.rule02_briefMaxLines(brief));
440
436
  results.push(rules.rule03_briefHasProblema(brief));
@@ -443,6 +439,12 @@ export async function validateCommand(options = {}) {
443
439
  results.push(rules.rule06_noDuplicateReqIds(brief));
444
440
  }
445
441
 
442
+ // Enrich brief with REQs from scenarios (union) — covers specs where brief doesn't list REQs
443
+ // Runs AFTER rule05/rule06 so those rules see the original brief REQs (with duplicates)
444
+ if (brief && scenarios) {
445
+ brief.reqIds = [...new Set([...brief.reqIds, ...scenarios.allReqRefs])];
446
+ }
447
+
446
448
  // Rule 7: CT IDs
447
449
  if (scenarios) {
448
450
  results.push(rules.rule07_ctIdFormat(scenarios));
@@ -0,0 +1,54 @@
1
+ import https from 'https';
2
+ import http from 'http';
3
+
4
+ /**
5
+ * Simple HTTP GET that returns parsed JSON. Used by init and doctor
6
+ * to validate Confluence and Figma credentials.
7
+ */
8
+ function httpGetJson(url, headers, { timeoutMs = 15000, allowInsecure = false } = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ try {
11
+ const fullUrl = new URL(url);
12
+ const mod = fullUrl.protocol === 'https:' ? https : http;
13
+ const options = {
14
+ headers: { ...headers, 'Accept': 'application/json' },
15
+ timeout: timeoutMs,
16
+ ...(allowInsecure && fullUrl.protocol === 'https:'
17
+ ? { agent: new https.Agent({ rejectUnauthorized: false }) }
18
+ : {}),
19
+ };
20
+ const req = mod.get(fullUrl, options, (res) => {
21
+ let data = '';
22
+ res.on('data', (chunk) => { data += chunk; });
23
+ res.on('end', () => {
24
+ if (res.statusCode >= 200 && res.statusCode < 300) {
25
+ try {
26
+ resolve({ status: res.statusCode, data: JSON.parse(data) });
27
+ } catch {
28
+ resolve({ status: res.statusCode, data });
29
+ }
30
+ } else {
31
+ reject({ status: res.statusCode, message: `HTTP ${res.statusCode}` });
32
+ }
33
+ });
34
+ });
35
+ req.on('error', (err) => reject({ status: 0, message: err.message, code: err.code }));
36
+ req.on('timeout', () => {
37
+ req.destroy();
38
+ reject({ status: 0, message: `Timeout (${timeoutMs / 1000}s)` });
39
+ });
40
+ } catch (err) {
41
+ reject({ status: 0, message: err.message, code: err.code });
42
+ }
43
+ });
44
+ }
45
+
46
+ export function confluenceRequest(baseUrl, path, user, token, timeoutMs = 15000, allowInsecure = false) {
47
+ const url = baseUrl.replace(/\/$/, '') + path;
48
+ const auth = Buffer.from(`${user}:${token}`).toString('base64');
49
+ return httpGetJson(url, { 'Authorization': `Basic ${auth}` }, { timeoutMs, allowInsecure });
50
+ }
51
+
52
+ export function figmaRequest(token, timeoutMs = 15000) {
53
+ return httpGetJson('https://api.figma.com/v1/me', { 'X-Figma-Token': token }, { timeoutMs });
54
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * MCP Runner Detection — cross-platform detection of the best way to run MCP servers.
3
+ *
4
+ * Detects whether mcp-atlassian is directly on PATH, or available via uvx/pipx.
5
+ * Used by both init.js (config generation) and doctor.js (health checks).
6
+ */
7
+
8
+ import { execSync } from 'child_process';
9
+
10
+ const WHERE_CMD = process.platform === 'win32' ? 'where' : 'which';
11
+
12
+ /**
13
+ * Check if a command exists on PATH.
14
+ * @param {string} cmd
15
+ * @returns {boolean}
16
+ */
17
+ function commandExists(cmd) {
18
+ try {
19
+ execSync(`${WHERE_CMD} ${cmd}`, { stdio: 'pipe', timeout: 5000 });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Detect the best way to run mcp-atlassian.
28
+ * Priority: direct PATH > uvx > pipx > pip-fallback
29
+ *
30
+ * @returns {{ command: string, prefix: string[], method: 'direct'|'uvx'|'pipx'|'pip-fallback' }}
31
+ * - command: the executable to run
32
+ * - prefix: args to prepend before mcp-atlassian's own args
33
+ * - method: how it was detected (for logging)
34
+ */
35
+ export function detectMcpRunner() {
36
+ if (commandExists('mcp-atlassian')) {
37
+ return { command: 'mcp-atlassian', prefix: [], method: 'direct' };
38
+ }
39
+ if (commandExists('uvx')) {
40
+ return { command: 'uvx', prefix: ['mcp-atlassian'], method: 'uvx' };
41
+ }
42
+ if (commandExists('pipx')) {
43
+ return { command: 'pipx', prefix: ['run', 'mcp-atlassian'], method: 'pipx' };
44
+ }
45
+ return { command: 'mcp-atlassian', prefix: [], method: 'pip-fallback' };
46
+ }
47
+
48
+ /**
49
+ * Detect the best way to run npx (for Figma MCP).
50
+ * @returns {{ command: string, prefix: string[], method: 'npx'|'npm-exec'|'fallback' }}
51
+ */
52
+ export function detectNpxRunner() {
53
+ const npxCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
54
+ if (commandExists(npxCmd)) {
55
+ return { command: npxCmd === 'npx.cmd' ? 'npx' : npxCmd, prefix: ['-y'], method: 'npx' };
56
+ }
57
+ if (commandExists('npm')) {
58
+ return { command: 'npm', prefix: ['exec', '--yes', '--'], method: 'npm-exec' };
59
+ }
60
+ return { command: 'npx', prefix: ['-y'], method: 'fallback' };
61
+ }
62
+
63
+ /**
64
+ * Try to install mcp-atlassian using the best available package manager.
65
+ * @returns {{ installed: boolean, method: string, message: string }}
66
+ */
67
+ export function tryInstallMcpAtlassian() {
68
+ // Already available — no install needed
69
+ const runner = detectMcpRunner();
70
+ if (runner.method !== 'pip-fallback') {
71
+ return { installed: true, method: runner.method, message: `mcp-atlassian acessível via ${runner.method}` };
72
+ }
73
+
74
+ // Try uv tool install
75
+ if (commandExists('uv')) {
76
+ try {
77
+ execSync('uv tool install mcp-atlassian', { stdio: 'pipe', timeout: 120000 });
78
+ return { installed: true, method: 'uv-tool', message: 'Instalado via uv tool install' };
79
+ } catch { /* failed */ }
80
+ }
81
+
82
+ // Try pipx install
83
+ if (commandExists('pipx')) {
84
+ try {
85
+ execSync('pipx install mcp-atlassian', { stdio: 'pipe', timeout: 120000 });
86
+ return { installed: true, method: 'pipx', message: 'Instalado via pipx install' };
87
+ } catch { /* failed */ }
88
+ }
89
+
90
+ // Try pip install (legacy fallback)
91
+ const pipCmd = process.platform === 'win32' ? 'pip' : 'pip3';
92
+ if (commandExists(pipCmd)) {
93
+ try {
94
+ execSync(`${pipCmd} install mcp-atlassian`, { stdio: 'pipe', timeout: 120000 });
95
+ // Verify it landed on PATH
96
+ if (commandExists('mcp-atlassian')) {
97
+ return { installed: true, method: 'pip', message: `Instalado via ${pipCmd}` };
98
+ }
99
+ return { installed: false, method: 'pip-no-path', message: `${pipCmd} install executado mas mcp-atlassian não está no PATH` };
100
+ } catch { /* failed */ }
101
+ }
102
+
103
+ return { installed: false, method: 'none', message: 'Nenhum gerenciador de pacotes Python encontrado' };
104
+ }
105
+
106
+ /**
107
+ * Get platform-specific install instructions for mcp-atlassian.
108
+ * @returns {string[]} Array of instruction lines
109
+ */
110
+ export function getInstallInstructions() {
111
+ const platform = process.platform;
112
+ if (platform === 'win32') {
113
+ return [
114
+ 'pip install mcp-atlassian',
115
+ 'OU: instale uv (https://docs.astral.sh/uv/) e rode: uv tool install mcp-atlassian',
116
+ ];
117
+ }
118
+ if (platform === 'darwin') {
119
+ return [
120
+ 'brew install uv && uv tool install mcp-atlassian',
121
+ 'OU: pip3 install mcp-atlassian',
122
+ ];
123
+ }
124
+ // linux
125
+ return [
126
+ 'curl -LsSf https://astral.sh/uv/install.sh | sh && uv tool install mcp-atlassian',
127
+ 'OU: pip3 install mcp-atlassian',
128
+ ];
129
+ }
130
+
131
+ export { commandExists };
@@ -54,7 +54,18 @@ else
54
54
  fi
55
55
 
56
56
  # Build and send via Python — avoids bash UTF-8 corruption
57
- PYTHONIOENCODING=utf-8 python -c "
57
+ # Use python3 if available (Linux/macOS default), fall back to python (Windows)
58
+ PYTHON_CMD="python3"
59
+ if ! command -v python3 &>/dev/null; then
60
+ PYTHON_CMD="python"
61
+ fi
62
+
63
+ if ! command -v "$PYTHON_CMD" &>/dev/null; then
64
+ echo "[notify-gchat] Python não encontrado (nem python3, nem python) — notificação ignorada" >&2
65
+ exit 0
66
+ fi
67
+
68
+ PYTHONIOENCODING=utf-8 $PYTHON_CMD -c "
58
69
  import json, sys, subprocess
59
70
 
60
71
  card_mode = sys.argv[1] == 'true'
@@ -101,7 +101,9 @@ This ensures the PRD is a **complete description of the new feature** with expli
101
101
  1. `projects.yml` has a `figma:` section with `file_url` filled
102
102
  2. The Figma MCP is available (`figma` server configured)
103
103
 
104
- If either condition fails, **skip silently**.
104
+ If `projects.yml` has `figma.file_url` but the MCP is not available, **warn the user**: "⚠ Figma configurado em projects.yml mas MCP Figma não disponível. Execute 'open-spec-kit doctor' para diagnosticar. Continuando sem contexto do Figma."
105
+
106
+ If `projects.yml` does NOT have a `figma:` section, skip silently (Figma is optional).
105
107
 
106
108
  **If available:**
107
109
  1. Extract the file key from the URL in `projects.yml` → `figma.file_url` (segment after `/design/` or `/file/`)
@@ -106,7 +106,9 @@ The setup supports two Confluence layouts:
106
106
  1. `projects.yml` has a `figma:` section with `file_url` filled
107
107
  2. The Figma MCP is available (`figma` server configured in `.mcp.json`)
108
108
 
109
- If either condition fails, **skip silently** do not report an error or ask for configuration.
109
+ If `projects.yml` has `figma.file_url` but the MCP is not available, **warn the user**: "⚠ Figma configurado em projects.yml mas MCP Figma não disponível. Execute 'open-spec-kit doctor' para diagnosticar. Continuando sem contexto do Figma."
110
+
111
+ If `projects.yml` does NOT have a `figma:` section, skip silently (Figma is optional).
110
112
 
111
113
  **If available:**
112
114
  1. Extract the file key from the URL in `projects.yml` → `figma.file_url`
@@ -129,9 +131,11 @@ When generating the living docs (step 3), reference Figma screens where relevant
129
131
 
130
132
  ### 3. Generate living docs on Confluence
131
133
 
132
- Via MCP Confluence, create these pages **under the project root**, in this order.
134
+ **CRITICAL: All pages in this section MUST be created as children of `ROOT_ID` (the project root page), NOT as children of `DEMANDAS_ID`. `Demandas/` is a SIBLING — the generated pages go NEXT TO it, not INSIDE it. Use `ROOT_ID` as the parent_id for every `confluence_create_page` call in this step.**
135
+
136
+ Via MCP Confluence, create these pages **under the project root (`ROOT_ID`)**, in this order.
133
137
 
134
- **Title prefix (multi-project)**: when the layout is multi-project (argument is a numeric page ID), ALL created page titles must be prefixed with the project acronym (`sigla` field in projects.yml). This avoids title conflicts within the same Confluence space. The project root page does NOT get a prefix (it's already unique by definition).
138
+ **Title prefix (multi-project)**: when the layout is multi-project (argument is a numeric page ID), ALL created page titles must be prefixed with the project acronym (`sigla` field in projects.yml). **Read the sigla from `projects.yml` field `project.sigla` — NEVER derive or invent a new one.** This avoids title conflicts within the same Confluence space. The project root page does NOT get a prefix (it's already unique by definition).
135
139
 
136
140
  Title format:
137
141
  - Domain pages: `{SIGLA} — Visão do Produto`, `{SIGLA} — Glossário`, etc.
@@ -147,8 +151,8 @@ In simple layout (1 space = 1 project), the prefix is optional — no conflict r
147
151
  - `🔀 {SIGLA} — Fluxos`
148
152
  - `📊 {SIGLA} — Tabelas de Referência`
149
153
  - `🔌 {SIGLA} — Integrações`
150
- - `🚀 Features`
151
- - `📦 Arquivados`
154
+ - `🚀 {SIGLA} — Features`
155
+ - `📦 {SIGLA} — Arquivados`
152
156
  - Business features: `🚀 {SIGLA}-NNN - Feature name`
153
157
  - Infrastructure feature: `⚙️ {SIGLA}-000 - Infraestrutura e Scaffold`
154
158
 
@@ -246,8 +250,8 @@ Minimum expected terms for any project:
246
250
  - External integrations: list or write "Nenhuma na v1"
247
251
 
248
252
  **Step 3.4 — Structure pages:**
249
- - Create `Features/` parent page under the project root if it doesn't exist
250
- - Create `Arquivados/` parent page under the project root if it doesn't exist
253
+ - Create `🚀 {SIGLA} — Features` parent page under the project root if it doesn't exist
254
+ - Create `📦 {SIGLA} — Arquivados` parent page under the project root if it doesn't exist
251
255
 
252
256
  **Step 3.5 — Create initial feature pages:**
253
257
 
@@ -290,9 +294,11 @@ All subsequent features ({SIGLA}-001 onwards) have an implicit dependency on {SI
290
294
 
291
295
  **Step 3.5b — Business features:**
292
296
 
297
+ **Reminder: feature pages are children of `{SIGLA} — Features` (which is a child of `ROOT_ID`). Do NOT create features inside `Demandas/`.**
298
+
293
299
  Analyze the PO documents in `Demandas/` and identify distinct features/capabilities described. For EACH feature identified:
294
300
 
295
- 1. Create a child page under `Features/` with title: `🚀 {SIGLA}-NNN - Feature name` (numbering sequential: 001, 002, ...). E.g., `🚀 FT-001 - Cadastro e Autenticação`, `🚀 TD-003 - Gerenciamento de Tarefas`
301
+ 1. Create a child page under `{SIGLA} — Features` with title: `🚀 {SIGLA}-NNN - Feature name` (numbering sequential: 001, 002, ...). E.g., `🚀 FT-001 - Cadastro e Autenticação`, `🚀 TD-003 - Gerenciamento de Tarefas`
296
302
  2. Content MUST start with a metadata table:
297
303
  ```
298
304
  | Campo | Valor |
@@ -315,7 +321,7 @@ In the local spec repo:
315
321
 
316
322
  **Fill `projects.yml`:**
317
323
  - Project name, domain, status, team size
318
- - **Sigla**: `sigla: XX` — short abbreviation (2-4 letters) used as prefix in Confluence titles and feature IDs. Derive from project name (e.g., FinTrack FT, Todo TD, CRM CRM). Ask the dev if ambiguous.
324
+ - **Sigla**: `sigla: XX` — short abbreviation (2-4 letters) used as prefix in Confluence titles and feature IDs. **MUST read from `projects.yml` field `project.sigla`** this was already set by the user during `open-spec-kit init`. NEVER derive or invent a new sigla. If `projects.yml` has no sigla, ask the dev.
319
325
  - Stack (from PO's docs or ask the dev)
320
326
  - **VCS**: `vcs: github | gitlab` and `vcs_org: {org/group}` — ask the dev if not obvious from context
321
327
  - **Repos**: detect from the PRD what repos will be needed. If the PRD mentions "API .NET", declare a `project-api` repo with stack `dotnet` and status `planned`. If it mentions "React frontend", declare a `project-front` repo with stack `nodejs` and status `planned`. Do NOT leave repos commented out — declare them with `status: planned`.
@@ -443,9 +449,9 @@ The output is considered good when:
443
449
  - [ ] Fluxos: all flows have happy path + exceptions + Mermaid flowchart TD
444
450
  - [ ] Tabelas de Referencia: all enums defined, state transitions documented + Mermaid stateDiagram-v2
445
451
  - [ ] Integracoes: all events have producer + consumer + expected payload
446
- - [ ] Features/ page created
447
- - [ ] Initial feature pages created under Features/ (with label `em-discovery`)
448
- - [ ] Arquivados/ page created
452
+ - [ ] {SIGLA} — Features parent page created
453
+ - [ ] Initial feature pages created under {SIGLA} — Features (with label `em-discovery`)
454
+ - [ ] {SIGLA} — Arquivados parent page created
449
455
 
450
456
  ### Spec repo updated
451
457
  - [ ] projects.yml filled with all detected repos (status: planned, not commented)