airgen-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * AIRGen HTTP API client with JWT authentication.
3
+ *
4
+ * Handles login, token caching, and automatic refresh.
5
+ */
6
+ export interface ClientConfig {
7
+ apiUrl: string;
8
+ email: string;
9
+ password: string;
10
+ }
11
+ export declare class AirgenClient {
12
+ private config;
13
+ private auth;
14
+ private loginPromise;
15
+ constructor(config: ClientConfig);
16
+ /** The base API URL this client is configured for. */
17
+ get apiUrl(): string;
18
+ get<T = unknown>(path: string, query?: Record<string, string | number | undefined>): Promise<T>;
19
+ post<T = unknown>(path: string, body?: unknown): Promise<T>;
20
+ patch<T = unknown>(path: string, body?: unknown): Promise<T>;
21
+ delete<T = unknown>(path: string, body?: unknown): Promise<T>;
22
+ /** POST multipart/form-data (for file uploads). */
23
+ postMultipart<T = unknown>(path: string, fields: Record<string, string>, file: {
24
+ fieldName?: string;
25
+ filename: string;
26
+ contentType: string;
27
+ data: Buffer;
28
+ }): Promise<T>;
29
+ /** Fetch binary content from an API path (e.g. document file download). */
30
+ fetchBinary(path: string): Promise<{
31
+ data: Buffer;
32
+ contentType: string;
33
+ }>;
34
+ /** Fetch a static file from the server root (not the /api prefix). */
35
+ fetchStaticFile(relativePath: string): Promise<{
36
+ data: Buffer;
37
+ contentType: string;
38
+ }>;
39
+ private request;
40
+ private fetch;
41
+ private handleResponse;
42
+ private ensureAuth;
43
+ private login;
44
+ private refresh;
45
+ }
46
+ export declare class AirgenApiError extends Error {
47
+ statusCode: number;
48
+ apiMessage: string;
49
+ endpoint: string;
50
+ constructor(statusCode: number, apiMessage: string, endpoint: string);
51
+ }
package/dist/client.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * AIRGen HTTP API client with JWT authentication.
3
+ *
4
+ * Handles login, token caching, and automatic refresh.
5
+ */
6
+ export class AirgenClient {
7
+ config;
8
+ auth = {
9
+ accessToken: null,
10
+ refreshToken: null,
11
+ tokenExpiresAt: 0,
12
+ };
13
+ loginPromise = null;
14
+ constructor(config) {
15
+ this.config = config;
16
+ }
17
+ /** The base API URL this client is configured for. */
18
+ get apiUrl() {
19
+ return this.config.apiUrl;
20
+ }
21
+ // ── HTTP methods ──────────────────────────────────────────────
22
+ async get(path, query) {
23
+ const qs = buildQueryString(query);
24
+ return this.request("GET", `${path}${qs}`);
25
+ }
26
+ async post(path, body) {
27
+ return this.request("POST", path, body);
28
+ }
29
+ async patch(path, body) {
30
+ return this.request("PATCH", path, body);
31
+ }
32
+ async delete(path, body) {
33
+ return this.request("DELETE", path, body);
34
+ }
35
+ /** POST multipart/form-data (for file uploads). */
36
+ async postMultipart(path, fields, file) {
37
+ await this.ensureAuth();
38
+ const boundary = `----AirgenMCP${Date.now()}${Math.random().toString(36).slice(2)}`;
39
+ const parts = [];
40
+ for (const [key, value] of Object.entries(fields)) {
41
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`));
42
+ }
43
+ const fieldName = file.fieldName ?? "file";
44
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`));
45
+ parts.push(file.data);
46
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
47
+ const body = Buffer.concat(parts);
48
+ const headers = {
49
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
50
+ "Content-Length": String(body.byteLength),
51
+ };
52
+ if (this.auth.accessToken) {
53
+ headers["Authorization"] = `Bearer ${this.auth.accessToken}`;
54
+ }
55
+ if (this.auth.refreshToken) {
56
+ headers["Cookie"] = `airgen_refresh=${this.auth.refreshToken}`;
57
+ }
58
+ const url = `${this.config.apiUrl}${path}`;
59
+ const res = await globalThis.fetch(url, { method: "POST", headers, body });
60
+ return this.handleResponse(res, path);
61
+ }
62
+ /** Fetch binary content from an API path (e.g. document file download). */
63
+ async fetchBinary(path) {
64
+ await this.ensureAuth();
65
+ const res = await this.fetch("GET", path);
66
+ if (!res.ok) {
67
+ const text = await res.text();
68
+ const msg = text || `Request failed with status ${res.status}`;
69
+ throw new AirgenApiError(res.status, msg, path);
70
+ }
71
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
72
+ const buffer = Buffer.from(await res.arrayBuffer());
73
+ return { data: buffer, contentType };
74
+ }
75
+ /** Fetch a static file from the server root (not the /api prefix). */
76
+ async fetchStaticFile(relativePath) {
77
+ const baseUrl = this.config.apiUrl.replace(/\/api\/?$/, "");
78
+ const url = `${baseUrl}${relativePath}`;
79
+ const res = await globalThis.fetch(url);
80
+ if (!res.ok) {
81
+ throw new AirgenApiError(res.status, `Failed to fetch ${relativePath}`, relativePath);
82
+ }
83
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
84
+ const buffer = Buffer.from(await res.arrayBuffer());
85
+ return { data: buffer, contentType };
86
+ }
87
+ // ── Internal ──────────────────────────────────────────────────
88
+ async request(method, path, body) {
89
+ await this.ensureAuth();
90
+ const res = await this.fetch(method, path, body);
91
+ // If 401, try refreshing once then retry
92
+ if (res.status === 401 && this.auth.refreshToken) {
93
+ await this.refresh();
94
+ const retry = await this.fetch(method, path, body);
95
+ return this.handleResponse(retry, path);
96
+ }
97
+ return this.handleResponse(res, path);
98
+ }
99
+ async fetch(method, path, body) {
100
+ const headers = {};
101
+ if (this.auth.accessToken) {
102
+ headers["Authorization"] = `Bearer ${this.auth.accessToken}`;
103
+ }
104
+ if (body !== undefined) {
105
+ headers["Content-Type"] = "application/json";
106
+ }
107
+ if (this.auth.refreshToken) {
108
+ headers["Cookie"] = `airgen_refresh=${this.auth.refreshToken}`;
109
+ }
110
+ const url = `${this.config.apiUrl}${path}`;
111
+ try {
112
+ return await globalThis.fetch(url, {
113
+ method,
114
+ headers,
115
+ body: body !== undefined ? JSON.stringify(body) : undefined,
116
+ });
117
+ }
118
+ catch (err) {
119
+ const e = err;
120
+ const cause = e.cause ? ` cause=${e.cause.message ?? e.cause}` : "";
121
+ console.error(`[fetch error] ${method} ${url}: ${e.message}${cause}`);
122
+ throw new Error(`API request failed: ${method} ${path} — ${e.message}`, { cause: err });
123
+ }
124
+ }
125
+ async handleResponse(res, path) {
126
+ if (res.status === 204) {
127
+ return undefined;
128
+ }
129
+ const text = await res.text();
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(text);
133
+ }
134
+ catch {
135
+ parsed = text;
136
+ }
137
+ if (!res.ok) {
138
+ const msg = typeof parsed === "object" && parsed !== null && "error" in parsed
139
+ ? parsed.error
140
+ : typeof parsed === "string"
141
+ ? parsed
142
+ : `Request failed with status ${res.status}`;
143
+ throw new AirgenApiError(res.status, msg, path);
144
+ }
145
+ return parsed;
146
+ }
147
+ // ── Auth ──────────────────────────────────────────────────────
148
+ async ensureAuth() {
149
+ // Already have a valid token (with 2min buffer)
150
+ if (this.auth.accessToken && Date.now() < this.auth.tokenExpiresAt - 120_000) {
151
+ return;
152
+ }
153
+ // Token exists but is expiring — try refresh
154
+ if (this.auth.refreshToken) {
155
+ await this.refresh();
156
+ return;
157
+ }
158
+ // No token at all — login
159
+ // Deduplicate concurrent login attempts
160
+ if (!this.loginPromise) {
161
+ this.loginPromise = this.login().finally(() => {
162
+ this.loginPromise = null;
163
+ });
164
+ }
165
+ await this.loginPromise;
166
+ }
167
+ async login() {
168
+ const res = await globalThis.fetch(`${this.config.apiUrl}/auth/login`, {
169
+ method: "POST",
170
+ headers: { "Content-Type": "application/json" },
171
+ body: JSON.stringify({
172
+ email: this.config.email,
173
+ password: this.config.password,
174
+ }),
175
+ });
176
+ if (!res.ok) {
177
+ const text = await res.text();
178
+ let msg = `Login failed (${res.status})`;
179
+ try {
180
+ const json = JSON.parse(text);
181
+ msg = json.error ?? msg;
182
+ }
183
+ catch { /* keep default */ }
184
+ throw new AirgenApiError(res.status, msg, "/auth/login");
185
+ }
186
+ const data = await res.json();
187
+ if ("status" in data && data.status === "MFA_REQUIRED") {
188
+ throw new Error("AIRGen account has MFA enabled. MFA is not supported in headless MCP mode. " +
189
+ "Please disable MFA for the MCP service account, or use a dedicated account without MFA.");
190
+ }
191
+ if (!("token" in data)) {
192
+ throw new Error("Unexpected login response from AIRGen API");
193
+ }
194
+ this.auth.accessToken = data.token;
195
+ this.auth.tokenExpiresAt = decodeTokenExpiry(data.token);
196
+ this.auth.refreshToken = extractRefreshToken(res);
197
+ }
198
+ async refresh() {
199
+ const headers = {};
200
+ if (this.auth.refreshToken) {
201
+ headers["Cookie"] = `airgen_refresh=${this.auth.refreshToken}`;
202
+ }
203
+ const res = await globalThis.fetch(`${this.config.apiUrl}/auth/refresh`, {
204
+ method: "POST",
205
+ headers,
206
+ });
207
+ if (!res.ok) {
208
+ // Refresh failed — clear state and re-login
209
+ this.auth = { accessToken: null, refreshToken: null, tokenExpiresAt: 0 };
210
+ await this.login();
211
+ return;
212
+ }
213
+ const data = await res.json();
214
+ this.auth.accessToken = data.token;
215
+ this.auth.tokenExpiresAt = decodeTokenExpiry(data.token);
216
+ const newRefresh = extractRefreshToken(res);
217
+ if (newRefresh) {
218
+ this.auth.refreshToken = newRefresh;
219
+ }
220
+ }
221
+ }
222
+ // ── Error class ───────────────────────────────────────────────
223
+ export class AirgenApiError extends Error {
224
+ statusCode;
225
+ apiMessage;
226
+ endpoint;
227
+ constructor(statusCode, apiMessage, endpoint) {
228
+ super(`AIRGen API error (${statusCode}) on ${endpoint}: ${apiMessage}`);
229
+ this.statusCode = statusCode;
230
+ this.apiMessage = apiMessage;
231
+ this.endpoint = endpoint;
232
+ this.name = "AirgenApiError";
233
+ }
234
+ }
235
+ // ── Helpers ───────────────────────────────────────────────────
236
+ function decodeTokenExpiry(jwt) {
237
+ try {
238
+ const payload = jwt.split(".")[1];
239
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString());
240
+ return (decoded.exp ?? 0) * 1000; // Convert seconds to ms
241
+ }
242
+ catch {
243
+ // If we can't decode, assume 10 minutes from now
244
+ return Date.now() + 10 * 60 * 1000;
245
+ }
246
+ }
247
+ function extractRefreshToken(res) {
248
+ const setCookie = res.headers.get("set-cookie");
249
+ if (!setCookie)
250
+ return null;
251
+ // Parse airgen_refresh cookie value
252
+ const match = setCookie.match(/airgen_refresh=([^;]+)/);
253
+ return match ? match[1] : null;
254
+ }
255
+ function buildQueryString(params) {
256
+ if (!params)
257
+ return "";
258
+ const entries = Object.entries(params).filter((entry) => entry[1] !== undefined);
259
+ if (entries.length === 0)
260
+ return "";
261
+ const qs = new URLSearchParams(entries.map(([k, v]) => [k, String(v)]));
262
+ return `?${qs.toString()}`;
263
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import type { AirgenClient } from "../client.js";
3
+ export declare function registerActivityCommands(program: Command, client: AirgenClient): void;
@@ -0,0 +1,33 @@
1
+ import { output, printTable, isJsonMode, truncate } from "../output.js";
2
+ export function registerActivityCommands(program, client) {
3
+ const cmd = program.command("activity").description("Project activity timeline");
4
+ cmd
5
+ .command("list")
6
+ .description("Get activity timeline for a project")
7
+ .argument("<tenant>", "Tenant slug")
8
+ .argument("<project>", "Project slug")
9
+ .option("-l, --limit <n>", "Max entries")
10
+ .option("--type <type>", "Activity type filter")
11
+ .option("--action <action>", "Action type filter")
12
+ .action(async (tenant, project, opts) => {
13
+ const data = await client.get("/activity", {
14
+ tenantSlug: tenant,
15
+ projectSlug: project,
16
+ limit: opts.limit,
17
+ activityTypes: opts.type,
18
+ actionTypes: opts.action,
19
+ });
20
+ const activities = data.activities ?? [];
21
+ if (isJsonMode()) {
22
+ output(activities);
23
+ }
24
+ else {
25
+ printTable(["Time", "Type", "Action", "Description"], activities.map(a => [
26
+ a.createdAt ?? "",
27
+ a.type ?? "",
28
+ a.action ?? "",
29
+ truncate(a.description ?? "", 60),
30
+ ]));
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import type { AirgenClient } from "../client.js";
3
+ export declare function registerAiCommands(program: Command, client: AirgenClient): void;
@@ -0,0 +1,86 @@
1
+ import { output, printTable, isJsonMode, truncate } from "../output.js";
2
+ export function registerAiCommands(program, client) {
3
+ const cmd = program.command("ai").description("AI generation and candidates");
4
+ cmd
5
+ .command("generate")
6
+ .description("Generate requirement candidates using AI")
7
+ .argument("<tenant>", "Tenant slug")
8
+ .argument("<project-key>", "Project key")
9
+ .requiredOption("--input <text>", "Natural language input")
10
+ .option("--glossary <text>", "Domain glossary")
11
+ .option("--constraints <text>", "Constraints")
12
+ .option("-n, --count <n>", "Number of candidates")
13
+ .action(async (tenant, projectKey, opts) => {
14
+ const data = await client.post("/airgen/chat", {
15
+ tenant,
16
+ projectKey,
17
+ user_input: opts.input,
18
+ glossary: opts.glossary,
19
+ constraints: opts.constraints,
20
+ n: opts.count ? parseInt(opts.count, 10) : undefined,
21
+ mode: "generate",
22
+ });
23
+ output(data);
24
+ });
25
+ cmd
26
+ .command("candidates")
27
+ .description("List pending AI-generated candidates")
28
+ .argument("<tenant>", "Tenant slug")
29
+ .argument("<project>", "Project slug")
30
+ .action(async (tenant, project) => {
31
+ const data = await client.get(`/airgen/candidates/${tenant}/${project}`);
32
+ const candidates = data.candidates ?? [];
33
+ if (isJsonMode()) {
34
+ output(candidates);
35
+ }
36
+ else {
37
+ printTable(["ID", "Text", "Status", "Created"], candidates.map(c => [
38
+ c.id,
39
+ truncate(c.text ?? "", 60),
40
+ c.status ?? "",
41
+ c.createdAt ?? "",
42
+ ]));
43
+ }
44
+ });
45
+ cmd
46
+ .command("accept")
47
+ .description("Accept a candidate and promote to requirement")
48
+ .argument("<candidate-id>", "Candidate ID")
49
+ .requiredOption("--tenant <slug>", "Tenant slug")
50
+ .requiredOption("--project-key <key>", "Project key")
51
+ .option("--pattern <p>", "Pattern")
52
+ .option("--verification <v>", "Verification method")
53
+ .option("--document <slug>", "Document slug")
54
+ .option("--section <id>", "Section ID")
55
+ .option("--tags <tags>", "Comma-separated tags")
56
+ .action(async (candidateId, opts) => {
57
+ const data = await client.post(`/airgen/candidates/${candidateId}/accept`, {
58
+ tenant: opts.tenant,
59
+ projectKey: opts.projectKey,
60
+ pattern: opts.pattern,
61
+ verification: opts.verification,
62
+ documentSlug: opts.document,
63
+ sectionId: opts.section,
64
+ tags: opts.tags?.split(",").map(t => t.trim()),
65
+ });
66
+ if (isJsonMode()) {
67
+ output(data);
68
+ }
69
+ else {
70
+ console.log("Candidate accepted.");
71
+ }
72
+ });
73
+ cmd
74
+ .command("reject")
75
+ .description("Reject a candidate")
76
+ .argument("<candidate-id>", "Candidate ID")
77
+ .requiredOption("--tenant <slug>", "Tenant slug")
78
+ .requiredOption("--project-key <key>", "Project key")
79
+ .action(async (candidateId, opts) => {
80
+ await client.post(`/airgen/candidates/${candidateId}/reject`, {
81
+ tenant: opts.tenant,
82
+ projectKey: opts.projectKey,
83
+ });
84
+ console.log("Candidate rejected.");
85
+ });
86
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import type { AirgenClient } from "../client.js";
3
+ export declare function registerBaselineCommands(program: Command, client: AirgenClient): void;
@@ -0,0 +1,55 @@
1
+ import { output, printTable, isJsonMode } from "../output.js";
2
+ export function registerBaselineCommands(program, client) {
3
+ const cmd = program.command("baselines").alias("bl").description("Baseline snapshots");
4
+ cmd
5
+ .command("list")
6
+ .description("List all baselines for a project")
7
+ .argument("<tenant>", "Tenant slug")
8
+ .argument("<project>", "Project slug")
9
+ .action(async (tenant, project) => {
10
+ const data = await client.get(`/baselines/${tenant}/${project}`);
11
+ const baselines = data.baselines ?? [];
12
+ if (isJsonMode()) {
13
+ output(baselines);
14
+ }
15
+ else {
16
+ printTable(["Ref", "Label", "Created", "Requirements"], baselines.map(b => [
17
+ b.ref ?? b.id,
18
+ b.label ?? "",
19
+ b.createdAt ?? "",
20
+ String(b.requirementCount ?? 0),
21
+ ]));
22
+ }
23
+ });
24
+ cmd
25
+ .command("create")
26
+ .description("Create a new baseline snapshot")
27
+ .argument("<tenant>", "Tenant slug")
28
+ .argument("<project-key>", "Project key")
29
+ .option("--label <label>", "Baseline label")
30
+ .action(async (tenant, projectKey, opts) => {
31
+ const data = await client.post("/baseline", {
32
+ tenant,
33
+ projectKey,
34
+ label: opts.label,
35
+ });
36
+ if (isJsonMode()) {
37
+ output(data);
38
+ }
39
+ else {
40
+ console.log("Baseline created.");
41
+ output(data);
42
+ }
43
+ });
44
+ cmd
45
+ .command("compare")
46
+ .description("Compare two baselines")
47
+ .argument("<tenant>", "Tenant slug")
48
+ .argument("<project>", "Project slug")
49
+ .requiredOption("--from <ref>", "From baseline ref")
50
+ .requiredOption("--to <ref>", "To baseline ref")
51
+ .action(async (tenant, project, opts) => {
52
+ const data = await client.get(`/baselines/${tenant}/${project}/compare`, { from: opts.from, to: opts.to });
53
+ output(data);
54
+ });
55
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import type { AirgenClient } from "../client.js";
3
+ export declare function registerDiagramCommands(program: Command, client: AirgenClient): void;