qatools-cli 1.1.6 → 1.1.8
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 +5 -1
- package/qatools.js +630 -44
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qatools-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "CLI for QA Tools — requirements, epics, sprints, sessions, App Context sync, project agenda. The companion to qa-tools.com.",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"release": "npm version patch --no-git-tag-version && npm publish",
|
|
7
|
+
"release:minor": "npm version minor --no-git-tag-version && npm publish"
|
|
8
|
+
},
|
|
5
9
|
"bin": {
|
|
6
10
|
"qatools": "./qatools.js"
|
|
7
11
|
},
|
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")
|
|
@@ -432,6 +465,7 @@ program
|
|
|
432
465
|
.option("--parent <label>", "Parent item label (e.g. BUG-5)")
|
|
433
466
|
.option("--requirement-key <key>", "Requirement key (e.g. auth.login)")
|
|
434
467
|
.option("--tags <tags>", "Tags (comma-separated)")
|
|
468
|
+
.option("--notify", "Enable email notifications on this item (default off)")
|
|
435
469
|
.option("-e, --email <email>", "Login email")
|
|
436
470
|
.option("-p, --password <password>", "Login password")
|
|
437
471
|
.action(async (options) => {
|
|
@@ -453,6 +487,7 @@ program
|
|
|
453
487
|
parentId: options.parent || "",
|
|
454
488
|
requirementKey: options.requirementKey || "",
|
|
455
489
|
tags: options.tags ? options.tags.split(",").map(t => t.trim().toLowerCase()) : [],
|
|
490
|
+
mailNotify: !!options.notify,
|
|
456
491
|
});
|
|
457
492
|
console.log(`Created: ${result.data.type.toUpperCase()}-${result.data.bugNumber} (${result.data.id})`);
|
|
458
493
|
if (options.tags) console.log(` Tags: ${options.tags}`);
|
|
@@ -463,6 +498,33 @@ program
|
|
|
463
498
|
process.exit(0);
|
|
464
499
|
});
|
|
465
500
|
|
|
501
|
+
program
|
|
502
|
+
.command("notify")
|
|
503
|
+
.description("Send a deliberate notification email to a project member (respects their opt-out)")
|
|
504
|
+
.requiredOption("--to <email>", "Recipient (must be a member of the project)")
|
|
505
|
+
.requiredOption("--subject <text>", "Email subject")
|
|
506
|
+
.requiredOption("--message <text>", "Email body (plain text)")
|
|
507
|
+
.option("--project <id>", "Target project ID (overrides default)")
|
|
508
|
+
.option("-e, --email <email>", "Login email")
|
|
509
|
+
.option("-p, --password <password>", "Login password")
|
|
510
|
+
.action(async (options) => {
|
|
511
|
+
await authenticate(options);
|
|
512
|
+
const creds = getCredentials();
|
|
513
|
+
const projectId = options.project || creds?.projectId;
|
|
514
|
+
if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
|
|
515
|
+
|
|
516
|
+
const notify = httpsCallable(functions, "notifyUser");
|
|
517
|
+
try {
|
|
518
|
+
const result = await notify({ projectId, to: options.to, subject: options.subject, message: options.message });
|
|
519
|
+
if (result.data.skipped) console.log(`Skipped ${options.to}: ${result.data.reason}`);
|
|
520
|
+
else console.log(`Sent to ${result.data.to}`);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
console.error("Failed:", e.message);
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
process.exit(0);
|
|
526
|
+
});
|
|
527
|
+
|
|
466
528
|
program
|
|
467
529
|
.command("templates")
|
|
468
530
|
.description("List templates and their review status")
|
|
@@ -699,9 +761,11 @@ program
|
|
|
699
761
|
|
|
700
762
|
const getCtx = httpsCallable(functions, "getAppContext");
|
|
701
763
|
let ctx;
|
|
764
|
+
let systemAiRules = [];
|
|
702
765
|
try {
|
|
703
766
|
const result = await getCtx({ projectId });
|
|
704
767
|
ctx = result.data.appContext;
|
|
768
|
+
systemAiRules = result.data.systemAiRules || [];
|
|
705
769
|
} catch (e) {
|
|
706
770
|
console.error("Failed to fetch App Context:", e.message);
|
|
707
771
|
process.exit(1);
|
|
@@ -711,7 +775,7 @@ program
|
|
|
711
775
|
process.exit(1);
|
|
712
776
|
}
|
|
713
777
|
|
|
714
|
-
const md = renderAppContextMarkdown(ctx, projectId);
|
|
778
|
+
const md = renderAppContextMarkdown(ctx, projectId, systemAiRules);
|
|
715
779
|
const beginMarker = "<!-- BEGIN qatools-context (auto-synced from qa-tools, do not edit by hand) -->";
|
|
716
780
|
const endMarker = "<!-- END qatools-context -->";
|
|
717
781
|
const block = `${beginMarker}\n${md}\n${endMarker}`;
|
|
@@ -792,13 +856,21 @@ program
|
|
|
792
856
|
process.exit(0);
|
|
793
857
|
});
|
|
794
858
|
|
|
795
|
-
function renderAppContextMarkdown(ctx, projectId) {
|
|
859
|
+
function renderAppContextMarkdown(ctx, projectId, systemAiRules) {
|
|
796
860
|
const lines = [];
|
|
797
861
|
const ts = new Date().toISOString();
|
|
798
862
|
lines.push(`<!-- Last synced: ${ts} from project ${projectId} -->`);
|
|
799
863
|
lines.push("");
|
|
800
864
|
lines.push(`## ${ctx.name || "Project Context"}`);
|
|
801
865
|
lines.push("");
|
|
866
|
+
// System rules first — they apply to every project, inherited from qa-tools itself
|
|
867
|
+
if (systemAiRules && systemAiRules.length) {
|
|
868
|
+
lines.push("### qa-tools System Rules (apply to every project)");
|
|
869
|
+
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.");
|
|
870
|
+
lines.push("");
|
|
871
|
+
systemAiRules.forEach((r, i) => lines.push(`${i + 1}. ${r}`));
|
|
872
|
+
lines.push("");
|
|
873
|
+
}
|
|
802
874
|
if (ctx.purpose) { lines.push("### Purpose"); lines.push(ctx.purpose); lines.push(""); }
|
|
803
875
|
if (ctx.domainObjects?.length) {
|
|
804
876
|
lines.push("### Domain Objects");
|
|
@@ -1069,6 +1141,54 @@ program
|
|
|
1069
1141
|
process.exit(0);
|
|
1070
1142
|
});
|
|
1071
1143
|
|
|
1144
|
+
program
|
|
1145
|
+
.command("requirement-delete")
|
|
1146
|
+
.description("Delete a requirement by id or key. Refuses if backlog/tests reference it — use --force to delete anyway.")
|
|
1147
|
+
.option("-i, --id <id>", "Requirement document ID to delete")
|
|
1148
|
+
.option("-k, --key <key>", "Requirement semantic key to delete (resolved to its id)")
|
|
1149
|
+
.option("--force", "Delete even if backlog items or test cases reference the requirement's key (leaves them dangling, not cascade-deleted)")
|
|
1150
|
+
.option("--project <id>", "Target project ID (overrides default)")
|
|
1151
|
+
.option("-e, --email <email>", "Login email")
|
|
1152
|
+
.option("-p, --password <password>", "Login password")
|
|
1153
|
+
.action(async (options) => {
|
|
1154
|
+
await authenticate(options);
|
|
1155
|
+
const creds = getCredentials();
|
|
1156
|
+
const projectId = options.project || creds?.projectId;
|
|
1157
|
+
if (!projectId) { console.error("No project configured. Run: qatools setup, or pass --project <id>"); process.exit(1); }
|
|
1158
|
+
if (!options.id && !options.key) { console.error("Provide --id or --key"); process.exit(1); }
|
|
1159
|
+
|
|
1160
|
+
let requirementId = options.id;
|
|
1161
|
+
if (!requirementId) {
|
|
1162
|
+
const listReqs = httpsCallable(functions, "listRequirements");
|
|
1163
|
+
const res = await listReqs({ projectId });
|
|
1164
|
+
const matches = res.data.requirements.filter(r => r.key === options.key);
|
|
1165
|
+
if (matches.length === 0) { console.error(`Requirement with key '${options.key}' not found.`); process.exit(1); }
|
|
1166
|
+
if (matches.length > 1) {
|
|
1167
|
+
console.error(`Key '${options.key}' has ${matches.length} versions — ambiguous. Delete by --id instead:`);
|
|
1168
|
+
for (const m of matches) console.error(` --id ${m.id} v${m.version ?? "?"} [${m.status}] ${m.title || ""}`);
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
requirementId = matches[0].id;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const deleteReq = httpsCallable(functions, "deleteRequirement");
|
|
1175
|
+
try {
|
|
1176
|
+
const res = await deleteReq({ projectId, requirementId, force: !!options.force });
|
|
1177
|
+
const d = res.data;
|
|
1178
|
+
console.log(`Deleted requirement${d.key ? ` ${d.key}` : ""}${d.deletedTitle ? `: ${d.deletedTitle}` : ""}`);
|
|
1179
|
+
if (d.orphanedBacklog || d.orphanedTests) {
|
|
1180
|
+
const parts = [];
|
|
1181
|
+
if (d.orphanedBacklog) parts.push(`${d.orphanedBacklog} backlog item(s)`);
|
|
1182
|
+
if (d.orphanedTests) parts.push(`${d.orphanedTests} test case(s)`);
|
|
1183
|
+
console.log(` ⚠ ${parts.join(" and ")} now have a dangling requirementKey — re-point or clear them.`);
|
|
1184
|
+
}
|
|
1185
|
+
} catch (e) {
|
|
1186
|
+
console.error("Failed:", e.message);
|
|
1187
|
+
process.exit(1);
|
|
1188
|
+
}
|
|
1189
|
+
process.exit(0);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1072
1192
|
program
|
|
1073
1193
|
.command("template-upload")
|
|
1074
1194
|
.description("Upload a test template JSON to the project")
|
|
@@ -1199,6 +1319,7 @@ program
|
|
|
1199
1319
|
.option("--requirement-key <key>", "Set requirement key")
|
|
1200
1320
|
.option("--tags <tags>", "Set tags (comma-separated, replaces existing)")
|
|
1201
1321
|
.option("--description <text>", "Update description")
|
|
1322
|
+
.option("--notify <on|off>", "Turn item email notifications on/off")
|
|
1202
1323
|
.option("--author <name>", "Author name for comments (default: your email)", "Claude")
|
|
1203
1324
|
.option("--skip-test-verification", "Allow setting status=resolved without going through ready_for_test (use only when no test applies). Evidence is still required.")
|
|
1204
1325
|
.option("--project <id>", "Target project ID (overrides default; needed for status pre-check)")
|
|
@@ -1226,6 +1347,7 @@ program
|
|
|
1226
1347
|
if (options.requirementKey) u.requirementKey = options.requirementKey;
|
|
1227
1348
|
if (options.tags) u.tags = options.tags.split(",").map(t => t.trim().toLowerCase());
|
|
1228
1349
|
if (options.description) u.description = options.description;
|
|
1350
|
+
if (options.notify) u.mailNotify = options.notify === 'on';
|
|
1229
1351
|
updates[options.id] = u;
|
|
1230
1352
|
} else {
|
|
1231
1353
|
console.error("Provide --file or --id");
|
|
@@ -1371,8 +1493,8 @@ program
|
|
|
1371
1493
|
|
|
1372
1494
|
program
|
|
1373
1495
|
.command("context")
|
|
1374
|
-
.description("Show App Context for the project")
|
|
1375
|
-
.option("-o, --output <path>", "Save to JSON file")
|
|
1496
|
+
.description("Show App Context for the project, including system AI rules that apply to every qa-tools project")
|
|
1497
|
+
.option("-o, --output <path>", "Save to JSON file (project context only — system rules are inherited at runtime)")
|
|
1376
1498
|
.option("--project <id>", "Target project ID (overrides default; requires access to that project)")
|
|
1377
1499
|
.option("-e, --email <email>", "Login email")
|
|
1378
1500
|
.option("-p, --password <password>", "Login password")
|
|
@@ -1384,10 +1506,16 @@ program
|
|
|
1384
1506
|
const getCtx = httpsCallable(functions, "getAppContext");
|
|
1385
1507
|
const result = await getCtx({ projectId });
|
|
1386
1508
|
const ctx = result.data.appContext;
|
|
1387
|
-
|
|
1509
|
+
const systemRules = result.data.systemAiRules || [];
|
|
1388
1510
|
if (options.output) {
|
|
1389
|
-
fs.writeFileSync(path.resolve(options.output), JSON.stringify(ctx, null, 2));
|
|
1390
|
-
console.log(`Saved to ${options.output}`);
|
|
1511
|
+
fs.writeFileSync(path.resolve(options.output), JSON.stringify(ctx || {}, null, 2));
|
|
1512
|
+
console.log(`Saved project context to ${options.output}`);
|
|
1513
|
+
if (systemRules.length) console.log(`Note: ${systemRules.length} system AI rules are inherited at runtime, not saved to file.`);
|
|
1514
|
+
process.exit(0);
|
|
1515
|
+
}
|
|
1516
|
+
if (!ctx) {
|
|
1517
|
+
console.log("No App Context defined for this project.");
|
|
1518
|
+
console.log("Run 'qatools context-template -o ctx.json' to start, then 'qatools context-set -f ctx.json' to upload.");
|
|
1391
1519
|
} else {
|
|
1392
1520
|
console.log(`\n${ctx.name}`);
|
|
1393
1521
|
if (ctx.description) console.log(ctx.description);
|
|
@@ -1398,8 +1526,16 @@ program
|
|
|
1398
1526
|
if (ctx.architecture) console.log(`\nArchitecture: ${ctx.architecture}`);
|
|
1399
1527
|
if (ctx.constraints?.length) { console.log("\nConstraints:"); ctx.constraints.forEach(c => console.log(` - ${c}`)); }
|
|
1400
1528
|
if (ctx.conventions?.length) { console.log("\nConventions:"); ctx.conventions.forEach(c => console.log(` - ${c.area}: ${c.rule}`)); }
|
|
1529
|
+
if (ctx.aiRules?.length) { console.log("\nProject AI Rules:"); ctx.aiRules.forEach((r, i) => console.log(` ${i + 1}. ${r}`)); }
|
|
1401
1530
|
if (ctx.sections?.length) { ctx.sections.forEach(s => console.log(`\n${s.title}:\n ${s.content}`)); }
|
|
1402
1531
|
}
|
|
1532
|
+
if (systemRules.length) {
|
|
1533
|
+
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
1534
|
+
console.log(`qa-tools System Rules (apply to every project — inherited):`);
|
|
1535
|
+
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
1536
|
+
systemRules.forEach((r, i) => console.log(` ${i + 1}. ${r}`));
|
|
1537
|
+
console.log(``);
|
|
1538
|
+
}
|
|
1403
1539
|
process.exit(0);
|
|
1404
1540
|
});
|
|
1405
1541
|
|
|
@@ -1735,16 +1871,53 @@ program
|
|
|
1735
1871
|
|
|
1736
1872
|
program
|
|
1737
1873
|
.command("init")
|
|
1738
|
-
.description("
|
|
1874
|
+
.description("One command to set up everything: login + skills + sync + agenda. Just run: qatools init --api-key <key>")
|
|
1739
1875
|
.option("-k, --api-key <key>", "Claude API key (from QA-Tools → Members → Claude AI Integration)")
|
|
1876
|
+
.option("-e, --email <email>", "Login email")
|
|
1877
|
+
.option("-p, --password <password>", "Login password")
|
|
1740
1878
|
.action(async (options) => {
|
|
1741
1879
|
const cwd = process.cwd();
|
|
1742
|
-
const apiKey = options.apiKey || getCredentials()?.apiKey || "";
|
|
1743
1880
|
|
|
1744
|
-
//
|
|
1881
|
+
// Step 0: Login if api-key provided and not already logged in
|
|
1882
|
+
const existingCreds = getCredentials();
|
|
1883
|
+
const apiKey = options.apiKey || existingCreds?.apiKey || "";
|
|
1884
|
+
if (options.apiKey && (!existingCreds?.projectId || existingCreds?.apiKey !== options.apiKey)) {
|
|
1885
|
+
console.log("Step 1/5: Logging in with API key...");
|
|
1886
|
+
try {
|
|
1887
|
+
const authRes = await fetch("https://us-central1-qatools-app.cloudfunctions.net/authenticateWithApiKey", {
|
|
1888
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1889
|
+
body: JSON.stringify({ data: { apiKey: options.apiKey } }),
|
|
1890
|
+
});
|
|
1891
|
+
const authData = await authRes.json();
|
|
1892
|
+
if (authData.result?.customToken) {
|
|
1893
|
+
const { signInWithCustomToken } = require("firebase/auth");
|
|
1894
|
+
const { getAuth } = require("firebase/auth");
|
|
1895
|
+
await signInWithCustomToken(getAuth(), authData.result.customToken);
|
|
1896
|
+
const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".qatools");
|
|
1897
|
+
const config = { apiKey: options.apiKey, projectId: authData.result.projectId, projectName: authData.result.projectName, email: authData.result.email };
|
|
1898
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
1899
|
+
console.log(` ✓ Logged in: ${authData.result.projectName} (${authData.result.projectId})`);
|
|
1900
|
+
} else {
|
|
1901
|
+
console.error(" ✗ Invalid API key.");
|
|
1902
|
+
process.exit(1);
|
|
1903
|
+
}
|
|
1904
|
+
} catch (e) {
|
|
1905
|
+
console.error(" ✗ Login failed:", e.message);
|
|
1906
|
+
process.exit(1);
|
|
1907
|
+
}
|
|
1908
|
+
} else if (existingCreds?.projectId) {
|
|
1909
|
+
console.log(`Step 1/5: Already logged in: ${existingCreds.projectName || existingCreds.projectId}`);
|
|
1910
|
+
// Ensure Firebase auth is initialized
|
|
1911
|
+
try { await authenticate(options); } catch (e) { /* best effort */ }
|
|
1912
|
+
} else {
|
|
1913
|
+
console.log("Step 1/5: No API key provided. Run: qatools init --api-key <key>");
|
|
1914
|
+
console.log(" Get your key from: qa-tools.com → your project → Members → Claude AI Integration");
|
|
1915
|
+
process.exit(1);
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
console.log("\nStep 2/5: Writing skills...");
|
|
1745
1919
|
const skillsDir = path.join(cwd, ".claude", "skills");
|
|
1746
1920
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
1747
|
-
console.log("Created .claude/skills/");
|
|
1748
1921
|
|
|
1749
1922
|
// 2. Write skills files
|
|
1750
1923
|
const skills = {
|
|
@@ -1879,6 +2052,7 @@ qatools requirements
|
|
|
1879
2052
|
qatools requirements --status approved -o reqs.json
|
|
1880
2053
|
qatools requirement-keys
|
|
1881
2054
|
qatools requirement-create -f requirements.json
|
|
2055
|
+
qatools requirement-delete -k <key> # Delete a wrong requirement (refuses if linked; --force to override)
|
|
1882
2056
|
|
|
1883
2057
|
# Build & backlog
|
|
1884
2058
|
qatools backlog
|
|
@@ -1900,6 +2074,7 @@ qatools upload-analysis -s <id> -f analysis.json
|
|
|
1900
2074
|
# Sprints
|
|
1901
2075
|
qatools sprints
|
|
1902
2076
|
qatools sprint-items --sprint <id>
|
|
2077
|
+
qatools sprint-create --name "Release 3.2" --start 2026-05-01 --end 2026-05-28
|
|
1903
2078
|
\`\`\`
|
|
1904
2079
|
|
|
1905
2080
|
## Key rules
|
|
@@ -2136,6 +2311,9 @@ You manage sprints, backlog, and verify release readiness.
|
|
|
2136
2311
|
1. Review sprints:
|
|
2137
2312
|
\`qatools sprints\`
|
|
2138
2313
|
\`qatools sprint-items --sprint <id> -o sprint.json\`
|
|
2314
|
+
Create a new one when starting a cycle:
|
|
2315
|
+
\`qatools sprint-create --name "Release 3.2" --start 2026-05-01 --end 2026-05-28\`
|
|
2316
|
+
(status auto-derives from dates: planning → active → overdue → completed)
|
|
2139
2317
|
|
|
2140
2318
|
2. Break down requirements into stories/tasks:
|
|
2141
2319
|
\`qatools backlog-create -d "Implement login flow" -t story --requirement-key auth.login-trial\`
|
|
@@ -2283,7 +2461,8 @@ If the verification fails: do NOT mark tested. Go back to step 1, find the discr
|
|
|
2283
2461
|
console.log(" ✓ qa-evidence-first.md");
|
|
2284
2462
|
console.log(" ✓ qa-numerical-verification.md");
|
|
2285
2463
|
|
|
2286
|
-
// 4.
|
|
2464
|
+
// 4. Save built-in skills to App Context skills[] so they become editable per project.
|
|
2465
|
+
// Only adds skills that don't already exist in ctx.skills[] (respects user edits).
|
|
2287
2466
|
try {
|
|
2288
2467
|
await authenticate(options);
|
|
2289
2468
|
const creds = getCredentials();
|
|
@@ -2292,22 +2471,49 @@ If the verification fails: do NOT mark tested. Go back to step 1, find the discr
|
|
|
2292
2471
|
const ctxResult = await getCtx({ projectId: creds.projectId });
|
|
2293
2472
|
const ctx = ctxResult.data.appContext;
|
|
2294
2473
|
if (ctx) {
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2474
|
+
const existingSkills = ctx.skills || [];
|
|
2475
|
+
const existingNames = new Set(existingSkills.map(s => s.filename?.replace(/\.md$/, '')));
|
|
2476
|
+
let added = 0;
|
|
2477
|
+
for (const [filename, content] of Object.entries(skills)) {
|
|
2478
|
+
const name = filename.replace(/\.md$/, '');
|
|
2479
|
+
if (existingNames.has(name)) continue; // respect user edits
|
|
2480
|
+
// Extract title from first # heading in the content
|
|
2481
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
2482
|
+
const title = titleMatch ? titleMatch[1].trim() : name;
|
|
2483
|
+
existingSkills.push({ filename: name, title, content });
|
|
2484
|
+
added++;
|
|
2485
|
+
}
|
|
2486
|
+
if (added > 0) {
|
|
2487
|
+
ctx.skills = existingSkills;
|
|
2488
|
+
const saveCtx = httpsCallable(functions, "saveAppContext");
|
|
2489
|
+
await saveCtx({ projectId: creds.projectId, context: ctx });
|
|
2490
|
+
console.log(`\n ✓ ${added} built-in skills saved to App Context (now editable via qatools skill add/remove)`);
|
|
2491
|
+
} else {
|
|
2492
|
+
console.log(`\n ✓ All built-in skills already in App Context (editable)`);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// Generate app context skill + deploy custom skills from context
|
|
2497
|
+
// Re-fetch to get updated skills[]
|
|
2498
|
+
const ctxRefresh = await getCtx({ projectId: creds.projectId });
|
|
2499
|
+
const ctxFinal = ctxRefresh.data.appContext;
|
|
2500
|
+
if (ctxFinal) {
|
|
2501
|
+
let ctxSkill = `---\ndescription: App-specific context for ${ctxFinal.name}. Auto-generated from QA-Tools App Context.\n---\n\n# ${ctxFinal.name}\n\n`;
|
|
2502
|
+
if (ctxFinal.purpose) ctxSkill += `## Purpose\n${ctxFinal.purpose}\n\n`;
|
|
2503
|
+
if (ctxFinal.domainObjects?.length) ctxSkill += `## Domain Objects\n${ctxFinal.domainObjects.map(d => '- ' + d).join('\n')}\n\n`;
|
|
2504
|
+
if (ctxFinal.actors?.length) ctxSkill += `## Actors\n${ctxFinal.actors.map(a => '- ' + a).join('\n')}\n\n`;
|
|
2505
|
+
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`;
|
|
2506
|
+
if (ctxFinal.architecture) ctxSkill += `## Architecture\n${ctxFinal.architecture}\n\n`;
|
|
2507
|
+
if (ctxFinal.security?.length) ctxSkill += `## Security\n${ctxFinal.security.map(s => '- ' + s).join('\n')}\n\n`;
|
|
2508
|
+
if (ctxFinal.aiRules?.length) ctxSkill += `## AI Rules\n${ctxFinal.aiRules.map(r => '- ' + r).join('\n')}\n\n`;
|
|
2509
|
+
if (ctxFinal.conventions?.length) ctxSkill += `## Design Principles\n${ctxFinal.conventions.map(c => '- **' + c.area + ':** ' + c.rule).join('\n')}\n\n`;
|
|
2510
|
+
if (ctxFinal.constraints?.length) ctxSkill += `## Constraints\n${ctxFinal.constraints.map(c => '- ' + c).join('\n')}\n\n`;
|
|
2305
2511
|
fs.writeFileSync(path.join(skillsDir, "app-context.md"), ctxSkill);
|
|
2306
2512
|
console.log(" ✓ app-context.md (from project)");
|
|
2307
2513
|
|
|
2308
2514
|
// Generate custom skills from App Context
|
|
2309
|
-
if (
|
|
2310
|
-
for (const skill of
|
|
2515
|
+
if (ctxFinal.skills?.length) {
|
|
2516
|
+
for (const skill of ctxFinal.skills) {
|
|
2311
2517
|
const fname = skill.filename.endsWith('.md') ? skill.filename : skill.filename + '.md';
|
|
2312
2518
|
const content = `---\ndescription: ${skill.title}\n---\n\n# ${skill.title}\n\n${skill.content}\n`;
|
|
2313
2519
|
fs.writeFileSync(path.join(skillsDir, fname), content);
|
|
@@ -2344,6 +2550,7 @@ export QATOOLS_API_KEY=${apiKey || "<din-nyckel>"}
|
|
|
2344
2550
|
- \`qatools upload-analysis -s <id> -f analysis.json\` — upload analysis
|
|
2345
2551
|
- \`qatools requirement-create -f file.json\` — create requirements
|
|
2346
2552
|
- \`qatools requirement-update -i <id> --status tested --evidence "..."\` — mark verified (evidence required)
|
|
2553
|
+
- \`qatools requirement-delete -k <key>\` — delete a wrong requirement (refuses if backlog/tests link it; --force to override)
|
|
2347
2554
|
- \`qatools template-upload -f file.json\` — upload test template
|
|
2348
2555
|
- \`qatools backlog-create -d "..." -t bug\` — create backlog item
|
|
2349
2556
|
- \`qatools backlog-update -i <id> --status resolved --evidence "..."\` — mark resolved (evidence required)
|
|
@@ -2366,41 +2573,420 @@ See \`.claude/skills/\` for role-specific instructions. Start with \`qa-purpose.
|
|
|
2366
2573
|
console.log("\n✓ Created CLAUDE.md with QA-Tools section");
|
|
2367
2574
|
}
|
|
2368
2575
|
|
|
2369
|
-
// 5
|
|
2370
|
-
|
|
2576
|
+
// Step 4/5: Sync App Context into CLAUDE.md
|
|
2577
|
+
console.log("\nStep 4/5: Syncing App Context into CLAUDE.md...");
|
|
2371
2578
|
try {
|
|
2372
2579
|
const creds = getCredentials();
|
|
2373
2580
|
if (creds?.projectId) {
|
|
2374
|
-
const
|
|
2375
|
-
const
|
|
2376
|
-
const
|
|
2377
|
-
|
|
2378
|
-
|
|
2581
|
+
const getCtx2 = httpsCallable(functions, "getAppContext");
|
|
2582
|
+
const syncResult = await getCtx2({ projectId: creds.projectId });
|
|
2583
|
+
const syncCtx = syncResult.data.appContext;
|
|
2584
|
+
const systemRules = syncResult.data.systemAiRules || [];
|
|
2585
|
+
if (syncCtx) {
|
|
2586
|
+
// Use the same sync logic as qatools sync
|
|
2587
|
+
const syncMd = renderAppContextMarkdown(syncCtx, creds.projectId, systemRules);
|
|
2588
|
+
const beginMarker = "<!-- BEGIN qatools-context (auto-synced from qa-tools, do not edit by hand) -->";
|
|
2589
|
+
const endMarker = "<!-- END qatools-context -->";
|
|
2590
|
+
const block = `${beginMarker}\n${syncMd}\n${endMarker}`;
|
|
2591
|
+
const syncPath = fs.existsSync(path.join(cwd, ".claude", "CLAUDE.md")) ? path.join(cwd, ".claude", "CLAUDE.md") : claudeMdPath;
|
|
2592
|
+
let existing = fs.existsSync(syncPath) ? fs.readFileSync(syncPath, "utf8") : "";
|
|
2593
|
+
const beginIdx = existing.indexOf(beginMarker);
|
|
2594
|
+
const endIdx = existing.indexOf(endMarker);
|
|
2595
|
+
if (beginIdx >= 0 && endIdx > beginIdx) {
|
|
2596
|
+
existing = existing.slice(0, beginIdx) + block + existing.slice(endIdx + endMarker.length);
|
|
2597
|
+
} else {
|
|
2598
|
+
existing = existing + (existing && !existing.endsWith("\n") ? "\n\n" : "\n") + block + "\n";
|
|
2599
|
+
}
|
|
2600
|
+
fs.writeFileSync(syncPath, existing);
|
|
2601
|
+
console.log(` ✓ Synced to ${path.relative(cwd, syncPath)}`);
|
|
2602
|
+
|
|
2603
|
+
// App Context state
|
|
2604
|
+
if (!syncCtx.purpose && !(syncCtx.stack || []).length) {
|
|
2605
|
+
console.log("\n ⚠ App Context is empty for this project.");
|
|
2606
|
+
console.log(" Without it, the AI has no basis for substituting for you.");
|
|
2607
|
+
console.log(" Run: qatools context-template -o ctx.json → edit → qatools context-set -f ctx.json");
|
|
2608
|
+
} else {
|
|
2609
|
+
const lastSync = syncCtx.updatedAt?._seconds ? new Date(syncCtx.updatedAt._seconds * 1000).toISOString().split('T')[0] : 'unknown';
|
|
2610
|
+
console.log(` ✓ App Context loaded (last updated: ${lastSync})`);
|
|
2611
|
+
}
|
|
2612
|
+
} else {
|
|
2613
|
+
console.log(" ⚠ No App Context defined. Create one: qatools context-template -o ctx.json → edit → qatools context-set -f ctx.json");
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
} catch (e) {
|
|
2617
|
+
console.log(` ⚠ Sync skipped (${e.message})`);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// Step 5/5: Show agenda
|
|
2621
|
+
console.log("\nStep 5/5: Project agenda\n");
|
|
2622
|
+
try {
|
|
2623
|
+
const creds = getCredentials();
|
|
2624
|
+
if (creds?.projectId) {
|
|
2625
|
+
const getAgenda = httpsCallable(functions, "getProjectAgenda");
|
|
2626
|
+
const agendaResult = await getAgenda({ projectId: creds.projectId });
|
|
2627
|
+
const data = agendaResult.data;
|
|
2628
|
+
const sections = [];
|
|
2629
|
+
if (data.agenda.pendingSessions?.length) {
|
|
2630
|
+
sections.push(` 🔴 ${data.agenda.pendingSessions.length} session(s) waiting for analysis`);
|
|
2631
|
+
}
|
|
2632
|
+
if (data.agenda.approvedNoCoverage?.length) {
|
|
2633
|
+
sections.push(` 🟡 ${data.agenda.approvedNoCoverage.length} approved requirement(s) without test coverage`);
|
|
2634
|
+
}
|
|
2635
|
+
if (data.agenda.draftReqs?.length) {
|
|
2636
|
+
sections.push(` ⚪ ${data.agenda.draftReqs.length} draft requirement(s) need acceptance criteria / approval`);
|
|
2637
|
+
}
|
|
2638
|
+
if (data.agenda.accessModelMissing) {
|
|
2639
|
+
sections.push(` ⚪ No access model defined — run: qatools access show`);
|
|
2640
|
+
}
|
|
2641
|
+
if (sections.length === 0) {
|
|
2642
|
+
console.log(" ✓ Nothing on the agenda. The project is in a clean state.");
|
|
2379
2643
|
} else {
|
|
2380
|
-
const
|
|
2381
|
-
|
|
2644
|
+
for (const s of sections) console.log(s);
|
|
2645
|
+
console.log(`\n ${data.totalCount} thing(s) on the agenda.`);
|
|
2382
2646
|
}
|
|
2383
2647
|
}
|
|
2384
2648
|
} catch (e) {
|
|
2385
|
-
|
|
2649
|
+
console.log(` Could not load agenda (${e.message}). Run: qatools next`);
|
|
2386
2650
|
}
|
|
2387
2651
|
|
|
2388
2652
|
console.log(`
|
|
2653
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2389
2654
|
🎉 QA-Tools initialized!
|
|
2390
2655
|
|
|
2391
2656
|
⚠ READ THIS FIRST: .claude/skills/qa-purpose.md
|
|
2392
2657
|
|
|
2393
|
-
qa-tools exists to let anyone build production-quality apps
|
|
2394
|
-
that follow their own rules — as if you built it
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
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
|
|
2658
|
+
qa-tools exists to let anyone build production-quality apps
|
|
2659
|
+
with AI that follow their own rules — as if you built it
|
|
2660
|
+
yourself, just faster.
|
|
2661
|
+
|
|
2662
|
+
The AI is not an assistant. It is a substitute.
|
|
2663
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2402
2664
|
`);
|
|
2403
|
-
|
|
2665
|
+
process.exit(0);
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
program
|
|
2669
|
+
// Skill library metadata — names and descriptions of the built-in skills shipped by qatools init.
|
|
2670
|
+
// The full content lives in the init action above (huge template literals). This is just metadata
|
|
2671
|
+
// for `qatools skill list` and `qatools skill add` to reference by name.
|
|
2672
|
+
const BUILTIN_SKILLS = [
|
|
2673
|
+
{ name: "qa-purpose", description: "Read this BEFORE anything else. The purpose of qa-tools and what it expects from you as AI." },
|
|
2674
|
+
{ name: "qa-loop", description: "Overview of the QA-Tools delivery loop — from requirement to verified release." },
|
|
2675
|
+
{ name: "qa-requirement-analyst", description: "Analyze the codebase and define requirements that are testable, traceable, and complete." },
|
|
2676
|
+
{ name: "qa-developer", description: "Implement features based on approved requirements. Use when building new functionality." },
|
|
2677
|
+
{ name: "qa-test-generator", description: "Generate test protocols from approved requirements." },
|
|
2678
|
+
{ name: "qa-test-analyst", description: "Analyze test results, identify root causes, create backlog items." },
|
|
2679
|
+
{ name: "qa-release-manager", description: "Sprint planning and release verification." },
|
|
2680
|
+
{ name: "qa-evidence-first", description: "Core principle — never speculate, always cite evidence. Applies to ALL work." },
|
|
2681
|
+
{ name: "qa-numerical-verification", description: "How to verify algorithmic and numerical bug fixes." },
|
|
2682
|
+
];
|
|
2683
|
+
|
|
2684
|
+
const skillCmd = program
|
|
2685
|
+
.command("skill")
|
|
2686
|
+
.description("Manage skills — the instruction files Claude reads in .claude/skills/");
|
|
2687
|
+
|
|
2688
|
+
// --- Library subcommands (global, not per-project) ---
|
|
2689
|
+
|
|
2690
|
+
const libraryCmd = skillCmd
|
|
2691
|
+
.command("library")
|
|
2692
|
+
.description("Manage the global skill library (shared across all projects)");
|
|
2693
|
+
|
|
2694
|
+
libraryCmd
|
|
2695
|
+
.command("list")
|
|
2696
|
+
.description("List all skills in the global library")
|
|
2697
|
+
.option("-e, --email <email>", "Login email")
|
|
2698
|
+
.option("-p, --password <password>", "Login password")
|
|
2699
|
+
.action(async (options) => {
|
|
2700
|
+
await authenticate(options);
|
|
2701
|
+
const listSkills = httpsCallable(functions, "listSkillLibrary");
|
|
2702
|
+
try {
|
|
2703
|
+
const result = await listSkills({});
|
|
2704
|
+
const skills = result.data.skills;
|
|
2705
|
+
if (skills.length === 0) {
|
|
2706
|
+
console.log("Skill library is empty. Seed it with: qatools skill library add -f <file.md>");
|
|
2707
|
+
process.exit(0);
|
|
2708
|
+
}
|
|
2709
|
+
console.log(`\nSkill Library (${skills.length} skills):\n`);
|
|
2710
|
+
for (const s of skills) {
|
|
2711
|
+
console.log(` ${s.name.padEnd(30)} v${s.version} ${s.title}`);
|
|
2712
|
+
if (s.description) console.log(` ${''.padEnd(30)} ${s.description}`);
|
|
2713
|
+
}
|
|
2714
|
+
console.log("");
|
|
2715
|
+
} catch (e) {
|
|
2716
|
+
console.error("Failed:", e.message);
|
|
2717
|
+
process.exit(1);
|
|
2718
|
+
}
|
|
2719
|
+
process.exit(0);
|
|
2720
|
+
});
|
|
2721
|
+
|
|
2722
|
+
libraryCmd
|
|
2723
|
+
.command("show")
|
|
2724
|
+
.description("Show the full content of a skill from the library")
|
|
2725
|
+
.requiredOption("-n, --name <name>", "Skill name (e.g. qa-purpose)")
|
|
2726
|
+
.option("-o, --output <path>", "Save to file instead of stdout")
|
|
2727
|
+
.option("-e, --email <email>", "Login email")
|
|
2728
|
+
.option("-p, --password <password>", "Login password")
|
|
2729
|
+
.action(async (options) => {
|
|
2730
|
+
await authenticate(options);
|
|
2731
|
+
const getSkill = httpsCallable(functions, "getLibrarySkill");
|
|
2732
|
+
try {
|
|
2733
|
+
const result = await getSkill({ name: options.name });
|
|
2734
|
+
const skill = result.data.skill;
|
|
2735
|
+
if (!skill) { console.error(`Skill '${options.name}' not found in library.`); process.exit(1); }
|
|
2736
|
+
if (options.output) {
|
|
2737
|
+
fs.writeFileSync(path.resolve(options.output), skill.content);
|
|
2738
|
+
console.log(`Saved to ${options.output}`);
|
|
2739
|
+
} else {
|
|
2740
|
+
console.log(skill.content);
|
|
2741
|
+
}
|
|
2742
|
+
} catch (e) {
|
|
2743
|
+
console.error("Failed:", e.message);
|
|
2744
|
+
process.exit(1);
|
|
2745
|
+
}
|
|
2746
|
+
process.exit(0);
|
|
2747
|
+
});
|
|
2748
|
+
|
|
2749
|
+
libraryCmd
|
|
2750
|
+
.command("add")
|
|
2751
|
+
.description("Add or update a skill in the global library from a .md file")
|
|
2752
|
+
.requiredOption("-f, --file <path>", "Path to .md file")
|
|
2753
|
+
.option("-n, --name <name>", "Skill name (default: filename without .md)")
|
|
2754
|
+
.option("-d, --description <text>", "Short description")
|
|
2755
|
+
.option("-e, --email <email>", "Login email")
|
|
2756
|
+
.option("-p, --password <password>", "Login password")
|
|
2757
|
+
.action(async (options) => {
|
|
2758
|
+
await authenticate(options);
|
|
2759
|
+
const filePath = path.resolve(options.file);
|
|
2760
|
+
if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); }
|
|
2761
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
2762
|
+
const name = options.name || path.basename(filePath, ".md");
|
|
2763
|
+
// Extract title from frontmatter or first heading
|
|
2764
|
+
let title = name;
|
|
2765
|
+
const descMatch = content.match(/^description:\s*(.+)$/m);
|
|
2766
|
+
if (descMatch) title = descMatch[1].trim();
|
|
2767
|
+
else {
|
|
2768
|
+
const headingMatch = content.match(/^#\s+(.+)$/m);
|
|
2769
|
+
if (headingMatch) title = headingMatch[1].trim();
|
|
2770
|
+
}
|
|
2771
|
+
const saveSkill = httpsCallable(functions, "saveLibrarySkill");
|
|
2772
|
+
try {
|
|
2773
|
+
const result = await saveSkill({ name, title, description: options.description || "", content });
|
|
2774
|
+
console.log(`Skill '${name}' saved to library (v${result.data.version})`);
|
|
2775
|
+
} catch (e) {
|
|
2776
|
+
console.error("Failed:", e.message);
|
|
2777
|
+
process.exit(1);
|
|
2778
|
+
}
|
|
2779
|
+
process.exit(0);
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
libraryCmd
|
|
2783
|
+
.command("remove")
|
|
2784
|
+
.description("Remove a skill from the global library")
|
|
2785
|
+
.requiredOption("-n, --name <name>", "Skill name to remove")
|
|
2786
|
+
.option("-e, --email <email>", "Login email")
|
|
2787
|
+
.option("-p, --password <password>", "Login password")
|
|
2788
|
+
.action(async (options) => {
|
|
2789
|
+
await authenticate(options);
|
|
2790
|
+
const deleteSkill = httpsCallable(functions, "deleteLibrarySkill");
|
|
2791
|
+
try {
|
|
2792
|
+
await deleteSkill({ name: options.name });
|
|
2793
|
+
console.log(`Removed '${options.name}' from library.`);
|
|
2794
|
+
} catch (e) {
|
|
2795
|
+
console.error("Failed:", e.message);
|
|
2796
|
+
process.exit(1);
|
|
2797
|
+
}
|
|
2798
|
+
process.exit(0);
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2801
|
+
// --- Per-project skill subcommands ---
|
|
2802
|
+
|
|
2803
|
+
skillCmd
|
|
2804
|
+
.command("list")
|
|
2805
|
+
.description("List skills: global library + what this project has selected")
|
|
2806
|
+
.option("--project <id>", "Target project ID")
|
|
2807
|
+
.option("-e, --email <email>", "Login email")
|
|
2808
|
+
.option("-p, --password <password>", "Login password")
|
|
2809
|
+
.action(async (options) => {
|
|
2810
|
+
await authenticate(options);
|
|
2811
|
+
// 1. Show global library
|
|
2812
|
+
try {
|
|
2813
|
+
const listSkills = httpsCallable(functions, "listSkillLibrary");
|
|
2814
|
+
const libResult = await listSkills({});
|
|
2815
|
+
const libSkills = libResult.data.skills;
|
|
2816
|
+
if (libSkills.length) {
|
|
2817
|
+
console.log(`\nGlobal Skill Library (${libSkills.length}):\n`);
|
|
2818
|
+
for (const s of libSkills) console.log(` ${s.name.padEnd(30)} v${s.version} ${s.title}`);
|
|
2819
|
+
} else {
|
|
2820
|
+
console.log("\nGlobal Skill Library: empty. Seed with: qatools skill library add -f <file.md>");
|
|
2821
|
+
}
|
|
2822
|
+
} catch (e) {
|
|
2823
|
+
console.log("\nGlobal Skill Library: could not load (" + e.message + ")");
|
|
2824
|
+
}
|
|
2825
|
+
// 2. Show project skills
|
|
2826
|
+
try {
|
|
2827
|
+
const creds = getCredentials();
|
|
2828
|
+
const projectId = options.project || creds?.projectId;
|
|
2829
|
+
if (projectId) {
|
|
2830
|
+
const getCtx = httpsCallable(functions, "getAppContext");
|
|
2831
|
+
const result = await getCtx({ projectId });
|
|
2832
|
+
const custom = result.data.appContext?.skills || [];
|
|
2833
|
+
if (custom.length) {
|
|
2834
|
+
console.log(`\nProject skills (${custom.length}):\n`);
|
|
2835
|
+
for (const s of custom) console.log(` ${(s.filename || '').padEnd(30)} ${s.title || ''}`);
|
|
2836
|
+
} else {
|
|
2837
|
+
console.log(`\nProject skills: none. Add from library: qatools skill add <name>`);
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
} catch (e) {
|
|
2841
|
+
// best effort
|
|
2842
|
+
}
|
|
2843
|
+
console.log("");
|
|
2844
|
+
process.exit(0);
|
|
2845
|
+
});
|
|
2846
|
+
|
|
2847
|
+
skillCmd
|
|
2848
|
+
.command("add")
|
|
2849
|
+
.description("Add a skill to the project — from the global library (by name) or from a local .md file")
|
|
2850
|
+
.option("-n, --name <name>", "Skill name from the global library (e.g. qa-purpose)")
|
|
2851
|
+
.option("-f, --file <path>", "Path to a local .md skill file")
|
|
2852
|
+
.option("--project <id>", "Target project ID")
|
|
2853
|
+
.option("-e, --email <email>", "Login email")
|
|
2854
|
+
.option("-p, --password <password>", "Login password")
|
|
2855
|
+
.action(async (options) => {
|
|
2856
|
+
if (!options.name && !options.file) { console.error("Provide --name <library-skill> or --file <path>"); process.exit(1); }
|
|
2857
|
+
await authenticate(options);
|
|
2858
|
+
const creds = getCredentials();
|
|
2859
|
+
const projectId = options.project || creds?.projectId;
|
|
2860
|
+
if (!projectId) { console.error("No project configured."); process.exit(1); }
|
|
2861
|
+
|
|
2862
|
+
let filename, title, content;
|
|
2863
|
+
|
|
2864
|
+
if (options.name) {
|
|
2865
|
+
// Fetch from global library
|
|
2866
|
+
const getSkill = httpsCallable(functions, "getLibrarySkill");
|
|
2867
|
+
const skillResult = await getSkill({ name: options.name });
|
|
2868
|
+
const skill = skillResult.data.skill;
|
|
2869
|
+
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); }
|
|
2870
|
+
filename = skill.name;
|
|
2871
|
+
title = skill.title;
|
|
2872
|
+
content = skill.content;
|
|
2873
|
+
console.log(`Fetched '${filename}' v${skill.version} from global library.`);
|
|
2874
|
+
} else {
|
|
2875
|
+
// From local file
|
|
2876
|
+
const filePath = path.resolve(options.file);
|
|
2877
|
+
if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); }
|
|
2878
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
2879
|
+
filename = path.basename(filePath, ".md");
|
|
2880
|
+
title = filename;
|
|
2881
|
+
const descMatch = content.match(/^description:\s*(.+)$/m);
|
|
2882
|
+
if (descMatch) title = descMatch[1].trim();
|
|
2883
|
+
else {
|
|
2884
|
+
const headingMatch = content.match(/^#\s+(.+)$/m);
|
|
2885
|
+
if (headingMatch) title = headingMatch[1].trim();
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// Load context, add to skills[], save
|
|
2890
|
+
const getCtx = httpsCallable(functions, "getAppContext");
|
|
2891
|
+
const result = await getCtx({ projectId });
|
|
2892
|
+
const ctx = result.data.appContext || {};
|
|
2893
|
+
const skills = ctx.skills || [];
|
|
2894
|
+
const existing = skills.findIndex(s => s.filename === filename);
|
|
2895
|
+
if (existing >= 0) {
|
|
2896
|
+
skills[existing] = { filename, title, content };
|
|
2897
|
+
console.log(`Updated existing project skill: ${filename}`);
|
|
2898
|
+
} else {
|
|
2899
|
+
skills.push({ filename, title, content });
|
|
2900
|
+
console.log(`Added to project: ${filename}`);
|
|
2901
|
+
}
|
|
2902
|
+
ctx.skills = skills;
|
|
2903
|
+
const saveCtx = httpsCallable(functions, "saveAppContext");
|
|
2904
|
+
await saveCtx({ projectId, context: ctx });
|
|
2905
|
+
console.log(`Saved. Deploy with: qatools skill deploy`);
|
|
2906
|
+
process.exit(0);
|
|
2907
|
+
});
|
|
2908
|
+
|
|
2909
|
+
skillCmd
|
|
2910
|
+
.command("remove")
|
|
2911
|
+
.description("Remove a custom skill from the project's App Context")
|
|
2912
|
+
.requiredOption("-n, --name <filename>", "Skill filename (without .md)")
|
|
2913
|
+
.option("--project <id>", "Target project ID")
|
|
2914
|
+
.option("-e, --email <email>", "Login email")
|
|
2915
|
+
.option("-p, --password <password>", "Login password")
|
|
2916
|
+
.action(async (options) => {
|
|
2917
|
+
await authenticate(options);
|
|
2918
|
+
const creds = getCredentials();
|
|
2919
|
+
const projectId = options.project || creds?.projectId;
|
|
2920
|
+
if (!projectId) { console.error("No project configured."); process.exit(1); }
|
|
2921
|
+
const getCtx = httpsCallable(functions, "getAppContext");
|
|
2922
|
+
const result = await getCtx({ projectId });
|
|
2923
|
+
const ctx = result.data.appContext;
|
|
2924
|
+
if (!ctx) { console.error("No App Context for this project."); process.exit(1); }
|
|
2925
|
+
const skills = ctx.skills || [];
|
|
2926
|
+
const idx = skills.findIndex(s => s.filename === options.name || s.filename === options.name + '.md');
|
|
2927
|
+
if (idx < 0) { console.error(`Skill "${options.name}" not found in project skills.`); process.exit(1); }
|
|
2928
|
+
const removed = skills.splice(idx, 1)[0];
|
|
2929
|
+
ctx.skills = skills;
|
|
2930
|
+
const saveCtx = httpsCallable(functions, "saveAppContext");
|
|
2931
|
+
await saveCtx({ projectId, context: ctx });
|
|
2932
|
+
console.log(`Removed: ${removed.filename} ("${removed.title}")`);
|
|
2933
|
+
console.log(`Deploy with: qatools skill deploy`);
|
|
2934
|
+
process.exit(0);
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2937
|
+
skillCmd
|
|
2938
|
+
.command("deploy")
|
|
2939
|
+
.description("Write all skills (built-in + project custom) to .claude/skills/ in the current directory")
|
|
2940
|
+
.option("--project <id>", "Target project ID")
|
|
2941
|
+
.option("-e, --email <email>", "Login email")
|
|
2942
|
+
.option("-p, --password <password>", "Login password")
|
|
2943
|
+
.action(async (options) => {
|
|
2944
|
+
const cwd = process.cwd();
|
|
2945
|
+
const skillsDir = path.join(cwd, ".claude", "skills");
|
|
2946
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
2947
|
+
// 1. Deploy built-in skills from init (run init logic for skills only)
|
|
2948
|
+
// We re-use the init action's skill writing. For now, tell user to run init for built-ins.
|
|
2949
|
+
console.log("Deploying skills to .claude/skills/\n");
|
|
2950
|
+
// 2. Deploy project custom skills from App Context
|
|
2951
|
+
let customCount = 0;
|
|
2952
|
+
try {
|
|
2953
|
+
await authenticate(options);
|
|
2954
|
+
const creds = getCredentials();
|
|
2955
|
+
const projectId = options.project || creds?.projectId;
|
|
2956
|
+
if (projectId) {
|
|
2957
|
+
const getCtx = httpsCallable(functions, "getAppContext");
|
|
2958
|
+
const result = await getCtx({ projectId });
|
|
2959
|
+
const ctx = result.data.appContext;
|
|
2960
|
+
if (ctx?.skills?.length) {
|
|
2961
|
+
for (const skill of ctx.skills) {
|
|
2962
|
+
const fname = skill.filename.endsWith('.md') ? skill.filename : skill.filename + '.md';
|
|
2963
|
+
const content = `---\ndescription: ${skill.title}\n---\n\n# ${skill.title}\n\n${skill.content}\n`;
|
|
2964
|
+
fs.writeFileSync(path.join(skillsDir, fname), content);
|
|
2965
|
+
console.log(` ✓ ${fname} (project custom)`);
|
|
2966
|
+
customCount++;
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
// Also deploy app-context skill
|
|
2970
|
+
if (ctx) {
|
|
2971
|
+
let ctxSkill = `---\ndescription: App-specific context for ${ctx.name}. Auto-generated from QA-Tools App Context.\n---\n\n# ${ctx.name}\n\n`;
|
|
2972
|
+
if (ctx.purpose) ctxSkill += `## Purpose\n${ctx.purpose}\n\n`;
|
|
2973
|
+
if (ctx.domainObjects?.length) ctxSkill += `## Domain Objects\n${ctx.domainObjects.map(d => '- ' + d).join('\n')}\n\n`;
|
|
2974
|
+
if (ctx.actors?.length) ctxSkill += `## Actors\n${ctx.actors.map(a => '- ' + a).join('\n')}\n\n`;
|
|
2975
|
+
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`;
|
|
2976
|
+
if (ctx.architecture) ctxSkill += `## Architecture\n${ctx.architecture}\n\n`;
|
|
2977
|
+
if (ctx.security?.length) ctxSkill += `## Security\n${ctx.security.map(s => '- ' + s).join('\n')}\n\n`;
|
|
2978
|
+
if (ctx.aiRules?.length) ctxSkill += `## AI Rules\n${ctx.aiRules.map(r => '- ' + r).join('\n')}\n\n`;
|
|
2979
|
+
if (ctx.conventions?.length) ctxSkill += `## Design Principles\n${ctx.conventions.map(c => '- **' + c.area + ':** ' + c.rule).join('\n')}\n\n`;
|
|
2980
|
+
if (ctx.constraints?.length) ctxSkill += `## Constraints\n${ctx.constraints.map(c => '- ' + c).join('\n')}\n\n`;
|
|
2981
|
+
fs.writeFileSync(path.join(skillsDir, "app-context.md"), ctxSkill);
|
|
2982
|
+
console.log(` ✓ app-context.md (from project)`);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
} catch (e) {
|
|
2986
|
+
console.error(` Note: could not load project context (${e.message}). Only local files written.`);
|
|
2987
|
+
}
|
|
2988
|
+
console.log(`\nDone. ${customCount} project skill(s) deployed.`);
|
|
2989
|
+
console.log(`Built-in skills are deployed by 'qatools init'. Run init if they're missing.`);
|
|
2404
2990
|
process.exit(0);
|
|
2405
2991
|
});
|
|
2406
2992
|
|