airgen-cli 0.1.2 → 0.1.4

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.
@@ -1,25 +1,272 @@
1
- import { output } from "../output.js";
1
+ import { readFileSync } from "node:fs";
2
+ import { output, printTable, isJsonMode, truncate } from "../output.js";
2
3
  import { resolveRequirementId } from "../resolve.js";
4
+ const IMPL_STATUSES = ["not_started", "in_progress", "implemented", "verified", "blocked"];
5
+ const IMPL_TAG_PREFIX = "impl:";
6
+ const PAGE_SIZE = 100;
7
+ const MAX_PAGES = 50;
8
+ // ── Helpers ──────────────────────────────────────────────────
9
+ async function fetchAllRequirements(client, tenant, project) {
10
+ const all = [];
11
+ for (let page = 1; page <= MAX_PAGES; page++) {
12
+ const data = await client.get(`/requirements/${tenant}/${project}`, {
13
+ page: String(page),
14
+ limit: String(PAGE_SIZE),
15
+ });
16
+ const items = data.data ?? [];
17
+ all.push(...items);
18
+ const totalPages = data.meta?.totalPages ?? 1;
19
+ if (page >= totalPages)
20
+ break;
21
+ }
22
+ return all.filter((r) => !r.deleted && !r.deletedAt);
23
+ }
24
+ function getImplStatus(tags) {
25
+ if (!tags)
26
+ return null;
27
+ const implTag = tags.find((t) => t.startsWith(IMPL_TAG_PREFIX));
28
+ if (!implTag)
29
+ return null;
30
+ const status = implTag.slice(IMPL_TAG_PREFIX.length);
31
+ return IMPL_STATUSES.includes(status) ? status : null;
32
+ }
33
+ function parseArtifacts(attributes) {
34
+ if (!attributes?.artifacts || typeof attributes.artifacts !== "string")
35
+ return [];
36
+ try {
37
+ const parsed = JSON.parse(attributes.artifacts);
38
+ return Array.isArray(parsed) ? parsed : [];
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
44
+ function statusEmoji(status) {
45
+ switch (status) {
46
+ case "not_started": return "[ ]";
47
+ case "in_progress": return "[~]";
48
+ case "implemented": return "[x]";
49
+ case "verified": return "[v]";
50
+ case "blocked": return "[!]";
51
+ default: return "[-]";
52
+ }
53
+ }
54
+ function pct(n, total) {
55
+ if (total === 0)
56
+ return "0.0%";
57
+ return ((n / total) * 100).toFixed(1) + "%";
58
+ }
59
+ // ── Command registration ─────────────────────────────────────
3
60
  export function registerImplementationCommands(program, client) {
4
61
  const cmd = program.command("impl").description("Implementation tracking");
62
+ // ── impl status ──────────────────────────────────────────
5
63
  cmd
6
64
  .command("status")
7
- .description("Set implementation status on a requirement")
65
+ .description("Set implementation status on a requirement (syncs tags + attributes)")
8
66
  .argument("<tenant>", "Tenant slug")
9
67
  .argument("<project>", "Project slug")
10
68
  .argument("<requirement>", "Requirement ref, ID, or hashId")
11
69
  .requiredOption("--status <s>", "Status: not_started, in_progress, implemented, verified, blocked")
12
70
  .option("--notes <text>", "Notes")
13
71
  .action(async (tenant, project, requirement, opts) => {
72
+ if (!IMPL_STATUSES.includes(opts.status)) {
73
+ console.error(`Invalid status: ${opts.status}. Must be one of: ${IMPL_STATUSES.join(", ")}`);
74
+ process.exit(1);
75
+ }
14
76
  const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
15
- const data = await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
16
- attributes: {
17
- implementationStatus: opts.status,
18
- implementationNotes: opts.notes,
19
- },
77
+ // Fetch current requirement to get existing tags + attributes
78
+ const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
79
+ const req = (reqData.data ?? []).find(r => r.id === resolvedId);
80
+ if (!req) {
81
+ console.error(`Requirement ${requirement} not found.`);
82
+ process.exit(1);
83
+ }
84
+ const now = new Date().toISOString();
85
+ // Update tags: remove existing impl:* tags, add new one
86
+ const currentTags = (req.tags ?? []).filter(t => !t.startsWith(IMPL_TAG_PREFIX));
87
+ currentTags.push(`${IMPL_TAG_PREFIX}${opts.status}`);
88
+ // Update attributes (matching MCP server's field names)
89
+ const updatedAttributes = {
90
+ ...(req.attributes ?? {}),
91
+ impl_status: opts.status,
92
+ impl_updated_at: now,
93
+ };
94
+ if (opts.notes !== undefined) {
95
+ updatedAttributes.impl_notes = opts.notes || null;
96
+ }
97
+ await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
98
+ tags: currentTags,
99
+ attributes: updatedAttributes,
20
100
  });
21
- output(data);
101
+ if (isJsonMode()) {
102
+ output({ ok: true, id: resolvedId, status: opts.status, tags: currentTags });
103
+ }
104
+ else {
105
+ console.log(`Implementation status set to ${opts.status} on ${req.ref ?? resolvedId}.`);
106
+ }
107
+ });
108
+ // ── impl summary ─────────────────────────────────────────
109
+ cmd
110
+ .command("summary")
111
+ .description("Implementation coverage report for a project")
112
+ .argument("<tenant>", "Tenant slug")
113
+ .argument("<project>", "Project slug")
114
+ .action(async (tenant, project) => {
115
+ const requirements = await fetchAllRequirements(client, tenant, project);
116
+ const total = requirements.length;
117
+ if (total === 0) {
118
+ console.log("No requirements found.");
119
+ return;
120
+ }
121
+ const statusCounts = {};
122
+ for (const s of IMPL_STATUSES)
123
+ statusCounts[s] = 0;
124
+ statusCounts["unset"] = 0;
125
+ let withArtifactCount = 0;
126
+ for (const req of requirements) {
127
+ const status = getImplStatus(req.tags);
128
+ if (status) {
129
+ statusCounts[status]++;
130
+ }
131
+ else {
132
+ statusCounts["unset"]++;
133
+ }
134
+ const artifacts = parseArtifacts(req.attributes);
135
+ if (artifacts.length > 0)
136
+ withArtifactCount++;
137
+ }
138
+ const done = (statusCounts["implemented"] ?? 0) + (statusCounts["verified"] ?? 0);
139
+ const inProgress = statusCounts["in_progress"] ?? 0;
140
+ const blocked = statusCounts["blocked"] ?? 0;
141
+ if (isJsonMode()) {
142
+ output({ total, statusCounts, done, inProgress, blocked, withArtifacts: withArtifactCount });
143
+ return;
144
+ }
145
+ console.log(`Implementation Report (${total} requirements)\n`);
146
+ console.log(`Coverage: ${pct(done, total)} | Artifacts: ${pct(withArtifactCount, total)}\n`);
147
+ printTable(["Status", "Count", "%", ""], [...IMPL_STATUSES, "unset"].map(s => {
148
+ const count = statusCounts[s] ?? 0;
149
+ const bar = "\u2588".repeat(Math.round((count / total) * 30));
150
+ const emoji = s === "unset" ? "[-]" : statusEmoji(s);
151
+ return [`${emoji} ${s}`, String(count), pct(count, total), bar];
152
+ }));
153
+ console.log(`\nDone (implemented + verified): ${done}`);
154
+ console.log(`In progress: ${inProgress}`);
155
+ console.log(`Blocked: ${blocked}`);
156
+ console.log(`Not started / unset: ${total - done - inProgress - blocked}`);
157
+ console.log(`With artifact links: ${withArtifactCount}`);
158
+ });
159
+ // ── impl list ────────────────────────────────────────────
160
+ cmd
161
+ .command("list")
162
+ .description("List requirements filtered by implementation status")
163
+ .argument("<tenant>", "Tenant slug")
164
+ .argument("<project>", "Project slug")
165
+ .option("--status <s>", "Filter by status: not_started, in_progress, implemented, verified, blocked, unset")
166
+ .action(async (tenant, project, opts) => {
167
+ const requirements = await fetchAllRequirements(client, tenant, project);
168
+ let filtered = requirements;
169
+ if (opts.status) {
170
+ if (opts.status === "unset") {
171
+ filtered = requirements.filter(r => getImplStatus(r.tags) === null);
172
+ }
173
+ else {
174
+ filtered = requirements.filter(r => getImplStatus(r.tags) === opts.status);
175
+ }
176
+ }
177
+ if (isJsonMode()) {
178
+ output(filtered.map(r => ({
179
+ ref: r.ref,
180
+ id: r.id,
181
+ text: r.text,
182
+ status: getImplStatus(r.tags) ?? "unset",
183
+ artifacts: parseArtifacts(r.attributes).length,
184
+ })));
185
+ return;
186
+ }
187
+ console.log(`Implementation status: ${opts.status ?? "all"} (${filtered.length} requirements)\n`);
188
+ if (filtered.length === 0) {
189
+ console.log("No matching requirements.");
190
+ return;
191
+ }
192
+ printTable(["Ref", "Status", "Text", "Artifacts"], filtered.map(r => [
193
+ r.ref ?? "?",
194
+ statusEmoji(getImplStatus(r.tags)) + " " + (getImplStatus(r.tags) ?? "unset"),
195
+ truncate(r.text ?? "", 50),
196
+ String(parseArtifacts(r.attributes).length),
197
+ ]));
198
+ });
199
+ // ── impl bulk-update ─────────────────────────────────────
200
+ cmd
201
+ .command("bulk-update")
202
+ .description("Bulk update implementation status from a JSON file")
203
+ .argument("<tenant>", "Tenant slug")
204
+ .argument("<project>", "Project slug")
205
+ .requiredOption("--file <path>", "JSON file with array of {ref, status, notes?}")
206
+ .action(async (tenant, project, opts) => {
207
+ let items;
208
+ try {
209
+ const raw = readFileSync(opts.file, "utf-8");
210
+ items = JSON.parse(raw);
211
+ if (!Array.isArray(items))
212
+ throw new Error("File must contain a JSON array");
213
+ }
214
+ catch (err) {
215
+ console.error(`Failed to read file: ${err.message}`);
216
+ process.exit(1);
217
+ }
218
+ let updated = 0;
219
+ let failed = 0;
220
+ const errors = [];
221
+ for (const item of items) {
222
+ if (!IMPL_STATUSES.includes(item.status)) {
223
+ errors.push(`${item.ref}: invalid status "${item.status}"`);
224
+ failed++;
225
+ continue;
226
+ }
227
+ try {
228
+ const resolvedId = await resolveRequirementId(client, tenant, project, item.ref);
229
+ // Fetch requirement for current tags/attributes
230
+ const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
231
+ const req = (reqData.data ?? []).find(r => r.id === resolvedId);
232
+ if (!req) {
233
+ errors.push(`${item.ref}: not found`);
234
+ failed++;
235
+ continue;
236
+ }
237
+ const now = new Date().toISOString();
238
+ const currentTags = (req.tags ?? []).filter(t => !t.startsWith(IMPL_TAG_PREFIX));
239
+ currentTags.push(`${IMPL_TAG_PREFIX}${item.status}`);
240
+ const updatedAttributes = {
241
+ ...(req.attributes ?? {}),
242
+ impl_status: item.status,
243
+ impl_updated_at: now,
244
+ };
245
+ if (item.notes !== undefined) {
246
+ updatedAttributes.impl_notes = item.notes || null;
247
+ }
248
+ await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
249
+ tags: currentTags,
250
+ attributes: updatedAttributes,
251
+ });
252
+ updated++;
253
+ }
254
+ catch (err) {
255
+ errors.push(`${item.ref}: ${err.message}`);
256
+ failed++;
257
+ }
258
+ }
259
+ if (isJsonMode()) {
260
+ output({ total: items.length, updated, failed, errors });
261
+ }
262
+ else {
263
+ console.log(`Bulk update: ${updated} updated, ${failed} failed (${items.length} total)`);
264
+ for (const e of errors) {
265
+ console.error(` - ${e}`);
266
+ }
267
+ }
22
268
  });
