qatools-cli 1.1.6 → 1.1.7

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 +1 -1
  2. package/qatools.js +599 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qatools-cli",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "CLI for QA Tools — requirements, epics, sprints, sessions, App Context sync, project agenda. The companion to qa-tools.com.",
5
5
  "bin": {
6
6
  "qatools": "./qatools.js"
package/qatools.js CHANGED
@@ -419,6 +419,39 @@ program
419
419
  process.exit(0);
420
420
  });
421
421
 
422
+ program
423
+ .command("sprint-create")
424
+ .description("Create a new sprint (status auto-derives from dates: planning/active/overdue)")
425
+ .requiredOption("--name <text>", "Sprint name")
426
+ .requiredOption("--end <YYYY-MM-DD>", "End date (release date)")
427
+ .option("--start <YYYY-MM-DD>", "Start date (optional — without it the sprint stays in planning until you set one)")
428
+ .option("--project <id>", "Target project ID (overrides default)")
429
+ .option("-e, --email <email>", "Login email")
430
+ .option("-p, --password <password>", "Login password")
431
+ .action(async (options) => {
432
+ await authenticate(options);
433
+ const creds = getCredentials();
434
+ const projectId = options.project || creds?.projectId;
435
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
436
+
437
+ const createSprint = httpsCallable(functions, "createSprint");
438
+ try {
439
+ const result = await createSprint({
440
+ projectId,
441
+ name: options.name,
442
+ startDate: options.start || "",
443
+ releaseDate: options.end,
444
+ });
445
+ const s = result.data.sprint;
446
+ console.log(`Created sprint ${s.id} ${s.name} [${s.status}]`);
447
+ console.log(` Start: ${s.startDate || "(not set)"} End: ${s.releaseDate}`);
448
+ } catch (e) {
449
+ console.error("Failed:", e.message);
450
+ process.exit(1);
451
+ }
452
+ process.exit(0);
453
+ });
454
+
422
455
  program
423
456
  .command("backlog-create")
424
457
  .description("Create a new backlog item")
@@ -699,9 +732,11 @@ program
699
732
 
700
733
  const getCtx = httpsCallable(functions, "getAppContext");
701
734
  let ctx;
735
+ let systemAiRules = [];
702
736
  try {
703
737
  const result = await getCtx({ projectId });
704
738
  ctx = result.data.appContext;
739
+ systemAiRules = result.data.systemAiRules || [];
705
740
  } catch (e) {
706
741
  console.error("Failed to fetch App Context:", e.message);
707
742
  process.exit(1);
@@ -711,7 +746,7 @@ program
711
746
  process.exit(1);
712
747
  }
713
748
 
714
- const md = renderAppContextMarkdown(ctx, projectId);
749
+ const md = renderAppContextMarkdown(ctx, projectId, systemAiRules);
715
750
  const beginMarker = "<!-- BEGIN qatools-context (auto-synced from qa-tools, do not edit by hand) -->";
716
751
  const endMarker = "<!-- END qatools-context -->";
717
752
  const block = `${beginMarker}\n${md}\n${endMarker}`;
@@ -792,13 +827,21 @@ program
792
827
  process.exit(0);
793
828
  });
794
829
 
