qatools-cli 1.1.5 → 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.
- package/package.json +1 -1
- package/qatools.js +716 -45
package/package.json
CHANGED
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")
|
|
@@ -684,6 +717,7 @@ program
|
|
|
684
717
|
.option("--project <id>", "Target project ID (overrides default)")
|
|
685
718
|
.option("-o, --output <path>", "Path to file to update (auto: .claude/CLAUDE.md if it exists, else CLAUDE.md)")
|
|
686
719
|
.option("--check", "Exit 1 if file is out of sync (does not modify)")
|
|
720
|
+
.option("--dry-run", "Show what would change without writing the file")
|
|
687
721
|
.option("-e, --email <email>", "Login email")
|
|
688
722
|
.option("-p, --password <password>", "Login password")
|
|
689
723
|
.action(async (options) => {
|
|
@@ -698,9 +732,11 @@ program
|
|
|
698
732
|
|
|
699
733
|
const getCtx = httpsCallable(functions, "getAppContext");
|
|
700
734
|
let ctx;
|
|
735
|
+
let systemAiRules = [];
|
|
701
736
|
try {
|
|
702
737
|
const result = await getCtx({ projectId });
|
|
703
738
|
ctx = result.data.appContext;
|
|
739
|
+
systemAiRules = result.data.systemAiRules || [];
|
|
704
740
|
} catch (e) {
|
|
705
741
|
console.error("Failed to fetch App Context:", e.message);
|
|
706
742
|
process.exit(1);
|
|
@@ -710,7 +746,7 @@ program
|
|
|
710
746
|
process.exit(1);
|
|
711
747
|
}
|
|
712
748
|
|
|
713
|
-
const md = renderAppContextMarkdown(ctx, projectId);
|
|
749
|
+
const md = renderAppContextMarkdown(ctx, projectId, systemAiRules);
|
|
714
750
|
const beginMarker = "<!-- BEGIN qatools-context (auto-synced from qa-tools, do not edit by hand) -->";
|
|
715
751
|
const endMarker = "<!-- END qatools-context -->";
|
|
716
752
|
const block = `${beginMarker}\n${md}\n${endMarker}`;
|
|
@@ -739,6 +775,49 @@ program
|
|
|
739
775
|
}
|
|
740
776
|
}
|
|
741
777
|
|
|
778
|
+
if (options.dryRun) {
|
|
779
|
+
if (existing === updated) {
|
|
780
|
+
console.log(`✓ ${options.output} already in sync — nothing would change.`);
|
|
781
|
+
process.exit(0);
|
|
782
|
+
}
|
|
783
|
+
const beginIdxNow = existing.indexOf(beginMarker);
|
|
784
|
+
if (beginIdxNow < 0) {
|
|
785
|
+
console.log(`Would CREATE qatools-context block in ${options.output}:`);
|
|
786
|
+
console.log(` + ${md.split('\n').length} lines (${md.length} chars)`);
|
|
787
|
+
console.log(` Block markers: ${beginMarker} ... ${endMarker}`);
|
|
788
|
+
console.log(``);
|
|
789
|
+
console.log(`First 10 lines of new content:`);
|
|
790
|
+
md.split('\n').slice(0, 10).forEach(l => console.log(` + ${l}`));
|
|
791
|
+
process.exit(0);
|
|
792
|
+
}
|
|
793
|
+
// Compute a simple line-level diff between old block and new block
|
|
794
|
+
const oldEnd = existing.indexOf(endMarker);
|
|
795
|
+
const oldBlockLines = existing.slice(beginIdxNow, oldEnd + endMarker.length).split('\n');
|
|
796
|
+
const newBlockLines = block.split('\n');
|
|
797
|
+
console.log(`Would UPDATE qatools-context block in ${options.output}:`);
|
|
798
|
+
console.log(` Old: ${oldBlockLines.length} lines`);
|
|
799
|
+
console.log(` New: ${newBlockLines.length} lines`);
|
|
800
|
+
console.log(``);
|
|
801
|
+
// Show added/removed lines (simple set diff — ignores reordering)
|
|
802
|
+
const oldSet = new Set(oldBlockLines.map(l => l.trim()).filter(Boolean));
|
|
803
|
+
const newSet = new Set(newBlockLines.map(l => l.trim()).filter(Boolean));
|
|
804
|
+
const added = [...newSet].filter(l => !oldSet.has(l));
|
|
805
|
+
const removed = [...oldSet].filter(l => !newSet.has(l));
|
|
806
|
+
if (removed.length) {
|
|
807
|
+
console.log(` ${removed.length} line(s) removed:`);
|
|
808
|
+
removed.slice(0, 15).forEach(l => console.log(` - ${l.substring(0, 100)}`));
|
|
809
|
+
if (removed.length > 15) console.log(` ... and ${removed.length - 15} more`);
|
|
810
|
+
}
|
|
811
|
+
if (added.length) {
|
|
812
|
+
console.log(` ${added.length} line(s) added:`);
|
|
813
|
+
added.slice(0, 15).forEach(l => console.log(` + ${l.substring(0, 100)}`));
|
|
814
|
+
if (added.length > 15) console.log(` ... and ${added.length - 15} more`);
|
|
815
|
+
}
|
|
816
|
+
console.log(``);
|
|
817
|
+
console.log(`Run without --dry-run to write the changes.`);
|
|
818
|
+
process.exit(0);
|
|
819
|
+
}
|
|
820
|
+
|
|
742
821
|
if (existing === updated) {
|
|
743
822
|
console.log(`✓ ${options.output} already in sync (no changes)`);
|
|
744
823
|
} else {
|
|
@@ -748,13 +827,21 @@ program
|
|
|
748
827
|
process.exit(0);
|
|
749
828
|
});
|
|
750
829
|
|
|
751
|
-
function renderAppContextMarkdown(ctx, projectId) {
|
|
830
|
+
function renderAppContextMarkdown(ctx, projectId, systemAiRules) {
|
|
752
831
|
const lines = [];
|
|
753
832
|
const ts = new Date().toISOString();
|
|
754
833
|
lines.push(`<!-- Last synced: ${ts} from project ${projectId} -->`);
|
|
755
834
|
lines.push("");
|
|
756
835
|
lines.push(`## ${ctx.name || "Project Context"}`);
|
|
757
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
|
+
}
|
|
758
845
|
if (ctx.purpose) { lines.push("### Purpose"); lines.push(ctx.purpose); lines.push(""); }
|
|
759
846
|
if (ctx.domainObjects?.length) {
|
|
760
847
|
lines.push("### Domain Objects");
|
|
@@ -1025,6 +1112,54 @@ program
|
|
|
1025
1112
|
process.exit(0);
|
|
1026
1113
|
});
|
|
1027
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
|
+
|
|
1028
1163
|
program
|
|
1029
1164
|
.command("template-upload")
|
|
1030
1165
|
.description("Upload a test template JSON to the project")
|
|
@@ -1327,8 +1462,8 @@ program
|
|
|
1327
1462
|
|
|
1328
1463
|
program
|
|
1329
1464
|
.command("context")
|
|
1330
|
-
.description("Show App Context for the project")
|
|
1331
|
-
.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)")
|
|
1332
1467
|
.option("--project <id>", "Target project ID (overrides default; requires access to that project)")
|
|
1333
1468
|
.option("-e, --email <email>", "Login email")
|
|
1334
1469
|
.option("-p, --password <password>", "Login password")
|
|
@@ -1340,10 +1475,16 @@ program
|
|
|
1340
1475
|
const getCtx = httpsCallable(functions, "getAppContext");
|
|
1341
1476
|
const result = await getCtx({ projectId });
|
|
1342
1477
|
const ctx = result.data.appContext;
|
|
1343
|
-
|
|
1478
|
+
const systemRules = result.data.systemAiRules || [];
|
|
1344
1479
|
if (options.output) {
|
|
1345
|
-
fs.writeFileSync(path.resolve(options.output), JSON.stringify(ctx, null, 2));
|
|
1346
|
-
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.");
|
|
1347
1488
|
} else {
|
|
1348
1489
|
console.log(`\n${ctx.name}`);
|
|
1349
1490
|
if (ctx.description) console.log(ctx.description);
|
|
@@ -1354,14 +1495,87 @@ program
|
|
|
1354
1495
|
if (ctx.architecture) console.log(`\nArchitecture: ${ctx.architecture}`);
|
|
1355
1496
|
if (ctx.constraints?.length) { console.log("\nConstraints:"); ctx.constraints.forEach(c => console.log(` - ${c}`)); }
|
|
1356
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}`)); }
|
|
1357
1499
|
if (ctx.sections?.length) { ctx.sections.forEach(s => console.log(`\n${s.title}:\n ${s.content}`)); }
|
|
1358
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
|
+
}
|
|
1508
|
+
process.exit(0);
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
program
|
|
1512
|
+
.command("context-template")
|
|
1513
|
+
.description("Print an empty App Context JSON template with all fields and example values. Pipe to a file, edit, then context-set.")
|
|
1514
|
+
.option("-o, --output <path>", "Write to file instead of stdout")
|
|
1515
|
+
.action((options) => {
|
|
1516
|
+
const template = {
|
|
1517
|
+
name: "Your project name",
|
|
1518
|
+
purpose: "One sentence: what does this app do and why does it exist?",
|
|
1519
|
+
domainObjects: [
|
|
1520
|
+
"User",
|
|
1521
|
+
"Order",
|
|
1522
|
+
"Product"
|
|
1523
|
+
],
|
|
1524
|
+
actors: [
|
|
1525
|
+
"End user",
|
|
1526
|
+
"Admin",
|
|
1527
|
+
"External system"
|
|
1528
|
+
],
|
|
1529
|
+
stack: [
|
|
1530
|
+
{ layer: "Frontend", technology: "React", version: "18", notes: "with TypeScript" },
|
|
1531
|
+
{ layer: "Backend", technology: "Node.js", version: "20", notes: "Express" },
|
|
1532
|
+
{ layer: "Database", technology: "PostgreSQL", version: "16", notes: "" }
|
|
1533
|
+
],
|
|
1534
|
+
architecture: "Short description of how the system is structured. Where does data flow? What's the main pattern?",
|
|
1535
|
+
security: [
|
|
1536
|
+
"All endpoints require authentication",
|
|
1537
|
+
"Data is isolated per tenant via tenantId column"
|
|
1538
|
+
],
|
|
1539
|
+
aiRules: [
|
|
1540
|
+
"Build from requirements — never from backlog items",
|
|
1541
|
+
"Evidence-first: cite specific code, test output, or error message",
|
|
1542
|
+
"Never guess — say 'I'm unsure' if evidence is missing",
|
|
1543
|
+
"Acceptance criteria in Given/When/Then on the project's language (sv: Givet/När/Då, en: Given/When/Then) — never mix"
|
|
1544
|
+
],
|
|
1545
|
+
constraints: [
|
|
1546
|
+
"Hard constraint #1 (e.g. must run offline)",
|
|
1547
|
+
"Hard constraint #2 (e.g. no third-party AI calls)"
|
|
1548
|
+
],
|
|
1549
|
+
conventions: [
|
|
1550
|
+
{ area: "Naming", rule: "camelCase for variables, PascalCase for components" },
|
|
1551
|
+
{ area: "Errors", rule: "Always throw with a Result type, never silent fail" }
|
|
1552
|
+
],
|
|
1553
|
+
environments: [
|
|
1554
|
+
{ name: "Production", url: "https://app.example.com", notes: "" },
|
|
1555
|
+
{ name: "Staging", url: "https://staging.example.com", notes: "" }
|
|
1556
|
+
],
|
|
1557
|
+
integrations: [
|
|
1558
|
+
{ name: "Stripe", purpose: "Payments" },
|
|
1559
|
+
{ name: "SendGrid", purpose: "Transactional email" }
|
|
1560
|
+
],
|
|
1561
|
+
sections: [
|
|
1562
|
+
{ title: "Custom section title", content: "Free-text content for anything that doesn't fit the structured fields above. Use this for project-specific docs, runbooks, glossaries, etc." }
|
|
1563
|
+
]
|
|
1564
|
+
};
|
|
1565
|
+
const json = JSON.stringify(template, null, 2);
|
|
1566
|
+
if (options.output) {
|
|
1567
|
+
fs.writeFileSync(path.resolve(options.output), json);
|
|
1568
|
+
console.error(`Template written to ${options.output}`);
|
|
1569
|
+
console.error(`Edit it, then upload with: qatools context-set -f ${options.output}`);
|
|
1570
|
+
} else {
|
|
1571
|
+
console.log(json);
|
|
1572
|
+
}
|
|
1359
1573
|
process.exit(0);
|
|
1360
1574
|
});
|
|
1361
1575
|
|
|
1362
1576
|
program
|
|
1363
1577
|
.command("context-set")
|
|
1364
|
-
.description("Upload App Context from JSON file")
|
|
1578
|
+
.description("Upload App Context from JSON file. Run 'qatools context-template' to see the expected schema.")
|
|
1365
1579
|
.requiredOption("-f, --file <path>", "Path to App Context JSON file")
|
|
1366
1580
|
.option("--project <id>", "Target project ID (overrides default; requires access to that project)")
|
|
1367
1581
|
.option("-e, --email <email>", "Login email")
|
|
@@ -1375,6 +1589,13 @@ program
|
|
|
1375
1589
|
const saveCtx = httpsCallable(functions, "saveAppContext");
|
|
1376
1590
|
const result = await saveCtx({ projectId, context: data });
|
|
1377
1591
|
console.log(`App Context saved (${result.data.id}) — project ${projectId}`);
|
|
1592
|
+
if (result.data.warnings && result.data.warnings.length > 0) {
|
|
1593
|
+
console.error(``);
|
|
1594
|
+
console.error(`⚠ Schema warnings (${result.data.warnings.length}):`);
|
|
1595
|
+
for (const w of result.data.warnings) console.error(` ${w}`);
|
|
1596
|
+
console.error(``);
|
|
1597
|
+
console.error(` These fields were saved but may be ignored. Run 'qatools context-template' to see the schema.`);
|
|
1598
|
+
}
|
|
1378
1599
|
process.exit(0);
|
|
1379
1600
|
});
|
|
1380
1601
|
|
|
@@ -1619,16 +1840,53 @@ program
|
|
|
1619
1840
|
|
|
1620
1841
|
program
|
|
1621
1842
|
.command("init")
|
|
1622
|
-
.description("
|
|
1843
|
+
.description("One command to set up everything: login + skills + sync + agenda. Just run: qatools init --api-key <key>")
|
|
1623
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")
|
|
1624
1847
|
.action(async (options) => {
|
|
1625
1848
|
const cwd = process.cwd();
|
|
1626
|
-
const apiKey = options.apiKey || getCredentials()?.apiKey || "";
|
|
1627
1849
|
|
|
1628
|
-
//
|
|
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...");
|
|
1629
1888
|
const skillsDir = path.join(cwd, ".claude", "skills");
|
|
1630
1889
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
1631
|
-
console.log("Created .claude/skills/");
|
|
1632
1890
|
|
|
1633
1891
|
// 2. Write skills files
|
|
1634
1892
|
const skills = {
|
|
@@ -1763,6 +2021,7 @@ qatools requirements
|
|
|
1763
2021
|
qatools requirements --status approved -o reqs.json
|
|
1764
2022
|
qatools requirement-keys
|
|
1765
2023
|
qatools requirement-create -f requirements.json
|
|
2024
|
+
qatools requirement-delete -k <key> # Delete a wrong requirement (refuses if linked; --force to override)
|
|
1766
2025
|
|
|
1767
2026
|
# Build & backlog
|
|
1768
2027
|
qatools backlog
|
|
@@ -1784,6 +2043,7 @@ qatools upload-analysis -s <id> -f analysis.json
|
|
|
1784
2043
|
# Sprints
|
|
1785
2044
|
qatools sprints
|
|
1786
2045
|
qatools sprint-items --sprint <id>
|
|
2046
|
+
qatools sprint-create --name "Release 3.2" --start 2026-05-01 --end 2026-05-28
|
|
1787
2047
|
\`\`\`
|
|
1788
2048
|
|
|
1789
2049
|
## Key rules
|
|
@@ -2020,6 +2280,9 @@ You manage sprints, backlog, and verify release readiness.
|
|
|
2020
2280
|
1. Review sprints:
|
|
2021
2281
|
\`qatools sprints\`
|
|
2022
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)
|
|
2023
2286
|
|
|
2024
2287
|
2. Break down requirements into stories/tasks:
|
|
2025
2288
|
\`qatools backlog-create -d "Implement login flow" -t story --requirement-key auth.login-trial\`
|
|
@@ -2167,7 +2430,8 @@ If the verification fails: do NOT mark tested. Go back to step 1, find the discr
|
|
|
2167
2430
|
console.log(" ✓ qa-evidence-first.md");
|
|
2168
2431
|
console.log(" ✓ qa-numerical-verification.md");
|
|
2169
2432
|
|
|
2170
|
-
// 4.
|
|
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).
|
|
2171
2435
|
try {
|
|
2172
2436
|
await authenticate(options);
|
|
2173
2437
|
const creds = getCredentials();
|
|
@@ -2176,22 +2440,49 @@ If the verification fails: do NOT mark tested. Go back to step 1, find the discr
|
|
|
2176
2440
|
const ctxResult = await getCtx({ projectId: creds.projectId });
|
|
2177
2441
|
const ctx = ctxResult.data.appContext;
|
|
2178
2442
|
if (ctx) {
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
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`;
|
|
2189
2480
|
fs.writeFileSync(path.join(skillsDir, "app-context.md"), ctxSkill);
|
|
2190
2481
|
console.log(" ✓ app-context.md (from project)");
|
|
2191
2482
|
|
|
2192
2483
|
// Generate custom skills from App Context
|
|
2193
|
-
if (
|
|
2194
|
-
for (const skill of
|
|
2484
|
+
if (ctxFinal.skills?.length) {
|
|
2485
|
+
for (const skill of ctxFinal.skills) {
|
|
2195
2486
|
const fname = skill.filename.endsWith('.md') ? skill.filename : skill.filename + '.md';
|
|
2196
2487
|
const content = `---\ndescription: ${skill.title}\n---\n\n# ${skill.title}\n\n${skill.content}\n`;
|
|
2197
2488
|
fs.writeFileSync(path.join(skillsDir, fname), content);
|
|
@@ -2228,6 +2519,7 @@ export QATOOLS_API_KEY=${apiKey || "<din-nyckel>"}
|
|
|
2228
2519
|
- \`qatools upload-analysis -s <id> -f analysis.json\` — upload analysis
|
|
2229
2520
|
- \`qatools requirement-create -f file.json\` — create requirements
|
|
2230
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)
|
|
2231
2523
|
- \`qatools template-upload -f file.json\` — upload test template
|
|
2232
2524
|
- \`qatools backlog-create -d "..." -t bug\` — create backlog item
|
|
2233
2525
|
- \`qatools backlog-update -i <id> --status resolved --evidence "..."\` — mark resolved (evidence required)
|
|
@@ -2250,41 +2542,420 @@ See \`.claude/skills/\` for role-specific instructions. Start with \`qa-purpose.
|
|
|
2250
2542
|
console.log("\n✓ Created CLAUDE.md with QA-Tools section");
|
|
2251
2543
|
}
|
|
2252
2544
|
|
|
2253
|
-
// 5
|
|
2254
|
-
|
|
2545
|
+
// Step 4/5: Sync App Context into CLAUDE.md
|
|
2546
|
+
console.log("\nStep 4/5: Syncing App Context into CLAUDE.md...");
|
|
2255
2547
|
try {
|
|
2256
2548
|
const creds = getCredentials();
|
|
2257
2549
|
if (creds?.projectId) {
|
|
2258
|
-
const
|
|
2259
|
-
const
|
|
2260
|
-
const
|
|
2261
|
-
|
|
2262
|
-
|
|
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
|
+
}
|
|
2581
|
+
} else {
|
|
2582
|
+
console.log(" ⚠ No App Context defined. Create one: qatools context-template -o ctx.json → edit → qatools context-set -f ctx.json");
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
} catch (e) {
|
|
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.");
|
|
2263
2612
|
} else {
|
|
2264
|
-
const
|
|
2265
|
-
|
|
2613
|
+
for (const s of sections) console.log(s);
|
|
2614
|
+
console.log(`\n ${data.totalCount} thing(s) on the agenda.`);
|
|
2266
2615
|
}
|
|
2267
2616
|
}
|
|
2268
2617
|
} catch (e) {
|
|
2269
|
-
|
|
2618
|
+
console.log(` Could not load agenda (${e.message}). Run: qatools next`);
|
|
2270
2619
|
}
|
|
2271
2620
|
|
|
2272
2621
|
console.log(`
|
|
2622
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2273
2623
|
🎉 QA-Tools initialized!
|
|
2274
2624
|
|
|
2275
2625
|
⚠ READ THIS FIRST: .claude/skills/qa-purpose.md
|
|
2276
2626
|
|
|
2277
|
-
qa-tools exists to let anyone build production-quality apps
|
|
2278
|
-
that follow their own rules — as if you built it
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
1. qatools context — read the project's rule book
|
|
2284
|
-
2. qatools epics — see what capabilities exist
|
|
2285
|
-
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
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2286
2633
|
`);
|
|
2287
|
-
|
|
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.`);
|
|
2288
2959
|
process.exit(0);
|
|
2289
2960
|
});
|
|
2290
2961
|
|