269
+ // ── impl link ────────────────────────────────────────────
23
270
  cmd
24
271
  .command("link")
25
272
  .description("Link a code artifact to a requirement")
@@ -32,26 +279,36 @@ export function registerImplementationCommands(program, client) {
32
279
  .option("--line <n>", "Line number (for file type)")
33
280
  .action(async (tenant, project, requirement, opts) => {
34
281
  const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
35
- // Fetch current requirement to get existing artifacts
282
+ // Fetch current requirement
36
283
  const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
37
284
  const req = (reqData.data ?? []).find(r => r.id === resolvedId);
38
285
  if (!req) {
39
286
  console.error(`Requirement ${requirement} not found.`);
40
287
  process.exit(1);
41
288
  }
42
- const attrs = req.attributes ?? {};
43
- const artifacts = attrs.artifacts ?? [];
289
+ const artifacts = parseArtifacts(req.attributes);
44
290
  const newArtifact = { type: opts.type, path: opts.path };
45
291
  if (opts.label)
46
292
  newArtifact.label = opts.label;
47
293
  if (opts.line)
48
294
  newArtifact.line = parseInt(opts.line, 10);
295
+ // Check for duplicates
296
+ const exists = artifacts.some(a => a.type === newArtifact.type && a.path === newArtifact.path);
297
+ if (exists) {
298
+ console.log("Artifact already linked.");
299
+ return;
300
+ }
49
301
  artifacts.push(newArtifact);
302
+ const updatedAttributes = {
303
+ ...(req.attributes ?? {}),
304
+ artifacts: JSON.stringify(artifacts),
305
+ };
50
306
  await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
51
- attributes: { ...attrs, artifacts },
307
+ attributes: updatedAttributes,
52
308
  });
53
309
  console.log("Artifact linked.");
54
310
  });