795
- function renderAppContextMarkdown(ctx, projectId) {
830
+ function renderAppContextMarkdown(ctx, projectId, systemAiRules) {
796
831
  const lines = [];
797
832
  const ts = new Date().toISOString();
798
833
  lines.push(`<!-- Last synced: ${ts} from project ${projectId} -->`);
799
834
  lines.push("");
800
835
  lines.push(`## ${ctx.name || "Project Context"}`);
801
836
  lines.push("");
837
+ // System rules first — they apply to every project, inherited from qa-tools itself
838
+ if (systemAiRules && systemAiRules.length) {
839
+ lines.push("### qa-tools System Rules (apply to every project)");
840
+ lines.push("> These rules are inherited by every project that uses qa-tools. They cannot be overridden — they define what qa-tools is. To see them: `qatools context`. To change them: contact the qa-tools team.");
841
+ lines.push("");
842
+ systemAiRules.forEach((r, i) => lines.push(`${i + 1}. ${r}`));
843
+ lines.push("");
844
+ }
802
845
  if (ctx.purpose) { lines.push("### Purpose"); lines.push(ctx.purpose); lines.push(""); }
803
846
  if (ctx.domainObjects?.length) {
804
847
  lines.push("### Domain Objects");
@@ -1069,6 +1112,54 @@ program
1069
1112
  process.exit(0);
1070
1113
  });
1071
1114
 
1115
+ program
1116
+ .command("requirement-delete")
1117
+ .description("Delete a requirement by id or key. Refuses if backlog/tests reference it — use --force to delete anyway.")
1118
+ .option("-i, --id <id>", "Requirement document ID to delete")
1119
+ .option("-k, --key <key>", "Requirement semantic key to delete (resolved to its id)")
1120
+ .option("--force", "Delete even if backlog items or test cases reference the requirement's key (leaves them dangling, not cascade-deleted)")
1121
+ .option("--project <id>", "Target project ID (overrides default)")
1122
+ .option("-e, --email <email>", "Login email")
1123
+ .option("-p, --password <password>", "Login password")
1124
+ .action(async (options) => {
1125
+ await authenticate(options);
1126
+ const creds = getCredentials();
1127
+ const projectId = options.project || creds?.projectId;
1128
+ if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
1129
+ if (!options.id && !options.key) { console.error("Provide --id or --key"); process.exit(1); }
1130
+
1131
+ let requirementId = options.id;
1132
+ if (!requirementId) {
1133
+ const listReqs = httpsCallable(functions, "listRequirements");
1134
+ const res = await listReqs({ projectId });
1135
+ const matches = res.data.requirements.filter(r => r.key === options.key);
1136
+ if (matches.length === 0) { console.error(`Requirement with key '${options.key}' not found.`); process.exit(1); }
1137
+ if (matches.length > 1) {
1138
+ console.error(`Key '${options.key}' has ${matches.length} versions — ambiguous. Delete by --id instead:`);
1139
+ for (const m of matches) console.error(` --id ${m.id} v${m.version ?? "?"} [${m.status}] ${m.title || ""}`);
1140
+ process.exit(1);
1141
+ }
1142
+ requirementId = matches[0].id;
1143
+ }
1144
+
1145
+ const deleteReq = httpsCallable(functions, "deleteRequirement");
1146
+ try {
1147
+ const res = await deleteReq({ projectId, requirementId, force: !!options.force });
1148
+ const d = res.data;
1149
+ console.log(`Deleted requirement${d.key ? ` ${d.key}` : ""}${d.deletedTitle ? `: ${d.deletedTitle}` : ""}`);
1150
+ if (d.orphanedBacklog || d.orphanedTests) {
1151
+ const parts = [];
1152
+ if (d.orphanedBacklog) parts.push(`${d.orphanedBacklog} backlog item(s)`);
1153
+ if (d.orphanedTests) parts.push(`${d.orphanedTests} test case(s)`);
1154
+ console.log(` ⚠ ${parts.join(" and ")} now have a dangling requirementKey — re-point or clear them.`);
1155
+ }
1156
+ } catch (e) {
1157
+ console.error("Failed:", e.message);
1158
+ process.exit(1);
1159
+ }
1160
+ process.exit(0);
1161
+ });
1162
+
1072
1163
  program
1073
1164
  .command("template-upload")
1074
1165
  .description("Upload a test template JSON to the project")
@@ -1371,8 +1462,8 @@ program
1371
1462
 
1372
1463
  program
1373
1464
  .command("context")
1374
- .description("Show App Context for the project")
1375
- .option("-o, --output <path>", "Save to JSON file")
1465
+ .description("Show App Context for the project, including system AI rules that apply to every qa-tools project")
1466
+ .option("-o, --output <path>", "Save to JSON file (project context only — system rules are inherited at runtime)")
1376
1467
  .option("--project <id>", "Target project ID (overrides default; requires access to that project)")
1377
1468
  .option("-e, --email <email>", "Login email")
1378
1469
  .option("-p, --password <password>", "Login password")
@@ -1384,10 +1475,16 @@ program
1384
1475
  const getCtx = httpsCallable(functions, "getAppContext");
1385
1476
  const result = await getCtx({ projectId });
1386
1477
  const ctx = result.data.appContext;
1387
- if (!ctx) { console.log("No App Context defined for this project."); process.exit(0); }
1478
+ const systemRules = result.data.systemAiRules || [];
1388
1479
  if (options.output) {
1389
- fs.writeFileSync(path.resolve(options.output), JSON.stringify(ctx, null, 2));
1390
- console.log(`Saved to ${options.output}`);
1480
+ fs.writeFileSync(path.resolve(options.output), JSON.stringify(ctx || {}, null, 2));
1481
+ console.log(`Saved project context to ${options.output}`);
1482
+ if (systemRules.length) console.log(`Note: ${systemRules.length} system AI rules are inherited at runtime, not saved to file.`);
1483
+ process.exit(0);
1484
+ }
1485
+ if (!ctx) {
1486
+ console.log("No App Context defined for this project.");
1487
+ console.log("Run 'qatools context-template -o ctx.json' to start, then 'qatools context-set -f ctx.json' to upload.");
1391
1488
  } else {
1392
1489
  console.log(`\n${ctx.name}`);
1393
1490
  if (ctx.description) console.log(ctx.description);
@@ -1398,8 +1495,16 @@ program
1398
1495
  if (ctx.architecture) console.log(`\nArchitecture: ${ctx.architecture}`);
1399
1496
  if (ctx.constraints?.length) { console.log("\nConstraints:"); ctx.constraints.forEach(c => console.log(` - ${c}`)); }
1400
1497
  if (ctx.conventions?.length) { console.log("\nConventions:"); ctx.conventions.forEach(c => console.log(` - ${c.area}: ${c.rule}`)); }
1498
+ if (ctx.aiRules?.length) { console.log("\nProject AI Rules:"); ctx.aiRules.forEach((r, i) => console.log(` ${i + 1}. ${r}`)); }
1401
1499
  if (ctx.sections?.length) { ctx.sections.forEach(s => console.log(`\n${s.title}:\n ${s.content}`)); }
1402
1500
  }
1501
+ if (systemRules.length) {
1502
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1503
+ console.log(`qa-tools System Rules (apply to every project — inherited):`);
1504
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1505
+ systemRules.forEach((r, i) => console.log(` ${i + 1}. ${r}`));
1506
+ console.log(``);
1507
+ }
1403
1508
  process.exit(0);
1404
1509
  });
1405
1510
 
@@ -1735,16 +1840,53 @@ program
1735
1840
 
1736
1841
  program
1737
1842
  .command("init")
1738
- .description("Initialize QA-Tools in current project (creates skills, CLAUDE.md section)")
1843
+ .description("One command to set up everything: login + skills + sync + agenda. Just run: qatools init --api-key <key>")
1739
1844
  .option("-k, --api-key <key>", "Claude API key (from QA-Tools → Members → Claude AI Integration)")
