qatools-cli 1.0.0 β†’ 1.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 (2) hide show
  1. package/package.json +11 -3
  2. package/qatools.js +1356 -26
package/qatools.js CHANGED
@@ -38,10 +38,38 @@ async function authenticate(options) {
38
38
  const email = options.email || creds?.email;
39
39
  const password = options.password || creds?.password;
40
40
  const refreshToken = creds?.refreshToken;
41
+ const apiKey = process.env.QATOOLS_API_KEY || creds?.apiKey;
42
+
43
+ if (apiKey) {
44
+ try {
45
+ const authRes = await fetch("https://us-central1-qatools-app.cloudfunctions.net/authenticateWithApiKey", {
46
+ method: "POST", headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ data: { apiKey } }),
48
+ });
49
+ const authData = await authRes.json();
50
+ if (authData.result?.customToken) {
51
+ await signInWithCustomToken(auth, authData.result.customToken);
52
+ // Set projectId from API key response if not already configured
53
+ if (authData.result.projectId && (!creds?.projectId || process.env.QATOOLS_API_KEY)) {
54
+ const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".qatools");
55
+ const existing = creds || {};
56
+ existing.projectId = authData.result.projectId;
57
+ existing.projectName = authData.result.projectName;
58
+ existing.apiKey = apiKey;
59
+ fs.writeFileSync(configPath, JSON.stringify(existing, null, 2));
60
+ }
61
+ return;
62
+ }
63
+ throw new Error("API key authentication failed");
64
+ } catch (e) {
65
+ console.error("API key auth failed:", e.message);
66
+ process.exit(1);
67
+ }
68
+ }
41
69
 
42
70
  if (refreshToken) {
43
71
  try {
44
- // Exchange refresh token for a new ID token, then sign in
72
+ // Exchange refresh token for a new ID token
45
73
  const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${firebaseConfig.apiKey}`, {
46
74
  method: 'POST',
47
75
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -49,8 +77,10 @@ async function authenticate(options) {
49
77
  });
50
78
  const data = await res.json();
51
79
  if (!data.id_token) throw new Error("Refresh failed");
52
- // Use the auth emulator approach: sign in with custom token from server
53
- // Actually we need to use signInWithCustomToken, so mint one
80
+
81
+ // Sign in with the ID token via signInWithCredential
82
+ const { GoogleAuthProvider, signInWithCredential } = require('firebase/auth');
83
+ // First try: sign in directly by minting custom token via REST with proper callable format
54
84
  const mintRes = await fetch(`https://us-central1-qatools-app.cloudfunctions.net/mintCliToken`, {
55
85
  method: 'POST',
56
86
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${data.id_token}` },
@@ -61,9 +91,23 @@ async function authenticate(options) {
61
91
  await signInWithCustomToken(auth, mintData.result.customToken);
62
92
  return;
63
93
  }
64
- throw new Error("Mint failed");
94
+
95
+ // Fallback: use the ID token to sign in via custom token REST API
96
+ const signInRes = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${firebaseConfig.apiKey}`, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ token: data.id_token, returnSecureToken: true }),
100
+ });
101
+ const signInData = await signInRes.json();
102
+ if (signInData.error) throw new Error(signInData.error.message);
103
+
104
+ // Use the refresh token from sign-in to authenticate Firebase SDK
105
+ await signInWithCustomToken(auth, data.id_token).catch(() => {});
106
+
107
+ // If all else fails, try email sign in if available
108
+ throw new Error("Could not authenticate with refresh token");
65
109
  } catch (e) {
66
- console.error("Google token refresh failed:", e.message, "\nRun 'qatools login --google' again.");
110
+ console.error("Token refresh failed:", e.message, "\nRun 'qatools login --google' to re-authenticate.");
67
111
  process.exit(1);
68
112
  }
69
113
  }
@@ -200,15 +244,62 @@ program
200
244
  process.exit(0);
201
245
  });
202
246
 
247
+ program
248
+ .command("projects")
249
+ .description("List all your projects")
250
+ .action(async (options) => {
251
+ await authenticate(options);
252
+ const listProjects = httpsCallable(functions, "listProjects");
253
+ try {
254
+ const result = await listProjects({});
255
+ const projects = result.data.projects;
256
+ console.log(`\n${projects.length} project(s):\n`);
257
+ projects.forEach((p, i) => {
258
+ console.log(` ${i + 1}. ${p.name} ${p.claudeEnabled ? 'πŸ€–' : ''} (${p.id})`);
259
+ });
260
+ } catch (e) {
261
+ console.error("Failed:", e.message);
262
+ }
263
+ process.exit(0);
264
+ });
265
+
266
+ program
267
+ .command("project-update")
268
+ .description("Update project name or description")
269
+ .option("--name <name>", "New project name")
270
+ .option("--description <desc>", "New project description")
271
+ .option("-i, --id <projectId>", "Project ID (defaults to configured project)")
272
+ .action(async (options) => {
273
+ await authenticate(options);
274
+ const creds = getCredentials();
275
+ const projectId = options.id || creds?.projectId;
276
+ if (!projectId) { console.error("No project. Run: qatools setup"); process.exit(1); }
277
+ if (!options.name && !options.description) { console.error("Specify --name and/or --description"); process.exit(1); }
278
+ const updateProject = httpsCallable(functions, "updateProjectFields");
279
+ try {
280
+ const updates = {};
281
+ if (options.name) updates.name = options.name;
282
+ if (options.description) updates.description = options.description;
283
+ await updateProject({ projectId, updates });
284
+ console.log(`Project updated.`);
285
+ if (options.name) console.log(` Name: ${options.name}`);
286
+ if (options.description) console.log(` Description: ${options.description}`);
287
+ } catch (e) {
288
+ console.error("Failed:", e.message);
289
+ }
290
+ process.exit(0);
291
+ });
292
+
203
293
  program
204
294
  .command("pending")
205
295
  .description("List test sessions awaiting Claude analysis")
296
+ .option("--project <id>", "Target project ID (overrides default; omit both to scan all your projects)")
206
297
  .option("-e, --email <email>", "Login email")
207
298
  .option("-p, --password <password>", "Login password")