311
+ // ── impl unlink ──────────────────────────────────────────
55
312
  cmd
56
313
  .command("unlink")
57
314
  .description("Remove an artifact link from a requirement")
@@ -68,11 +325,18 @@ export function registerImplementationCommands(program, client) {
68
325
  console.error(`Requirement ${requirement} not found.`);
69
326
  process.exit(1);
70
327
  }
71
- const attrs = req.attributes ?? {};
72
- const artifacts = attrs.artifacts ?? [];
328
+ const artifacts = parseArtifacts(req.attributes);
73
329
  const filtered = artifacts.filter(a => !(a.type === opts.type && a.path === opts.path));
330
+ if (filtered.length === artifacts.length) {
331
+ console.log("No matching artifact found.");
332
+ return;
333
+ }
334
+ const updatedAttributes = {
335
+ ...(req.attributes ?? {}),
336
+ artifacts: filtered.length > 0 ? JSON.stringify(filtered) : null,
337
+ };
74
338
  await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
75
- attributes: { ...attrs, artifacts: filtered },
339
+ attributes: updatedAttributes,
76
340
  });
77
341
  console.log("Artifact unlinked.");
78
342
  });
@@ -1,4 +1,32 @@
1
1
  import { output, printTable, isJsonMode, truncate } from "../output.js";