1845
+ .option("-e, --email <email>", "Login email")
1846
+ .option("-p, --password <password>", "Login password")
1740
1847
  .action(async (options) => {
1741
1848
  const cwd = process.cwd();
1742
- const apiKey = options.apiKey || getCredentials()?.apiKey || "";
1743
1849
 
1744
- // 1. Create .claude/skills/ directory
1850
+ // Step 0: Login if api-key provided and not already logged in
1851
+ const existingCreds = getCredentials();
1852
+ const apiKey = options.apiKey || existingCreds?.apiKey || "";
1853
+ if (options.apiKey && (!existingCreds?.projectId || existingCreds?.apiKey !== options.apiKey)) {
1854
+ console.log("Step 1/5: Logging in with API key...");
1855
+ try {
1856
+ const authRes = await fetch("https://us-central1-qatools-app.cloudfunctions.net/authenticateWithApiKey", {
1857
+ method: "POST", headers: { "Content-Type": "application/json" },
1858
+ body: JSON.stringify({ data: { apiKey: options.apiKey } }),
1859
+ });
1860
+ const authData = await authRes.json();
1861
+ if (authData.result?.customToken) {
1862
+ const { signInWithCustomToken } = require("firebase/auth");
1863
+ const { getAuth } = require("firebase/auth");
1864
+ await signInWithCustomToken(getAuth(), authData.result.customToken);
1865
+ const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".qatools");
1866
+ const config = { apiKey: options.apiKey, projectId: authData.result.projectId, projectName: authData.result.projectName, email: authData.result.email };
1867
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1868
+ console.log(` ✓ Logged in: ${authData.result.projectName} (${authData.result.projectId})`);
1869
+ } else {
1870
+ console.error(" ✗ Invalid API key.");
1871
+ process.exit(1);
1872
+ }
1873
+ } catch (e) {
1874
+ console.error(" ✗ Login failed:", e.message);
1875
+ process.exit(1);
1876
+ }
1877
+ } else if (existingCreds?.projectId) {
1878
+ console.log(`Step 1/5: Already logged in: ${existingCreds.projectName || existingCreds.projectId}`);
1879
+ // Ensure Firebase auth is initialized
1880
+ try { await authenticate(options); } catch (e) { /* best effort */ }
1881
+ } else {
1882
+ console.log("Step 1/5: No API key provided. Run: qatools init --api-key <key>");
1883
+ console.log(" Get your key from: qa-tools.com → your project → Members → Claude AI Integration");
1884
+ process.exit(1);
1885
+ }
1886
+
1887
+ console.log("\nStep 2/5: Writing skills...");
1745
1888
  const skillsDir = path.join(cwd, ".claude", "skills");
1746
1889
  fs.mkdirSync(skillsDir, { recursive: true });
1747
- console.log("Created .claude/skills/");
1748
1890
 
1749
1891
  // 2. Write skills files
1750
1892
  const skills = {
@@ -1879,6 +2021,7 @@ qatools requirements
1879
2021
  qatools requirements --status approved -o reqs.json
1880
2022
  qatools requirement-keys
1881
2023
  qatools requirement-create -f requirements.json
2024
+ qatools requirement-delete -k <key> # Delete a wrong requirement (refuses if linked; --force to override)
1882
2025
 
1883
2026
  # Build & backlog
1884
2027
  qatools backlog
@@ -1900,6 +2043,7 @@ qatools upload-analysis -s <id> -f analysis.json
1900
2043
  # Sprints
1901
2044
  qatools sprints
1902
2045
  qatools sprint-items --sprint <id>
2046
+ qatools sprint-create --name "Release 3.2" --start 2026-05-01 --end 2026-05-28
1903
2047
  \`\`\`
1904
2048
 
1905
2049
  ## Key rules
@@ -2136,6 +2280,9 @@ You manage sprints, backlog, and verify release readiness.
2136
2280
  1. Review sprints:
2137
2281
  \`qatools sprints\`
2138
2282
  \`qatools sprint-items --sprint <id> -o sprint.json\`
2283
+ Create a new one when starting a cycle:
2284
+ \`qatools sprint-create --name "Release 3.2" --start 2026-05-01 --end 2026-05-28\`
2285
+ (status auto-derives from dates: planning → active → overdue → completed)
2139
2286
 
2140
2287
  2. Break down requirements into stories/tasks:
2141
2288
  \`qatools backlog-create -d "Implement login flow" -t story --requirement-key auth.login-trial\`
@@ -2283,7 +2430,8 @@ If the verification fails: do NOT mark tested. Go back to step 1, find the discr
2283
2430
  console.log(" ✓ qa-evidence-first.md");
2284
2431
  console.log(" ✓ qa-numerical-verification.md");
2285
2432
 
2286
- // 4. Generate app context skill from API
2433
+ // 4. Save built-in skills to App Context skills[] so they become editable per project.
2434
+ // Only adds skills that don't already exist in ctx.skills[] (respects user edits).
2287
2435
  try {
2288
2436
  await authenticate(options);
2289
2437
  const creds = getCredentials();
@@ -2292,22 +2440,49 @@ If the verification fails: do NOT mark tested. Go back to step 1, find the discr
2292
2440
  const ctxResult = await getCtx({ projectId: creds.projectId });
2293
2441
  const ctx = ctxResult.data.appContext;
2294
2442
  if (ctx) {
2295
- let ctxSkill = `---\ndescription: App-specific context for ${ctx.name}. Auto-generated from QA-Tools App Context.\n---\n\n# ${ctx.name}\n\n`;
2296
- if (ctx.purpose) ctxSkill += `## Purpose\n${ctx.purpose}\n\n`;
2297
- if (ctx.domainObjects?.length) ctxSkill += `## Domain Objects\n${ctx.domainObjects.map(d => '- ' + d).join('\n')}\n\n`;
2298
- if (ctx.actors?.length) ctxSkill += `## Actors\n${ctx.actors.map(a => '- ' + a).join('\n')}\n\n`;
2299
- 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`;
2300
- if (ctx.architecture) ctxSkill += `## Architecture\n${ctx.architecture}\n\n`;
2301
- if (ctx.security?.length) ctxSkill += `## Security\n${ctx.security.map(s => '- ' + s).join('\n')}\n\n`;
2302
- if (ctx.aiRules?.length) ctxSkill += `## AI Rules\n${ctx.aiRules.map(r => '- ' + r).join('\n')}\n\n`;
2303
- if (ctx.conventions?.length) ctxSkill += `## Design Principles\n${ctx.conventions.map(c => '- **' + c.area + ':** ' + c.rule).join('\n')}\n\n`;
2304
- if (ctx.constraints?.length) ctxSkill += `## Constraints\n${ctx.constraints.map(c => '- ' + c).join('\n')}\n\n`;
2443
+ const existingSkills = ctx.skills || [];
2444
+ const existingNames = new Set(existingSkills.map(s => s.filename?.replace(/\.md$/, '')));
2445
+ let added = 0;
2446
+ for (const [filename, content] of Object.entries(skills)) {
2447
+ const name = filename.replace(/\.md$/, '');
2448
+ if (existingNames.has(name)) continue; // respect user edits
2449
+ // Extract title from first # heading in the content
2450
+ const titleMatch = content.match(/^#\s+(.+)$/m);
2451
+ const title = titleMatch ? titleMatch[1].trim() : name;
2452
+ existingSkills.push({ filename: name, title, content });
2453
+ added++;
2454
+ }
2455
+ if (added > 0) {
2456
+ ctx.skills = existingSkills;
2457
+ const saveCtx = httpsCallable(functions, "saveAppContext");
2458
+ await saveCtx({ projectId: creds.projectId, context: ctx });
2459
+ console.log(`\n ✓ ${added} built-in skills saved to App Context (now editable via qatools skill add/remove)`);
2460
+ } else {
2461
+ console.log(`\n ✓ All built-in skills already in App Context (editable)`);
2462
+ }
2463
+ }
2464
+
2465
+ // Generate app context skill + deploy custom skills from context
2466
+ // Re-fetch to get updated skills[]
2467
+ const ctxRefresh = await getCtx({ projectId: creds.projectId });
2468
+ const ctxFinal = ctxRefresh.data.appContext;
2469
+ if (ctxFinal) {
2470
+ let ctxSkill = `---\ndescription: App-specific context for ${ctxFinal.name}. Auto-generated from QA-Tools App Context.\n---\n\n# ${ctxFinal.name}\n\n`;
2471
+ if (ctxFinal.purpose) ctxSkill += `## Purpose\n${ctxFinal.purpose}\n\n`;
2472
+ if (ctxFinal.domainObjects?.length) ctxSkill += `## Domain Objects\n${ctxFinal.domainObjects.map(d => '- ' + d).join('\n')}\n\n`;
2473
+ if (ctxFinal.actors?.length) ctxSkill += `## Actors\n${ctxFinal.actors.map(a => '- ' + a).join('\n')}\n\n`;
2474
+ if (ctxFinal.stack?.length) ctxSkill += `## Tech Stack\n${ctxFinal.stack.map(s => '- **' + s.layer + ':** ' + s.technology + (s.version ? ' ' + s.version : '') + (s.notes ? ' — ' + s.notes : '')).join('\n')}\n\n`;
2475
+ if (ctxFinal.architecture) ctxSkill += `## Architecture\n${ctxFinal.architecture}\n\n`;
2476
+ if (ctxFinal.security?.length) ctxSkill += `## Security\n${ctxFinal.security.map(s => '- ' + s).join('\n')}\n\n`;
2477
+ if (ctxFinal.aiRules?.length) ctxSkill += `## AI Rules\n${ctxFinal.aiRules.map(r => '- ' + r).join('\n')}\n\n`;
2478
+ if (ctxFinal.conventions?.length) ctxSkill += `## Design Principles\n${ctxFinal.conventions.map(c => '- **' + c.area + ':** ' + c.rule).join('\n')}\n\n`;
2479
+ if (ctxFinal.constraints?.length) ctxSkill += `## Constraints\n${ctxFinal.constraints.map(c => '- ' + c).join('\n')}\n\n`;
2305
2480
  fs.writeFileSync(path.join(skillsDir, "app-context.md"), ctxSkill);
2306
2481
  console.log(" ✓ app-context.md (from project)");
2307
2482
 
2308
2483
  // Generate custom skills from App Context
2309
- if (ctx.skills?.length) {
2310
- for (const skill of ctx.skills) {
2484
+ if (ctxFinal.skills?.length) {
2485
+ for (const skill of ctxFinal.skills) {
2311
2486
  const fname = skill.filename.endsWith('.md') ? skill.filename : skill.filename + '.md';
2312
2487
  const content = `---\ndescription: ${skill.title}\n---\n\n# ${skill.title}\n\n${skill.content}\n`;
2313
2488
  fs.writeFileSync(path.join(skillsDir, fname), content);
@@ -2344,6 +2519,7 @@ export QATOOLS_API_KEY=${apiKey || "<din-nyckel>"}
2344
2519
  - \`qatools upload-analysis -s <id> -f analysis.json\` — upload analysis
2345
2520
  - \`qatools requirement-create -f file.json\` — create requirements
2346
2521
  - \`qatools requirement-update -i <id> --status tested --evidence "..."\` — mark verified (evidence required)
2522
+ - \`qatools requirement-delete -k <key>\` — delete a wrong requirement (refuses if backlog/tests link it; --force to override)
2347
2523
  - \`qatools template-upload -f file.json\` — upload test template
2348
2524
  - \`qatools backlog-create -d "..." -t bug\` — create backlog item
2349
2525
  - \`qatools backlog-update -i <id> --status resolved --evidence "..."\` — mark resolved (evidence required)
@@ -2366,41 +2542,420 @@ See \`.claude/skills/\` for role-specific instructions. Start with \`qa-purpose.
2366
2542
  console.log("\n✓ Created CLAUDE.md with QA-Tools section");
2367
2543
  }
2368
2544
 
2369
- // 5. Final purpose-first banner — check App Context state and tell the AI/user what to do
2370
- let contextStateMessage = "";
2545
+ // Step 4/5: Sync App Context into CLAUDE.md
2546
+ console.log("\nStep 4/5: Syncing App Context into CLAUDE.md...");
2371
2547
  try {
2372
2548
  const creds = getCredentials();
2373
2549
  if (creds?.projectId) {
2374
- const getCtx = httpsCallable(functions, "getAppContext");
2375
- const ctxResult = await getCtx({ projectId: creds.projectId });
2376
- const ctx = ctxResult.data.appContext;
2377
- if (!ctx || (!ctx.purpose && !(ctx.stack || []).length && !(ctx.aiRules || []).length)) {
2378
- contextStateMessage = "\n⚠ App Context is empty for this project.\n Without it, the AI has no basis for substituting for you.\n First step: open https://qa-tools.com and define App Context (purpose, stack, conventions, AI rules)\n or upload a JSON via: qatools context-set -f context.json\n";
2550
+ const getCtx2 = httpsCallable(functions, "getAppContext");
2551
+ const syncResult = await getCtx2({ projectId: creds.projectId });
2552
+ const syncCtx = syncResult.data.appContext;
2553
+ const systemRules = syncResult.data.systemAiRules || [];
2554
+ if (syncCtx) {
2555
+ // Use the same sync logic as qatools sync
2556
+ const syncMd = renderAppContextMarkdown(syncCtx, creds.projectId, systemRules);
2557
+ const beginMarker = "<!-- BEGIN qatools-context (auto-synced from qa-tools, do not edit by hand) -->";
2558
+ const endMarker = "<!-- END qatools-context -->";
2559
+ const block = `${beginMarker}\n${syncMd}\n${endMarker}`;
2560
+ const syncPath = fs.existsSync(path.join(cwd, ".claude", "CLAUDE.md")) ? path.join(cwd, ".claude", "CLAUDE.md") : claudeMdPath;
2561
+ let existing = fs.existsSync(syncPath) ? fs.readFileSync(syncPath, "utf8") : "";
2562
+ const beginIdx = existing.indexOf(beginMarker);
2563
+ const endIdx = existing.indexOf(endMarker);
2564
+ if (beginIdx >= 0 && endIdx > beginIdx) {
2565
+ existing = existing.slice(0, beginIdx) + block + existing.slice(endIdx + endMarker.length);
2566
+ } else {
2567
+ existing = existing + (existing && !existing.endsWith("\n") ? "\n\n" : "\n") + block + "\n";
2568
+ }
2569
+ fs.writeFileSync(syncPath, existing);
2570
+ console.log(` ✓ Synced to ${path.relative(cwd, syncPath)}`);
2571
+
2572
+ // App Context state
2573
+ if (!syncCtx.purpose && !(syncCtx.stack || []).length) {
2574
+ console.log("\n ⚠ App Context is empty for this project.");
2575
+ console.log(" Without it, the AI has no basis for substituting for you.");
2576
+ console.log(" Run: qatools context-template -o ctx.json → edit → qatools context-set -f ctx.json");
2577
+ } else {
2578
+ const lastSync = syncCtx.updatedAt?._seconds ? new Date(syncCtx.updatedAt._seconds * 1000).toISOString().split('T')[0] : 'unknown';
2579
+ console.log(` ✓ App Context loaded (last updated: ${lastSync})`);
2580
+ }
2379
2581
  } else {
2380
- const lastSync = ctx.updatedAt?._seconds ? new Date(ctx.updatedAt._seconds * 1000).toISOString().split('T')[0] : 'unknown';
2381
- contextStateMessage = `\n✓ App Context loaded (last updated: ${lastSync})\n`;
2582
+ console.log(" ⚠ No App Context defined. Create one: qatools context-template -o ctx.json → edit → qatools context-set -f ctx.json");
2382
2583
  }
2383
2584
  }
2384
2585
  } catch (e) {
2385
- // Best-effort — don't block init on this check
2586
+ console.log(` ⚠ Sync skipped (${e.message})`);
2587
+ }
2588
+
2589
+ // Step 5/5: Show agenda
2590
+ console.log("\nStep 5/5: Project agenda\n");
2591
+ try {
2592
+ const creds = getCredentials();
2593
+ if (creds?.projectId) {
2594
+ const getAgenda = httpsCallable(functions, "getProjectAgenda");
2595
+ const agendaResult = await getAgenda({ projectId: creds.projectId });
2596
+ const data = agendaResult.data;
2597
+ const sections = [];
2598
+ if (data.agenda.pendingSessions?.length) {
2599
+ sections.push(` 🔴 ${data.agenda.pendingSessions.length} session(s) waiting for analysis`);
2600
+ }
2601
+ if (data.agenda.approvedNoCoverage?.length) {
2602
+ sections.push(` 🟡 ${data.agenda.approvedNoCoverage.length} approved requirement(s) without test coverage`);
2603
+ }
2604
+ if (data.agenda.draftReqs?.length) {
2605
+ sections.push(` ⚪ ${data.agenda.draftReqs.length} draft requirement(s) need acceptance criteria / approval`);
2606
+ }
2607
+ if (data.agenda.accessModelMissing) {
2608
+ sections.push(` ⚪ No access model defined — run: qatools access show`);
2609
+ }
2610
+ if (sections.length === 0) {
2611
+ console.log(" ✓ Nothing on the agenda. The project is in a clean state.");
2612
+ } else {
2613
+ for (const s of sections) console.log(s);
2614
+ console.log(`\n ${data.totalCount} thing(s) on the agenda.`);
2615
+ }
2616
+ }
2617
+ } catch (e) {
2618
+ console.log(` Could not load agenda (${e.message}). Run: qatools next`);
2386
2619
  }
2387
2620
 
2388
2621
  console.log(`
2622
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2389
2623
  🎉 QA-Tools initialized!
2390
2624
 
2391
2625
  ⚠ READ THIS FIRST: .claude/skills/qa-purpose.md
2392
2626
 
2393
- qa-tools exists to let anyone build production-quality apps with AI
2394
- that follow their own rules — as if you built it yourself, just faster.
2395
- The AI is not an assistant. It is a substitute for the developer.
2396
- Read qa-purpose.md before doing anything else in this project.
2397
- ${contextStateMessage}
2398
- Next steps for the AI in this project:
2399
- 1. qatools context — read the project's rule book
2400
- 2. qatools epics — see what capabilities exist
2401
- 3. qatools next — see what's on the agenda
2627
+ qa-tools exists to let anyone build production-quality apps
2628
+ with AI that follow their own rules — as if you built it
2629
+ yourself, just faster.
2630
+
2631
+ The AI is not an assistant. It is a substitute.
2632
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2402
2633
  `);
2403
- if (!apiKey) console.log("⚠ No API key set. Add it to CLAUDE.md: export QATOOLS_API_KEY=<key>\n");
2634
+ process.exit(0);
2635
+ });
2636
+
2637
+ program
2638
+ // Skill library metadata — names and descriptions of the built-in skills shipped by qatools init.
2639
+ // The full content lives in the init action above (huge template literals). This is just metadata
2640
+ // for `qatools skill list` and `qatools skill add` to reference by name.
2641
+ const BUILTIN_SKILLS = [
2642
+ { name: "qa-purpose", description: "Read this BEFORE anything else. The purpose of qa-tools and what it expects from you as AI." },
2643
+ { name: "qa-loop", description: "Overview of the QA-Tools delivery loop — from requirement to verified release." },
2644
+ { name: "qa-requirement-analyst", description: "Analyze the codebase and define requirements that are testable, traceable, and complete." },
2645
+ { name: "qa-developer", description: "Implement features based on approved requirements. Use when building new functionality." },
2646
+ { name: "qa-test-generator", description: "Generate test protocols from approved requirements." },
2647
+ { name: "qa-test-analyst", description: "Analyze test results, identify root causes, create backlog items." },
2648
+ { name: "qa-release-manager", description: "Sprint planning and release verification." },
2649
+ { name: "qa-evidence-first", description: "Core principle — never speculate, always cite evidence. Applies to ALL work." },
2650
+ { name: "qa-numerical-verification", description: "How to verify algorithmic and numerical bug fixes." },
2651
+ ];
2652
+
2653
+ const skillCmd = program
2654
+ .command("skill")
2655
+ .description("Manage skills — the instruction files Claude reads in .claude/skills/");
2656
+
2657
+ // --- Library subcommands (global, not per-project) ---
2658
+
2659
+ const libraryCmd = skillCmd
2660
+ .command("library")
2661
+ .description("Manage the global skill library (shared across all projects)");
2662
+
2663
+ libraryCmd
2664
+ .command("list")
2665
+ .description("List all skills in the global library")
2666
+ .option("-e, --email <email>", "Login email")
2667
+ .option("-p, --password <password>", "Login password")
2668
+ .action(async (options) => {
2669
+ await authenticate(options);
2670
+ const listSkills = httpsCallable(functions, "listSkillLibrary");
2671
+ try {
2672
+ const result = await listSkills({});
2673
+ const skills = result.data.skills;
2674
+ if (skills.length === 0) {
2675
+ console.log("Skill library is empty. Seed it with: qatools skill library add -f <file.md>");
2676
+ process.exit(0);
2677
+ }
2678
+ console.log(`\nSkill Library (${skills.length} skills):\n`);
2679
+ for (const s of skills) {
2680
+ console.log(` ${s.name.padEnd(30)} v${s.version} ${s.title}`);
2681
+ if (s.description) console.log(` ${''.padEnd(30)} ${s.description}`);
2682
+ }
2683
+ console.log("");
2684
+ } catch (e) {
2685
+ console.error("Failed:", e.message);
2686
+ process.exit(1);
2687
+ }
2688
+ process.exit(0);
2689
+ });
2690
+
2691
+ libraryCmd
2692
+ .command("show")
2693
+ .description("Show the full content of a skill from the library")
2694
+ .requiredOption("-n, --name <name>", "Skill name (e.g. qa-purpose)")
2695
+ .option("-o, --output <path>", "Save to file instead of stdout")
2696
+ .option("-e, --email <email>", "Login email")
2697
+ .option("-p, --password <password>", "Login password")
2698
+ .action(async (options) => {
2699
+ await authenticate(options);
2700
+ const getSkill = httpsCallable(functions, "getLibrarySkill");
2701
+ try {
2702
+ const result = await getSkill({ name: options.name });
2703
+ const skill = result.data.skill;
2704
+ if (!skill) { console.error(`Skill '${options.name}' not found in library.`); process.exit(1); }
2705
+ if (options.output) {
2706
+ fs.writeFileSync(path.resolve(options.output), skill.content);
2707
+ console.log(`Saved to ${options.output}`);
2708
+ } else {
2709
+ console.log(skill.content);
2710
+ }
2711
+ } catch (e) {
2712
+ console.error("Failed:", e.message);
2713
+ process.exit(1);
2714
+ }
2715
+ process.exit(0);
2716
+ });
2717
+
2718
+ libraryCmd
2719
+ .command("add")
2720
+ .description("Add or update a skill in the global library from a .md file")
2721
+ .requiredOption("-f, --file <path>", "Path to .md file")
2722
+ .option("-n, --name <name>", "Skill name (default: filename without .md)")
2723
+ .option("-d, --description <text>", "Short description")
2724
+ .option("-e, --email <email>", "Login email")
2725
+ .option("-p, --password <password>", "Login password")
2726
+ .action(async (options) => {
2727
+ await authenticate(options);
2728
+ const filePath = path.resolve(options.file);
2729
+ if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); }
2730
+ const content = fs.readFileSync(filePath, "utf8");
2731
+ const name = options.name || path.basename(filePath, ".md");
2732
+ // Extract title from frontmatter or first heading
2733
+ let title = name;
2734
+ const descMatch = content.match(/^description:\s*(.+)$/m);
2735
+ if (descMatch) title = descMatch[1].trim();
2736
+ else {
2737
+ const headingMatch = content.match(/^#\s+(.+)$/m);
2738
+ if (headingMatch) title = headingMatch[1].trim();
2739
+ }
2740
+ const saveSkill = httpsCallable(functions, "saveLibrarySkill");
2741
+ try {
2742
+ const result = await saveSkill({ name, title, description: options.description || "", content });
2743
+ console.log(`Skill '${name}' saved to library (v${result.data.version})`);
2744
+ } catch (e) {
2745
+ console.error("Failed:", e.message);
2746
+ process.exit(1);
2747
+ }
2748
+ process.exit(0);
2749
+ });
2750
+
2751
+ libraryCmd
2752
+ .command("remove")
2753
+ .description("Remove a skill from the global library")
2754
+ .requiredOption("-n, --name <name>", "Skill name to remove")
2755
+ .option("-e, --email <email>", "Login email")
2756
+ .option("-p, --password <password>", "Login password")
2757
+ .action(async (options) => {
2758
+ await authenticate(options);
2759
+ const deleteSkill = httpsCallable(functions, "deleteLibrarySkill");
2760
+ try {
2761
+ await deleteSkill({ name: options.name });
2762
+ console.log(`Removed '${options.name}' from library.`);
2763
+ } catch (e) {
2764
+ console.error("Failed:", e.message);
2765
+ process.exit(1);
2766
+ }
2767
+ process.exit(0);
2768
+ });
2769
+
2770
+ // --- Per-project skill subcommands ---
2771
+
2772
+ skillCmd
2773
+ .command("list")
2774
+ .description("List skills: global library + what this project has selected")
2775
+ .option("--project <id>", "Target project ID")
2776
+ .option("-e, --email <email>", "Login email")
2777
+ .option("-p, --password <password>", "Login password")
2778
+ .action(async (options) => {
2779
+ await authenticate(options);
2780
+ // 1. Show global library
2781
+ try {
2782
+ const listSkills = httpsCallable(functions, "listSkillLibrary");
2783
+ const libResult = await listSkills({});
2784
+ const libSkills = libResult.data.skills;
2785
+ if (libSkills.length) {
2786
+ console.log(`\nGlobal Skill Library (${libSkills.length}):\n`);
2787
+ for (const s of libSkills) console.log(` ${s.name.padEnd(30)} v${s.version} ${s.title}`);
2788
+ } else {
2789
+ console.log("\nGlobal Skill Library: empty. Seed with: qatools skill library add -f <file.md>");
2790
+ }
2791
+ } catch (e) {
2792
+ console.log("\nGlobal Skill Library: could not load (" + e.message + ")");
2793
+ }
2794
+ // 2. Show project skills
2795
+ try {
2796
+ const creds = getCredentials();
2797
+ const projectId = options.project || creds?.projectId;
2798
+ if (projectId) {
2799
+ const getCtx = httpsCallable(functions, "getAppContext");
2800
+ const result = await getCtx({ projectId });
2801
+ const custom = result.data.appContext?.skills || [];
2802
+ if (custom.length) {
2803
+ console.log(`\nProject skills (${custom.length}):\n`);
2804
+ for (const s of custom) console.log(` ${(s.filename || '').padEnd(30)} ${s.title || ''}`);
2805
+ } else {
2806
+ console.log(`\nProject skills: none. Add from library: qatools skill add <name>`);
2807
+ }
2808
+ }
2809
+ } catch (e) {
2810
+ // best effort
2811
+ }
2812
+ console.log("");
2813
+ process.exit(0);
2814
+ });
2815
+
2816
+ skillCmd
2817
+ .command("add")
2818
+ .description("Add a skill to the project — from the global library (by name) or from a local .md file")
2819
+ .option("-n, --name <name>", "Skill name from the global library (e.g. qa-purpose)")
2820
+ .option("-f, --file <path>", "Path to a local .md skill file")
2821
+ .option("--project <id>", "Target project ID")
2822
+ .option("-e, --email <email>", "Login email")
2823
+ .option("-p, --password <password>", "Login password")
2824
+ .action(async (options) => {
2825
+ if (!options.name && !options.file) { console.error("Provide --name <library-skill> or --file <path>"); process.exit(1); }
2826
+ await authenticate(options);
2827
+ const creds = getCredentials();
2828
+ const projectId = options.project || creds?.projectId;
2829
+ if (!projectId) { console.error("No project configured."); process.exit(1); }
2830
+
2831
+ let filename, title, content;
2832
+
2833
+ if (options.name) {
2834
+ // Fetch from global library
2835
+ const getSkill = httpsCallable(functions, "getLibrarySkill");
2836
+ const skillResult = await getSkill({ name: options.name });
2837
+ const skill = skillResult.data.skill;
2838
+ if (!skill) { console.error(`Skill '${options.name}' not found in the global library. Run 'qatools skill library list' to see available skills.`); process.exit(1); }
2839
+ filename = skill.name;
2840
+ title = skill.title;
2841
+ content = skill.content;
2842
+ console.log(`Fetched '${filename}' v${skill.version} from global library.`);
2843
+ } else {
2844
+ // From local file
2845
+ const filePath = path.resolve(options.file);
2846
+ if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); }
2847
+ content = fs.readFileSync(filePath, "utf8");
2848
+ filename = path.basename(filePath, ".md");
2849
+ title = filename;
2850
+ const descMatch = content.match(/^description:\s*(.+)$/m);
2851
+ if (descMatch) title = descMatch[1].trim();
2852
+ else {
2853
+ const headingMatch = content.match(/^#\s+(.+)$/m);
2854
+ if (headingMatch) title = headingMatch[1].trim();
2855
+ }
2856
+ }
2857
+
2858
+ // Load context, add to skills[], save
2859
+ const getCtx = httpsCallable(functions, "getAppContext");
2860
+ const result = await getCtx({ projectId });
2861
+ const ctx = result.data.appContext || {};
2862
+ const skills = ctx.skills || [];
2863
+ const existing = skills.findIndex(s => s.filename === filename);
2864
+ if (existing >= 0) {
2865
+ skills[existing] = { filename, title, content };
2866
+ console.log(`Updated existing project skill: ${filename}`);
2867
+ } else {
2868
+ skills.push({ filename, title, content });
2869
+ console.log(`Added to project: ${filename}`);
2870
+ }
2871
+ ctx.skills = skills;
2872
+ const saveCtx = httpsCallable(functions, "saveAppContext");
2873
+ await saveCtx({ projectId, context: ctx });
2874
+ console.log(`Saved. Deploy with: qatools skill deploy`);
2875
+ process.exit(0);
2876
+ });
2877
+
2878
+ skillCmd
2879
+ .command("remove")
2880
+ .description("Remove a custom skill from the project's App Context")
2881
+ .requiredOption("-n, --name <filename>", "Skill filename (without .md)")
2882
+ .option("--project <id>", "Target project ID")
2883
+ .option("-e, --email <email>", "Login email")
2884
+ .option("-p, --password <password>", "Login password")
2885
+ .action(async (options) => {
2886
+ await authenticate(options);
2887
+ const creds = getCredentials();
2888
+ const projectId = options.project || creds?.projectId;
2889
+ if (!projectId) { console.error("No project configured."); process.exit(1); }
2890
+ const getCtx = httpsCallable(functions, "getAppContext");
2891
+ const result = await getCtx({ projectId });
2892
+ const ctx = result.data.appContext;
2893
+ if (!ctx) { console.error("No App Context for this project."); process.exit(1); }
2894
+ const skills = ctx.skills || [];
2895
+ const idx = skills.findIndex(s => s.filename === options.name || s.filename === options.name + '.md');
2896
+ if (idx < 0) { console.error(`Skill "${options.name}" not found in project skills.`); process.exit(1); }
2897
+ const removed = skills.splice(idx, 1)[0];
2898
+ ctx.skills = skills;
2899
+ const saveCtx = httpsCallable(functions, "saveAppContext");
2900
+ await saveCtx({ projectId, context: ctx });
2901
+ console.log(`Removed: ${removed.filename} ("${removed.title}")`);
2902
+ console.log(`Deploy with: qatools skill deploy`);
2903
+ process.exit(0);
2904
+ });
2905
+
2906
+ skillCmd
2907
+ .command("deploy")
2908
+ .description("Write all skills (built-in + project custom) to .claude/skills/ in the current directory")
2909
+ .option("--project <id>", "Target project ID")
2910
+ .option("-e, --email <email>", "Login email")
2911
+ .option("-p, --password <password>", "Login password")
2912
+ .action(async (options) => {
2913
+ const cwd = process.cwd();
2914
+ const skillsDir = path.join(cwd, ".claude", "skills");
2915
+ fs.mkdirSync(skillsDir, { recursive: true });
2916
+ // 1. Deploy built-in skills from init (run init logic for skills only)
2917
+ // We re-use the init action's skill writing. For now, tell user to run init for built-ins.
2918
+ console.log("Deploying skills to .claude/skills/\n");
2919
+ // 2. Deploy project custom skills from App Context
2920
+ let customCount = 0;
2921
+ try {
2922
+ await authenticate(options);
2923
+ const creds = getCredentials();
2924
+ const projectId = options.project || creds?.projectId;
2925
+ if (projectId) {
2926
+ const getCtx = httpsCallable(functions, "getAppContext");
2927
+ const result = await getCtx({ projectId });
2928
+ const ctx = result.data.appContext;
2929
+ if (ctx?.skills?.length) {
2930
+ for (const skill of ctx.skills) {
2931
+ const fname = skill.filename.endsWith('.md') ? skill.filename : skill.filename + '.md';
2932
+ const content = `---\ndescription: ${skill.title}\n---\n\n# ${skill.title}\n\n${skill.content}\n`;
2933
+ fs.writeFileSync(path.join(skillsDir, fname), content);
2934
+ console.log(` ✓ ${fname} (project custom)`);
2935
+ customCount++;
2936
+ }
2937
+ }
2938
+ // Also deploy app-context skill
2939
+ if (ctx) {
2940
+ let ctxSkill = `---\ndescription: App-specific context for ${ctx.name}. Auto-generated from QA-Tools App Context.\n---\n\n# ${ctx.name}\n\n`;
2941
+ if (ctx.purpose) ctxSkill += `## Purpose\n${ctx.purpose}\n\n`;
2942
+ if (ctx.domainObjects?.length) ctxSkill += `## Domain Objects\n${ctx.domainObjects.map(d => '- ' + d).join('\n')}\n\n`;
2943
+ if (ctx.actors?.length) ctxSkill += `## Actors\n${ctx.actors.map(a => '- ' + a).join('\n')}\n\n`;
2944
+ 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`;
2945
+ if (ctx.architecture) ctxSkill += `## Architecture\n${ctx.architecture}\n\n`;
2946
+ if (ctx.security?.length) ctxSkill += `## Security\n${ctx.security.map(s => '- ' + s).join('\n')}\n\n`;
2947
+ if (ctx.aiRules?.length) ctxSkill += `## AI Rules\n${ctx.aiRules.map(r => '- ' + r).join('\n')}\n\n`;
2948
+ if (ctx.conventions?.length) ctxSkill += `## Design Principles\n${ctx.conventions.map(c => '- **' + c.area + ':** ' + c.rule).join('\n')}\n\n`;
2949
+ if (ctx.constraints?.length) ctxSkill += `## Constraints\n${ctx.constraints.map(c => '- ' + c).join('\n')}\n\n`;
2950
+ fs.writeFileSync(path.join(skillsDir, "app-context.md"), ctxSkill);
2951
+ console.log(` ✓ app-context.md (from project)`);
2952
+ }
2953
+ }
2954
+ } catch (e) {
2955
+ console.error(` Note: could not load project context (${e.message}). Only local files written.`);
2956
+ }
2957
+ console.log(`\nDone. ${customCount} project skill(s) deployed.`);
2958
+ console.log(`Built-in skills are deployed by 'qatools init'. Run init if they're missing.`);
2404
2959
  process.exit(0);
2405
2960
  });
2406
2961