208
299
  .action(async (options) => {
209
300
  await authenticate(options);
210
301
  const creds = getCredentials();
211
- const projectId = creds?.projectId;
302
+ const projectId = options.project || creds?.projectId;
212
303
 
213
304
  const pendingSessions = httpsCallable(functions, "pendingSessions");
214
305
  try {
@@ -237,8 +328,9 @@ program
237
328
  program
238
329
  .command("backlog")
239
330
  .description("List backlog items for configured project")
331
+ .option("--project <id>", "Target project ID (overrides default)")
240
332
  .option("--sprint <id>", "Filter by sprint ID")
241
- .option("--status <statuses>", "Filter by status (comma-separated: new,in_progress,on_hold)")
333
+ .option("--status <statuses>", "Filter by status (comma-separated: open,in_progress,resolved,rejected)")
242
334
  .option("--assigned <email>", "Filter by assignee")
243
335
  .option("-o, --output <path>", "Output file (default: stdout)")
244
336
  .option("-e, --email <email>", "Login email")
@@ -246,11 +338,12 @@ program
246
338
  .action(async (options) => {
247
339
  await authenticate(options);
248
340
  const creds = getCredentials();
249
- if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
341
+ const projectId = options.project || creds?.projectId;
342
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
250
343
 
251
344
  const listBacklog = httpsCallable(functions, "listBacklog");
252
345
  try {
253
- const filters = { projectId: creds.projectId };
346
+ const filters = { projectId };
254
347
  if (options.sprint) filters.sprintId = options.sprint;
255
348
  if (options.status) filters.status = options.status.split(",");
256
349
  if (options.assigned) filters.assignedTo = options.assigned;
@@ -274,17 +367,19 @@ program
274
367
  .command("sprint-items")
275
368
  .description("List all items in a sprint")
276
369
  .requiredOption("--sprint <id>", "Sprint ID")
370
+ .option("--project <id>", "Target project ID (overrides default)")
277
371
  .option("-o, --output <path>", "Output file")
278
372
  .option("-e, --email <email>", "Login email")
279
373
  .option("-p, --password <password>", "Login password")
280
374
  .action(async (options) => {
281
375
  await authenticate(options);
282
376
  const creds = getCredentials();
283
- if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
377
+ const projectId = options.project || creds?.projectId;
378
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
284
379
 
285
380
  const getSprintItems = httpsCallable(functions, "getSprintItems");
286
381
  try {
287
- const result = await getSprintItems({ projectId: creds.projectId, sprintId: options.sprint });
382
+ const result = await getSprintItems({ projectId, sprintId: options.sprint });
288
383
  const data = result.data;
289
384
  if (options.output) {
290
385
  fs.writeFileSync(path.resolve(options.output), JSON.stringify(data, null, 2));
@@ -302,16 +397,18 @@ program
302
397
  program
303
398
  .command("sprints")
304
399
  .description("List sprints for configured project")
400
+ .option("--project <id>", "Target project ID (overrides default)")
305
401
  .option("-e, --email <email>", "Login email")
306
402
  .option("-p, --password <password>", "Login password")
307
403
  .action(async (options) => {
308
404
  await authenticate(options);
309
405
  const creds = getCredentials();
310
- if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
406
+ const projectId = options.project || creds?.projectId;
407
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
311
408
 
312
409
  const listSprints = httpsCallable(functions, "listSprints");
313
410
  try {
314
- const result = await listSprints({ projectId: creds.projectId });
411
+ const result = await listSprints({ projectId });
315
412
  for (const s of result.data.sprints) {
316
413
  console.log(` ${s.id} ${s.name} [${s.status}] Release: ${s.releaseDate || "N/A"}`);
317
414
  }
@@ -326,11 +423,13 @@ program
326
423
  .command("backlog-create")
327
424
  .description("Create a new backlog item")
328
425
  .requiredOption("-d, --description <text>", "Description")
329
- .option("-t, --type <type>", "Type: bug, task, improvement, feature, epic, story", "task")
426
+ .option("--project <id>", "Target project ID (overrides default)")
427
+ .option("-t, --type <type>", "Type: bug, improvement, requirement_gap, technical_defect", "bug")
330
428
  .option("--priority <n>", "Priority: 1 (high), 2 (medium), 3 (low)", "2")
331
429
  .option("--assigned <email>", "Assign to")
430
+ .option("--developer <email>", "Developer (responsible)")
332
431
  .option("--sprint <id>", "Sprint ID")
333
- .option("--parent <label>", "Parent item label (e.g. STORY-5)")
432
+ .option("--parent <label>", "Parent item label (e.g. BUG-5)")
334
433
  .option("--requirement-key <key>", "Requirement key (e.g. auth.login)")
335
434
  .option("--tags <tags>", "Tags (comma-separated)")
336
435
  .option("-e, --email <email>", "Login email")
@@ -338,22 +437,25 @@ program
338
437
  .action(async (options) => {
339
438
  await authenticate(options);
340
439
  const creds = getCredentials();
341
- if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
440
+ const projectId = options.project || creds?.projectId;
441
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
342
442
 
343
443
  const createItem = httpsCallable(functions, "createBacklogItem");
344
444
  try {
345
445
  const result = await createItem({
346
- projectId: creds.projectId,
446
+ projectId,
347
447
  type: options.type,
348
448
  description: options.description,
349
449
  priority: parseInt(options.priority, 10),
350
450
  assignedTo: options.assigned || "",
451
+ developer: options.developer || "",
351
452
  sprintId: options.sprint || "",
352
453
  parentId: options.parent || "",
353
454
  requirementKey: options.requirementKey || "",
354
- tags: options.tags ? options.tags.split(",").map(t => t.trim()) : [],
455
+ tags: options.tags ? options.tags.split(",").map(t => t.trim().toLowerCase()) : [],
355
456
  });
356
457
  console.log(`Created: ${result.data.type.toUpperCase()}-${result.data.bugNumber} (${result.data.id})`);
458
+ if (options.tags) console.log(` Tags: ${options.tags}`);
357
459
  } catch (e) {
358
460
  console.error("Failed:", e.message);
359
461
  process.exit(1);
@@ -364,17 +466,19 @@ program
364
466
  program
365
467
  .command("templates")
366
468
  .description("List templates and their review status")
469
+ .option("--project <id>", "Target project ID (overrides default)")
367
470
  .option("--status <status>", "Filter: draft, pending_review, approved, rejected")
368
471
  .option("-e, --email <email>", "Login email")
369
472
  .option("-p, --password <password>", "Login password")
370
473
  .action(async (options) => {
371
474
  await authenticate(options);
372
475
  const creds = getCredentials();
373
- if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
476
+ const projectId = options.project || creds?.projectId;
477
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
374
478
 
375
479
  const listTemplates = httpsCallable(functions, "listTemplates");
376
480
  try {
377
- const result = await listTemplates({ projectId: creds.projectId, status: options.status || null });
481
+ const result = await listTemplates({ projectId, status: options.status || null });
378
482
  const templates = result.data.templates;
379
483
  if (templates.length === 0) {
380
484
  console.log("No templates found.");
@@ -392,19 +496,112 @@ program
392
496
  process.exit(0);
393
497
  });
394
498
 
499
+ program
500
+ .command("requirements")
501
+ .description("List requirements with rich filters. Superseded reqs are hidden by default.")
502
+ .option("--project <id>", "Target project ID (overrides default)")
503
+ .option("--status <statuses>", "Filter by status (comma-separated): draft,approved,rejected,implemented,tested,superseded")
504
+ .option("--epic <key>", "Only reqs linked to this epic")
505
+ .option("--no-epic", "Only reqs WITHOUT an epic")
506
+ .option("--type <type>", "Filter by type: functional, non-functional")
507
+ .option("--with-coverage", "Only reqs that have at least one test case in any template")
508
+ .option("--without-coverage", "Only reqs with NO test coverage")
509
+ .option("--include-superseded", "Include superseded reqs (hidden by default)")
510
+ .option("--json", "Output as JSON to stdout (machine-readable)")
511
+ .option("-o, --output <file>", "Write JSON output to file")
512
+ .option("-e, --email <email>", "Login email")
513
+ .option("-p, --password <password>", "Login password")
514
+ .action(async (options) => {
515
+ await authenticate(options);
516
+ const creds = getCredentials();
517
+ const projectId = options.project || creds?.projectId;
518
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
519
+
520
+ const listReqs = httpsCallable(functions, "listRequirements");
521
+ try {
522
+ // Server-side status filter only works for a single status; do all filtering client-side for consistency
523
+ const result = await listReqs({ projectId, status: null });
524
+ let reqs = result.data.requirements;
525
+
526
+ // Default: hide superseded
527
+ if (!options.includeSuperseded) reqs = reqs.filter(r => r.status !== "superseded");
528
+
529
+ // Status (comma-separated)
530
+ if (options.status) {
531
+ const statuses = new Set(options.status.split(",").map(s => s.trim()));
532
+ reqs = reqs.filter(r => statuses.has(r.status));
533
+ }
534
+
535
+ // Epic
536
+ if (options.epic) reqs = reqs.filter(r => r.epicKey === options.epic);
537
+ // commander turns --no-epic into options.epic === false
538
+ if (options.epic === false) reqs = reqs.filter(r => !r.epicKey);
539
+
540
+ // Type
541
+ if (options.type) reqs = reqs.filter(r => r.type === options.type);
542
+
543
+ // Coverage filters require loading templates
544
+ if (options.withCoverage || options.withoutCoverage) {
545
+ const listTemplates = httpsCallable(functions, "listTemplates");
546
+ const tplResult = await listTemplates({ projectId, status: null });
547
+ // listTemplates returns summary only β€” we need full templates for test cases
548
+ // Use the full templates list via a different path: load each template (or rely on coverage server-side later)
549
+ // For now: scan template summaries for requirementKey if present, otherwise we have to fall back
550
+ const covered = new Set();
551
+ for (const t of (tplResult.data.templates || [])) {
552
+ // Some template responses include sections, others only summaries
553
+ for (const sec of (t.sections || [])) {
554
+ for (const test of (sec.tests || [])) {
555
+ if (test.requirementKey) covered.add(test.requirementKey);
556
+ }
557
+ }
558
+ }
559
+ if (options.withCoverage) reqs = reqs.filter(r => covered.has(r.key));
560
+ if (options.withoutCoverage) reqs = reqs.filter(r => !covered.has(r.key));
561
+ if (covered.size === 0 && (options.withCoverage || options.withoutCoverage)) {
562
+ console.error("Note: listTemplates returned no test-case data. Coverage filter may be unreliable. Use the web app for accurate coverage.");
563
+ }
564
+ }
565
+
566
+ // Output
567
+ if (options.json) {
568
+ console.log(JSON.stringify(reqs, null, 2));
569
+ process.exit(0);
570
+ }
571
+ if (options.output) {
572
+ fs.writeFileSync(options.output, JSON.stringify(reqs, null, 2));
573
+ console.log(`Written ${reqs.length} requirements to ${options.output}`);
574
+ } else {
575
+ console.log(`\n${reqs.length} requirement${reqs.length !== 1 ? 's' : ''}:\n`);
576
+ for (const r of reqs) {
577
+ const badge = r.status === "rejected" ? "❌" : r.status === "approved" ? "βœ…" : r.status === "tested" ? "🟒" : r.status === "implemented" ? "🟑" : r.status === "superseded" ? "πŸ—‘" : "πŸ“";
578
+ const epic = r.epicKey ? ` epic:${r.epicKey}` : ' (no epic)';
579
+ console.log(` ${badge} [${r.key}]${epic} ${r.title} (${r.status})`);
580
+ if (r.reviewComment) console.log(` πŸ’¬ ${r.reviewComment}`);
581
+ }
582
+ }
583
+ } catch (e) {
584
+ console.error("Failed:", e.message);
585
+ process.exit(1);
586
+ }
587
+ process.exit(0);
588
+ });
589
+
395
590
  program
396
591
  .command("requirement-keys")
397
592
  .description("List all requirement keys in the project (from backlog + templates)")
593
+ .option("--project <id>", "Target project ID (overrides default)")
398
594
  .option("-e, --email <email>", "Login email")
399
595
  .option("-p, --password <password>", "Login password")
400
596
  .action(async (options) => {
401
597
  await authenticate(options);
402
598
  const creds = getCredentials();
403
- if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
599
+ const projectId = options.project || creds?.projectId;
600
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
404
601
 
405
602
  const listKeys = httpsCallable(functions, "listRequirementKeys");
406
603
  try {
407
- const result = await listKeys({ projectId: creds.projectId });
604
+ const result = await listKeys({ projectId });
408
605
  const keys = result.data.keys;
409
606
  if (keys.length === 0) {
410
607
  console.log("No requirement keys found.");
@@ -425,16 +622,372 @@ program
425
622
  process.exit(0);
426
623
  });
427
624
 
625
+ program
626
+ .command("requirement-create")
627
+ .description("Upload requirements from JSON file. Each req may include a 'status' field (draft/approved/rejected/implemented/tested); defaults to draft.")
628
+ .requiredOption("-f, --file <path>", "Path to requirements JSON file")
629
+ .option("--project <id>", "Target project ID (overrides default)")
630
+ .option("--force", "Skip semantic similarity check (allow create even if similar key/title exists)")
631
+ .option("-e, --email <email>", "Login email")
632
+ .option("-p, --password <password>", "Login password")
633
+ .action(async (options) => {
634
+ await authenticate(options);
635
+ const creds = getCredentials();
636
+ const projectId = options.project || creds?.projectId;
637
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
638
+
639
+ const fs = require("fs");
640
+ const data = JSON.parse(fs.readFileSync(options.file, "utf8"));
641
+ const requirements = Array.isArray(data) ? data : (data.requirements || [data]);
642
+
643
+ const createReq = httpsCallable(functions, "createRequirement");
644
+ let created = 0, skipped = 0, similar = 0;
645
+ const similarReports = [];
646
+ for (const req of requirements) {
647
+ try {
648
+ const payload = { projectId, ...req };
649
+ if (options.force) payload.force = true;
650
+ const result = await createReq(payload);
651
+ if (result.data.error === "duplicate") {
652
+ skipped++;
653
+ process.stdout.write(`\r Created ${created}, skipped ${skipped} (dup), similar ${similar} / ${requirements.length}`);
654
+ } else if (result.data.error === "similar_exists") {
655
+ similar++;
656
+ similarReports.push({ attempted: req, matches: result.data.matches });
657
+ process.stdout.write(`\r Created ${created}, skipped ${skipped} (dup), similar ${similar} / ${requirements.length}`);
658
+ } else {
659
+ created++;
660
+ process.stdout.write(`\r Created ${created}, skipped ${skipped} (dup), similar ${similar} / ${requirements.length}`);
661
+ }
662
+ } catch (e) {
663
+ console.error(`\nFailed to create ${req.key}: ${e.message}`);
664
+ }
665
+ }
666
+ console.log(`\n\nDone. ${created} created, ${skipped} skipped (exact dup), ${similar} blocked (semantically similar).`);
667
+ if (similar > 0) {
668
+ console.log(`\nSimilar matches found β€” review before deciding:\n`);
669
+ for (const rep of similarReports) {
670
+ console.log(` Attempted: [${rep.attempted.key}] ${rep.attempted.title}`);
671
+ for (const m of rep.matches) {
672
+ console.log(` ↳ similar (${m.matchedOn}): [${m.key}] ${m.title} (${m.status}, id=${m.id})`);
673
+ }
674
+ }
675
+ console.log(`\nResolve by either:\n - editing your input to align with the existing key/title\n - re-running with --force if you really mean a separate requirement`);
676
+ process.exit(2);
677
+ }
678
+ process.exit(0);
679
+ });
680
+
681
+ program
682
+ .command("sync")
683
+ .description("Sync App Context from qa-tools into a CLAUDE.md block")
684
+ .option("--project <id>", "Target project ID (overrides default)")
685
+ .option("-o, --output <path>", "Path to file to update (auto: .claude/CLAUDE.md if it exists, else CLAUDE.md)")
686
+ .option("--check", "Exit 1 if file is out of sync (does not modify)")
687
+ .option("-e, --email <email>", "Login email")
688
+ .option("-p, --password <password>", "Login password")
689
+ .action(async (options) => {
690
+ await authenticate(options);
691
+ const creds = getCredentials();
692
+ const projectId = options.project || creds?.projectId;
693
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
694
+ // Default output: prefer .claude/CLAUDE.md if it exists, else CLAUDE.md in cwd
695
+ if (!options.output) {
696
+ options.output = fs.existsSync(".claude/CLAUDE.md") ? ".claude/CLAUDE.md" : "CLAUDE.md";
697
+ }
698
+
699
+ const getCtx = httpsCallable(functions, "getAppContext");
700
+ let ctx;
701
+ try {
702
+ const result = await getCtx({ projectId });
703
+ ctx = result.data.appContext;
704
+ } catch (e) {
705
+ console.error("Failed to fetch App Context:", e.message);
706
+ process.exit(1);
707
+ }
708
+ if (!ctx) {
709
+ console.error(`No App Context defined for project ${projectId}.`);
710
+ process.exit(1);
711
+ }
712
+
713
+ const md = renderAppContextMarkdown(ctx, projectId);
714
+ const beginMarker = "<!-- BEGIN qatools-context (auto-synced from qa-tools, do not edit by hand) -->";
715
+ const endMarker = "<!-- END qatools-context -->";
716
+ const block = `${beginMarker}\n${md}\n${endMarker}`;
717
+
718
+ const filePath = path.resolve(options.output);
719
+ let existing = "";
720
+ if (fs.existsSync(filePath)) existing = fs.readFileSync(filePath, "utf8");
721
+
722
+ let updated;
723
+ const beginIdx = existing.indexOf(beginMarker);
724
+ const endIdx = existing.indexOf(endMarker);
725
+ if (beginIdx >= 0 && endIdx > beginIdx) {
726
+ updated = existing.slice(0, beginIdx) + block + existing.slice(endIdx + endMarker.length);
727
+ } else {
728
+ const sep = existing && !existing.endsWith("\n") ? "\n\n" : (existing ? "\n" : "");
729
+ updated = existing + sep + block + "\n";
730
+ }
731
+
732
+ if (options.check) {
733
+ if (existing === updated) {
734
+ console.log(`βœ“ ${options.output} is in sync with project ${projectId}`);
735
+ process.exit(0);
736
+ } else {
737
+ console.error(`βœ— ${options.output} is out of sync with project ${projectId}. Run: qatools sync`);
738
+ process.exit(1);
739
+ }
740
+ }
741
+
742
+ if (existing === updated) {
743
+ console.log(`βœ“ ${options.output} already in sync (no changes)`);
744
+ } else {
745
+ fs.writeFileSync(filePath, updated);
746
+ console.log(`βœ“ ${options.output} updated from project ${projectId} (${ctx.name})`);
747
+ }
748
+ process.exit(0);
749
+ });
750
+
751
+ function renderAppContextMarkdown(ctx, projectId) {
752
+ const lines = [];
753
+ const ts = new Date().toISOString();
754
+ lines.push(`<!-- Last synced: ${ts} from project ${projectId} -->`);
755
+ lines.push("");
756
+ lines.push(`## ${ctx.name || "Project Context"}`);
757
+ lines.push("");
758
+ if (ctx.purpose) { lines.push("### Purpose"); lines.push(ctx.purpose); lines.push(""); }
759
+ if (ctx.domainObjects?.length) {
760
+ lines.push("### Domain Objects");
761
+ lines.push(ctx.domainObjects.map(o => `- ${o}`).join("\n"));
762
+ lines.push("");
763
+ }
764
+ if (ctx.actors?.length) {
765
+ lines.push("### Actors");
766
+ lines.push(ctx.actors.map(a => `- ${a}`).join("\n"));
767
+ lines.push("");
768
+ }
769
+ if (ctx.stack?.length) {
770
+ lines.push("### Tech Stack");
771
+ for (const s of ctx.stack) {
772
+ const v = s.version ? ` ${s.version}` : "";
773
+ const n = s.notes ? ` β€” ${s.notes}` : "";
774
+ lines.push(`- **${s.layer}**: ${s.technology}${v}${n}`);
775
+ }
776
+ lines.push("");
777
+ }
778
+ if (ctx.architecture) { lines.push("### Architecture"); lines.push(ctx.architecture); lines.push(""); }
779
+ if (ctx.security?.length) {
780
+ lines.push("### Security");
781
+ lines.push(ctx.security.map(s => `- ${s}`).join("\n"));
782
+ lines.push("");
783
+ }
784
+ if (ctx.aiRules?.length) {
785
+ lines.push("### AI Rules");
786
+ ctx.aiRules.forEach((r, i) => lines.push(`${i + 1}. ${r}`));
787
+ lines.push("");
788
+ }
789
+ if (ctx.constraints?.length) {
790
+ lines.push("### Constraints");
791
+ lines.push(ctx.constraints.map(c => `- ${c}`).join("\n"));
792
+ lines.push("");
793
+ }
794
+ if (ctx.conventions?.length) {
795
+ lines.push("### Conventions");
796
+ for (const c of ctx.conventions) lines.push(`- **${c.area}**: ${c.rule}`);
797
+ lines.push("");
798
+ }
799
+ if (ctx.environments?.length) {
800
+ lines.push("### Environments");
801
+ for (const e of ctx.environments) {
802
+ const url = e.url ? ` β€” ${e.url}` : "";
803
+ const notes = e.notes ? ` (${e.notes})` : "";
804
+ lines.push(`- **${e.name}**${url}${notes}`);
805
+ }
806
+ lines.push("");
807
+ }
808
+ if (ctx.integrations?.length) {
809
+ lines.push("### Integrations");
810
+ for (const i of ctx.integrations) {
811
+ const purpose = i.purpose ? ` β€” ${i.purpose}` : "";
812
+ lines.push(`- **${i.name}**${purpose}`);
813
+ }
814
+ lines.push("");
815
+ }
816
+ if (ctx.sections?.length) {
817
+ for (const s of ctx.sections) {
818
+ lines.push(`### ${s.title}`);
819
+ // defensive: escape end-comment markers only in user content so it can't break our END marker
820
+ lines.push((s.content || "").replace(/-->/g, "--\\>"));
821
+ lines.push("");
822
+ }
823
+ }
824
+ return lines.join("\n");
825
+ }
826
+
827
+ program
828
+ .command("next")
829
+ .description("Show what Claude should work on next in this project")
830
+ .option("--project <id>", "Target project ID (overrides default)")
831
+ .option("--json", "Output raw JSON instead of formatted text")
832
+ .option("-e, --email <email>", "Login email")
833
+ .option("-p, --password <password>", "Login password")
834
+ .action(async (options) => {
835
+ await authenticate(options);
836
+ const creds = getCredentials();
837
+ const projectId = options.project || creds?.projectId;
838
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
839
+ const getAgenda = httpsCallable(functions, "getProjectAgenda");
840
+ let result;
841
+ try {
842
+ result = await getAgenda({ projectId });
843
+ } catch (e) {
844
+ console.error("Failed:", e.message);
845
+ process.exit(1);
846
+ }
847
+ const data = result.data;
848
+ if (options.json) {
849
+ console.log(JSON.stringify(data, null, 2));
850
+ process.exit(0);
851
+ }
852
+ // Formatted output
853
+ console.log(`\n ${data.projectName} (${data.projectId})`);
854
+ if (data.activeSprint) console.log(` Active sprint: ${data.activeSprint.name}`);
855
+ console.log("");
856
+
857
+ const sections = [];
858
+ if (data.agenda.pendingSessions.length) {
859
+ const items = data.agenda.pendingSessions.map(s => ` β€’ "${s.templateName}" (sess: ${s.id})`);
860
+ sections.push({
861
+ icon: "πŸ”΄",
862
+ title: `URGENT β€” ${data.agenda.pendingSessions.length} session${data.agenda.pendingSessions.length > 1 ? 's' : ''} waiting for analysis`,
863
+ items,
864
+ next: `qatools read-session <id> β†’ qatools upload-analysis <file>`,
865
+ });
866
+ }
867
+ if (data.agenda.approvedNoCoverage.length) {
868
+ const items = data.agenda.approvedNoCoverage.slice(0, 10).map(r => ` β€’ [${r.key}] ${r.title}`);
869
+ if (data.agenda.approvedNoCoverage.length > 10) items.push(` β€’ ... and ${data.agenda.approvedNoCoverage.length - 10} more`);
870
+ sections.push({
871
+ icon: "🟑",
872
+ title: `${data.agenda.approvedNoCoverage.length} approved requirement${data.agenda.approvedNoCoverage.length > 1 ? 's' : ''} without test coverage`,
873
+ items,
874
+ next: `write a template covering these β†’ qatools template-upload <file>`,
875
+ });
876
+ }
877
+ if (data.agenda.continuousUnverified.length) {
878
+ const items = data.agenda.continuousUnverified.map(r => ` β€’ [${r.key}] ${r.title}`);
879
+ sections.push({
880
+ icon: "🟒",
881
+ title: `${data.agenda.continuousUnverified.length} continuous compliance req${data.agenda.continuousUnverified.length > 1 ? 's' : ''} need verification for "${data.agenda.continuousUnverified[0].sprintName}"`,
882
+ items,
883
+ next: `verify each against this sprint's diff and record result via UI Sprintar tab`,
884
+ });
885
+ }
886
+ if (data.agenda.sprintReqsNotTested.length) {
887
+ const items = data.agenda.sprintReqsNotTested.slice(0, 10).map(r => ` β€’ [${r.key}] ${r.title} (${r.status})`);
888
+ if (data.agenda.sprintReqsNotTested.length > 10) items.push(` β€’ ... and ${data.agenda.sprintReqsNotTested.length - 10} more`);
889
+ sections.push({
890
+ icon: "🟒",
891
+ title: `${data.agenda.sprintReqsNotTested.length} requirement${data.agenda.sprintReqsNotTested.length > 1 ? 's' : ''} in active sprint not yet tested`,
892
+ items,
893
+ next: `implement, test, then qatools requirement-update -i <id> --status tested`,
894
+ });
895
+ }
896
+ if (data.agenda.draftReqs.length) {
897
+ const items = data.agenda.draftReqs.slice(0, 10).map(r => ` β€’ [${r.key}] ${r.title}${r.hasAC ? '' : ' (no AC)'}`);
898
+ if (data.agenda.draftReqs.length > 10) items.push(` β€’ ... and ${data.agenda.draftReqs.length - 10} more`);
899
+ sections.push({
900
+ icon: "βšͺ",
901
+ title: `${data.agenda.draftReqs.length} draft requirement${data.agenda.draftReqs.length > 1 ? 's' : ''} need acceptance criteria / approval`,
902
+ items,
903
+ next: `qatools requirement-update -i <id> --status approved (or refine AC first)`,
904
+ });
905
+ }
906
+
907
+ if (sections.length === 0) {
908
+ console.log(" βœ“ Nothing on the agenda. The project is in a clean state.\n");
909
+ process.exit(0);
910
+ }
911
+
912
+ for (const sec of sections) {
913
+ console.log(`${sec.icon} ${sec.title}`);
914
+ sec.items.forEach(i => console.log(i));
915
+ console.log(` β†’ ${sec.next}`);
916
+ console.log("");
917
+ }
918
+
919
+ console.log(` ────────────────────`);
920
+ console.log(` ${data.totalCount} thing${data.totalCount !== 1 ? 's' : ''} on the agenda. Start with the ${sections[0].icon} row.\n`);
921
+ process.exit(0);
922
+ });
923
+
924
+ program
925
+ .command("requirement-update")
926
+ .description("Update one or more requirements (by id, optionally from a JSON file)")
927
+ .option("-f, --file <path>", "JSON file with updates: { '<requirementId>': { status, epicKey, ... } }")
928
+ .option("-i, --id <id>", "Single requirement ID to update")
929
+ .option("--status <status>", "New status (draft/approved/rejected/implemented/tested/superseded)")
930
+ .option("--epic <key>", "Set epicKey")
931
+ .option("--sprint <id>", "Set sprintId (use empty string to clear)")
932
+ .option("--type <type>", "Set type (functional/non-functional)")
933
+ .option("--title <text>", "Set title")
934
+ .option("--description <text>", "Set description")
935
+ .option("--ac <text>", "Set acceptance criteria")
936
+ .option("--project <id>", "Target project ID (overrides default; requires access to that project)")
937
+ .option("-e, --email <email>", "Login email")
938
+ .option("-p, --password <password>", "Login password")
939
+ .action(async (options) => {
940
+ await authenticate(options);
941
+ const creds = getCredentials();
942
+ const projectId = options.project || creds?.projectId;
943
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
944
+
945
+ let updates = {};
946
+ if (options.file) {
947
+ updates = JSON.parse(fs.readFileSync(path.resolve(options.file), "utf8"));
948
+ } else if (options.id) {
949
+ const u = {};
950
+ if (options.status) u.status = options.status;
951
+ if (options.epic !== undefined) u.epicKey = options.epic;
952
+ if (options.sprint !== undefined) u.sprintId = options.sprint;
953
+ if (options.type) u.type = options.type;
954
+ if (options.title) u.title = options.title;
955
+ if (options.description) u.description = options.description;
956
+ if (options.ac) u.acceptanceCriteria = options.ac;
957
+ updates[options.id] = u;
958
+ } else {
959
+ console.error("Provide --file or --id");
960
+ process.exit(1);
961
+ }
962
+
963
+ const updateReq = httpsCallable(functions, "updateRequirement");
964
+ let ok = 0, failed = 0;
965
+ for (const [requirementId, fields] of Object.entries(updates)) {
966
+ try {
967
+ await updateReq({ projectId, requirementId, updates: fields });
968
+ ok++;
969
+ process.stdout.write(`\r Updated ${ok}, failed ${failed} / ${Object.keys(updates).length}`);
970
+ } catch (e) {
971
+ failed++;
972
+ console.error(`\nFailed ${requirementId}: ${e.message}`);
973
+ }
974
+ }
975
+ console.log(`\n\nDone. ${ok} updated, ${failed} failed.`);
976
+ process.exit(0);
977
+ });
978
+
428
979
  program
429
980
  .command("template-upload")
430
981
  .description("Upload a test template JSON to the project")
431
982
  .requiredOption("-f, --file <path>", "Path to template JSON file")
983
+ .option("--project <id>", "Target project ID (overrides default)")
432
984
  .option("-e, --email <email>", "Login email")
433
985
  .option("-p, --password <password>", "Login password")
434
986
  .action(async (options) => {
435
987
  await authenticate(options);
436
988
  const creds = getCredentials();
437
- if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
989
+ const projectId = options.project || creds?.projectId;
990
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
438
991
 
439
992
  const filePath = path.resolve(options.file);
440
993
  if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); }
@@ -445,7 +998,7 @@ program
445
998
  const uploadTemplate = httpsCallable(functions, "uploadTemplate");
446
999
  try {
447
1000
  const result = await uploadTemplate({
448
- projectId: creds.projectId,
1001
+ projectId,
449
1002
  name: data.name,
450
1003
  version: data.version || "",
451
1004
  sections: data.sections,
@@ -459,6 +1012,85 @@ program
459
1012
  process.exit(0);
460
1013
  });
461
1014
 
1015
+ program
1016
+ .command("backlog-tags")
1017
+ .description("List all existing tags in the backlog")
1018
+ .option("--project <id>", "Target project ID (overrides default)")
1019
+ .option("-e, --email <email>", "Login email")
1020
+ .option("-p, --password <password>", "Login password")
1021
+ .action(async (options) => {
1022
+ await authenticate(options);
1023
+ const creds = getCredentials();
1024
+ const projectId = options.project || creds?.projectId;
1025
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1026
+ const listBacklog = httpsCallable(functions, "listBacklog");
1027
+ const result = await listBacklog({ projectId });
1028
+ const tags = new Set();
1029
+ result.data.items.forEach(i => (i.tags || []).forEach(t => tags.add(t)));
1030
+ const sorted = [...tags].sort();
1031
+ if (sorted.length === 0) { console.log("No tags found."); }
1032
+ else { sorted.forEach(t => console.log(t)); console.log(`\n${sorted.length} tag(s)`); }
1033
+ process.exit(0);
1034
+ });
1035
+
1036
+ program
1037
+ .command("backlog-duplicates")
1038
+ .description("Find potential duplicate backlog items using AI")
1039
+ .option("--project <id>", "Target project ID (overrides default)")
1040
+ .option("--local", "Output items for local AI analysis (no API key needed)")
1041
+ .option("-e, --email <email>", "Login email")
1042
+ .option("-p, --password <password>", "Login password")
1043
+ .action(async (options) => {
1044
+ await authenticate(options);
1045
+ const creds = getCredentials();
1046
+ const projectId = options.project || creds?.projectId;
1047
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1048
+
1049
+ if (options.local) {
1050
+ // Fetch items with full context for local analysis
1051
+ const listBacklog = httpsCallable(functions, "listBacklog");
1052
+ const result = await listBacklog({ projectId });
1053
+ const prefix = t => ({ bug: "AVV", improvement: "IMP", requirement_gap: "GAP", technical_defect: "DEF" }[t] || "BUG");
1054
+ const items = result.data.items.filter(i => i.status !== "rejected");
1055
+ console.log(`Find duplicates among these ${items.length} backlog items. Items that describe the same issue, feature, or task β€” even if worded differently.\n`);
1056
+ console.log(`Strategy: compare semantically, check shared requirementKey/testId, read comments for context. Would fixing one make the other unnecessary?\n`);
1057
+ for (const i of items) {
1058
+ let line = `${prefix(i.type)}-${i.bugNumber} [${i.type}] (${i.status})`;
1059
+ if (i.requirementKey) line += ` req:${i.requirementKey}`;
1060
+ if (i.testId) line += ` test:${i.testId}`;
1061
+ if (i.tags?.length) line += ` tags:[${i.tags.join(",")}]`;
1062
+ if (i.duplicateOf) line += ` duplicateOf:${i.duplicateOf}`;
1063
+ line += `\n "${i.description}"`;
1064
+ if (i.comments?.length) line += `\n Comments: ${i.comments.slice(0, 3).map(c => `"${(c.text || '').substring(0, 100)}"`).join(" | ")}`;
1065
+ console.log(line + "\n");
1066
+ }
1067
+ console.log(`For each duplicate group: items, confidence (high/medium/low), reason.`);
1068
+ process.exit(0);
1069
+ }
1070
+
1071
+ console.log("Analyzing backlog for duplicates...");
1072
+ const findDuplicates = httpsCallable(functions, "findDuplicates");
1073
+ try {
1074
+ const result = await findDuplicates({ projectId });
1075
+ const { duplicates, itemCount } = result.data;
1076
+ console.log(`Analyzed ${itemCount} items.\n`);
1077
+ if (duplicates.length === 0) {
1078
+ console.log("No duplicates found.");
1079
+ } else {
1080
+ for (const group of duplicates) {
1081
+ const conf = group.confidence === 'high' ? 'πŸ”΄' : group.confidence === 'medium' ? '🟑' : '🟒';
1082
+ console.log(`${conf} ${group.items.join(' ↔ ')} [${group.confidence}]`);
1083
+ console.log(` ${group.reason}\n`);
1084
+ }
1085
+ console.log(`Found ${duplicates.length} potential duplicate group(s).`);
1086
+ }
1087
+ } catch (e) {
1088
+ console.error("Failed:", e.message);
1089
+ process.exit(1);
1090
+ }
1091
+ process.exit(0);
1092
+ });
1093
+
462
1094
  program
463
1095
  .command("backlog-update")
464
1096
  .description("Update one or more backlog items (JSON file or inline)")
@@ -467,7 +1099,12 @@ program
467
1099
  .option("--status <status>", "New status")
468
1100
  .option("--comment <text>", "Add comment")
469
1101
  .option("--assigned <email>", "Assign to")
470
- .option("--tester <email>", "Set tester")
1102
+ .option("--sprint <id>", "Set sprint ID")
1103
+ .option("--priority <n>", "Priority: 1 (high), 2 (medium), 3 (low)")
1104
+ .option("--developer <email>", "Set developer")
1105
+ .option("--requirement-key <key>", "Set requirement key")
1106
+ .option("--tags <tags>", "Set tags (comma-separated, replaces existing)")
1107
+ .option("--description <text>", "Update description")
471
1108
  .option("--author <name>", "Author name for comments (default: your email)", "Claude")
472
1109
  .option("-e, --email <email>", "Login email")
473
1110
  .option("-p, --password <password>", "Login password")
@@ -486,7 +1123,12 @@ program
486
1123
  if (options.status) u.status = options.status;
487
1124
  if (options.comment) { u.comment = options.comment; u.authorName = options.author; }
488
1125
  if (options.assigned) u.assignedTo = options.assigned;
489
- if (options.tester) u.tester = options.tester;
1126
+ if (options.developer) u.developer = options.developer;
1127
+ if (options.sprint) u.sprintId = options.sprint;
1128
+ if (options.priority) u.priority = parseInt(options.priority, 10);
1129
+ if (options.requirementKey) u.requirementKey = options.requirementKey;
1130
+ if (options.tags) u.tags = options.tags.split(",").map(t => t.trim().toLowerCase());
1131
+ if (options.description) u.description = options.description;
490
1132
  updates[options.id] = u;
491
1133
  } else {
492
1134
  console.error("Provide --file or --id");
@@ -504,15 +1146,703 @@ program
504
1146
  process.exit(0);
505
1147
  });
506
1148
 
1149
+ program
1150
+ .command("security-import")
1151
+ .description("Import security scan results (SARIF format) as backlog items")
1152
+ .requiredOption("-f, --file <path>", "Path to SARIF JSON file")
1153
+ .option("--project <id>", "Target project ID (overrides default)")
1154
+ .option("-s, --source <name>", "Scanner name (e.g. semgrep, snyk, sonarqube)")
1155
+ .action(async (options) => {
1156
+ await authenticate(options);
1157
+ const creds = getCredentials();
1158
+ const projectId = options.project || creds?.projectId;
1159
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1160
+
1161
+ const sarif = JSON.parse(fs.readFileSync(path.resolve(options.file), "utf-8"));
1162
+ const importScan = httpsCallable(functions, "importSecurityScan");
1163
+ try {
1164
+ const result = await importScan({ projectId, sarif, source: options.source || "" });
1165
+ const data = result.data;
1166
+ console.log(`\nSecurity scan imported:`);
1167
+ console.log(` Total findings: ${data.total}`);
1168
+ console.log(` Created: ${data.created} backlog items`);
1169
+ console.log(` Skipped: ${data.skipped} (duplicates)`);
1170
+ } catch (e) {
1171
+ console.error("Failed:", e.message);
1172
+ }
1173
+ process.exit(0);
1174
+ });
1175
+
1176
+ program
1177
+ .command("context")
1178
+ .description("Show App Context for the project")
1179
+ .option("-o, --output <path>", "Save to JSON file")
1180
+ .option("--project <id>", "Target project ID (overrides default; requires access to that project)")
1181
+ .option("-e, --email <email>", "Login email")
1182
+ .option("-p, --password <password>", "Login password")
1183
+ .action(async (options) => {
1184
+ await authenticate(options);
1185
+ const creds = getCredentials();
1186
+ const projectId = options.project || creds?.projectId;
1187
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1188
+ const getCtx = httpsCallable(functions, "getAppContext");
1189
+ const result = await getCtx({ projectId });
1190
+ const ctx = result.data.appContext;
1191
+ if (!ctx) { console.log("No App Context defined for this project."); process.exit(0); }
1192
+ if (options.output) {
1193
+ fs.writeFileSync(path.resolve(options.output), JSON.stringify(ctx, null, 2));
1194
+ console.log(`Saved to ${options.output}`);
1195
+ } else {
1196
+ console.log(`\n${ctx.name}`);
1197
+ if (ctx.description) console.log(ctx.description);
1198
+ if (ctx.stack?.length) {
1199
+ console.log("\nTech Stack:");
1200
+ ctx.stack.forEach(s => console.log(` ${s.layer}: ${s.technology}${s.version ? ' ' + s.version : ''}${s.notes ? ' β€” ' + s.notes : ''}`));
1201
+ }
1202
+ if (ctx.architecture) console.log(`\nArchitecture: ${ctx.architecture}`);
1203
+ if (ctx.constraints?.length) { console.log("\nConstraints:"); ctx.constraints.forEach(c => console.log(` - ${c}`)); }
1204
+ if (ctx.conventions?.length) { console.log("\nConventions:"); ctx.conventions.forEach(c => console.log(` - ${c.area}: ${c.rule}`)); }
1205
+ if (ctx.sections?.length) { ctx.sections.forEach(s => console.log(`\n${s.title}:\n ${s.content}`)); }
1206
+ }
1207
+ process.exit(0);
1208
+ });
1209
+
1210
+ program
1211
+ .command("context-set")
1212
+ .description("Upload App Context from JSON file")
1213
+ .requiredOption("-f, --file <path>", "Path to App Context JSON file")
1214
+ .option("--project <id>", "Target project ID (overrides default; requires access to that project)")
1215
+ .option("-e, --email <email>", "Login email")
1216
+ .option("-p, --password <password>", "Login password")
1217
+ .action(async (options) => {
1218
+ await authenticate(options);
1219
+ const creds = getCredentials();
1220
+ const projectId = options.project || creds?.projectId;
1221
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1222
+ const data = JSON.parse(fs.readFileSync(path.resolve(options.file), "utf8"));
1223
+ const saveCtx = httpsCallable(functions, "saveAppContext");
1224
+ const result = await saveCtx({ projectId, context: data });
1225
+ console.log(`App Context saved (${result.data.id}) β€” project ${projectId}`);
1226
+ process.exit(0);
1227
+ });
1228
+
1229
+ program
1230
+ .command("epics")
1231
+ .description("List epics for the project")
1232
+ .option("--project <id>", "Target project ID (overrides default)")
1233
+ .option("-o, --output <path>", "Save to JSON file")
1234
+ .option("-e, --email <email>", "Login email")
1235
+ .option("-p, --password <password>", "Login password")
1236
+ .action(async (options) => {
1237
+ await authenticate(options);
1238
+ const creds = getCredentials();
1239
+ const projectId = options.project || creds?.projectId;
1240
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1241
+ const listEpics = httpsCallable(functions, "listEpics");
1242
+ const result = await listEpics({ projectId });
1243
+ const epics = result.data.epics;
1244
+ if (options.output) {
1245
+ fs.writeFileSync(path.resolve(options.output), JSON.stringify(epics, null, 2));
1246
+ console.log(`Saved ${epics.length} epics to ${options.output}`);
1247
+ } else {
1248
+ if (epics.length === 0) { console.log("No epics defined."); }
1249
+ else { epics.forEach(e => console.log(` [${e.key}] ${e.title} (${e.status})`)); }
1250
+ }
1251
+ process.exit(0);
1252
+ });
1253
+
1254
+ program
1255
+ .command("epic-update")
1256
+ .description("Update an epic (status, title, description, etc)")
1257
+ .requiredOption("-i, --id <id>", "Epic ID")
1258
+ .option("--status <status>", "draft/active/completed")
1259
+ .option("--title <text>", "Title")
1260
+ .option("--description <text>", "Description")
1261
+ .option("--value <text>", "Value statement")
1262
+ .option("--scope <items>", "Scope items (semicolon-separated, replaces existing)")
1263
+ .option("--priority <n>", "Priority: 1/2/3")
1264
+ .option("--project <id>", "Target project ID")
1265
+ .option("-e, --email <email>", "Login email")
1266
+ .option("-p, --password <password>", "Login password")
1267
+ .action(async (options) => {
1268
+ await authenticate(options);
1269
+ const creds = getCredentials();
1270
+ const projectId = options.project || creds?.projectId;
1271
+ if (!projectId) { console.error("No project configured."); process.exit(1); }
1272
+ const updates = {};
1273
+ if (options.status) updates.status = options.status;
1274
+ if (options.title) updates.title = options.title;
1275
+ if (options.description) updates.description = options.description;
1276
+ if (options.value) updates.value = options.value;
1277
+ if (options.scope) updates.scope = options.scope.split(";").map(s => s.trim()).filter(s => s);
1278
+ if (options.priority) updates.priority = parseInt(options.priority, 10);
1279
+ if (Object.keys(updates).length === 0) { console.error("No updates provided."); process.exit(1); }
1280
+ const updateEpic = httpsCallable(functions, "updateEpic");
1281
+ try {
1282
+ await updateEpic({ projectId, epicId: options.id, updates });
1283
+ console.log(`Epic ${options.id} updated:`, JSON.stringify(updates));
1284
+ } catch (e) {
1285
+ console.error("Failed:", e.message);
1286
+ process.exit(1);
1287
+ }
1288
+ process.exit(0);
1289
+ });
1290
+
1291
+ program
1292
+ .command("epic-create")
1293
+ .description("Create a new epic")
1294
+ .requiredOption("-k, --key <key>", "Epic key (e.g. auth, billing)")
1295
+ .requiredOption("-t, --title <title>", "Epic title")
1296
+ .option("--project <id>", "Target project ID (overrides default)")
1297
+ .option("-d, --description <text>", "Epic description")
1298
+ .option("--value <text>", "Why this capability exists")
1299
+ .option("--scope <items>", "Scope items (semicolon-separated)")
1300
+ .option("--depends-on <keys>", "Epic keys this depends on (comma-separated)")
1301
+ .option("--priority <n>", "Priority: 1 (high), 2 (medium), 3 (low)", "2")
1302
+ .option("-e, --email <email>", "Login email")
1303
+ .option("-p, --password <password>", "Login password")
1304
+ .action(async (options) => {
1305
+ await authenticate(options);
1306
+ const creds = getCredentials();
1307
+ const projectId = options.project || creds?.projectId;
1308
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1309
+ const createEpic = httpsCallable(functions, "createEpic");
1310
+ try {
1311
+ const result = await createEpic({
1312
+ projectId,
1313
+ key: options.key,
1314
+ title: options.title,
1315
+ description: options.description || "",
1316
+ value: options.value || "",
1317
+ scope: options.scope ? options.scope.split(";").map(s => s.trim()).filter(s => s) : [],
1318
+ dependsOn: options.dependsOn ? options.dependsOn.split(",").map(s => s.trim()).filter(s => s) : [],
1319
+ priority: parseInt(options.priority, 10),
1320
+ });
1321
+ if (result.data.error === "duplicate") { console.log(`Epic '${options.key}' already exists.`); }
1322
+ else { console.log(`Created epic: ${options.key} (${result.data.id})`); }
1323
+ } catch (e) {
1324
+ console.error("Failed:", e.message);
1325
+ process.exit(1);
1326
+ }
1327
+ process.exit(0);
1328
+ });
1329
+
1330
+ program
1331
+ .command("epic-delete")
1332
+ .description("Delete an epic (only if no requirements are linked)")
1333
+ .requiredOption("-k, --key <key>", "Epic key to delete")
1334
+ .option("--project <id>", "Target project ID (overrides default)")
1335
+ .option("-e, --email <email>", "Login email")
1336
+ .option("-p, --password <password>", "Login password")
1337
+ .action(async (options) => {
1338
+ await authenticate(options);
1339
+ const creds = getCredentials();
1340
+ const projectId = options.project || creds?.projectId;
1341
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1342
+ // Find epic ID by key
1343
+ const listEpics = httpsCallable(functions, "listEpics");
1344
+ const epicsResult = await listEpics({ projectId });
1345
+ const epic = epicsResult.data.epics.find(e => e.key === options.key);
1346
+ if (!epic) { console.error(`Epic '${options.key}' not found.`); process.exit(1); }
1347
+ const deleteEpic = httpsCallable(functions, "deleteEpic");
1348
+ try {
1349
+ await deleteEpic({ projectId, epicId: epic.id });
1350
+ console.log(`Deleted epic: ${options.key}`);
1351
+ } catch (e) {
1352
+ console.error("Failed:", e.message);
1353
+ process.exit(1);
1354
+ }
1355
+ process.exit(0);
1356
+ });
1357
+
1358
+ program
1359
+ .command("init")
1360
+ .description("Initialize QA-Tools in current project (creates skills, CLAUDE.md section)")
1361
+ .option("-k, --api-key <key>", "Claude API key (from QA-Tools β†’ Members β†’ Claude AI Integration)")
1362
+ .action(async (options) => {
1363
+ const cwd = process.cwd();
1364
+ const apiKey = options.apiKey || getCredentials()?.apiKey || "";
1365
+
1366
+ // 1. Create .claude/skills/ directory
1367
+ const skillsDir = path.join(cwd, ".claude", "skills");
1368
+ fs.mkdirSync(skillsDir, { recursive: true });
1369
+ console.log("Created .claude/skills/");
1370
+
1371
+ // 2. Write skills files
1372
+ const skills = {
1373
+ "qa-loop.md": `---
1374
+ description: Overview of the QA-Tools delivery loop β€” from requirement to verified release. Read this first.
1375
+ ---
1376
+
1377
+ # QA-Tools: The Quality Loop
1378
+
1379
+ This project uses QA-Tools (https://qa-tools.com) for requirement-driven development.
1380
+
1381
+ \`\`\`
1382
+ Requirement β†’ Build β†’ Test β†’ Backlog β†’ Fix β†’ Verify
1383
+ \`\`\`
1384
+
1385
+ ## How it works
1386
+
1387
+ 1. **Requirement defined** β€” Acceptance criteria (Given/When/Then) and negative scenarios. Each requirement has a semantic key (e.g. \`auth.login-trial\`).
1388
+ 2. **Feature built** β€” AI or human implements the feature. Traceable to the requirement via requirementKey.
1389
+ 3. **Test runs** β€” Tests are generated from acceptance criteria. Each test links to a requirementKey.
1390
+ 4. **Backlog updates automatically** β€” Failed tests create backlog items (bugs). Each item is traceable to requirement, feature, and test.
1391
+ 5. **AI analyzes and fixes** β€” AI identifies root cause: is it the code or the requirement? Fixes code or suggests requirement adjustment.
1392
+ 6. **Retest verifies** β€” The loop closes only when the requirement is verified.
1393
+
1394
+ ## CLI quick reference
1395
+
1396
+ \`\`\`bash
1397
+ # Auth
1398
+ qatools login --api-key <key>
1399
+
1400
+ # App Context & Epics
1401
+ qatools context # View app context (stack, architecture, constraints)
1402
+ qatools context-set -f context.json # Upload app context from JSON
1403
+ qatools epics # List epics
1404
+ qatools epic-create -k auth -t "Authentication" # Create epic
1405
+
1406
+ # Requirements
1407
+ qatools requirements
1408
+ qatools requirements --status approved -o reqs.json
1409
+ qatools requirement-keys
1410
+ qatools requirement-create -f requirements.json
1411
+
1412
+ # Build & backlog
1413
+ qatools backlog
1414
+ qatools backlog-tags # List existing tags (MUST check before tagging)
1415
+ qatools backlog-create -d "..." -t bug --requirement-key <key> --assigned <email> --developer <email> --sprint <id> --tags tag1,tag2
1416
+ qatools backlog-update -i <id> --status resolved --sprint <id> --priority 1 --requirement-key <key> --developer <email> --tags tag1,tag2
1417
+ qatools backlog-duplicates --local # Output for local AI analysis (no API key needed)
1418
+ qatools backlog-duplicates # AI-powered duplicate detection (requires API key)
1419
+
1420
+ # Test
1421
+ qatools templates
1422
+ qatools template-upload -f tests.json
1423
+ qatools pending
1424
+ qatools read-session -s <id> -o session.json
1425
+
1426
+ # Analyze & fix
1427
+ qatools upload-analysis -s <id> -f analysis.json
1428
+
1429
+ # Sprints
1430
+ qatools sprints
1431
+ qatools sprint-items --sprint <id>
1432
+ \`\`\`
1433
+
1434
+ ## Key rules
1435
+ - Start with \`qatools context\` to understand the app's tech stack, architecture, and constraints
1436
+ - Epics describe capabilities (what + why + scope). Requirements describe testable behaviors. Break down epics by asking: "What must the system do to fulfill this?"
1437
+ - A requirement MUST be expressible in Given/When/Then. If it can't β†’ it's an epic or too vague
1438
+ - Every requirement MUST link to an epic via epicKey
1439
+ - DEPENDENCY HYGIENE: Before creating or modifying epic dependencies, ALWAYS run \`qatools epics\` to review the full dependency graph. Validate: (1) no circular deps, (2) existing deps still make sense, (3) the graph stays minimal β€” remove deps that are no longer needed. The model evolves β€” stale dependencies are worse than missing ones.
1440
+ - Every test, bug, and fix MUST trace back to a requirementKey
1441
+ - Never create duplicate requirements β€” run \`qatools requirement-keys\` first
1442
+ - The loop runs until every requirement is verified
1443
+ - BACKLOG = SIGNALS ONLY: AVV (deviation from req), GAP (missing req), DEF (technical issue), IMP (idea). Never create requirements as backlog items. Backlog items are observations β€” requirements are what you build from.
1444
+ - PLANNING FLOW: App Context β†’ Epics β†’ Requirements β†’ Sprint. Backlog feeds back into requirements via GAP/IMP conversion.
1445
+ - Tags: ONLY use existing tags β€” run \`qatools backlog-tags\` first. New tags can only be created by humans in the UI
1446
+ - Member roles: developer, tester, project_lead, architect, stakeholder. Stakeholders cannot be assigned items. Use \`--developer <email>\` to set the responsible developer (separate from assignee)
1447
+ - Sprint status is automatic: planning/active/overdue/completed based on start and end dates. Only manual actions: complete and reopen
1448
+ - Each project has a \`language\` setting (sv/en). Write ALL comments, analyses, and backlog updates in the project's language. Check \`project.language\` in the response from \`qatools read-session\` or \`qatools backlog\`
1449
+ - EVIDENCE-FIRST: Never speculate. Every analysis, verdict, and duplicate claim must cite specific evidence (code, test output, error messages, requirement text). If unsure, say so.
1450
+ `,
1451
+
1452
+ "qa-requirement-analyst.md": `---
1453
+ description: Break down epics into testable requirements. Use when creating or refining requirements from epics.
1454
+ ---
1455
+
1456
+ # QA-Tools: Requirement Analyst
1457
+
1458
+ You break down epics into testable requirements with acceptance criteria.
1459
+
1460
+ ## Intent
1461
+ Each epic describes a capability. Your job is to break it down into testable requirements that describe specific behaviors. Ask: "What different things must the system do to fulfill this epic?" β€” each answer becomes a requirement.
1462
+
1463
+ ## Workflow
1464
+
1465
+ ### Step 1: Read epics and app context
1466
+ \`qatools context\` β€” understand the tech stack, architecture, constraints
1467
+ \`qatools epics\` β€” list all epics with their scope
1468
+
1469
+ ### Step 2: Break down one epic at a time
1470
+ For each epic, look at its scope items. Each scope item should become one or more requirements:
1471
+ - key (semantic, prefixed with epic key: \`locksmith.create\`, \`locksmith.permissions\`)
1472
+ - title β€” what the system must do
1473
+ - description β€” context and detail
1474
+ - acceptanceCriteria (Given/When/Then β€” ALWAYS)
1475
+ - negativeCriteria (ALWAYS β€” what must NOT happen)
1476
+ - epicKey β€” links to the epic
1477
+ - type, priority, category
1478
+
1479
+ Upload: \`qatools requirement-create -f <epic>-requirements.json\`
1480
+ Wait for approval before next epic.
1481
+
1482
+ ### Step 3: Handle feedback
1483
+ \`qatools requirements --status rejected\`
1484
+ Improve rejected requirements and re-upload.
1485
+
1486
+ ## Rules
1487
+ - A requirement MUST be expressible in Given/When/Then. If it can't, it's either an epic (too broad) or too vague (needs clarification)
1488
+ - A requirement describes a TESTABLE BEHAVIOR, not a UI element or implementation detail
1489
+ - ALWAYS Given/When/Then format
1490
+ - ALWAYS include negative scenarios
1491
+ - Every requirement MUST link to an epic via epicKey
1492
+ - One epic at a time β€” get approval before continuing
1493
+ - Run \`qatools requirement-keys\` before creating to avoid duplicates
1494
+ - EVIDENCE-FIRST: base requirements on observable behavior, not assumptions
1495
+ - Before creating new epics: run \`qatools epics\` and review the dependency graph. Remove stale dependencies. Keep the model clean β€” it evolves over time.
1496
+ `,
1497
+
1498
+ "qa-developer.md": `---
1499
+ description: Implement features based on approved requirements. Use when building new functionality.
1500
+ ---
1501
+
1502
+ # QA-Tools: Developer
1503
+
1504
+ You implement code based on approved requirements from QA-Tools.
1505
+
1506
+ ## The model
1507
+
1508
+ \\\`\\\`\\\`
1509
+ App Context β†’ Epics β†’ Requirements β†’ Sprint β†’ Implementation β†’ Test
1510
+ ↑
1511
+ Backlog (signals only)
1512
+ \\\`\\\`\\\`
1513
+
1514
+ - **Requirements** drive what you build. They come from epics.
1515
+ - **Backlog** is NOT your task list. It contains observations: AVV (deviations), GAP (missing reqs), DEF (technical defects), IMP (improvement ideas).
1516
+ - You build from REQUIREMENTS, not backlog items.
1517
+
1518
+ ## Workflow
1519
+
1520
+ 1. Read app context and current sprint:
1521
+ \`qatools context\`
1522
+ \`qatools requirements --status approved -o requirements.json\`
1523
+
1524
+ 2. Implement according to acceptanceCriteria AND negativeCriteria.
1525
+ Every piece of code must be traceable to a requirementKey.
1526
+
1527
+ 3. If you discover something unexpected during implementation:
1528
+ - Behavior doesn't match a requirement β†’ create AVV: \`qatools backlog-create -d "..." -t bug --requirement-key <key>\`
1529
+ - Requirement is missing or unclear β†’ create GAP: \`qatools backlog-create -d "..." -t requirement_gap --requirement-key <key>\`
1530
+ - Technical issue (infra, runtime) β†’ create DEF: \`qatools backlog-create -d "..." -t technical_defect\`
1531
+ - Improvement idea β†’ create IMP: \`qatools backlog-create -d "..." -t improvement\`
1532
+
1533
+ 4. NEVER create requirements directly as backlog items. If a GAP needs to become a requirement, flag it β€” the user decides.
1534
+
1535
+ 5. Check for pending test sessions:
1536
+ \`qatools pending\`
1537
+ \`qatools read-session -s <id> -o session.json\`
1538
+
1539
+ 6. If tests fail, analyze and fix:
1540
+ \`qatools upload-analysis -s <id> -f analysis.json\`
1541
+
1542
+ ## Rules
1543
+ - Build from REQUIREMENTS, not backlog
1544
+ - Backlog items are SIGNALS β€” observations during work, not tasks to implement
1545
+ - Implement EXACTLY what the requirement says β€” ask if unclear
1546
+ - If the requirement is ambiguous, create a GAP β€” don't guess
1547
+ - Include commitId when uploading analysis
1548
+ `,
1549
+
1550
+ "qa-test-generator.md": `---
1551
+ description: Generate test protocols from approved requirements. Use after requirements are approved.
1552
+ ---
1553
+
1554
+ # QA-Tools: Test Generator
1555
+
1556
+ You generate test protocols (templates) from approved requirements.
1557
+
1558
+ ## Workflow
1559
+
1560
+ 1. Fetch approved requirements:
1561
+ \`qatools requirements --status approved -o requirements.json\`
1562
+
1563
+ 2. Check existing keys:
1564
+ \`qatools requirement-keys\`
1565
+
1566
+ 3. Generate test JSON. Each test must have:
1567
+ - id (prefix + section.number, e.g. P-1.1)
1568
+ - requirementKey (links to the requirement)
1569
+ - requirementVersion (e.g. "1.0")
1570
+ - purpose (what this test verifies β€” locked in session)
1571
+ - name, steps, expected
1572
+
1573
+ 4. Upload:
1574
+ \`qatools template-upload -f test-protocol.json\`
1575
+
1576
+ ## JSON format
1577
+ \`\`\`json
1578
+ {
1579
+ "name": "Test Protocol v1.0",
1580
+ "sections": [{
1581
+ "title": "1. Authentication",
1582
+ "tests": [{
1583
+ "id": "P-1.1",
1584
+ "requirementKey": "auth.login-trial",
1585
+ "requirementVersion": "1.0",
1586
+ "purpose": "Verifies trial account login per acceptance criteria",
1587
+ "name": "Login with trial account",
1588
+ "steps": "1. Go to /login\n2. Click Try free\n3. Enter test@example.com",
1589
+ "expected": "User logged in, redirected to dashboard"
1590
+ }]
1591
+ }]
1592
+ }
1593
+ \`\`\`
1594
+
1595
+ ## Rules
1596
+ - Every test MUST have requirementKey, requirementVersion, and purpose
1597
+ - Generate from BOTH acceptance criteria AND negative scenarios
1598
+ `,
1599
+
1600
+ "qa-test-analyst.md": `---
1601
+ description: Analyze failed test sessions and fix bugs. Use when test sessions are pending analysis.
1602
+ ---
1603
+
1604
+ # QA-Tools: Test Analyst
1605
+
1606
+ You analyze test sessions and fix bugs found during testing.
1607
+
1608
+ ## Workflow
1609
+
1610
+ 1. Check for pending sessions:
1611
+ \`qatools pending\`
1612
+
1613
+ 2. Read session details:
1614
+ \`qatools read-session -s <id> -o session.json\`
1615
+
1616
+ 3. For each FAILED test:
1617
+ - Analyze root cause: is it the code or the requirement?
1618
+ - Determine verdict: bugg, troligen-db, testprotokoll, design, ux, ej-aktuell, krΓ€ver-genomgΓ₯ng
1619
+ - Fix the bug if possible (fixStatus: "fixed", list filesChanged)
1620
+ - If the requirement is wrong, set verdict to "testprotokoll"
1621
+
1622
+ 4. Upload analysis:
1623
+ \`qatools upload-analysis -s <id> -f analysis.json\`
1624
+
1625
+ 5. Update backlog items:
1626
+ \`qatools backlog-update -f updates.json\`
1627
+
1628
+ ## Analysis JSON format
1629
+ \`\`\`json
1630
+ {
1631
+ "sessionId": "<id>",
1632
+ "results": {
1633
+ "P-1.6": {
1634
+ "verdict": "bugg",
1635
+ "comment": "Race condition β€” auth0Id never set",
1636
+ "fixStatus": "fixed",
1637
+ "commitId": "a1b2c3d",
1638
+ "filesChanged": ["AuthService.cs", "OpenController.cs"]
1639
+ }
1640
+ }
1641
+ }
1642
+ \`\`\`
1643
+
1644
+ ## Rules
1645
+ - EVIDENCE-FIRST: Base every verdict and comment on observable evidence. Cite specific code, test steps, or error messages. Never speculate.
1646
+ - Always identify whether the problem is in the code or the requirement
1647
+ - If you fix code, list all changed files and include the commitId (short hash from \`git rev-parse --short HEAD\`)
1648
+ - When creating backlog items from test failures, use the correct type:
1649
+ - Code violates requirement β†’ AVV (bug): \`qatools backlog-create -t bug -d "..." --requirement-key <key>\`
1650
+ - Requirement missing/unclear β†’ GAP: \`qatools backlog-create -t requirement_gap -d "..." --requirement-key <key>\`
1651
+ - Technical/runtime issue β†’ DEF: \`qatools backlog-create -t technical_defect -d "..."\`
1652
+ - NEVER create requirements as backlog items β€” backlog is for signals only
1653
+ `,
1654
+
1655
+ "qa-release-manager.md": `---
1656
+ description: Manage sprints, backlog, and release readiness. Use for sprint planning and release verification.
1657
+ ---
1658
+
1659
+ # QA-Tools: Release Manager
1660
+
1661
+ You manage sprints, backlog, and verify release readiness.
1662
+
1663
+ ## Workflow
1664
+
1665
+ 1. Review sprints:
1666
+ \`qatools sprints\`
1667
+ \`qatools sprint-items --sprint <id> -o sprint.json\`
1668
+
1669
+ 2. Break down requirements into stories/tasks:
1670
+ \`qatools backlog-create -d "Implement login flow" -t story --requirement-key auth.login-trial\`
1671
+
1672
+ 3. Track progress:
1673
+ \`qatools backlog --sprint <id>\`
1674
+ \`qatools backlog-update -i <id> --status resolved\`
1675
+
1676
+ 4. Verify requirement coverage:
1677
+ \`qatools requirements --status approved\`
1678
+ Ensure every approved requirement has tests and all tests pass.
1679
+
1680
+ 5. Release when every requirement in the sprint is verified.
1681
+
1682
+ ## Rules
1683
+ - A sprint is ready ONLY when all requirements are verified
1684
+ - Every backlog item must link to a requirementKey
1685
+ - Track: requirement β†’ test β†’ bug β†’ fix β†’ retest β†’ verified
1686
+ `,
1687
+ "qa-evidence-first.md": `---
1688
+ description: Core principle β€” never speculate, always cite evidence. Applies to ALL work.
1689
+ ---
1690
+
1691
+ # Evidence-First Principle
1692
+
1693
+ This is a non-negotiable rule for all AI work in this project.
1694
+
1695
+ ## The rule
1696
+ Every claim, verdict, analysis, and recommendation MUST cite specific evidence:
1697
+ - Code references (file, function, line)
1698
+ - Test output (actual vs expected)
1699
+ - Error messages (exact text)
1700
+ - Requirement text (quote the GWT)
1701
+
1702
+ ## What this means in practice
1703
+
1704
+ ### When analyzing test failures
1705
+ ❌ "This is probably a timing issue"
1706
+ βœ… "AuthService.cs:42 sets auth0Id after the response is sent (line 45), causing a race condition. Test P-1.6 expects auth0Id to be set before redirect."
1707
+
1708
+ ### When creating backlog items
1709
+ ❌ "Login seems broken"
1710
+ βœ… "Given a user with valid credentials, When clicking login, Then the response is 500. Error: 'NullReferenceException at AuthController.cs:78'. Requirement auth.login expects redirect to dashboard."
1711
+
1712
+ ### When suggesting fixes
1713
+ ❌ "Try adding a null check"
1714
+ βœ… "auth0Id is null because createUserResponse (line 42) is awaited but its result is not assigned. Fix: \`var user = await createUserResponse;\` then \`auth0Id = user.Id;\`"
1715
+
1716
+ ### When flagging requirement issues
1717
+ ❌ "This requirement is unclear"
1718
+ βœ… "Requirement auth.login says 'user is redirected' but doesn't specify WHERE. The test expects /dashboard but the code redirects to /home."
1719
+
1720
+ ## If you're unsure
1721
+ Say "I'm unsure" β€” that's better than guessing. Suggest what evidence would resolve the uncertainty.
1722
+ `
1723
+ };
1724
+
1725
+ for (const [filename, content] of Object.entries(skills)) {
1726
+ fs.writeFileSync(path.join(skillsDir, filename), content);
1727
+ }
1728
+ console.log(" βœ“ qa-loop.md (start here)");
1729
+ console.log(" βœ“ qa-requirement-analyst.md");
1730
+ console.log(" βœ“ qa-developer.md");
1731
+ console.log(" βœ“ qa-test-generator.md");
1732
+ console.log(" βœ“ qa-test-analyst.md");
1733
+ console.log(" βœ“ qa-release-manager.md");
1734
+ console.log(" βœ“ qa-evidence-first.md");
1735
+
1736
+ // 4. Generate app context skill from API
1737
+ try {
1738
+ await authenticate(options);
1739
+ const creds = getCredentials();
1740
+ if (creds?.projectId) {
1741
+ const getCtx = httpsCallable(functions, "getAppContext");
1742
+ const ctxResult = await getCtx({ projectId: creds.projectId });
1743
+ const ctx = ctxResult.data.appContext;
1744
+ if (ctx) {
1745
+ let ctxSkill = `---\ndescription: App-specific context for ${ctx.name}. Auto-generated from QA-Tools App Context.\n---\n\n# ${ctx.name}\n\n`;
1746
+ if (ctx.purpose) ctxSkill += `## Purpose\n${ctx.purpose}\n\n`;
1747
+ if (ctx.domainObjects?.length) ctxSkill += `## Domain Objects\n${ctx.domainObjects.map(d => '- ' + d).join('\n')}\n\n`;
1748
+ if (ctx.actors?.length) ctxSkill += `## Actors\n${ctx.actors.map(a => '- ' + a).join('\n')}\n\n`;
1749
+ if (ctx.stack?.length) ctxSkill += `## Tech Stack\n${ctx.stack.map(s => '- **' + s.layer + ':** ' + s.technology + (s.version ? ' ' + s.version : '') + (s.notes ? ' β€” ' + s.notes : '')).join('\n')}\n\n`;
1750
+ if (ctx.architecture) ctxSkill += `## Architecture\n${ctx.architecture}\n\n`;
1751
+ if (ctx.security?.length) ctxSkill += `## Security\n${ctx.security.map(s => '- ' + s).join('\n')}\n\n`;
1752
+ if (ctx.aiRules?.length) ctxSkill += `## AI Rules\n${ctx.aiRules.map(r => '- ' + r).join('\n')}\n\n`;
1753
+ if (ctx.conventions?.length) ctxSkill += `## Design Principles\n${ctx.conventions.map(c => '- **' + c.area + ':** ' + c.rule).join('\n')}\n\n`;
1754
+ if (ctx.constraints?.length) ctxSkill += `## Constraints\n${ctx.constraints.map(c => '- ' + c).join('\n')}\n\n`;
1755
+ fs.writeFileSync(path.join(skillsDir, "app-context.md"), ctxSkill);
1756
+ console.log(" βœ“ app-context.md (from project)");
1757
+
1758
+ // Generate custom skills from App Context
1759
+ if (ctx.skills?.length) {
1760
+ for (const skill of ctx.skills) {
1761
+ const fname = skill.filename.endsWith('.md') ? skill.filename : skill.filename + '.md';
1762
+ const content = `---\ndescription: ${skill.title}\n---\n\n# ${skill.title}\n\n${skill.content}\n`;
1763
+ fs.writeFileSync(path.join(skillsDir, fname), content);
1764
+ console.log(" βœ“ " + fname + " (custom)");
1765
+ }
1766
+ }
1767
+ }
1768
+ }
1769
+ } catch (e) {
1770
+ // No auth or no context β€” skip, not critical
1771
+ }
1772
+
1773
+ // 3. Update or create CLAUDE.md
1774
+ const claudeMdPath = path.join(cwd, "CLAUDE.md");
1775
+ const qaSection = `
1776
+ ## QA-Tools Integration
1777
+
1778
+ \`\`\`bash
1779
+ export QATOOLS_API_KEY=${apiKey || "<din-nyckel>"}
1780
+ \`\`\`
1781
+
1782
+ ### Kommandon
1783
+ - \`qatools requirements\` β€” lista krav
1784
+ - \`qatools backlog\` β€” lista backlog
1785
+ - \`qatools pending\` β€” vΓ€ntande testsessioner
1786
+ - \`qatools read-session -s <id>\` β€” lΓ€s session
1787
+ - \`qatools upload-analysis -s <id> -f analysis.json\` β€” ladda upp analys
1788
+ - \`qatools requirement-create -f file.json\` β€” skapa krav
1789
+ - \`qatools template-upload -f file.json\` β€” ladda upp testprotokoll
1790
+ - \`qatools backlog-create -d "..." -t task\` β€” skapa Γ€rende
1791
+ - \`qatools backlog-update -i <id> --status resolved\` β€” uppdatera
1792
+
1793
+ ### Roller (skills)
1794
+ Se \`.claude/skills/\` fΓΆr instruktioner per roll.
1795
+ `;
1796
+
1797
+ if (fs.existsSync(claudeMdPath)) {
1798
+ const existing = fs.readFileSync(claudeMdPath, "utf8");
1799
+ if (!existing.includes("QA-Tools Integration")) {
1800
+ fs.appendFileSync(claudeMdPath, "\n" + qaSection);
1801
+ console.log("\nβœ“ Added QA-Tools section to CLAUDE.md");
1802
+ } else {
1803
+ console.log("\n⚠ CLAUDE.md already has QA-Tools section (skipped)");
1804
+ }
1805
+ } else {
1806
+ fs.writeFileSync(claudeMdPath, "# Project\n" + qaSection);
1807
+ console.log("\nβœ“ Created CLAUDE.md with QA-Tools section");
1808
+ }
1809
+
1810
+ console.log("\nπŸŽ‰ QA-Tools initialized! Claude is ready.");
1811
+ if (!apiKey) console.log("\n⚠ No API key set. Add it to CLAUDE.md: export QATOOLS_API_KEY=<key>");
1812
+ process.exit(0);
1813
+ });
1814
+
507
1815
  program
508
1816
  .command("login")
509
1817
  .description("Save credentials to ~/.qatools (email/password or --google)")
510
1818
  .option("-e, --email <email>", "Login email")
511
1819
  .option("-p, --password <password>", "Login password")
512
1820
  .option("-g, --google", "Login with Google (opens browser)")
1821
+ .option("-k, --api-key <key>", "Login with Claude API key (permanent, no browser)")
513
1822
  .action(async (options) => {
514
1823
  const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".qatools");
515
1824
 
1825
+ if (options.apiKey) {
1826
+ // API key login β€” no browser, no expiry
1827
+ console.log("Authenticating with API key...");
1828
+ const authRes = await fetch("https://us-central1-qatools-app.cloudfunctions.net/authenticateWithApiKey", {
1829
+ method: "POST", headers: { "Content-Type": "application/json" },
1830
+ body: JSON.stringify({ data: { apiKey: options.apiKey } }),
1831
+ });
1832
+ const authData = await authRes.json();
1833
+ if (authData.error || !authData.result) {
1834
+ console.error("Invalid API key.");
1835
+ process.exit(1);
1836
+ }
1837
+ const { customToken, projectId, projectName, email } = authData.result;
1838
+ // Save config
1839
+ const config = { apiKey: options.apiKey, projectId, projectName, email };
1840
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1841
+ console.log(`Logged in as Claude for project: ${projectName}`);
1842
+ console.log(`Project ID: ${projectId}`);
1843
+ process.exit(0);
1844
+ }
1845
+
516
1846
  if (options.google) {
517
1847
  // Google login via browser
518
1848
  console.log("Opening browser for Google sign-in...\n");