2
+ const IMPL_TAG_PREFIX = "impl:";
3
+ const IMPL_STATUSES = ["not_started", "in_progress", "implemented", "verified", "blocked"];
4
+ const PAGE_SIZE = 100;
5
+ const MAX_PAGES = 50;
6
+ function getImplStatus(tags) {
7
+ if (!tags)
8
+ return null;
9
+ const implTag = tags.find(t => t.startsWith(IMPL_TAG_PREFIX));
10
+ if (!implTag)
11
+ return null;
12
+ const status = implTag.slice(IMPL_TAG_PREFIX.length);
13
+ return IMPL_STATUSES.includes(status) ? status : null;
14
+ }
15
+ async function fetchAllRequirements(client, tenant, project) {
16
+ const all = [];
17
+ for (let page = 1; page <= MAX_PAGES; page++) {
18
+ const data = await client.get(`/requirements/${tenant}/${project}`, {
19
+ page: String(page),
20
+ limit: String(PAGE_SIZE),
21
+ });
22
+ const items = data.data ?? [];
23
+ all.push(...items);
24
+ const totalPages = data.meta?.totalPages ?? 1;
25
+ if (page >= totalPages)
26
+ break;
27
+ }
28
+ return all.filter(r => !r.deleted && !r.deletedAt);
29
+ }
2
30
  export function registerReportCommands(program, client) {
3
31
  const cmd = program.command("reports").alias("report").description("Project reports");
4
32
  cmd
@@ -33,10 +61,8 @@ export function registerReportCommands(program, client) {
33
61
  .description("Quality score summary")
34
62
  .argument("<tenant>", "Tenant slug")
35
63
  .argument("<project>", "Project slug")
36
- .option("-l, --limit <n>", "Max requirements to fetch", "100")
37
- .action(async (tenant, project, opts) => {
38
- const data = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: opts.limit });
39
- const reqs = data.data ?? [];
64
+ .action(async (tenant, project) => {
65
+ const reqs = await fetchAllRequirements(client, tenant, project);
40
66
  const scored = reqs.filter(r => r.qaScore != null);
41
67
  const avg = scored.length > 0
42
68
  ? scored.reduce((sum, r) => sum + (r.qaScore ?? 0), 0) / scored.length
@@ -61,23 +87,38 @@ export function registerReportCommands(program, client) {
61
87
  });
62
88
  cmd
63
89
  .command("compliance")
64
- .description("Compliance status summary")
90
+ .description("Compliance and implementation status summary")
65
91
  .argument("<tenant>", "Tenant slug")
66
92
  .argument("<project>", "Project slug")
67
- .option("-l, --limit <n>", "Max requirements to fetch", "100")
68
- .action(async (tenant, project, opts) => {
69
- const data = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: opts.limit });
70
- const reqs = data.data ?? [];
71
- const counts = {};
93
+ .action(async (tenant, project) => {
94
+ const reqs = await fetchAllRequirements(client, tenant, project);
95
+ // Compliance status
96
+ const compCounts = {};
72
97
  for (const r of reqs) {
73
98
  const status = r.complianceStatus || "Unset";
74
- counts[status] = (counts[status] ?? 0) + 1;
99
+ compCounts[status] = (compCounts[status] ?? 0) + 1;
100
+ }
101
+ // Implementation status (read from tags, matching MCP server)
102
+ const implCounts = {};
103
+ for (const s of IMPL_STATUSES)
104
+ implCounts[s] = 0;
105
+ implCounts["unset"] = 0;
106
+ for (const r of reqs) {
107
+ const status = getImplStatus(r.tags);
108
+ if (status)
109
+ implCounts[status]++;
110
+ else
111
+ implCounts["unset"]++;
75
112
  }
76
113
  if (isJsonMode()) {
77
- output(counts);
114
+ output({ total: reqs.length, compliance: compCounts, implementation: implCounts });
78
115
  }
79
116
  else {
80
- printTable(["Status", "Count"], Object.entries(counts).map(([k, v]) => [k, String(v)]));
117
+ console.log(`Total requirements: ${reqs.length}\n`);
118
+ console.log("Compliance Status:");
119
+ printTable(["Status", "Count"], Object.entries(compCounts).map(([k, v]) => [k, String(v)]));
120
+ console.log("\nImplementation Status:");
121
+ printTable(["Status", "Count"], Object.entries(implCounts).filter(([, v]) => v > 0).map(([k, v]) => [k, String(v)]));
81
122
  }
82
123
  });
83
124
  cmd
@@ -85,13 +126,11 @@ export function registerReportCommands(program, client) {
85
126
  .description("Find requirements with no trace links")
86
127
  .argument("<tenant>", "Tenant slug")
87
128
  .argument("<project>", "Project slug")
88
- .option("-l, --limit <n>", "Max requirements to fetch", "100")
89
- .action(async (tenant, project, opts) => {
90
- const [reqData, linkData] = await Promise.all([
91
- client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: opts.limit }),
129
+ .action(async (tenant, project) => {
130
+ const [reqs, linkData] = await Promise.all([
131
+ fetchAllRequirements(client, tenant, project),
92
132
  client.get(`/trace-links/${tenant}/${project}`),
93
133
  ]);
94
- const reqs = reqData.data ?? [];
95
134
  const links = linkData.links ?? [];
96
135
  const linked = new Set();
97
136
  for (const l of links) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgen-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "AIRGen CLI — requirements engineering from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",