@general-input/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.
package/dist/cli.js ADDED
@@ -0,0 +1,2999 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/lib/output.ts
7
+ import chalk from "chalk";
8
+ function printSuccess(message) {
9
+ process.stdout.write(`${chalk.green("\u2713")} ${message}
10
+ `);
11
+ }
12
+ function printInfo(message) {
13
+ process.stdout.write(`${chalk.dim("\xB7")} ${message}
14
+ `);
15
+ }
16
+ function printWarning(message) {
17
+ process.stderr.write(`${chalk.yellow("!")} ${message}
18
+ `);
19
+ }
20
+ function printError(message) {
21
+ process.stderr.write(`${chalk.red("\u2717")} ${message}
22
+ `);
23
+ }
24
+ function printJson(value) {
25
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
26
+ }
27
+
28
+ // src/lib/banner.ts
29
+ import chalk2 from "chalk";
30
+ function printBanner(args) {
31
+ if (args.json) return;
32
+ if (process.env.NO_GENI_BANNER === "1") return;
33
+ if (!process.stdout.isTTY) return;
34
+ if (!args.session) return;
35
+ const ws = args.session.workspace;
36
+ const parts = [
37
+ chalk2.dim("geni"),
38
+ chalk2.dim("\xB7"),
39
+ chalk2.dim("workspace:"),
40
+ chalk2.cyan(ws.slug)
41
+ ];
42
+ if (args.workflowId) {
43
+ parts.push(
44
+ chalk2.dim("\xB7"),
45
+ chalk2.dim("workflow:"),
46
+ chalk2.cyan(args.workflowId)
47
+ );
48
+ if (args.workflowName) {
49
+ parts.push(chalk2.dim(`(${args.workflowName})`));
50
+ }
51
+ }
52
+ process.stderr.write(parts.join(" ") + "\n");
53
+ const titleParts = [`geni \xB7 ${ws.slug}`];
54
+ if (args.workflowId) titleParts.push(args.workflowId);
55
+ process.stderr.write(`\x1B]0;${titleParts.join(" \xB7 ")}\x07`);
56
+ }
57
+
58
+ // src/lib/exitCodes.ts
59
+ var ExitCode = {
60
+ Ok: 0,
61
+ GenericError: 1,
62
+ InvalidArgs: 2,
63
+ NotFound: 4,
64
+ Forbidden: 5,
65
+ ValidationFailed: 9,
66
+ CredentialResolveFailed: 77,
67
+ SessionMissingOrExpired: 78,
68
+ UpgradeRequired: 79,
69
+ Timeout: 124,
70
+ InternalError: 125
71
+ };
72
+ function exit(code) {
73
+ process.exit(code);
74
+ }
75
+
76
+ // src/services/SessionContextService.ts
77
+ var SessionContextService = class {
78
+ constructor(sessionStore2, apiClientFactory2) {
79
+ this.sessionStore = sessionStore2;
80
+ this.apiClientFactory = apiClientFactory2;
81
+ }
82
+ sessionStore;
83
+ apiClientFactory;
84
+ /** Read the session file, returning `null` if no session exists. */
85
+ load() {
86
+ return this.sessionStore.load();
87
+ }
88
+ /**
89
+ * Load the session and build an authed API-client bundle. Exits
90
+ * with code 78 + a "run `geni login`" hint when no session exists.
91
+ *
92
+ * Also prints the workspace banner on stderr (via `printBanner`,
93
+ * which TTY-suppresses + has the `NO_GENI_BANNER` escape hatch),
94
+ * so the operator always sees which workspace the command is
95
+ * targeting before any output appears.
96
+ */
97
+ async requireAuthed() {
98
+ const session = await this.sessionStore.load();
99
+ if (!session) {
100
+ printError(
101
+ "No runner session on disk. Run `geni login` to authenticate, then retry."
102
+ );
103
+ exit(ExitCode.SessionMissingOrExpired);
104
+ }
105
+ printBanner({ session });
106
+ return {
107
+ session,
108
+ client: this.apiClientFactory.build({
109
+ server: session.server,
110
+ token: session.token
111
+ })
112
+ };
113
+ }
114
+ };
115
+
116
+ // src/services/AuthService.ts
117
+ import { hostname } from "os";
118
+ import chalk3 from "chalk";
119
+ var AuthService = class {
120
+ constructor(apiClientFactory2, sessionStore2, browserOpener2, configService2) {
121
+ this.apiClientFactory = apiClientFactory2;
122
+ this.sessionStore = sessionStore2;
123
+ this.browserOpener = browserOpener2;
124
+ this.configService = configService2;
125
+ }
126
+ apiClientFactory;
127
+ sessionStore;
128
+ browserOpener;
129
+ configService;
130
+ /**
131
+ * Run the device-code login flow end-to-end. Mints a runner-session
132
+ * token, saves it locally, and (when `--workspace <slug>` was
133
+ * passed) re-binds the session to a different workspace than the
134
+ * one the dashboard's approval picker chose.
135
+ */
136
+ async login(args) {
137
+ const server = this.configService.resolveApiUrl(args.server);
138
+ const client = this.apiClientFactory.build({ server, token: null });
139
+ const start = await client.auth.startDeviceCode(buildClientLabel());
140
+ printInfo(`Opening ${chalk3.cyan(start.verificationUri)}`);
141
+ printInfo("Approve in your browser to continue.");
142
+ this.browserOpener.open(start.verificationUri);
143
+ const result = await this.pollUntilResolved(client, start);
144
+ if (result.status === "denied") {
145
+ printError(
146
+ 'Login was declined in the browser. Run `geni login` again and click "Authorize" on the device-code page.'
147
+ );
148
+ exit(ExitCode.Forbidden);
149
+ }
150
+ if (result.status === "expired") {
151
+ printError(
152
+ "Device code expired before the browser approved it (codes live ~10 minutes). Run `geni login` to start a fresh code."
153
+ );
154
+ exit(ExitCode.GenericError);
155
+ }
156
+ if ("tokenAlreadyConsumed" in result) {
157
+ printError(
158
+ "Login approved but a parallel `geni login` already consumed the device code (race). Run `geni login` again to mint a new session."
159
+ );
160
+ exit(ExitCode.GenericError);
161
+ }
162
+ await this.sessionStore.save({
163
+ version: 1,
164
+ server,
165
+ token: result.sessionToken,
166
+ user: {
167
+ id: result.me.user.id,
168
+ email: result.me.user.email ?? null,
169
+ name: result.me.user.name ?? null
170
+ },
171
+ workspace: {
172
+ membershipId: result.me.workspace.membershipId,
173
+ organizationId: result.me.workspace.organizationId,
174
+ slug: result.me.workspace.slug,
175
+ name: result.me.workspace.name,
176
+ role: result.me.workspace.role
177
+ },
178
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
179
+ });
180
+ if (args.workspace) {
181
+ await this.maybeRebindWorkspace({
182
+ server,
183
+ requestedSlug: args.workspace
184
+ });
185
+ }
186
+ const final = await this.sessionStore.load();
187
+ if (!final) {
188
+ printError(
189
+ "Session was written to ~/.config/geni/runner-session.json but the file could not be re-read immediately. Check filesystem permissions on ~/.config/geni and re-run `geni login`."
190
+ );
191
+ exit(ExitCode.InternalError);
192
+ }
193
+ printSuccess(`Authenticated as ${final.user.email ?? final.user.id}`);
194
+ printSuccess(
195
+ `Active workspace: ${final.workspace.slug} (${final.workspace.name})`
196
+ );
197
+ printInfo("Session saved to ~/.config/geni/runner-session.json");
198
+ }
199
+ /**
200
+ * Revoke the runner session server-side and delete the local file.
201
+ * The local file is removed even if the server-side revoke fails:
202
+ * the operator running `geni logout` should never be left with a
203
+ * token they think is gone but is still on disk.
204
+ */
205
+ async logout() {
206
+ const session = await this.sessionStore.load();
207
+ if (!session) {
208
+ printInfo("No active session.");
209
+ return;
210
+ }
211
+ try {
212
+ const client = this.apiClientFactory.build({
213
+ server: session.server,
214
+ token: session.token
215
+ });
216
+ await client.auth.logout();
217
+ printSuccess("Session revoked.");
218
+ } catch (error) {
219
+ const message = error instanceof Error ? error.message : String(error);
220
+ printInfo(`Server revoke failed: ${message}`);
221
+ printInfo("Removing local session anyway.");
222
+ }
223
+ await this.sessionStore.delete();
224
+ printSuccess("Removed ~/.config/geni/runner-session.json");
225
+ }
226
+ /**
227
+ * Verify the active session by hitting `/cli/auth/me`. Returns the
228
+ * resolved status when the session is valid, or `null` when no
229
+ * local session exists. Stale-session failures throw `ApiError(401)`
230
+ * so commands can map them to exit code 78 with the same
231
+ * "run `geni login`" hint as the no-session path.
232
+ */
233
+ async status() {
234
+ const session = await this.sessionStore.load();
235
+ if (!session) return null;
236
+ const client = this.apiClientFactory.build({
237
+ server: session.server,
238
+ token: session.token
239
+ });
240
+ const me = await client.auth.me();
241
+ return {
242
+ authenticated: true,
243
+ user: {
244
+ id: me.user.id,
245
+ email: me.user.email ?? null,
246
+ name: me.user.name ?? null
247
+ },
248
+ workspace: me.workspace,
249
+ server: session.server
250
+ };
251
+ }
252
+ /**
253
+ * Re-bind the active session to a different workspace by slug. Used
254
+ * by `geni login --workspace <slug>` after the dashboard's approval
255
+ * picker chose a different workspace than the operator wanted.
256
+ */
257
+ async maybeRebindWorkspace(args) {
258
+ const session = await this.sessionStore.load();
259
+ if (!session) return;
260
+ if (args.requestedSlug === session.workspace.slug) return;
261
+ const client = this.apiClientFactory.build({
262
+ server: args.server,
263
+ token: session.token
264
+ });
265
+ const list = await client.workspaces.list();
266
+ const target = list.workspaces.find((w) => w.slug === args.requestedSlug);
267
+ if (!target) {
268
+ const available = list.workspaces.map((w) => w.slug).join(", ") || "none";
269
+ printError(
270
+ `No workspace with slug "${args.requestedSlug}" on this account. Available: [${available}]. Re-run \`geni login --workspace <slug>\` with one of those.`
271
+ );
272
+ exit(ExitCode.NotFound);
273
+ }
274
+ const me = await client.workspaces.switch(target.membershipId);
275
+ await this.persistSwitch({ session, me });
276
+ }
277
+ async persistSwitch(args) {
278
+ await this.sessionStore.save({
279
+ ...args.session,
280
+ workspace: {
281
+ membershipId: args.me.workspace.membershipId,
282
+ organizationId: args.me.workspace.organizationId,
283
+ slug: args.me.workspace.slug,
284
+ name: args.me.workspace.name,
285
+ role: args.me.workspace.role
286
+ },
287
+ user: {
288
+ id: args.me.user.id,
289
+ email: args.me.user.email ?? args.session.user.email,
290
+ name: args.me.user.name ?? args.session.user.name
291
+ },
292
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
293
+ });
294
+ }
295
+ /**
296
+ * Poll the device-code endpoint until status flips to a terminal
297
+ * value, or until the device code itself expires server-side. The
298
+ * `pending` variant is normalized away here — callers only see
299
+ * approved / denied / expired.
300
+ */
301
+ async pollUntilResolved(client, start) {
302
+ const intervalMs = start.intervalSeconds * 1e3;
303
+ const expiresAtMs = Date.parse(start.expiresAt);
304
+ const hardDeadline = expiresAtMs + 3e4;
305
+ while (Date.now() < hardDeadline) {
306
+ await sleep(intervalMs);
307
+ const status = await client.auth.pollDeviceCode(start.userCode);
308
+ if (status.status === "pending") continue;
309
+ return status;
310
+ }
311
+ return { status: "expired" };
312
+ }
313
+ };
314
+ function buildClientLabel() {
315
+ return `geni CLI on ${hostname()}`;
316
+ }
317
+ function sleep(ms) {
318
+ return new Promise((resolve) => setTimeout(resolve, ms));
319
+ }
320
+
321
+ // src/services/WorkspaceService.ts
322
+ var WorkspaceService = class {
323
+ constructor(sessionContext, sessionStore2) {
324
+ this.sessionContext = sessionContext;
325
+ this.sessionStore = sessionStore2;
326
+ }
327
+ sessionContext;
328
+ sessionStore;
329
+ /** Server-side list of every workspace the account belongs to. */
330
+ async list() {
331
+ const { client } = await this.sessionContext.requireAuthed();
332
+ return client.workspaces.list();
333
+ }
334
+ /**
335
+ * Switch the active workspace. Re-points the runner session
336
+ * server-side and updates the local cached pointer so subsequent
337
+ * commands operate against the new workspace.
338
+ *
339
+ * Returns:
340
+ * - `{ kind: 'switched', workspace }` on success,
341
+ * - `{ kind: 'no-change', workspace }` when the requested target
342
+ * is already the active one (lets the command print a friendly
343
+ * message without an unnecessary network round-trip),
344
+ * - `{ kind: 'not-found', requestedSlug }` when the slug isn't
345
+ * in the user's accessible workspaces. Commands map this to
346
+ * exit code 4.
347
+ */
348
+ async switch(args) {
349
+ const { client } = await this.sessionContext.requireAuthed();
350
+ const me = await client.workspaces.switch(args.membershipId);
351
+ await this.sessionStore.updateActiveWorkspace({
352
+ membershipId: me.workspace.membershipId,
353
+ organizationId: me.workspace.organizationId,
354
+ slug: me.workspace.slug,
355
+ name: me.workspace.name,
356
+ role: me.workspace.role
357
+ });
358
+ return me;
359
+ }
360
+ /**
361
+ * Read the active workspace from the local session cache (no
362
+ * network round-trip). Returns `null` when no session is loaded;
363
+ * the command renders the unauthenticated case.
364
+ */
365
+ async current() {
366
+ const session = await this.sessionStore.load();
367
+ if (!session) return null;
368
+ return {
369
+ workspace: {
370
+ membershipId: session.workspace.membershipId,
371
+ organizationId: session.workspace.organizationId,
372
+ slug: session.workspace.slug,
373
+ name: session.workspace.name,
374
+ role: session.workspace.role,
375
+ isActive: true
376
+ }
377
+ };
378
+ }
379
+ };
380
+
381
+ // src/services/ExecService.ts
382
+ import chalk4 from "chalk";
383
+
384
+ // package.json
385
+ var package_default = {
386
+ name: "@general-input/cli",
387
+ version: "0.1.0",
388
+ type: "module",
389
+ description: "The agent-facing CLI for General Input. Authenticate, manage workflows, run bash with operator credentials injected by the cloud.",
390
+ license: "SEE LICENSE IN LICENSE",
391
+ homepage: "https://generalinput.com/docs/cli",
392
+ keywords: [
393
+ "general-input",
394
+ "geni",
395
+ "cli",
396
+ "agent",
397
+ "ai",
398
+ "automation"
399
+ ],
400
+ engines: {
401
+ node: ">=20"
402
+ },
403
+ publishConfig: {
404
+ access: "public"
405
+ },
406
+ bin: {
407
+ geni: "./dist/cli.js"
408
+ },
409
+ files: [
410
+ "dist",
411
+ "README.md",
412
+ "LICENSE"
413
+ ],
414
+ scripts: {
415
+ dev: "tsup --watch",
416
+ build: "tsup",
417
+ clean: "rm -rf dist",
418
+ typecheck: "tsc --noEmit",
419
+ lint: "eslint . --fix --max-warnings 0",
420
+ format: "prettier --write . --ignore-path=../../.prettierignore",
421
+ test: "vitest run",
422
+ "test:watch": "vitest",
423
+ prepublishOnly: "pnpm build"
424
+ },
425
+ dependencies: {
426
+ "@clack/prompts": "^0.7.0",
427
+ chalk: "^5.3.0",
428
+ commander: "^12.1.0",
429
+ tar: "^7.4.3",
430
+ zod: "^4.3.6"
431
+ },
432
+ devDependencies: {
433
+ "@packages/api": "workspace:*",
434
+ "@packages/eslint-config": "workspace:*",
435
+ "@packages/typescript-config": "workspace:*",
436
+ "@types/node": "^24.12.2",
437
+ "@types/tar": "^6.1.13",
438
+ eslint: "^9.39.4",
439
+ tsup: "^8.3.5",
440
+ typescript: "^5.9.3",
441
+ vitest: "^4.1.5"
442
+ }
443
+ };
444
+
445
+ // src/lib/version.ts
446
+ var CLI_VERSION = package_default.version;
447
+
448
+ // src/clients/HttpClient.ts
449
+ var USER_AGENT = `geni/${CLI_VERSION} (${process.platform}/${process.arch}; node/${process.versions.node})`;
450
+ var ApiError = class extends Error {
451
+ constructor(message, status, body) {
452
+ super(message);
453
+ this.status = status;
454
+ this.body = body;
455
+ this.name = "ApiError";
456
+ }
457
+ status;
458
+ body;
459
+ };
460
+ var HttpClient = class {
461
+ constructor(server, token) {
462
+ this.server = server;
463
+ this.token = token;
464
+ }
465
+ server;
466
+ token;
467
+ /**
468
+ * Fetch a JSON endpoint. Caller owns the response shape via the
469
+ * generic; the wrapper returns the parsed JSON cast to `T`. Routes
470
+ * that need authentication MUST be reached via a client built with
471
+ * a non-null token; the wrapper does not enforce auth itself, that's
472
+ * the per-route client's job (each can `requireAuthed()` if needed).
473
+ */
474
+ async fetch(path, opts = {}) {
475
+ const url = `${this.server}${path}`;
476
+ const headers = {
477
+ "Content-Type": "application/json",
478
+ "User-Agent": USER_AGENT
479
+ };
480
+ if (this.token !== null) {
481
+ headers["Authorization"] = `Bearer ${this.token}`;
482
+ }
483
+ const response = await fetch(url, {
484
+ method: opts.method ?? "GET",
485
+ headers,
486
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
487
+ signal: opts.signal
488
+ });
489
+ return parseResponse(response);
490
+ }
491
+ /** True when this transport carries a bound runner-session token. */
492
+ get isAuthed() {
493
+ return this.token !== null;
494
+ }
495
+ /**
496
+ * Throw `ApiError(401)` locally when the transport has no token.
497
+ * Per-route clients call this before authed endpoints so a missing
498
+ * session surfaces with the same shape the server would produce for
499
+ * a missing Authorization header, without a network round-trip.
500
+ */
501
+ requireAuthed() {
502
+ if (this.token !== null) return;
503
+ throw new ApiError(
504
+ "No runner session on disk. Run `geni login` to authenticate, then retry.",
505
+ 401,
506
+ null
507
+ );
508
+ }
509
+ };
510
+ async function parseResponse(response) {
511
+ const text = await response.text();
512
+ let body = null;
513
+ if (text.length > 0) {
514
+ try {
515
+ body = JSON.parse(text);
516
+ } catch {
517
+ body = text;
518
+ }
519
+ }
520
+ if (!response.ok) {
521
+ const errorField = body !== null && typeof body === "object" && "error" in body ? body.error : null;
522
+ const message = typeof errorField === "string" ? errorField : response.statusText;
523
+ throw new ApiError(
524
+ typeof message === "string" && message.length > 0 ? message : `HTTP ${response.status}`,
525
+ response.status,
526
+ body
527
+ );
528
+ }
529
+ return body;
530
+ }
531
+
532
+ // src/lib/scrubber.ts
533
+ var MIN_SECRET_LEN = 8;
534
+ var Scrubber = class {
535
+ /** Map of secret value → redaction marker. Shared across streams. */
536
+ redactions = /* @__PURE__ */ new Map();
537
+ /** Length of the longest registered secret. Sets the tail size. */
538
+ maxLen = 0;
539
+ register(args) {
540
+ const { credentialId, value } = args;
541
+ if (value.length < MIN_SECRET_LEN) return;
542
+ if (this.redactions.has(value)) return;
543
+ this.redactions.set(value, `[REDACTED:credential_${credentialId}]`);
544
+ if (value.length > this.maxLen) this.maxLen = value.length;
545
+ }
546
+ /**
547
+ * Register the literal secret plus the common encoded forms a child
548
+ * process might emit instead of the raw value: base64, base64url, hex
549
+ * (upper + lower), URL-percent-encoded. Catches the lazy obfuscation
550
+ * class of leak (`echo $TOKEN | base64`, `echo $TOKEN | xxd`,
551
+ * `printf '%s' $TOKEN | jq -R @uri`).
552
+ *
553
+ * Does not catch determined adversaries: anything that transforms
554
+ * via `tr`, splits below MIN_SECRET_LEN, or exfiltrates over the
555
+ * network is out of scope for an output scrubber. For airtight
556
+ * isolation the plaintext must not enter the child's env at all.
557
+ */
558
+ registerWithEncodings(args) {
559
+ this.register(args);
560
+ for (const variant of encodingVariants(args.value)) {
561
+ this.register({ credentialId: args.credentialId, value: variant });
562
+ }
563
+ }
564
+ /**
565
+ * Build a per-stream scrubber view. Each stream gets its own rolling
566
+ * tail buffer; the underlying secret list is shared by reference. The
567
+ * spawner calls this twice per child (stdout + stderr) so the two
568
+ * pipes don't clobber each other's tail state.
569
+ */
570
+ stream() {
571
+ return new StreamScrubber(this.redactions, this.maxLen);
572
+ }
573
+ /**
574
+ * Single-shot redaction for callers that have the entire string in
575
+ * hand and don't need the streaming machinery. Useful for tests and
576
+ * for one-off helpers (`apps/server/src/services/SandboxWorkspace`'s
577
+ * non-streaming surfaces). Equivalent to creating a stream, redacting
578
+ * once with `final: true`, and discarding.
579
+ */
580
+ redact(text) {
581
+ return this.stream().redact(text, { final: true });
582
+ }
583
+ /** Test-only: how many secrets are registered. */
584
+ get size() {
585
+ return this.redactions.size;
586
+ }
587
+ };
588
+ var StreamScrubber = class {
589
+ constructor(redactions, maxLen) {
590
+ this.redactions = redactions;
591
+ this.maxLen = maxLen;
592
+ }
593
+ redactions;
594
+ maxLen;
595
+ tail = "";
596
+ /**
597
+ * Redact a chunk and return the safe-to-emit portion. The trailing
598
+ * `maxLen - 1` chars are buffered for the next call so a secret that
599
+ * straddles the chunk boundary still gets caught.
600
+ *
601
+ * Pass `final: true` on end-of-stream to flush the buffered tail.
602
+ */
603
+ redact(chunk, opts = {}) {
604
+ if (this.redactions.size === 0) {
605
+ if (opts.final) {
606
+ const out = this.tail + chunk;
607
+ this.tail = "";
608
+ return out;
609
+ }
610
+ return chunk;
611
+ }
612
+ const combined = this.tail + chunk;
613
+ const redacted = this.replaceAll(combined);
614
+ if (opts.final) {
615
+ this.tail = "";
616
+ return redacted;
617
+ }
618
+ const holdback = Math.max(0, this.maxLen - 1);
619
+ if (redacted.length <= holdback) {
620
+ this.tail = redacted;
621
+ return "";
622
+ }
623
+ const cut = redacted.length - holdback;
624
+ this.tail = redacted.slice(cut);
625
+ return redacted.slice(0, cut);
626
+ }
627
+ /**
628
+ * Replace every registered secret in `text`. Iterates longest-first
629
+ * so a superstring secret gets redacted before any of its substrings,
630
+ * which prevents partial overlaps from sneaking through.
631
+ */
632
+ replaceAll(text) {
633
+ let result = text;
634
+ const entries = [...this.redactions.entries()].sort(
635
+ (a, b) => b[0].length - a[0].length
636
+ );
637
+ for (const [value, marker] of entries) {
638
+ if (!result.includes(value)) continue;
639
+ result = result.split(value).join(marker);
640
+ }
641
+ return result;
642
+ }
643
+ };
644
+ function encodingVariants(value) {
645
+ const buf = Buffer.from(value, "utf8");
646
+ return [
647
+ buf.toString("base64"),
648
+ buf.toString("base64url"),
649
+ buf.toString("hex"),
650
+ buf.toString("hex").toUpperCase(),
651
+ encodeURIComponent(value)
652
+ ];
653
+ }
654
+
655
+ // src/lib/execEnv.ts
656
+ var SAFE_INHERIT_ENV = [
657
+ // Process essentials.
658
+ "PATH",
659
+ "HOME",
660
+ "USER",
661
+ "LOGNAME",
662
+ "SHELL",
663
+ "PWD",
664
+ // Locale.
665
+ "LANG",
666
+ "LC_ALL",
667
+ "LC_CTYPE",
668
+ "TZ",
669
+ // Terminal capabilities. Without these, color output and TUI
670
+ // programs render garbled.
671
+ "TERM",
672
+ "COLORTERM",
673
+ "LINES",
674
+ "COLUMNS",
675
+ // Tempfile location.
676
+ "TMPDIR"
677
+ ];
678
+ function buildSafeInheritedEnv() {
679
+ const out = {};
680
+ for (const key of SAFE_INHERIT_ENV) {
681
+ const value = process.env[key];
682
+ if (value !== void 0) out[key] = value;
683
+ }
684
+ return out;
685
+ }
686
+
687
+ // src/services/ExecService.ts
688
+ var ExecService = class {
689
+ constructor(sessionContext, spawner) {
690
+ this.sessionContext = sessionContext;
691
+ this.spawner = spawner;
692
+ }
693
+ sessionContext;
694
+ spawner;
695
+ async runBash(args) {
696
+ const { resolved, scrubber } = await this.resolveAndScrub(
697
+ args.credentials,
698
+ args.quiet
699
+ );
700
+ const env = {
701
+ ...buildSafeInheritedEnv(),
702
+ PLATFORM_API_KEY: resolved.platformApiKey,
703
+ PLATFORM_BASE_URL: resolved.platformBaseUrl
704
+ };
705
+ for (const cred of resolved.credentials) {
706
+ Object.assign(env, cred.envVars);
707
+ }
708
+ try {
709
+ return await this.spawner.run({
710
+ command: "bash",
711
+ args: ["-lc", args.command],
712
+ env,
713
+ cwd: args.cwd,
714
+ scrubber
715
+ });
716
+ } catch (err) {
717
+ const detail = err instanceof Error ? err.message : String(err);
718
+ printError(
719
+ `Could not spawn \`bash\` (${detail}). Verify bash is on $PATH with \`command -v bash\`. Credentials were already resolved (audit-logged); the subprocess never started.`
720
+ );
721
+ return ExitCode.InternalError;
722
+ }
723
+ }
724
+ /**
725
+ * Resolve credentials, register their secrets with a streaming
726
+ * scrubber, and print per-credential status lines unless quiet.
727
+ * Exits the process on resolve failure (the documented exit codes
728
+ * are a CLI-level contract — there's no useful recovery path above
729
+ * this).
730
+ */
731
+ async resolveAndScrub(credentials, quiet) {
732
+ const { client } = await this.sessionContext.requireAuthed();
733
+ let resolved;
734
+ try {
735
+ resolved = await client.exec.resolve({ credentials });
736
+ } catch (error) {
737
+ if (error instanceof ApiError) {
738
+ const ids = credentials.map((c) => c.id).join(", ") || "(none)";
739
+ if (error.status === 403) {
740
+ printError(
741
+ `Cloud refused credential resolution: ${error.message} Declared ids: [${ids}]. Verify each one with \`geni credential list\`.`
742
+ );
743
+ exit(ExitCode.CredentialResolveFailed);
744
+ }
745
+ if (error.status === 401) {
746
+ printError(
747
+ `Runner session is missing or expired: ${error.message} Run \`geni login\` to re-authenticate, then retry.`
748
+ );
749
+ exit(ExitCode.SessionMissingOrExpired);
750
+ }
751
+ printError(
752
+ `Cloud failed to resolve credentials (HTTP ${error.status}): ${error.message} The subprocess did not start. Retry once before reporting.`
753
+ );
754
+ exit(ExitCode.InternalError);
755
+ }
756
+ throw error;
757
+ }
758
+ const scrubber = new Scrubber();
759
+ scrubber.registerWithEncodings({
760
+ credentialId: "platform",
761
+ value: resolved.platformApiKey
762
+ });
763
+ for (const cred of resolved.credentials) {
764
+ for (const value of cred.redactionValues) {
765
+ scrubber.registerWithEncodings({
766
+ credentialId: cred.credentialId,
767
+ value
768
+ });
769
+ }
770
+ }
771
+ if (!quiet) this.printResolvedStatusLines(resolved);
772
+ return { resolved, scrubber };
773
+ }
774
+ /**
775
+ * Print one stderr status line per resolved credential plus one
776
+ * per cred error. Stays on stderr so stdout remains clean for
777
+ * pipe-friendly subprocess output.
778
+ */
779
+ printResolvedStatusLines(resolved) {
780
+ for (const cred of resolved.credentials) {
781
+ const envList = Object.keys(cred.envVars).sort().join(", ");
782
+ printInfo(
783
+ `resolved ${chalk4.cyan(cred.credentialId)} (${cred.providerTitle}, ${cred.credentialTitle}) \u2192 ${envList}`
784
+ );
785
+ }
786
+ for (const err of resolved.errors ?? []) {
787
+ printError(
788
+ `${err.credentialId} (${err.providerTitle}): ${err.message} The subprocess will run without this credential \u2014 calls that need it will 401. Re-auth ${err.providerTitle} from the dashboard.`
789
+ );
790
+ }
791
+ }
792
+ };
793
+
794
+ // src/services/DiscoveryService.ts
795
+ var DiscoveryService = class {
796
+ constructor(sessionContext, browserOpener2, configService2) {
797
+ this.sessionContext = sessionContext;
798
+ this.browserOpener = browserOpener2;
799
+ this.configService = configService2;
800
+ }
801
+ sessionContext;
802
+ browserOpener;
803
+ configService;
804
+ // ---- credentials ----------------------------------------------------
805
+ async listCredentials(args) {
806
+ const { client } = await this.sessionContext.requireAuthed();
807
+ const { credentials } = await client.credentials.list();
808
+ let result = credentials;
809
+ if (args.service) {
810
+ result = result.filter((c) => c.service === args.service);
811
+ }
812
+ if (args.mine) {
813
+ result = result.filter((c) => c.isOwnedByViewer);
814
+ }
815
+ if (args.query && args.query.length > 0) {
816
+ result = rankCredentials(result, args.query);
817
+ }
818
+ return result;
819
+ }
820
+ async getCredential(id) {
821
+ const { client } = await this.sessionContext.requireAuthed();
822
+ return client.credentials.get(id);
823
+ }
824
+ /**
825
+ * Validate the service slug against the integration catalog (404
826
+ * surfaces the same way `integration get` does, mapped to exit 4
827
+ * by the command), then return the dashboard connect URL the
828
+ * operator should be sent to.
829
+ *
830
+ * Side effect: if `printUrlOnly` is false, opens the URL in the
831
+ * operator's default browser. The CLI always prints the URL too,
832
+ * so a silent failure to launch a browser still leaves the
833
+ * operator with a clickable link.
834
+ */
835
+ async connectCredential(args) {
836
+ const { session, client } = await this.sessionContext.requireAuthed();
837
+ await client.integrations.get(args.service);
838
+ const url = `${this.configService.resolveDashboardUrl(session.server)}/credentials/connect?service=${encodeURIComponent(args.service)}`;
839
+ if (args.printUrlOnly) return { kind: "print-url", url };
840
+ this.browserOpener.open(url);
841
+ return { kind: "open-browser", url };
842
+ }
843
+ // ---- integrations ---------------------------------------------------
844
+ async listIntegrations(args) {
845
+ const { client } = await this.sessionContext.requireAuthed();
846
+ const { integrations } = await client.integrations.list({
847
+ query: args.query
848
+ });
849
+ return args.type ? integrations.filter((i) => i.credentialType === args.type) : integrations;
850
+ }
851
+ async getIntegration(service) {
852
+ const { client } = await this.sessionContext.requireAuthed();
853
+ return client.integrations.get(service);
854
+ }
855
+ // ---- operations -----------------------------------------------------
856
+ async listOperations(args) {
857
+ const { client } = await this.sessionContext.requireAuthed();
858
+ const { operations } = await client.integrations.listOperations(
859
+ args.service
860
+ );
861
+ if (!args.query || args.query.length === 0) return operations;
862
+ return rankOperations(operations, args.query);
863
+ }
864
+ /**
865
+ * Look up one operation. When `service` is provided, hits the
866
+ * service-scoped integrations route; otherwise hits the standalone
867
+ * `/cli/operations/:opId` route which lets the server resolve the
868
+ * service from the id alone.
869
+ */
870
+ async getOperation(args) {
871
+ const { client } = await this.sessionContext.requireAuthed();
872
+ return args.service ? client.integrations.getOperation({
873
+ service: args.service,
874
+ opId: args.opId
875
+ }) : client.operations.getById(args.opId);
876
+ }
877
+ };
878
+ function rankCredentials(credentials, query) {
879
+ const q = query.toLowerCase();
880
+ const scored = credentials.map((c) => {
881
+ let score = 0;
882
+ if (c.service.toLowerCase().includes(q)) score += 3;
883
+ if (c.providerTitle.toLowerCase().includes(q)) score += 2;
884
+ if (c.title.toLowerCase().includes(q)) score += 1;
885
+ return { c, score };
886
+ });
887
+ return scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score).map((s) => s.c);
888
+ }
889
+ function rankOperations(operations, query) {
890
+ const q = query.toLowerCase();
891
+ const scored = operations.map((op) => {
892
+ let score = 0;
893
+ if (op.title.toLowerCase().includes(q)) score += 2;
894
+ if (op.description.toLowerCase().includes(q)) score += 1;
895
+ return { op, score };
896
+ });
897
+ return scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score).map((s) => s.op);
898
+ }
899
+
900
+ // src/types/config.ts
901
+ import { z } from "zod";
902
+ var CliConfigSchema = z.object({
903
+ version: z.literal(1),
904
+ /**
905
+ * Override for the cloud API base URL used at fresh `geni login`
906
+ * time. Once a session exists, the URL stored on it takes
907
+ * precedence — switching `apiUrl` after login does NOT reroute
908
+ * existing tokens (the session knows which server minted it).
909
+ */
910
+ apiUrl: z.url().optional(),
911
+ /**
912
+ * Override for the dashboard base URL used by browser-opening
913
+ * commands (`geni credential connect`). Independent of `apiUrl`
914
+ * because in dev they live on different ports.
915
+ */
916
+ dashboardUrl: z.url().optional()
917
+ });
918
+ var SETTABLE_CONFIG_KEYS = ["apiUrl", "dashboardUrl"];
919
+ var SETTABLE_CONFIG_KEY_SET = new Set(
920
+ SETTABLE_CONFIG_KEYS
921
+ );
922
+ function isSettableConfigKey(key) {
923
+ return SETTABLE_CONFIG_KEY_SET.has(key);
924
+ }
925
+
926
+ // src/services/ConfigService.ts
927
+ var DEFAULT_API_URL = "https://cloud.generalinput.com";
928
+ var DEFAULT_DASHBOARD_URL = "https://web.generalinput.com";
929
+ var ConfigService = class {
930
+ constructor(configStore2, sessionStore2) {
931
+ this.configStore = configStore2;
932
+ this.sessionStore = sessionStore2;
933
+ }
934
+ configStore;
935
+ sessionStore;
936
+ /**
937
+ * Resolve the API URL the CLI should talk to. Precedence:
938
+ * 1. The session's stored server (locked at `geni login` time —
939
+ * the auth token was minted on that specific URL).
940
+ * 2. `$GENI_API_URL` env var.
941
+ * 3. `apiUrl` from the persistent config.
942
+ * 4. Compiled-in default.
943
+ *
944
+ * Callers that have a session loaded should pass `sessionServer`
945
+ * explicitly. The session lock means changing the config after
946
+ * login does NOT retarget existing commands until logout + re-login.
947
+ */
948
+ resolveApiUrl(sessionServer) {
949
+ return sessionServer ?? process.env.GENI_API_URL ?? this.configStore.loadSync()?.apiUrl ?? DEFAULT_API_URL;
950
+ }
951
+ /**
952
+ * Resolve the dashboard URL for browser-opening commands. Precedence:
953
+ * 1. `$GENI_DASHBOARD_URL` env var.
954
+ * 2. `dashboardUrl` from the persistent config.
955
+ * 3. Inferred from the session's API URL when it points at
956
+ * localhost (dev convenience: API on :4111 → dashboard on :5177).
957
+ * 4. Compiled-in default.
958
+ */
959
+ resolveDashboardUrl(sessionApiUrl) {
960
+ if (process.env.GENI_DASHBOARD_URL) return process.env.GENI_DASHBOARD_URL;
961
+ const config = this.configStore.loadSync();
962
+ if (config?.dashboardUrl) return config.dashboardUrl;
963
+ if (sessionApiUrl?.includes("localhost")) return "http://localhost:5177";
964
+ return DEFAULT_DASHBOARD_URL;
965
+ }
966
+ /**
967
+ * Read what's literally in the persistent config file, with no
968
+ * resolver fallbacks layered on. This is what `set` writes and what
969
+ * `get` should display — symmetric, predictable, no "I set X, get
970
+ * shows Y" surprise from a session-locked URL trumping the file.
971
+ *
972
+ * For "what URL is the CLI actually hitting right now?" the answer
973
+ * lives on the session (printed by `geni auth status`); the resolver
974
+ * itself stays in `resolveApiUrl` / `resolveDashboardUrl`.
975
+ */
976
+ fileValues() {
977
+ const file = this.configStore.loadSync();
978
+ return {
979
+ apiUrl: file?.apiUrl,
980
+ dashboardUrl: file?.dashboardUrl
981
+ };
982
+ }
983
+ /**
984
+ * Write a config value. Validates against the schema; a malformed
985
+ * URL fails loudly here rather than waiting for the next CLI
986
+ * command to crash.
987
+ *
988
+ * Refuses to change `apiUrl` while a runner-session is bound to a
989
+ * different URL: the session's server is what the CLI actually hits
990
+ * at runtime, so silently letting the file diverge from that would
991
+ * make `geni config set` a lie. The operator must logout (or use
992
+ * `geni login --server <url>`) to switch servers cleanly.
993
+ */
994
+ async set(args) {
995
+ const existing = this.configStore.loadSync() ?? { version: 1 };
996
+ const next = { ...existing, [args.key]: args.value };
997
+ const parsed = CliConfigSchema.safeParse(next);
998
+ if (!parsed.success) {
999
+ return {
1000
+ ok: false,
1001
+ reason: "invalid",
1002
+ error: parsed.error.issues[0]?.message ?? "failed validation"
1003
+ };
1004
+ }
1005
+ if (args.key === "apiUrl") {
1006
+ const session = await this.sessionStore.load();
1007
+ if (session && session.server !== args.value) {
1008
+ return {
1009
+ ok: false,
1010
+ reason: "session_conflict",
1011
+ sessionUrl: session.server
1012
+ };
1013
+ }
1014
+ }
1015
+ await this.configStore.save(parsed.data);
1016
+ return { ok: true };
1017
+ }
1018
+ /**
1019
+ * Remove a key. When the last key is removed, the file itself is
1020
+ * deleted so `cat $(geni config path)` doesn't show an empty
1021
+ * `{ "version": 1 }` shell.
1022
+ *
1023
+ * Returns whether the key was actually present (lets the command
1024
+ * pick "Unset apiUrl." vs "apiUrl was already unset.").
1025
+ */
1026
+ async unset(args) {
1027
+ const existing = this.configStore.loadSync();
1028
+ if (!existing || existing[args.key] === void 0) {
1029
+ return { wasSet: false };
1030
+ }
1031
+ const next = { version: 1 };
1032
+ for (const k of SETTABLE_CONFIG_KEYS) {
1033
+ if (k === args.key) continue;
1034
+ const value = existing[k];
1035
+ if (value !== void 0) next[k] = value;
1036
+ }
1037
+ const hasRemainingValues = SETTABLE_CONFIG_KEYS.some(
1038
+ (k) => next[k] !== void 0
1039
+ );
1040
+ if (hasRemainingValues) {
1041
+ await this.configStore.save(next);
1042
+ } else {
1043
+ await this.configStore.delete();
1044
+ }
1045
+ return { wasSet: true };
1046
+ }
1047
+ /** Absolute path to the config file. */
1048
+ get path() {
1049
+ return this.configStore.path;
1050
+ }
1051
+ /** Re-export the type guard from the types module for convenience. */
1052
+ isSettableKey(key) {
1053
+ return isSettableConfigKey(key);
1054
+ }
1055
+ };
1056
+
1057
+ // src/clients/AuthApiClient.ts
1058
+ var AuthApiClient = class {
1059
+ constructor(http) {
1060
+ this.http = http;
1061
+ }
1062
+ http;
1063
+ async startDeviceCode(clientLabel) {
1064
+ return this.http.fetch("/cli/auth/device-code", {
1065
+ method: "POST",
1066
+ body: { clientLabel }
1067
+ });
1068
+ }
1069
+ async pollDeviceCode(userCode) {
1070
+ return this.http.fetch(
1071
+ `/cli/auth/device-code/${encodeURIComponent(userCode)}/poll`,
1072
+ { method: "POST" }
1073
+ );
1074
+ }
1075
+ async me() {
1076
+ this.http.requireAuthed();
1077
+ return this.http.fetch("/cli/auth/me");
1078
+ }
1079
+ async logout() {
1080
+ this.http.requireAuthed();
1081
+ return this.http.fetch("/cli/auth/logout", { method: "POST" });
1082
+ }
1083
+ };
1084
+
1085
+ // src/clients/WorkspacesApiClient.ts
1086
+ var WorkspacesApiClient = class {
1087
+ constructor(http) {
1088
+ this.http = http;
1089
+ }
1090
+ http;
1091
+ async list() {
1092
+ this.http.requireAuthed();
1093
+ return this.http.fetch("/cli/workspaces");
1094
+ }
1095
+ async switch(membershipId) {
1096
+ this.http.requireAuthed();
1097
+ return this.http.fetch("/cli/workspaces/switch", {
1098
+ method: "POST",
1099
+ body: { membershipId }
1100
+ });
1101
+ }
1102
+ };
1103
+
1104
+ // src/clients/ExecApiClient.ts
1105
+ var ExecApiClient = class {
1106
+ constructor(http) {
1107
+ this.http = http;
1108
+ }
1109
+ http;
1110
+ async resolve(body) {
1111
+ this.http.requireAuthed();
1112
+ return this.http.fetch("/cli/exec/resolve", {
1113
+ method: "POST",
1114
+ body
1115
+ });
1116
+ }
1117
+ };
1118
+
1119
+ // src/clients/CredentialsApiClient.ts
1120
+ var CredentialsApiClient = class {
1121
+ constructor(http) {
1122
+ this.http = http;
1123
+ }
1124
+ http;
1125
+ async list() {
1126
+ this.http.requireAuthed();
1127
+ return this.http.fetch("/cli/credentials");
1128
+ }
1129
+ async get(id) {
1130
+ this.http.requireAuthed();
1131
+ return this.http.fetch(`/cli/credentials/${encodeURIComponent(id)}`);
1132
+ }
1133
+ };
1134
+
1135
+ // src/clients/IntegrationsApiClient.ts
1136
+ var IntegrationsApiClient = class {
1137
+ constructor(http) {
1138
+ this.http = http;
1139
+ }
1140
+ http;
1141
+ async list(args) {
1142
+ this.http.requireAuthed();
1143
+ const path = args?.query && args.query.length > 0 ? `/cli/integrations?q=${encodeURIComponent(args.query)}` : "/cli/integrations";
1144
+ return this.http.fetch(path);
1145
+ }
1146
+ async get(service) {
1147
+ this.http.requireAuthed();
1148
+ return this.http.fetch(`/cli/integrations/${encodeURIComponent(service)}`);
1149
+ }
1150
+ async listOperations(service) {
1151
+ this.http.requireAuthed();
1152
+ return this.http.fetch(
1153
+ `/cli/integrations/${encodeURIComponent(service)}/operations`
1154
+ );
1155
+ }
1156
+ async getOperation(args) {
1157
+ this.http.requireAuthed();
1158
+ return this.http.fetch(
1159
+ `/cli/integrations/${encodeURIComponent(args.service)}/operations/${encodeURIComponent(args.opId)}`
1160
+ );
1161
+ }
1162
+ };
1163
+
1164
+ // src/clients/OperationsApiClient.ts
1165
+ var OperationsApiClient = class {
1166
+ constructor(http) {
1167
+ this.http = http;
1168
+ }
1169
+ http;
1170
+ async getById(opId) {
1171
+ this.http.requireAuthed();
1172
+ return this.http.fetch(`/cli/operations/${encodeURIComponent(opId)}`);
1173
+ }
1174
+ };
1175
+
1176
+ // src/clients/ApiClientFactory.ts
1177
+ var ApiClientFactory = class {
1178
+ build(args) {
1179
+ const http = new HttpClient(args.server, args.token);
1180
+ return {
1181
+ auth: new AuthApiClient(http),
1182
+ workspaces: new WorkspacesApiClient(http),
1183
+ exec: new ExecApiClient(http),
1184
+ credentials: new CredentialsApiClient(http),
1185
+ integrations: new IntegrationsApiClient(http),
1186
+ operations: new OperationsApiClient(http)
1187
+ };
1188
+ }
1189
+ };
1190
+
1191
+ // src/clients/SessionStore.ts
1192
+ import { mkdir, readFile, writeFile, unlink, chmod } from "fs/promises";
1193
+
1194
+ // src/types/session.ts
1195
+ import { z as z2 } from "zod";
1196
+ var RunnerSessionFileSchema = z2.object({
1197
+ version: z2.literal(1),
1198
+ server: z2.url(),
1199
+ /** Plaintext runner-session token (`geni_rs_…`). */
1200
+ token: z2.string().startsWith("geni_rs_"),
1201
+ user: z2.object({
1202
+ id: z2.string(),
1203
+ email: z2.string().nullable(),
1204
+ name: z2.string().nullable()
1205
+ }),
1206
+ workspace: z2.object({
1207
+ membershipId: z2.string(),
1208
+ organizationId: z2.string(),
1209
+ slug: z2.string(),
1210
+ name: z2.string(),
1211
+ role: z2.string()
1212
+ }),
1213
+ /** ISO 8601 — when the file was last written by login or workspace switch. */
1214
+ savedAt: z2.string()
1215
+ });
1216
+
1217
+ // src/clients/SessionStore.ts
1218
+ var SessionStore = class {
1219
+ constructor(filePath, directoryPath) {
1220
+ this.filePath = filePath;
1221
+ this.directoryPath = directoryPath;
1222
+ }
1223
+ filePath;
1224
+ directoryPath;
1225
+ /**
1226
+ * Read the file, or `null` if no session exists. Returns `null` for
1227
+ * unparseable / schema-invalid files too — corrupt local state
1228
+ * shouldn't prevent re-login. The user can rerun `geni login` to
1229
+ * write a fresh file over the broken one.
1230
+ */
1231
+ async load() {
1232
+ let raw;
1233
+ try {
1234
+ raw = await readFile(this.filePath, "utf-8");
1235
+ } catch (err) {
1236
+ if (isErrnoCode(err, "ENOENT")) return null;
1237
+ throw err;
1238
+ }
1239
+ let json;
1240
+ try {
1241
+ json = JSON.parse(raw);
1242
+ } catch {
1243
+ return null;
1244
+ }
1245
+ const parsed = RunnerSessionFileSchema.safeParse(json);
1246
+ return parsed.success ? parsed.data : null;
1247
+ }
1248
+ /**
1249
+ * Persist the session. Creates the config dir if missing and lands
1250
+ * mode 0600 on the file. Tightens dir mode to 0700 best-effort —
1251
+ * if the chmod fails (e.g. on a CI mount), we continue rather than
1252
+ * failing the login outright.
1253
+ */
1254
+ async save(session) {
1255
+ await mkdir(this.directoryPath, { recursive: true, mode: 448 });
1256
+ await chmod(this.directoryPath, 448).catch(() => {
1257
+ });
1258
+ await writeFile(this.filePath, JSON.stringify(session, null, 2), {
1259
+ mode: 384
1260
+ });
1261
+ }
1262
+ /**
1263
+ * Delete the session file. Idempotent — missing file is not an error.
1264
+ * Used by `geni logout` after server revoke, and as a recovery path
1265
+ * when the local file is corrupt or stale.
1266
+ */
1267
+ async delete() {
1268
+ try {
1269
+ await unlink(this.filePath);
1270
+ } catch (err) {
1271
+ if (isErrnoCode(err, "ENOENT")) return;
1272
+ throw err;
1273
+ }
1274
+ }
1275
+ /**
1276
+ * Update just the workspace pointer in the session file, used by
1277
+ * `geni workspace switch` after the server confirms the membership.
1278
+ * Throws if no session exists — the caller must have verified one
1279
+ * is loaded before calling this.
1280
+ */
1281
+ async updateActiveWorkspace(workspace) {
1282
+ const current = await this.load();
1283
+ if (!current) {
1284
+ throw new Error("No active session to update");
1285
+ }
1286
+ await this.save({
1287
+ ...current,
1288
+ workspace,
1289
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
1290
+ });
1291
+ }
1292
+ };
1293
+ function isErrnoCode(err, expected) {
1294
+ if (typeof err !== "object" || err === null) return false;
1295
+ if (!("code" in err)) return false;
1296
+ return err.code === expected;
1297
+ }
1298
+
1299
+ // src/clients/ConfigStore.ts
1300
+ import { readFileSync } from "fs";
1301
+ import { mkdir as mkdir2, writeFile as writeFile2, unlink as unlink2 } from "fs/promises";
1302
+ import { dirname } from "path";
1303
+ var ConfigStore = class {
1304
+ constructor(filePath) {
1305
+ this.filePath = filePath;
1306
+ }
1307
+ filePath;
1308
+ /**
1309
+ * Read the file synchronously. Returns `null` for any unreadable /
1310
+ * corrupt / schema-invalid file so the CLI degrades to defaults
1311
+ * instead of crashing on a stale on-disk format.
1312
+ */
1313
+ loadSync() {
1314
+ let raw;
1315
+ try {
1316
+ raw = readFileSync(this.filePath, "utf8");
1317
+ } catch {
1318
+ return null;
1319
+ }
1320
+ let json;
1321
+ try {
1322
+ json = JSON.parse(raw);
1323
+ } catch {
1324
+ return null;
1325
+ }
1326
+ const parsed = CliConfigSchema.safeParse(json);
1327
+ return parsed.success ? parsed.data : null;
1328
+ }
1329
+ /**
1330
+ * Persist the config. Creates the directory if missing. Mode 0644:
1331
+ * config is non-secret, unlike the session file.
1332
+ */
1333
+ async save(config) {
1334
+ await mkdir2(dirname(this.filePath), { recursive: true });
1335
+ await writeFile2(this.filePath, JSON.stringify(config, null, 2) + "\n", {
1336
+ mode: 420
1337
+ });
1338
+ }
1339
+ /**
1340
+ * Delete the config file. Idempotent — succeeds silently when the
1341
+ * file doesn't exist (the user's intent is "ensure no config",
1342
+ * not "the file definitely existed").
1343
+ */
1344
+ async delete() {
1345
+ try {
1346
+ await unlink2(this.filePath);
1347
+ } catch (err) {
1348
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
1349
+ return;
1350
+ }
1351
+ throw err;
1352
+ }
1353
+ }
1354
+ /** Path the file would be at, regardless of whether it exists. */
1355
+ get path() {
1356
+ return this.filePath;
1357
+ }
1358
+ };
1359
+
1360
+ // src/clients/BrowserOpener.ts
1361
+ import { spawn } from "child_process";
1362
+ var BrowserOpener = class {
1363
+ open(url) {
1364
+ const cmd = openerCommandForPlatform();
1365
+ try {
1366
+ const child = spawn(cmd, [url], { stdio: "ignore", detached: true });
1367
+ child.unref();
1368
+ return true;
1369
+ } catch {
1370
+ return false;
1371
+ }
1372
+ }
1373
+ };
1374
+ function openerCommandForPlatform() {
1375
+ switch (process.platform) {
1376
+ case "darwin":
1377
+ return "open";
1378
+ case "win32":
1379
+ return "start";
1380
+ default:
1381
+ return "xdg-open";
1382
+ }
1383
+ }
1384
+
1385
+ // src/clients/ChildProcessSpawner.ts
1386
+ import { spawn as spawn2 } from "child_process";
1387
+ var ChildProcessSpawner = class {
1388
+ /**
1389
+ * Run `command` with `args` and return the child's exit code. Pipes
1390
+ * stdout + stderr through `scrubber` before forwarding to the
1391
+ * parent process's streams.
1392
+ */
1393
+ async run(args) {
1394
+ const child = spawn2(args.command, args.args, {
1395
+ env: args.env,
1396
+ cwd: args.cwd ?? process.cwd(),
1397
+ stdio: ["inherit", "pipe", "pipe"]
1398
+ });
1399
+ const stdoutClosed = pipeWithScrubbing(
1400
+ child.stdout,
1401
+ process.stdout,
1402
+ args.scrubber.stream(),
1403
+ args.onStdoutChunk
1404
+ );
1405
+ const stderrClosed = pipeWithScrubbing(
1406
+ child.stderr,
1407
+ process.stderr,
1408
+ args.scrubber.stream()
1409
+ );
1410
+ const forwardSignal = (signal) => {
1411
+ if (!child.killed) child.kill(signal);
1412
+ };
1413
+ process.on("SIGINT", forwardSignal);
1414
+ process.on("SIGTERM", forwardSignal);
1415
+ try {
1416
+ const exitCode = await new Promise((resolve, reject) => {
1417
+ child.once("exit", (code, signal) => {
1418
+ if (code !== null) resolve(code);
1419
+ else if (signal !== null) resolve(128 + signalNumber(signal));
1420
+ else resolve(1);
1421
+ });
1422
+ child.once("error", (err) => reject(err));
1423
+ });
1424
+ await Promise.all([stdoutClosed, stderrClosed]);
1425
+ return exitCode;
1426
+ } finally {
1427
+ process.removeListener("SIGINT", forwardSignal);
1428
+ process.removeListener("SIGTERM", forwardSignal);
1429
+ }
1430
+ }
1431
+ };
1432
+ function pipeWithScrubbing(source, dest, scrubber, onChunk) {
1433
+ return new Promise((resolve) => {
1434
+ let flushed = false;
1435
+ const emit = (chunk) => {
1436
+ if (chunk.length === 0) return;
1437
+ dest.write(chunk);
1438
+ onChunk?.(chunk);
1439
+ };
1440
+ const finishOnce = () => {
1441
+ if (flushed) return;
1442
+ flushed = true;
1443
+ emit(scrubber.redact("", { final: true }));
1444
+ resolve();
1445
+ };
1446
+ source.on("end", finishOnce);
1447
+ source.on("close", finishOnce);
1448
+ source.on("error", () => {
1449
+ flushed = true;
1450
+ resolve();
1451
+ });
1452
+ source.setEncoding("utf8");
1453
+ source.on("data", (chunk) => {
1454
+ emit(scrubber.redact(chunk));
1455
+ });
1456
+ });
1457
+ }
1458
+ function signalNumber(signal) {
1459
+ const map = {
1460
+ SIGHUP: 1,
1461
+ SIGINT: 2,
1462
+ SIGQUIT: 3,
1463
+ SIGKILL: 9,
1464
+ SIGTERM: 15
1465
+ };
1466
+ return map[signal] ?? 1;
1467
+ }
1468
+
1469
+ // src/lib/paths.ts
1470
+ import { homedir } from "os";
1471
+ import { join } from "path";
1472
+ function configDir() {
1473
+ return process.env.GENI_CONFIG_DIR ?? join(homedir(), ".config", "geni");
1474
+ }
1475
+ function sessionFilePath() {
1476
+ return join(configDir(), "runner-session.json");
1477
+ }
1478
+ function configFilePath() {
1479
+ return join(configDir(), "config.json");
1480
+ }
1481
+
1482
+ // src/dependencyInjection/clients.ts
1483
+ var sessionStore = new SessionStore(sessionFilePath(), configDir());
1484
+ var configStore = new ConfigStore(configFilePath());
1485
+ var apiClientFactory = new ApiClientFactory();
1486
+ var browserOpener = new BrowserOpener();
1487
+ var childProcessSpawner = new ChildProcessSpawner();
1488
+
1489
+ // src/dependencyInjection/services.ts
1490
+ var configService = new ConfigService(configStore, sessionStore);
1491
+ var sessionContextService = new SessionContextService(
1492
+ sessionStore,
1493
+ apiClientFactory
1494
+ );
1495
+ var authService = new AuthService(
1496
+ apiClientFactory,
1497
+ sessionStore,
1498
+ browserOpener,
1499
+ configService
1500
+ );
1501
+ var workspaceService = new WorkspaceService(
1502
+ sessionContextService,
1503
+ sessionStore
1504
+ );
1505
+ var execService = new ExecService(
1506
+ sessionContextService,
1507
+ childProcessSpawner
1508
+ );
1509
+ var discoveryService = new DiscoveryService(
1510
+ sessionContextService,
1511
+ browserOpener,
1512
+ configService
1513
+ );
1514
+
1515
+ // src/lib/cliErrors.ts
1516
+ function exitOnApiError(error, opts = {}) {
1517
+ if (error instanceof ApiError) {
1518
+ if (error.status === 404 && opts.notFoundMessage) {
1519
+ printError(opts.notFoundMessage);
1520
+ exit(ExitCode.NotFound);
1521
+ }
1522
+ if (error.status === 401) {
1523
+ printError(
1524
+ `Runner session is missing or expired: ${error.message} Run \`geni login\` to re-authenticate, then retry.`
1525
+ );
1526
+ exit(ExitCode.SessionMissingOrExpired);
1527
+ }
1528
+ if (error.status === 403) {
1529
+ printError(
1530
+ `Forbidden: ${error.message} Verify the resource id and the active workspace (\`geni workspace current\`).`
1531
+ );
1532
+ exit(ExitCode.Forbidden);
1533
+ }
1534
+ if (error.status === 404) {
1535
+ printError(
1536
+ `Not found: ${error.message} Verify the id exists in the active workspace.`
1537
+ );
1538
+ exit(ExitCode.NotFound);
1539
+ }
1540
+ if (error.status >= 500) {
1541
+ printError(
1542
+ `Server error (HTTP ${error.status}): ${error.message} Retry once before reporting.`
1543
+ );
1544
+ exit(ExitCode.InternalError);
1545
+ }
1546
+ printError(`Request failed (HTTP ${error.status}): ${error.message}`);
1547
+ exit(ExitCode.InternalError);
1548
+ }
1549
+ printError(
1550
+ `Unexpected error: ${error instanceof Error ? error.message : String(error)}`
1551
+ );
1552
+ exit(ExitCode.InternalError);
1553
+ }
1554
+
1555
+ // src/commands/auth/login.ts
1556
+ function registerLogin(parent) {
1557
+ parent.command("login").description(
1558
+ "Authenticate via browser device-code flow. The CLI prints (and tries to open) a one-time approval URL; the operator picks a workspace in the browser; on approval the CLI saves a runner-session token to ~/.config/geni/runner-session.json. The token is bound to the URL it was minted against, so switching API URL after login requires logout + re-login."
1559
+ ).option(
1560
+ "--server <url>",
1561
+ "Override the API base URL for this login. Precedence: this flag > $GENI_API_URL > `apiUrl` from `geni config` > https://cloud.generalinput.com. Whatever URL wins is locked into the session file."
1562
+ ).option(
1563
+ "--workspace <slug>",
1564
+ "After approval, re-bind the session to this workspace slug instead of whatever the dashboard picker chose. Useful in CI / scripted setups where there is no human at the browser."
1565
+ ).action(async (opts) => {
1566
+ try {
1567
+ await authService.login({
1568
+ server: opts.server,
1569
+ workspace: opts.workspace
1570
+ });
1571
+ } catch (error) {
1572
+ exitOnApiError(error);
1573
+ }
1574
+ });
1575
+ }
1576
+
1577
+ // src/commands/auth/logout.ts
1578
+ function registerLogout(parent) {
1579
+ parent.command("logout").description(
1580
+ "Revoke the runner-session token server-side and delete the local session file (~/.config/geni/runner-session.json). The local file is removed even if the server-side revoke fails. Running `geni logout` should never leave a token the operator thinks is gone still on disk."
1581
+ ).action(async () => {
1582
+ try {
1583
+ await authService.logout();
1584
+ } catch (error) {
1585
+ exitOnApiError(error);
1586
+ }
1587
+ });
1588
+ }
1589
+
1590
+ // src/commands/auth/status.ts
1591
+ async function executeAuthStatus(opts) {
1592
+ try {
1593
+ const status = await authService.status();
1594
+ if (!status) {
1595
+ if (opts.json) {
1596
+ printJson({ authenticated: false });
1597
+ } else {
1598
+ printError(
1599
+ "No runner session on disk. Run `geni login` to authenticate, then retry."
1600
+ );
1601
+ }
1602
+ exit(ExitCode.SessionMissingOrExpired);
1603
+ }
1604
+ if (opts.json) {
1605
+ printJson(status);
1606
+ exit(ExitCode.Ok);
1607
+ }
1608
+ printSuccess(`Authenticated as ${status.user.email ?? status.user.id}`);
1609
+ printInfo(
1610
+ `Active workspace: ${status.workspace.slug} (${status.workspace.name}, ${status.workspace.role})`
1611
+ );
1612
+ exit(ExitCode.Ok);
1613
+ } catch (error) {
1614
+ if (error instanceof ApiError && error.status === 401) {
1615
+ printError(
1616
+ "Local session token was rejected by the server (revoked, expired, or pointed at a different server). Run `geni login` to mint a fresh session."
1617
+ );
1618
+ exit(ExitCode.SessionMissingOrExpired);
1619
+ }
1620
+ const detail = error instanceof Error ? error.message : String(error);
1621
+ printError(
1622
+ `Failed to verify session: ${detail}. Check that the server is reachable; \`geni auth status --json\` shows the bound server URL.`
1623
+ );
1624
+ exit(ExitCode.InternalError);
1625
+ }
1626
+ }
1627
+ function registerStatus(parent) {
1628
+ parent.command("status").description(
1629
+ 'Verify the active session and print operator + active workspace. Hits `/cli/auth/me` to confirm the token is still valid server-side; a stale local session (server revoked, expired) exits 78 with a clear "run geni login" message rather than reporting a fake-OK from the local file alone.'
1630
+ ).option(
1631
+ "--json",
1632
+ "Emit a machine-readable JSON object: `{ authenticated, user, workspace, server }`. When unauthenticated, `{ authenticated: false }` is the only field."
1633
+ ).action((opts) => executeAuthStatus(opts));
1634
+ }
1635
+
1636
+ // src/commands/auth/index.ts
1637
+ function registerAuthCommands(program2) {
1638
+ registerLogin(program2);
1639
+ registerLogout(program2);
1640
+ const auth = program2.command("auth").description("Inspect the active CLI session.").action(() => executeAuthStatus({}));
1641
+ registerStatus(auth);
1642
+ }
1643
+
1644
+ // src/lib/printTable.ts
1645
+ import chalk5 from "chalk";
1646
+ function printTable(headers, rows, opts = {}) {
1647
+ const out = opts.out ?? process.stdout;
1648
+ const colCount = headers.length;
1649
+ const markers = opts.markerFn ? rows.map((row, i) => opts.markerFn(row, i)) : null;
1650
+ const usesMarker = markers !== null && markers.some((m) => m) && !markers.every((m) => m);
1651
+ const widths = new Array(colCount).fill(0);
1652
+ for (let i = 0; i < colCount; i++) widths[i] = headers[i].length;
1653
+ for (const row of rows) {
1654
+ for (let i = 0; i < colCount; i++) {
1655
+ const len = row[i]?.length ?? 0;
1656
+ if (len > widths[i]) widths[i] = len;
1657
+ }
1658
+ }
1659
+ const headerCells = headers.map(
1660
+ (h, i) => pad(chalk5.dim(h), h.length, widths[i], i === colCount - 1)
1661
+ );
1662
+ out.write((usesMarker ? " " : "") + headerCells.join(" ") + "\n");
1663
+ rows.forEach((row, rowIdx) => {
1664
+ const isMarked = usesMarker && markers[rowIdx] === true;
1665
+ const cells = row.map((raw, i) => {
1666
+ const value = raw ?? "";
1667
+ let colored = value;
1668
+ if (isMarked && i === 0) colored = chalk5.cyan(value);
1669
+ else if (opts.colorFn)
1670
+ colored = opts.colorFn(value, { row: rowIdx, col: i });
1671
+ return pad(colored, value.length, widths[i], i === colCount - 1);
1672
+ });
1673
+ const gutter = usesMarker ? `${isMarked ? chalk5.green("*") : " "} ` : "";
1674
+ out.write(gutter + cells.join(" ") + "\n");
1675
+ });
1676
+ }
1677
+ function pad(colored, rawLen, width, isLast) {
1678
+ if (isLast) return colored;
1679
+ if (rawLen >= width) return colored;
1680
+ return colored + " ".repeat(width - rawLen);
1681
+ }
1682
+ function dimColumn(colIndex) {
1683
+ return (cell, args) => args.col === colIndex ? chalk5.dim(cell) : cell;
1684
+ }
1685
+
1686
+ // src/commands/workspace/list.ts
1687
+ async function executeWorkspaceList(opts) {
1688
+ try {
1689
+ const { workspaces } = await workspaceService.list();
1690
+ if (opts.json) {
1691
+ printJson({
1692
+ active: workspaces.find((w) => w.isActive)?.slug ?? null,
1693
+ workspaces
1694
+ });
1695
+ exit(ExitCode.Ok);
1696
+ }
1697
+ if (workspaces.length === 0) {
1698
+ process.stdout.write(
1699
+ "No workspaces yet. Create or join one in the dashboard, then re-run.\n"
1700
+ );
1701
+ exit(ExitCode.Ok);
1702
+ }
1703
+ printTable(
1704
+ ["SLUG", "NAME", "ROLE"],
1705
+ workspaces.map((w) => [w.slug, w.name, w.role]),
1706
+ { markerFn: (_row, i) => workspaces[i].isActive }
1707
+ );
1708
+ exit(ExitCode.Ok);
1709
+ } catch (error) {
1710
+ exitOnApiError(error);
1711
+ }
1712
+ }
1713
+ function registerWorkspaceList(parent) {
1714
+ parent.command("list").description(
1715
+ "List every workspace the authenticated account belongs to. The active workspace is marked with a `*` and rendered in cyan. Server is the source of truth for both the list and the active marker."
1716
+ ).option(
1717
+ "--json",
1718
+ "Emit `{ active: <slug>, workspaces: [...] }`. Each workspace carries `membershipId`, `organizationId`, `slug`, `name`, `role`, `isActive`."
1719
+ ).action((opts) => executeWorkspaceList(opts));
1720
+ }
1721
+
1722
+ // src/commands/workspace/switch.ts
1723
+ import * as p from "@clack/prompts";
1724
+ function registerWorkspaceSwitch(parent) {
1725
+ parent.command("switch").argument(
1726
+ "[slug]",
1727
+ "Workspace slug to switch to. Omit for an interactive picker."
1728
+ ).description(
1729
+ "Re-point the runner session at a different workspace the same account belongs to. The session token keeps working; only the active-workspace pointer changes server-side and in the local cache. Pass a slug for scriptable use, or omit for an interactive picker (TTY only)."
1730
+ ).action(async (slug) => {
1731
+ try {
1732
+ const { session } = await sessionContextService.requireAuthed();
1733
+ const { workspaces } = await workspaceService.list();
1734
+ const target = slug ? findBySlug(workspaces, slug) : await pickInteractively({
1735
+ workspaces,
1736
+ currentMembershipId: session.workspace.membershipId
1737
+ });
1738
+ if (!target) return;
1739
+ if (target.membershipId === session.workspace.membershipId) {
1740
+ printInfo(`Already on ${target.slug} (${target.name}). No change.`);
1741
+ exit(ExitCode.Ok);
1742
+ }
1743
+ const me = await workspaceService.switch({
1744
+ membershipId: target.membershipId
1745
+ });
1746
+ printSuccess(
1747
+ `Active workspace: ${me.workspace.slug} (${me.workspace.name})`
1748
+ );
1749
+ exit(ExitCode.Ok);
1750
+ } catch (error) {
1751
+ exitOnApiError(error);
1752
+ }
1753
+ });
1754
+ }
1755
+ function findBySlug(workspaces, slug) {
1756
+ const target = workspaces.find((w) => w.slug === slug);
1757
+ if (!target) {
1758
+ const available = workspaces.map((w) => w.slug).join(", ") || "none";
1759
+ printError(
1760
+ `No workspace with slug "${slug}" on this account. Available: [${available}]. Pick one of those, or run \`geni workspace list\` for the full set with names + roles.`
1761
+ );
1762
+ exit(ExitCode.NotFound);
1763
+ }
1764
+ return target;
1765
+ }
1766
+ async function pickInteractively(args) {
1767
+ if (!process.stdin.isTTY) {
1768
+ printError(
1769
+ "Interactive picker needs a TTY. Pass a slug: `geni workspace switch <slug>`."
1770
+ );
1771
+ exit(ExitCode.GenericError);
1772
+ }
1773
+ if (args.workspaces.length === 0) {
1774
+ printError(
1775
+ "No workspaces on this account. Create or join one in the dashboard before running `geni workspace switch`."
1776
+ );
1777
+ exit(ExitCode.NotFound);
1778
+ }
1779
+ if (args.workspaces.length === 1) {
1780
+ printInfo("You only have one workspace; nothing to switch to.");
1781
+ exit(ExitCode.Ok);
1782
+ }
1783
+ const choice = await p.select({
1784
+ message: "Pick a workspace",
1785
+ initialValue: args.currentMembershipId,
1786
+ options: args.workspaces.map((w) => ({
1787
+ value: w.membershipId,
1788
+ label: `${w.slug} (${w.name})`,
1789
+ hint: w.role
1790
+ }))
1791
+ });
1792
+ if (p.isCancel(choice)) {
1793
+ printInfo("Cancelled.");
1794
+ return void 0;
1795
+ }
1796
+ return args.workspaces.find((w) => w.membershipId === choice);
1797
+ }
1798
+
1799
+ // src/commands/workspace/current.ts
1800
+ function registerWorkspaceCurrent(parent) {
1801
+ parent.command("current").description(
1802
+ "Print the active workspace's slug. Reads the local session file directly (no network round-trip), so it's safe to use in shell substitutions like `cd ~/repos/$(geni workspace current)`. Use `--verbose` for slug + name + role + id, or `--json` for machine-readable."
1803
+ ).option("--verbose", "Include name, role, and id alongside the slug.").option(
1804
+ "--json",
1805
+ "Emit the workspace record: `{ membershipId, organizationId, slug, name, role }`."
1806
+ ).action((opts) => {
1807
+ void run(opts);
1808
+ });
1809
+ }
1810
+ async function run(opts) {
1811
+ const current = await workspaceService.current();
1812
+ if (!current) {
1813
+ if (opts.json) {
1814
+ printJson({ authenticated: false });
1815
+ } else {
1816
+ printError(
1817
+ "No runner session on disk. Run `geni login` to authenticate, then retry."
1818
+ );
1819
+ }
1820
+ exit(ExitCode.SessionMissingOrExpired);
1821
+ }
1822
+ const ws = current.workspace;
1823
+ if (opts.json) {
1824
+ printJson(ws);
1825
+ exit(ExitCode.Ok);
1826
+ }
1827
+ if (opts.verbose) {
1828
+ process.stdout.write(`slug: ${ws.slug}
1829
+ `);
1830
+ process.stdout.write(`name: ${ws.name}
1831
+ `);
1832
+ process.stdout.write(`role: ${ws.role}
1833
+ `);
1834
+ process.stdout.write(`id: ${ws.organizationId}
1835
+ `);
1836
+ exit(ExitCode.Ok);
1837
+ }
1838
+ process.stdout.write(`${ws.slug}
1839
+ `);
1840
+ exit(ExitCode.Ok);
1841
+ }
1842
+
1843
+ // src/commands/workspace/index.ts
1844
+ function registerWorkspaceCommands(program2) {
1845
+ const workspace = program2.command("workspace").alias("workspaces").description(
1846
+ "List the workspaces the operator's account belongs to and switch which one this CLI session targets. The runner-session token is account-scoped; the active workspace pointer determines which org's credentials, integrations, and audit logs every other command operates on."
1847
+ ).action(() => executeWorkspaceList({}));
1848
+ registerWorkspaceList(workspace);
1849
+ registerWorkspaceSwitch(workspace);
1850
+ registerWorkspaceCurrent(workspace);
1851
+ }
1852
+
1853
+ // src/commands/exec/bash.ts
1854
+ function registerExecBash(parent) {
1855
+ parent.command("bash").description(
1856
+ "Run `bash -lc <cmd>` locally with cloud-resolved credentials injected as env vars. Output is streamed back through a scrubber that replaces every registered secret with [REDACTED:credential_<id>]."
1857
+ ).option(
1858
+ "--cred <id>",
1859
+ "Credential id to inject. Repeat once per credential; each --cred MUST be followed by exactly one --reason. Pairing is by order: the Nth --cred goes with the Nth --reason. Discover ids via `geni credential list --service <service>`.",
1860
+ collect,
1861
+ []
1862
+ ).option(
1863
+ "--reason <text>",
1864
+ "Why this credential is being accessed. Lands in the credential access log and is shown to the operator. Re-state on every call; the audit log is per-invocation.",
1865
+ collect,
1866
+ []
1867
+ ).option(
1868
+ "--cwd <path>",
1869
+ "Working directory for the command. Defaults to your current shell cwd."
1870
+ ).option(
1871
+ "--quiet",
1872
+ "Suppress geni's `resolved <cred> \u2192 ...` status lines on stderr. Subprocess output still passes through, scrubbed."
1873
+ ).allowExcessArguments(true).action(async (opts, command) => {
1874
+ try {
1875
+ const code = await runExecBash(opts, command.args);
1876
+ process.exit(code);
1877
+ } catch (error) {
1878
+ exitOnApiError(error);
1879
+ }
1880
+ }).addHelpText(
1881
+ "after",
1882
+ `
1883
+ Always-injected env vars (no --cred required):
1884
+ $PLATFORM_API_KEY short-lived bearer for $PLATFORM_BASE_URL/<service> calls
1885
+ $PLATFORM_BASE_URL the cloud's base URL
1886
+
1887
+ Per-credential env vars are derived from the integration's secret
1888
+ schema, with the credential id as a suffix so two credentials of the
1889
+ same service can coexist: $<SERVICE>_<FIELD>_<id>
1890
+ Look up the exact names before constructing the command:
1891
+ geni credential get <id> --field envVars # for a known cred
1892
+ geni integration get <service> --field envVars # for a service
1893
+
1894
+ Examples:
1895
+
1896
+ # One credential, simple curl:
1897
+ geni exec bash \\
1898
+ --cred cred_01HX --reason "Listing Slack channels" \\
1899
+ -- 'curl -s -H "Authorization: Bearer $SLACK_ACCESS_TOKEN_01HX" https://slack.com/api/conversations.list'
1900
+
1901
+ # Multiple credentials, fan-out (suffix keeps them distinct):
1902
+ geni exec bash \\
1903
+ --cred cred_slackA --reason "Posting to #engineering" \\
1904
+ --cred cred_slackB --reason "Posting to #marketing" \\
1905
+ -- 'curl ... $SLACK_ACCESS_TOKEN_SLACKA ... && curl ... $SLACK_ACCESS_TOKEN_SLACKB ...'
1906
+
1907
+ # No --cred. Platform service:
1908
+ geni exec bash -- 'curl -s -H "Authorization: Bearer $PLATFORM_API_KEY" "$PLATFORM_BASE_URL/v1/web-search" -d ...'
1909
+
1910
+ Exit codes:
1911
+ 0\u2013125 subprocess's own exit code
1912
+ 77 server refused to resolve a credential, don't retry
1913
+ 78 runner session missing or expired, run \`geni login\`
1914
+ 125 internal CLI error
1915
+ `
1916
+ );
1917
+ }
1918
+ function collect(value, prev) {
1919
+ return [...prev, value];
1920
+ }
1921
+ async function runExecBash(opts, positional) {
1922
+ const command = positional.join(" ").trim();
1923
+ if (!command) {
1924
+ printError(
1925
+ "Missing the bash command. Put it after a literal `--` (flags before `--` belong to geni). Example: `geni exec bash --cred cred_X --reason \"...\" -- 'curl ...'`."
1926
+ );
1927
+ exit(ExitCode.InvalidArgs);
1928
+ }
1929
+ if (opts.cred.length !== opts.reason.length) {
1930
+ printError(
1931
+ `--cred and --reason must be paired one-to-one (got ${opts.cred.length} --cred and ${opts.reason.length} --reason). Example: \`--cred cred_A --reason "..." --cred cred_B --reason "..."\`.`
1932
+ );
1933
+ exit(ExitCode.InvalidArgs);
1934
+ }
1935
+ return execService.runBash({
1936
+ command,
1937
+ // Pair-by-index: Commander's `collect` reducer keeps both arrays
1938
+ // in declaration order, so opts.cred[i] always corresponds to
1939
+ // opts.reason[i].
1940
+ credentials: opts.cred.map((id, i) => ({
1941
+ id,
1942
+ reason: opts.reason[i]
1943
+ })),
1944
+ cwd: opts.cwd,
1945
+ quiet: opts.quiet
1946
+ });
1947
+ }
1948
+
1949
+ // src/commands/exec/index.ts
1950
+ function registerExecCommands(program2) {
1951
+ const exec = program2.command("exec").description(
1952
+ "Run bash locally with the operator's credentials resolved by the cloud and injected as env vars. Plaintext secrets never enter the agent's transcript \u2014 output is streamed through a scrubber that redacts every registered secret value."
1953
+ );
1954
+ registerExecBash(exec);
1955
+ }
1956
+
1957
+ // src/commands/credential/list.ts
1958
+ async function executeCredentialList(opts) {
1959
+ try {
1960
+ const credentials = await discoveryService.listCredentials({
1961
+ service: opts.service,
1962
+ mine: opts.mine,
1963
+ query: opts.query
1964
+ });
1965
+ if (opts.json) {
1966
+ printJson({ credentials });
1967
+ return;
1968
+ }
1969
+ if (credentials.length === 0) {
1970
+ const filterDesc = describeCredentialFilters(opts);
1971
+ process.stdout.write(
1972
+ filterDesc ? `No credentials match (${filterDesc}). Drop filters or run \`geni credential connect <service>\` to add one.
1973
+ ` : "No credentials connected yet. Connect one with `geni credential connect <service>` (find a service slug with `geni integration list -q <keyword>`).\n"
1974
+ );
1975
+ return;
1976
+ }
1977
+ printTable(
1978
+ ["ID", "SERVICE", "TITLE"],
1979
+ credentials.map((c) => [c.id, c.service, c.title]),
1980
+ {
1981
+ // `*` flags the rows you own (auto-suppressed if every row is
1982
+ // yours or none are — i.e. the marker only renders when it
1983
+ // actually distinguishes a subset).
1984
+ markerFn: (_row, i) => credentials[i].isOwnedByViewer,
1985
+ colorFn: dimColumn(0)
1986
+ }
1987
+ );
1988
+ } catch (error) {
1989
+ exitOnApiError(error);
1990
+ }
1991
+ }
1992
+ function registerCredentialList(parent) {
1993
+ parent.command("list").description(
1994
+ "List credentials the runner session can use (owned, org-shared, or per-credential collaborator). Default columns are id / service / title \u2014 for env var names, OAuth scopes, or the full record use `geni credential get <id>` (or `--field <path>` / `--json`)."
1995
+ ).option(
1996
+ "--service <slug>",
1997
+ "Filter to one service (e.g. slack, github, salesforce). Use `geni integration list` to discover slugs."
1998
+ ).option(
1999
+ "--mine",
2000
+ "Only credentials owned by you. Excludes credentials shared with you via org-grant or collaborator rows."
2001
+ ).option(
2002
+ "-q, --query <text>",
2003
+ "Substring rank across service, title, and provider name. Service slug weighs highest."
2004
+ ).option(
2005
+ "--json",
2006
+ "Machine-readable output. Each entry carries `id`, `service`, `envVars`, `grantedScopes`, etc."
2007
+ ).action((opts) => executeCredentialList(opts));
2008
+ }
2009
+ function describeCredentialFilters(opts) {
2010
+ const parts = [];
2011
+ if (opts.service) parts.push(`--service ${opts.service}`);
2012
+ if (opts.mine) parts.push("--mine");
2013
+ if (opts.query) parts.push(`-q "${opts.query}"`);
2014
+ return parts.join(" ");
2015
+ }
2016
+
2017
+ // src/lib/jsonField.ts
2018
+ function extractField(value, path) {
2019
+ const segments = path.split(".").filter((s) => s.length > 0);
2020
+ let current = value;
2021
+ for (const segment of segments) {
2022
+ if (current === null || current === void 0) return void 0;
2023
+ if (Array.isArray(current)) {
2024
+ const idx = Number.parseInt(segment, 10);
2025
+ if (Number.isNaN(idx)) return void 0;
2026
+ current = current[idx];
2027
+ } else if (typeof current === "object") {
2028
+ current = Reflect.get(current, segment);
2029
+ } else {
2030
+ return void 0;
2031
+ }
2032
+ }
2033
+ return current;
2034
+ }
2035
+ function formatExtractedField(value) {
2036
+ if (typeof value === "string") return value;
2037
+ if (value === null || value === void 0) return "";
2038
+ return JSON.stringify(value, null, 2);
2039
+ }
2040
+
2041
+ // src/commands/credential/get.ts
2042
+ function registerCredentialGet(parent) {
2043
+ parent.command("get").argument("<id>", "Credential id (e.g. `cred_01HX\u2026`).").description(
2044
+ "Print one credential's full record: env var names, per-field secret/non-secret breakdown, ownership, sharing, scopes. No plaintext secret values are returned. Those resolve server-side at `geni exec bash` time."
2045
+ ).option("--json", "Machine-readable output (full record).").option(
2046
+ "--field <path>",
2047
+ "Print one dotted-path field instead of the whole record. Examples: `envVars`, `grantedScopes`, `service`, `isShared`."
2048
+ ).action(async (id, opts) => {
2049
+ try {
2050
+ const detail = await discoveryService.getCredential(id);
2051
+ if (opts.field) {
2052
+ const value = extractField(detail, opts.field);
2053
+ if (value === void 0) {
2054
+ const topLevel = Object.keys(detail).join(", ");
2055
+ printError(
2056
+ `Field "${opts.field}" is not on the credential record. Top-level fields: [${topLevel}]. Pass a dotted path that exists, or run \`geni credential get ${id} --json\` to see the whole shape.`
2057
+ );
2058
+ exit(ExitCode.NotFound);
2059
+ }
2060
+ process.stdout.write(formatExtractedField(value) + "\n");
2061
+ return;
2062
+ }
2063
+ if (opts.json) {
2064
+ printJson(detail);
2065
+ return;
2066
+ }
2067
+ printDefault(detail);
2068
+ } catch (error) {
2069
+ exitOnApiError(error, {
2070
+ notFoundMessage: `No credential with id "${id}" in the active workspace. Run \`geni credential list\` to find the right id.`
2071
+ });
2072
+ }
2073
+ });
2074
+ }
2075
+ function printDefault(detail) {
2076
+ const out = process.stdout;
2077
+ out.write(`${detail.id} ${detail.title}
2078
+ `);
2079
+ out.write(`provider: ${detail.providerTitle}
2080
+ `);
2081
+ out.write(`type: ${detail.credentialType}
2082
+ `);
2083
+ out.write(`created: ${detail.createdAt.slice(0, 10)}
2084
+ `);
2085
+ out.write(
2086
+ `ownership: ${detail.isOwnedByViewer ? "owned by you" : "shared with you"}
2087
+ `
2088
+ );
2089
+ out.write(`shared: ${detail.isShared ? "yes" : "no"}
2090
+
2091
+ `);
2092
+ out.write("envVars:\n");
2093
+ for (const v of detail.envVars) out.write(` ${v}
2094
+ `);
2095
+ out.write("\n");
2096
+ if (detail.fields.length > 0) {
2097
+ out.write("fields:\n");
2098
+ for (const f of detail.fields) {
2099
+ out.write(` ${f.name.padEnd(14)} ${f.isSecret ? "secret" : "config"}
2100
+ `);
2101
+ }
2102
+ out.write("\n");
2103
+ }
2104
+ if (detail.grantedScopes && detail.grantedScopes.length > 0) {
2105
+ out.write(`scopes: ${detail.grantedScopes.join(", ")}
2106
+ `);
2107
+ }
2108
+ }
2109
+
2110
+ // src/commands/credential/connect.ts
2111
+ import chalk6 from "chalk";
2112
+ function registerCredentialConnect(parent) {
2113
+ parent.command("connect").argument(
2114
+ "<service>",
2115
+ "Service slug (slack, github, \u2026). Use `geni integration list` to discover."
2116
+ ).description(
2117
+ "Open the dashboard's authorize page for a service so the operator can connect a new credential. Lightweight by design: no polling, no rendezvous. After the operator finishes in the dashboard, re-run `geni credential list --service <service>` to discover the new credential id."
2118
+ ).option(
2119
+ "--print-url",
2120
+ "Don't auto-open the browser; print the URL to stdout. Useful in headless / SSH / CI sessions."
2121
+ ).option("--no-browser", "Alias for --print-url.").action(async (service, opts) => {
2122
+ try {
2123
+ const intent = await discoveryService.connectCredential({
2124
+ service,
2125
+ // Commander turns `--no-browser` into `noBrowser: false`,
2126
+ // so the print-only branch is "either flag was passed".
2127
+ printUrlOnly: opts.printUrl || opts.noBrowser === false
2128
+ });
2129
+ if (intent.kind === "print-url") {
2130
+ process.stdout.write(`${intent.url}
2131
+ `);
2132
+ return;
2133
+ }
2134
+ printInfo(`Opening ${chalk6.cyan(intent.url)}`);
2135
+ printInfo("\u21B3 approve in your browser");
2136
+ process.stdout.write(
2137
+ `
2138
+ When you're done, re-run: geni credential list --service ${service}
2139
+ `
2140
+ );
2141
+ } catch (error) {
2142
+ exitOnApiError(error, {
2143
+ notFoundMessage: `Service "${service}" not found.`
2144
+ });
2145
+ }
2146
+ });
2147
+ }
2148
+
2149
+ // src/commands/credential/index.ts
2150
+ function registerCredentialCommands(program2) {
2151
+ const credential = program2.command("credential").alias("credentials").description(
2152
+ "Discover the operator's connected credentials and prompt them to connect new ones. Discovery-only: the agent sees credential ids and the env var names that get set when each is declared on `geni exec bash`, never plaintext secrets. Resolution and decryption happen server-side at exec time."
2153
+ ).action(() => executeCredentialList({}));
2154
+ registerCredentialList(credential);
2155
+ registerCredentialGet(credential);
2156
+ registerCredentialConnect(credential);
2157
+ }
2158
+
2159
+ // src/commands/integration/list.ts
2160
+ import chalk7 from "chalk";
2161
+ var VALID_TYPES = ["oauth2", "apiKey", "platform", "noAuth"];
2162
+ var VALID_TYPE_SET = new Set(VALID_TYPES);
2163
+ async function executeIntegrationList(opts) {
2164
+ if (opts.type && !VALID_TYPE_SET.has(opts.type)) {
2165
+ printError(
2166
+ `Invalid --type "${opts.type}". Valid values: [${VALID_TYPES.join(", ")}]. \`oauth2\` and \`apiKey\` are user-connected credentials; \`platform\` is first-party (uses $PLATFORM_API_KEY); \`noAuth\` is a public API.`
2167
+ );
2168
+ exit(ExitCode.InvalidArgs);
2169
+ }
2170
+ try {
2171
+ const integrations = await discoveryService.listIntegrations({
2172
+ type: opts.type,
2173
+ query: opts.query
2174
+ });
2175
+ if (opts.json) {
2176
+ printJson({ integrations });
2177
+ return;
2178
+ }
2179
+ if (integrations.length === 0) {
2180
+ const filterDesc = [
2181
+ opts.type ? `--type ${opts.type}` : null,
2182
+ opts.query ? `-q "${opts.query}"` : null
2183
+ ].filter(Boolean).join(" ");
2184
+ process.stdout.write(
2185
+ `No integrations match (${filterDesc || "no filters"}). Drop filters or broaden the query.
2186
+ `
2187
+ );
2188
+ return;
2189
+ }
2190
+ printTable(
2191
+ ["SERVICE", "TITLE", "TYPE"],
2192
+ integrations.map((i) => [i.service, i.title, i.credentialType]),
2193
+ {
2194
+ // Service slug is the lookup key for every other CLI verb
2195
+ // (`credential connect <service>`, `integration get <service>`),
2196
+ // so render it cyan to draw the eye. Type is metadata — dim.
2197
+ colorFn: (cell, args) => {
2198
+ if (args.col === 0) return chalk7.cyan(cell);
2199
+ if (args.col === 2) return chalk7.dim(cell);
2200
+ return cell;
2201
+ }
2202
+ }
2203
+ );
2204
+ } catch (error) {
2205
+ exitOnApiError(error);
2206
+ }
2207
+ }
2208
+ function registerIntegrationList(parent) {
2209
+ parent.command("list").description(
2210
+ "List every integration available to the operator's organization: third-party services (slack, github, salesforce), platform services (chat-completion, search-internet), and noAuth APIs. The starting point for finding the canonical `service` slug to pass to `geni credential connect` or to filter `geni credential list`."
2211
+ ).option(
2212
+ "--type <kind>",
2213
+ `Filter by credential type: ${VALID_TYPES.join(" | ")}. \`oauth2\` and \`apiKey\` need a connected credential; \`platform\` uses $PLATFORM_API_KEY; \`noAuth\` is a public API that needs no auth.`
2214
+ ).option(
2215
+ "-q, --query <text>",
2216
+ 'Server-side hybrid (semantic + lexical) search across service slug, title, and description. Search by capability ("send message", "calendar"), not just service name.'
2217
+ ).option(
2218
+ "--json",
2219
+ "Machine-readable output. Each entry is `{ service, title, description, credentialType }`."
2220
+ ).action((opts) => executeIntegrationList(opts));
2221
+ }
2222
+
2223
+ // src/commands/integration/get.ts
2224
+ function registerIntegrationGet(parent) {
2225
+ parent.command("get").argument("<service>", "Service slug (slack, github, \u2026).").description(
2226
+ "Full setup metadata for one integration: bash env var names that get set when its credentials are declared, per-field secret/non-secret breakdown, OAuth scope catalog (when applicable), operator-facing setup guide. Read this when the agent needs to walk the operator through credential setup or wants to know exactly what env vars a credential will produce before declaring one."
2227
+ ).option("--json", "Machine-readable output (full record).").option(
2228
+ "--field <path>",
2229
+ "Print one dotted-path field instead of the whole record. Examples: `envVars`, `oauthScopes`, `fields`, `setupGuide`."
2230
+ ).action(async (service, opts) => {
2231
+ try {
2232
+ const detail = await discoveryService.getIntegration(service);
2233
+ if (opts.field) {
2234
+ const value = extractField(detail, opts.field);
2235
+ if (value === void 0) {
2236
+ const topLevel = Object.keys(detail).join(", ");
2237
+ printError(
2238
+ `Field "${opts.field}" is not on the integration record. Top-level fields: [${topLevel}]. Pass a dotted path that exists, or run \`geni integration get ${service} --json\` to see the whole shape.`
2239
+ );
2240
+ exit(ExitCode.NotFound);
2241
+ }
2242
+ process.stdout.write(formatExtractedField(value) + "\n");
2243
+ return;
2244
+ }
2245
+ if (opts.json) {
2246
+ printJson(detail);
2247
+ return;
2248
+ }
2249
+ printDefault2(detail);
2250
+ } catch (error) {
2251
+ exitOnApiError(error, {
2252
+ notFoundMessage: `No integration with slug "${service}". Run \`geni integration list -q <keyword>\` to find the right slug (slugs are the service's brand name lowercased, e.g. \`slack\`).`
2253
+ });
2254
+ }
2255
+ });
2256
+ }
2257
+ function printDefault2(detail) {
2258
+ const out = process.stdout;
2259
+ out.write(`${detail.service} ${detail.title}
2260
+ `);
2261
+ out.write(`type: ${detail.credentialType}
2262
+
2263
+ `);
2264
+ out.write("description:\n");
2265
+ out.write(` ${detail.description}
2266
+
2267
+ `);
2268
+ if (detail.oauthScopes && detail.oauthScopes.length > 0) {
2269
+ out.write("OAuth scopes:\n");
2270
+ for (const scope of detail.oauthScopes) {
2271
+ out.write(
2272
+ ` ${scope.name.padEnd(40)} ${scope.description || "(no description)"}
2273
+ `
2274
+ );
2275
+ }
2276
+ out.write("\n");
2277
+ }
2278
+ if (detail.fields.length > 0) {
2279
+ out.write("Secret schema:\n");
2280
+ for (const f of detail.fields) {
2281
+ out.write(` ${f.name.padEnd(20)} ${f.isSecret ? "secret" : "config"}
2282
+ `);
2283
+ }
2284
+ out.write("\n");
2285
+ }
2286
+ if (detail.envVars.length > 0) {
2287
+ out.write("Bash env vars set when used:\n");
2288
+ out.write(` ${detail.envVars.map((v) => `$${v}`).join(", ")}
2289
+ `);
2290
+ }
2291
+ }
2292
+
2293
+ // src/commands/integration/operations.ts
2294
+ function registerIntegrationOperations(parent) {
2295
+ parent.command("operations").argument("<service>", "Service slug (e.g. slack, github, stripe).").description(
2296
+ "List the operations one integration exposes (e.g. for slack: 'Send a Message', 'Get Channel History'). Each row's id is the input to `geni integration operation <id>` for full reference docs."
2297
+ ).option(
2298
+ "-q, --query <text>",
2299
+ "Substring rank across operation title and description. Title weighs higher."
2300
+ ).option(
2301
+ "--json",
2302
+ "Machine-readable output. Each entry is `{ id, title, description }`."
2303
+ ).action(async (service, opts) => {
2304
+ try {
2305
+ const operations = await discoveryService.listOperations({
2306
+ service,
2307
+ query: opts.query
2308
+ });
2309
+ if (opts.json) {
2310
+ printJson({ operations });
2311
+ return;
2312
+ }
2313
+ if (operations.length === 0) {
2314
+ process.stdout.write(
2315
+ opts.query ? `No operations on \`${service}\` match -q "${opts.query}". Drop the query to list all, or broaden the keyword.
2316
+ ` : `\`${service}\` exposes no operations. Verify the service slug with \`geni integration list\`.
2317
+ `
2318
+ );
2319
+ return;
2320
+ }
2321
+ printTable(
2322
+ ["ID", "TITLE", "DESCRIPTION"],
2323
+ operations.map((op) => [op.id, op.title, op.description]),
2324
+ { colorFn: dimColumn(0) }
2325
+ );
2326
+ } catch (error) {
2327
+ exitOnApiError(error, {
2328
+ notFoundMessage: `No integration with slug "${service}". Run \`geni integration list -q <keyword>\` to find the right slug.`
2329
+ });
2330
+ }
2331
+ });
2332
+ }
2333
+
2334
+ // src/commands/integration/operation.ts
2335
+ var VALID_FORMATS = ["text", "markdown", "json"];
2336
+ var VALID_FORMAT_SET = new Set(VALID_FORMATS);
2337
+ function registerIntegrationOperation(parent) {
2338
+ parent.command("operation").argument(
2339
+ "<serviceOrId>",
2340
+ "Operation id, or service slug followed by operation id."
2341
+ ).argument("[opId]", "Operation id when the first arg is a service slug.").description(
2342
+ "Full reference for one operation: HTTP method+path, required scopes, params, response shape, code example, and the env var names that get set when a credential of this service is declared on `geni exec bash`. The single most important command before constructing a credentialed request. Read this, then write the call."
2343
+ ).option(
2344
+ "--format <fmt>",
2345
+ `Output format: ${VALID_FORMATS.join(" | ")}. \`markdown\` is the cleanest paste into an LLM context window.`,
2346
+ "text"
2347
+ ).action(
2348
+ async (serviceOrId, maybeOpId, opts) => {
2349
+ const format = opts.format ?? "text";
2350
+ if (!VALID_FORMAT_SET.has(format)) {
2351
+ printError(
2352
+ `Invalid --format "${format}". Valid values: [${VALID_FORMATS.join(", ")}]. Default is \`text\`; use \`markdown\` when pasting into an LLM context.`
2353
+ );
2354
+ exit(ExitCode.InvalidArgs);
2355
+ }
2356
+ try {
2357
+ const detail = await discoveryService.getOperation(
2358
+ maybeOpId ? { service: serviceOrId, opId: maybeOpId } : { opId: serviceOrId }
2359
+ );
2360
+ if (format === "json") {
2361
+ printJson(detail);
2362
+ return;
2363
+ }
2364
+ if (format === "markdown") {
2365
+ process.stdout.write(detail.documentation + "\n");
2366
+ return;
2367
+ }
2368
+ printText(detail);
2369
+ } catch (error) {
2370
+ const target = maybeOpId ? `${serviceOrId} ${maybeOpId}` : serviceOrId;
2371
+ exitOnApiError(error, {
2372
+ notFoundMessage: `No operation found for "${target}". List operations for a service with \`geni integration operations <service>\`, then re-run with one of those ids.`
2373
+ });
2374
+ }
2375
+ }
2376
+ ).addHelpText(
2377
+ "after",
2378
+ `
2379
+ Examples:
2380
+
2381
+ # Look up by bare operation id (server resolves the service):
2382
+ geni integration operation 4c21e1ee-4d54-4413-a4f2-80a80dff4c99
2383
+
2384
+ # Look up with service prefix (works the same, useful when the agent
2385
+ # already knows the service):
2386
+ geni integration operation slack 4c21e1ee-4d54-4413-a4f2-80a80dff4c99
2387
+
2388
+ # Paste-ready for an LLM context window:
2389
+ geni integration operation slack 4c21e1ee... --format markdown
2390
+
2391
+ Find ids first with: geni integration operations <service>
2392
+ `
2393
+ );
2394
+ }
2395
+ function printText(detail) {
2396
+ const out = process.stdout;
2397
+ out.write(`${detail.service} \xB7 ${detail.title}
2398
+
2399
+ `);
2400
+ if (detail.description) out.write(`${detail.description}
2401
+
2402
+ `);
2403
+ const cleaned = detail.documentation.replace(/^#+\s*/gm, "").replace(/^```[\w-]*$/gm, "");
2404
+ out.write(cleaned);
2405
+ if (!cleaned.endsWith("\n")) out.write("\n");
2406
+ }
2407
+
2408
+ // src/commands/integration/index.ts
2409
+ function registerIntegrationCommands(program2) {
2410
+ const integration = program2.command("integration").alias("integrations").description(
2411
+ "Discover the integration catalog: which third-party and platform services the operator's organization can use, the env var names each service's credentials will inject, and per-operation reference docs (HTTP method, params, examples) needed to construct an exec call."
2412
+ ).action(() => executeIntegrationList({}));
2413
+ registerIntegrationList(integration);
2414
+ registerIntegrationGet(integration);
2415
+ registerIntegrationOperations(integration);
2416
+ registerIntegrationOperation(integration);
2417
+ }
2418
+
2419
+ // src/commands/config/get.ts
2420
+ var UNSET_PLACEHOLDER = "(unset)";
2421
+ function executeConfigGet(args) {
2422
+ const file = configService.fileValues();
2423
+ if (args.key) {
2424
+ if (!isSettableConfigKey(args.key)) {
2425
+ printError(
2426
+ `Unknown config key "${args.key}". Valid keys: ${SETTABLE_CONFIG_KEYS.join(", ")}.`
2427
+ );
2428
+ exit(ExitCode.InvalidArgs);
2429
+ }
2430
+ const value = file[args.key];
2431
+ if (args.json) {
2432
+ printJson({ [args.key]: value ?? null });
2433
+ return;
2434
+ }
2435
+ process.stdout.write((value ?? UNSET_PLACEHOLDER) + "\n");
2436
+ return;
2437
+ }
2438
+ if (args.json) {
2439
+ const payload = {};
2440
+ for (const k of SETTABLE_CONFIG_KEYS) payload[k] = file[k] ?? null;
2441
+ printJson(payload);
2442
+ return;
2443
+ }
2444
+ printTable(
2445
+ ["KEY", "VALUE"],
2446
+ SETTABLE_CONFIG_KEYS.map((k) => [k, file[k] ?? UNSET_PLACEHOLDER])
2447
+ );
2448
+ }
2449
+ function registerConfigGet(parent) {
2450
+ parent.command("get").argument(
2451
+ "[key]",
2452
+ `Specific key to read (${SETTABLE_CONFIG_KEYS.join(" | ")}). Omit to list every settable key with its value.`
2453
+ ).description(
2454
+ "Print what's written to the persistent config file. Symmetric with `geni config set` \u2014 whatever you wrote is what you read. Unset keys render as `(unset)` in table output and `null` in --json. For the URL the CLI is actually hitting at runtime (which can differ if a runner-session is bound to a different server), run `geni auth status`."
2455
+ ).option(
2456
+ "--json",
2457
+ "Machine-readable output. Unset keys are emitted as JSON `null`."
2458
+ ).action(
2459
+ (key, opts) => executeConfigGet({ key, json: opts.json })
2460
+ );
2461
+ }
2462
+
2463
+ // src/commands/config/set.ts
2464
+ function registerConfigSet(parent) {
2465
+ parent.command("set").argument("<key>", `Config key (${SETTABLE_CONFIG_KEYS.join(" | ")}).`).argument(
2466
+ "<value>",
2467
+ "New value. URL keys must be valid http:// or https:// URLs (validated on write)."
2468
+ ).description(
2469
+ "Write a config value. Validated against the schema on write, so a malformed URL fails loudly here rather than waiting for the next CLI command to crash. Refuses to change `apiUrl` while a runner-session is bound to a different server \u2014 logout (or use `geni login --server <url>`) to switch first."
2470
+ ).action(async (key, value) => {
2471
+ if (!isSettableConfigKey(key)) {
2472
+ printError(
2473
+ `Unknown config key "${key}". Valid keys: ${SETTABLE_CONFIG_KEYS.join(", ")}.`
2474
+ );
2475
+ exit(ExitCode.InvalidArgs);
2476
+ }
2477
+ const result = await configService.set({ key, value });
2478
+ if (result.ok) {
2479
+ printSuccess(`Set ${key} = ${value}`);
2480
+ return;
2481
+ }
2482
+ switch (result.reason) {
2483
+ case "invalid":
2484
+ printError(`Invalid value for ${key}: ${result.error}.`);
2485
+ exit(ExitCode.InvalidArgs);
2486
+ // eslint-disable-next-line no-fallthrough
2487
+ case "session_conflict":
2488
+ printError(
2489
+ `Refusing to change apiUrl: an active session is bound to ${result.sessionUrl}.`
2490
+ );
2491
+ printError(
2492
+ `Run \`geni logout\` first, or switch in one step with \`geni login --server ${value}\`.`
2493
+ );
2494
+ exit(ExitCode.GenericError);
2495
+ // eslint-disable-next-line no-fallthrough
2496
+ default: {
2497
+ const _exhaustive = result;
2498
+ throw new Error(
2499
+ `Unhandled set result: ${JSON.stringify(_exhaustive)}`
2500
+ );
2501
+ }
2502
+ }
2503
+ });
2504
+ }
2505
+
2506
+ // src/commands/config/unset.ts
2507
+ function registerConfigUnset(parent) {
2508
+ parent.command("unset").argument("<key>", `Config key (${SETTABLE_CONFIG_KEYS.join(" | ")}).`).description(
2509
+ "Remove a key from the config file. The resolver falls back through env vars then the compiled-in default, so unsetting doesn't break the CLI; it just goes to the next layer. When the last key is removed, the file itself is deleted instead of leaving an empty config behind."
2510
+ ).action(async (key) => {
2511
+ if (!isSettableConfigKey(key)) {
2512
+ printError(
2513
+ `Unknown config key "${key}". Valid keys: ${SETTABLE_CONFIG_KEYS.join(", ")}.`
2514
+ );
2515
+ exit(ExitCode.InvalidArgs);
2516
+ }
2517
+ const result = await configService.unset({ key });
2518
+ if (!result.wasSet) {
2519
+ printSuccess(`${key} was already unset.`);
2520
+ return;
2521
+ }
2522
+ printSuccess(`Unset ${key}.`);
2523
+ });
2524
+ }
2525
+
2526
+ // src/commands/config/path.ts
2527
+ function registerConfigPath(parent) {
2528
+ parent.command("path").description(
2529
+ "Print the absolute path to the config file. Useful in shell substitutions: `cat $(geni config path)`, `vim $(geni config path)`. Honors $GENI_CONFIG_DIR; defaults to ~/.config/geni/config.json. Always prints what the path WOULD be, the file may not exist yet."
2530
+ ).action(() => {
2531
+ process.stdout.write(configService.path + "\n");
2532
+ });
2533
+ }
2534
+
2535
+ // src/commands/config/index.ts
2536
+ function registerConfigCommands(program2) {
2537
+ const config = program2.command("config").description(
2538
+ "Read and write the persistent CLI config (`~/.config/geni/config.json` by default; honors $GENI_CONFIG_DIR). Holds defaults the resolver consults when nothing more specific is set, useful for pointing the CLI at a self-hosted or local-dev server without re-passing `--server` or exporting an env var on every shell."
2539
+ ).action(() => executeConfigGet({}));
2540
+ registerConfigGet(config);
2541
+ registerConfigSet(config);
2542
+ registerConfigUnset(config);
2543
+ registerConfigPath(config);
2544
+ config.addHelpText(
2545
+ "after",
2546
+ `
2547
+ Settable keys:
2548
+ apiUrl cloud API base URL used at fresh \`geni login\` time
2549
+ dashboardUrl dashboard base URL used by browser-opening commands
2550
+ (e.g. \`geni credential connect\`)
2551
+
2552
+ Resolver precedence for apiUrl (highest wins):
2553
+ 1. session file's stored server (locked at \`geni login\` time)
2554
+ 2. $GENI_API_URL env var
2555
+ 3. \`apiUrl\` in this config
2556
+ 4. compiled-in default (https://cloud.generalinput.com)
2557
+
2558
+ Switching API URL after login:
2559
+ $ geni logout
2560
+ $ geni config set apiUrl http://localhost:4111
2561
+ $ geni login
2562
+ The session token is bound to the URL it was minted against, so
2563
+ \`config set apiUrl <new>\` is refused while a session is active \u2014
2564
+ logout (or use \`geni login --server <url>\`) to switch in one step.
2565
+ `
2566
+ );
2567
+ }
2568
+
2569
+ // src/lib/skills.ts
2570
+ import { homedir as homedir2 } from "os";
2571
+ import { join as join2 } from "path";
2572
+ import {
2573
+ mkdirSync,
2574
+ writeFileSync,
2575
+ existsSync,
2576
+ readFileSync as readFileSync2,
2577
+ unlinkSync,
2578
+ rmSync
2579
+ } from "fs";
2580
+
2581
+ // src/skills/geni.md
2582
+ var geni_default = '---\nname: geni\ndescription: Use the operator\'s connected services (Slack, Gmail, GitHub, Stripe, anything they\'ve authorized in General Input) to fulfill their request. Load this whenever the user wants you to take an action on their behalf against an external SaaS account, fetch data from one of their tools, or wire something up that crosses service boundaries. Powers two modes today, one-off requests via `geni exec bash`, plus workflows (coming soon).\n---\n\n# geni\n\n`geni` is the CLI that gives you (the agent) credentialed access to\nthe operator\'s connected accounts. The cloud injects their tokens as\nenv vars into a fresh bash subprocess; you write the `curl` and\nnever see the secret.\n\n## Two modes\n\n**1. One-off requests (available now).** The operator asks you to do\na thing once: "post a Slack message to #eng," "show me my last 10\nStripe charges," "create a GitHub issue from this thread." You\ndiscover the right credential and operation, then run a single\n`geni exec bash --cred <id>` to do it. This is the entire surface\narea you should use today.\n\n**2. Workflows (coming soon).** Durable, scheduled, reusable runs\n(cron-like jobs, webhook handlers, multi-step pipelines) will live\nunder `geni workflow`. Not supported yet. If the operator asks for\n"every morning at 9," "whenever a webhook fires," or anything that\nneeds to keep running after this session ends, tell them workflows\naren\'t available yet and offer to do the work as a one-off right\nnow instead.\n\n## One-off request flow\n\nAlways run discovery before constructing a request. The operation\ndocs tell you the exact env var names and HTTP shape, derive nothing.\n\n1. **Find a connected credential.** `geni credential list` returns\n one row per credential the operator has access to (id, service,\n title). Filter by service: `geni credential list --service slack`.\n\n2. **If the service isn\'t connected**, search the catalog and prompt\n the operator: `geni integration list -q "<keyword>"`. Then\n `geni credential connect <service>` opens the dashboard for them.\n\n3. **Find the operation you need.** `geni integration operations\n<service>` lists every operation the integration exposes. Filter\n with `-q "<keyword>"`.\n\n4. **Read the operation\'s reference docs.** `geni integration\noperation <id> --format markdown` returns HTTP method, path,\n params, response shape, AND the exact env var names that bash\n will set when this credential is declared. **Always read this\n before constructing a curl.** Guessing at URLs or env var names\n is the most common failure mode.\n\n5. **Run the call.**\n ```\n geni exec bash --cred <cred_id> --reason "<what + why>" \\\n -- \'<bash command using the env vars>\'\n ```\n\n## Env var contract\n\nEvery credential\'s env vars are on the response of step 5\n(`credentials_resolved`) and on the operation docs from step 4. Read\nthem, don\'t derive.\n\n- **Per-field, suffixed**: `<SERVICE>_<FIELD>_<id>` (e.g.\n `$SLACK_ACCESS_TOKEN_ABC`, `$SALESFORCE_INSTANCE_URL_KG`). The\n `<id>` suffix is the credential id with `cred_` stripped, uppercased.\n Suffix means two credentials of the same service can coexist in one\n call without colliding.\n- **Canonical aliases (unsuffixed)**: well-known SDKs read fixed env\n var names (`$GH_TOKEN`, `$GITHUB_TOKEN`, `$SLACK_BOT_TOKEN`,\n `$OPENAI_API_KEY`, `$ANTHROPIC_API_KEY`, `$STRIPE_API_KEY`).\n These are emitted alongside the suffixed names for tools that\n hardcode them.\n- **Always-injected platform vars**: `$PLATFORM_API_KEY` /\n `$PLATFORM_BASE_URL` are set on every `geni exec bash` (no\n `--cred` needed). Use them to call General Input\'s first-party\n services (image gen, weather, send-email, etc.).\n\n## Reasons matter\n\nThe `--reason` you supply on every `geni exec bash --cred ...`\ncall lands in the credential access log and is shown to the\noperator. Be specific: "Posting daily digest to #engineering on the\noperator\'s request" is good; "Slack call" is not. Re-state the reason\non every call. The audit log is per-call, not per-session.\n\n## Output is scrubbed\n\nstdout/stderr from the bash subprocess pass through a streaming\nscrubber that replaces every literal occurrence of a registered\nsecret with `[REDACTED:credential_<id>]`. You won\'t see the token\nback. Tools like `echo $TOKEN | base64` don\'t help either, the\nscrubber knows the common encodings.\n\n## Patterns\n\n**One credential, simple curl:**\n\n```\ngeni exec bash --cred cred_01HX --reason "Listing Slack channels" \\\n -- \'curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" https://slack.com/api/conversations.list | jq .channels\'\n```\n\n**Multiple credentials, fan-out (suffix keeps them distinct):**\n\n```\ngeni exec bash \\\n --cred cred_slackA --reason "Posting to #engineering" \\\n --cred cred_slackB --reason "Posting to #marketing" \\\n -- \'curl ... $SLACK_ACCESS_TOKEN_SLACKA ...; curl ... $SLACK_ACCESS_TOKEN_SLACKB ...\'\n```\n\n**Platform service (no --cred needed):**\n\n```\ngeni exec bash \\\n -- \'curl -s -X POST "$PLATFORM_BASE_URL/v1/web-search" \\\n -H "Authorization: Bearer $PLATFORM_API_KEY" \\\n -d "{\\"query\\":\\"general input\\"}"\'\n```\n\n## Exit codes\n\n| Code | Meaning |\n| ----- | -------------------------------------------------------------------- |\n| 0 | Subprocess succeeded |\n| 1\u2013125 | Subprocess\'s own exit code (curl failed, jq parse error, etc.) |\n| 4 | Resource not found (cred id, integration slug, operation id) |\n| 5 | Forbidden, session valid but no access to this resource |\n| 9 | Validation failed (bad flag combo, malformed input) |\n| 77 | Server refused to resolve a credential. Don\'t retry, surface to user |\n| 78 | Runner session missing or expired. Operator runs `geni login` |\n| 124 | Timeout |\n| 125 | Internal CLI error |\n\n## Use `--json` everywhere for programmatic output\n\nEvery list/get command supports `--json` for machine-readable shape.\nUse it whenever you\'re going to parse the response. The table output\nis meant for humans.\n\n## Don\'t\n\n- Don\'t construct a curl without reading `geni integration operation\n<id>` first. The HTTP shape, required headers, and env var names\n are all in the operation docs; deriving them from the service slug\n is how you end up with 401s.\n- Don\'t reach for `run_managed_script` or any JS-script tool. `geni\nexec bash` is the only execution primitive locally. If you need\n npm packages, your bash command can call `bun --install auto run -e\n\'...\'` (if bun is installed locally) or `python3 -c \'...\'` for\n Python.\n- Don\'t promise the operator a recurring or scheduled job. Workflows\n are coming soon but not shipped. Offer the one-off equivalent now.\n';
2583
+
2584
+ // src/lib/skills.ts
2585
+ var GENI_SKILL_NAME = "geni";
2586
+ var GENI_SKILL_MD = geni_default;
2587
+ var home = homedir2();
2588
+ var claudeSkillsDir = join2(home, ".claude", "skills");
2589
+ var claudeGeniDir = join2(claudeSkillsDir, GENI_SKILL_NAME);
2590
+ var agentSkillsDir = join2(home, ".agents", "skills");
2591
+ var agentSkillsGeniDir = join2(agentSkillsDir, GENI_SKILL_NAME);
2592
+ var TARGETS = [
2593
+ {
2594
+ name: "Claude Code",
2595
+ detect: () => existsSync(join2(home, ".claude")),
2596
+ skillDir: claudeGeniDir,
2597
+ skillPath: join2(claudeGeniDir, "SKILL.md"),
2598
+ // Earlier versions of `geni skills install` wrote a loose .md file
2599
+ // at `~/.claude/skills/geni.md`, which Claude Code silently ignores.
2600
+ // Clean it up on install/uninstall so upgraders aren't left with a
2601
+ // stale sibling that confuses `doctor`.
2602
+ legacyPaths: [join2(claudeSkillsDir, `${GENI_SKILL_NAME}.md`)]
2603
+ },
2604
+ {
2605
+ name: "Codex CLI / Gemini CLI / VS Code Copilot",
2606
+ // Detection: any of the supporting agents has touched disk, or the
2607
+ // shared `~/.agents/` dir already exists (likely written by another
2608
+ // installer). We don't probe for VS Code Copilot Chat directly
2609
+ // because it lives inside VS Code's user data with no clean
2610
+ // home-dir signal — Copilot users typically have one of the CLIs
2611
+ // installed too, and the install is idempotent if they don't.
2612
+ detect: () => existsSync(join2(home, ".codex")) || existsSync(join2(home, ".gemini")) || existsSync(join2(home, ".agents")),
2613
+ skillDir: agentSkillsGeniDir,
2614
+ skillPath: join2(agentSkillsGeniDir, "SKILL.md"),
2615
+ legacyPaths: []
2616
+ }
2617
+ ];
2618
+ function detectSkillTargets() {
2619
+ const out = [];
2620
+ for (const target of TARGETS) {
2621
+ if (!target.detect()) continue;
2622
+ out.push({ name: target.name, path: target.skillPath });
2623
+ }
2624
+ return out;
2625
+ }
2626
+ function installSkills() {
2627
+ const results = [];
2628
+ for (const target of TARGETS) {
2629
+ if (!target.detect()) continue;
2630
+ const path = target.skillPath;
2631
+ try {
2632
+ mkdirSync(target.skillDir, { recursive: true });
2633
+ const previous = existsSync(path) ? readFileSync2(path, "utf-8") : null;
2634
+ const changed = previous !== GENI_SKILL_MD;
2635
+ writeFileSync(path, GENI_SKILL_MD);
2636
+ for (const legacy of target.legacyPaths) {
2637
+ if (existsSync(legacy)) unlinkSync(legacy);
2638
+ }
2639
+ results.push({
2640
+ name: target.name,
2641
+ path,
2642
+ status: changed ? "updated" : "unchanged"
2643
+ });
2644
+ } catch (err) {
2645
+ results.push({
2646
+ name: target.name,
2647
+ path,
2648
+ status: "failed",
2649
+ error: err instanceof Error ? err.message : String(err)
2650
+ });
2651
+ }
2652
+ }
2653
+ return results;
2654
+ }
2655
+ function uninstallSkills() {
2656
+ const results = [];
2657
+ for (const target of TARGETS) {
2658
+ if (!target.detect()) continue;
2659
+ const dir = target.skillDir;
2660
+ const path = target.skillPath;
2661
+ const legacies = target.legacyPaths.filter((p2) => existsSync(p2));
2662
+ if (!existsSync(dir) && legacies.length === 0) {
2663
+ results.push({ name: target.name, path, status: "absent" });
2664
+ continue;
2665
+ }
2666
+ try {
2667
+ rmSync(dir, { recursive: true, force: true });
2668
+ for (const legacy of legacies) unlinkSync(legacy);
2669
+ results.push({ name: target.name, path, status: "removed" });
2670
+ } catch (err) {
2671
+ results.push({
2672
+ name: target.name,
2673
+ path,
2674
+ status: "failed",
2675
+ error: err instanceof Error ? err.message : String(err)
2676
+ });
2677
+ }
2678
+ }
2679
+ return results;
2680
+ }
2681
+
2682
+ // src/commands/skills/install.ts
2683
+ function registerSkillsInstall(parent) {
2684
+ parent.command("install").description(
2685
+ "Install the bundled geni skill into every detected AI agent (Claude Code, Codex CLI, Gemini CLI, VS Code Copilot Chat). Idempotent, safe to re-run after upgrading geni."
2686
+ ).action(() => {
2687
+ const targets = detectSkillTargets();
2688
+ if (targets.length === 0) {
2689
+ printWarning(
2690
+ "No supported AI agent detected. Install Claude Code (https://claude.ai/download), Codex CLI, or Gemini CLI first, then re-run `geni skills install`."
2691
+ );
2692
+ exit(ExitCode.NotFound);
2693
+ }
2694
+ const results = installSkills();
2695
+ for (const r of results) {
2696
+ if (r.status === "failed") {
2697
+ printError(`${r.name}: ${r.error ?? "failed"} (${r.path})`);
2698
+ continue;
2699
+ }
2700
+ if (r.status === "updated") {
2701
+ printSuccess(`${r.name}: ${r.path}`);
2702
+ } else {
2703
+ printInfo(`${r.name}: ${r.path} (already up to date)`);
2704
+ }
2705
+ }
2706
+ const failed = results.some((r) => r.status === "failed");
2707
+ exit(failed ? ExitCode.InternalError : ExitCode.Ok);
2708
+ });
2709
+ }
2710
+
2711
+ // src/commands/skills/uninstall.ts
2712
+ function registerSkillsUninstall(parent) {
2713
+ parent.command("uninstall").description(
2714
+ "Remove the geni skill from every detected AI agent. Leaves the agent itself untouched."
2715
+ ).action(() => {
2716
+ const results = uninstallSkills();
2717
+ if (results.length === 0) {
2718
+ printInfo("No supported AI agent detected; nothing to remove.");
2719
+ exit(ExitCode.Ok);
2720
+ }
2721
+ for (const r of results) {
2722
+ if (r.status === "failed") {
2723
+ printError(`${r.name}: ${r.error ?? "failed"} (${r.path})`);
2724
+ continue;
2725
+ }
2726
+ if (r.status === "removed") {
2727
+ printSuccess(`${r.name}: removed ${r.path}`);
2728
+ } else {
2729
+ printInfo(`${r.name}: nothing to remove (${r.path} not present)`);
2730
+ }
2731
+ }
2732
+ const failed = results.some((r) => r.status === "failed");
2733
+ exit(failed ? ExitCode.InternalError : ExitCode.Ok);
2734
+ });
2735
+ }
2736
+
2737
+ // src/commands/skills/index.ts
2738
+ function registerSkillsCommands(program2) {
2739
+ const skills = program2.command("skills").description(
2740
+ "Install (or remove) the agent-facing geni instructions in your AI coding agent. Detects Claude Code (others coming) and writes a skill file the agent loads automatically."
2741
+ );
2742
+ registerSkillsInstall(skills);
2743
+ registerSkillsUninstall(skills);
2744
+ }
2745
+
2746
+ // src/commands/doctor.ts
2747
+ import chalk8 from "chalk";
2748
+
2749
+ // src/lib/preflight.ts
2750
+ import { delimiter, join as join3 } from "path";
2751
+ import { accessSync, constants } from "fs";
2752
+ var REQUIRED_RUNTIME_DEPS = ["bash", "curl", "jq"];
2753
+ function findOnPath(name) {
2754
+ const path = process.env.PATH;
2755
+ if (!path) return null;
2756
+ for (const dir of path.split(delimiter)) {
2757
+ if (dir.length === 0) continue;
2758
+ const candidate = join3(dir, name);
2759
+ try {
2760
+ accessSync(candidate, constants.X_OK);
2761
+ return candidate;
2762
+ } catch {
2763
+ }
2764
+ }
2765
+ return null;
2766
+ }
2767
+ function checkRuntimeDeps() {
2768
+ const found = {};
2769
+ const missing = [];
2770
+ for (const dep of REQUIRED_RUNTIME_DEPS) {
2771
+ const path = findOnPath(dep);
2772
+ found[dep] = path;
2773
+ if (path === null) missing.push(dep);
2774
+ }
2775
+ return { missing, found };
2776
+ }
2777
+ function requireRuntimeDeps() {
2778
+ const { missing } = checkRuntimeDeps();
2779
+ if (missing.length === 0) return;
2780
+ printError(
2781
+ `Missing required tool(s) on $PATH: ${missing.join(", ")}. ${installHint(missing)}`
2782
+ );
2783
+ exit(ExitCode.InternalError);
2784
+ }
2785
+ function installHint(deps) {
2786
+ const list = deps.join(" ");
2787
+ switch (process.platform) {
2788
+ case "darwin":
2789
+ return `Install with: \`brew install ${list}\``;
2790
+ case "linux":
2791
+ return `Install with your distro's package manager, e.g. \`sudo apt install ${list}\` or \`sudo dnf install ${list}\``;
2792
+ default:
2793
+ return `Install ${list} before re-running.`;
2794
+ }
2795
+ }
2796
+
2797
+ // src/commands/doctor.ts
2798
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
2799
+ function registerDoctorCommand(program2) {
2800
+ program2.command("doctor").description(
2801
+ "Diagnose your geni setup: required system tools, active session, network reach to the cloud, and skill installation across detected AI agents. Prints a checklist with \u2713/\u2717 for each."
2802
+ ).action(async () => {
2803
+ const checks = [];
2804
+ checks.push(...runtimeDepsCheck());
2805
+ checks.push(...await sessionCheck());
2806
+ checks.push(...skillsCheck());
2807
+ printReport(checks);
2808
+ const failures = checks.filter((c) => c.status === "fail").length;
2809
+ exit(failures > 0 ? ExitCode.GenericError : ExitCode.Ok);
2810
+ });
2811
+ }
2812
+ function runtimeDepsCheck() {
2813
+ const { found, missing } = checkRuntimeDeps();
2814
+ const out = [];
2815
+ for (const dep of REQUIRED_RUNTIME_DEPS) {
2816
+ const path = found[dep];
2817
+ if (path) {
2818
+ out.push({
2819
+ label: `${dep} on $PATH`,
2820
+ status: "pass",
2821
+ detail: path
2822
+ });
2823
+ } else {
2824
+ out.push({
2825
+ label: `${dep} on $PATH`,
2826
+ status: "fail",
2827
+ detail: `not found. ${installHint([dep])}`
2828
+ });
2829
+ }
2830
+ }
2831
+ if (missing.length > 1) {
2832
+ out.push({
2833
+ label: "install hint",
2834
+ status: "warn",
2835
+ detail: installHint(missing)
2836
+ });
2837
+ }
2838
+ return out;
2839
+ }
2840
+ async function sessionCheck() {
2841
+ const session = await sessionContextService.load();
2842
+ if (!session) {
2843
+ return [
2844
+ {
2845
+ label: "runner session",
2846
+ status: "fail",
2847
+ detail: "no session on disk. Run `geni login` to authenticate."
2848
+ }
2849
+ ];
2850
+ }
2851
+ const out = [
2852
+ {
2853
+ label: "runner session",
2854
+ status: "pass",
2855
+ detail: `${session.user.email ?? session.user.id} on ${session.server}`
2856
+ },
2857
+ {
2858
+ label: "active workspace",
2859
+ status: "pass",
2860
+ detail: `${session.workspace.slug} (${session.workspace.role})`
2861
+ }
2862
+ ];
2863
+ try {
2864
+ await authService.status();
2865
+ out.push({
2866
+ label: "server reachable + session valid",
2867
+ status: "pass",
2868
+ detail: session.server
2869
+ });
2870
+ } catch (err) {
2871
+ if (err instanceof ApiError && err.status === 401) {
2872
+ out.push({
2873
+ label: "server reachable + session valid",
2874
+ status: "fail",
2875
+ detail: "session token rejected. Run `geni login` to re-authenticate."
2876
+ });
2877
+ } else {
2878
+ const detail = err instanceof Error ? err.message : String(err);
2879
+ out.push({
2880
+ label: "server reachable",
2881
+ status: "fail",
2882
+ detail: `${session.server} unreachable: ${detail}`
2883
+ });
2884
+ }
2885
+ }
2886
+ return out;
2887
+ }
2888
+ function skillsCheck() {
2889
+ const targets = detectSkillTargets();
2890
+ if (targets.length === 0) {
2891
+ return [
2892
+ {
2893
+ label: "AI agent detected",
2894
+ status: "warn",
2895
+ detail: "no supported agent installed yet. Install Claude Code (https://claude.ai/download) and re-run `geni doctor`."
2896
+ }
2897
+ ];
2898
+ }
2899
+ const out = [];
2900
+ for (const target of targets) {
2901
+ if (!existsSync2(target.path)) {
2902
+ out.push({
2903
+ label: `${target.name} skill installed`,
2904
+ status: "fail",
2905
+ detail: `${target.path} missing. Run \`geni skills install\` to write it.`
2906
+ });
2907
+ continue;
2908
+ }
2909
+ const onDisk = readFileSync3(target.path, "utf-8");
2910
+ if (onDisk === GENI_SKILL_MD) {
2911
+ out.push({
2912
+ label: `${target.name} skill installed`,
2913
+ status: "pass",
2914
+ detail: `${target.path} (current)`
2915
+ });
2916
+ } else {
2917
+ out.push({
2918
+ label: `${target.name} skill installed`,
2919
+ status: "warn",
2920
+ detail: `${target.path} is out of date. Run \`geni skills install\` to refresh.`
2921
+ });
2922
+ }
2923
+ }
2924
+ return out;
2925
+ }
2926
+ function printReport(checks) {
2927
+ for (const check of checks) {
2928
+ const mark = check.status === "pass" ? chalk8.green("\u2713") : check.status === "warn" ? chalk8.yellow("!") : chalk8.red("\u2717");
2929
+ process.stdout.write(`${mark} ${check.label}
2930
+ `);
2931
+ process.stdout.write(` ${chalk8.dim(check.detail)}
2932
+ `);
2933
+ }
2934
+ const fails = checks.filter((c) => c.status === "fail").length;
2935
+ const warns = checks.filter((c) => c.status === "warn").length;
2936
+ process.stdout.write("\n");
2937
+ if (fails === 0 && warns === 0) {
2938
+ process.stdout.write(chalk8.green("All checks passed.\n"));
2939
+ } else {
2940
+ process.stdout.write(
2941
+ `${fails} failure${fails === 1 ? "" : "s"}, ${warns} warning${warns === 1 ? "" : "s"}.
2942
+ `
2943
+ );
2944
+ }
2945
+ }
2946
+
2947
+ // src/cli.ts
2948
+ var program = new Command();
2949
+ program.name("geni").description(
2950
+ "The agent-facing CLI for General Input. Discover the integrations and credentials the operator has connected, then run shell commands with those credentials injected as env vars by the cloud (audit-logged, output-scrubbed)."
2951
+ ).version(CLI_VERSION, "-v, --version", "Print the geni version and exit.").showHelpAfterError().option(
2952
+ "--workspace <slug>",
2953
+ "Override the active workspace for this invocation. Without it, commands run against the workspace set by `geni workspace switch`."
2954
+ );
2955
+ registerAuthCommands(program);
2956
+ registerWorkspaceCommands(program);
2957
+ registerExecCommands(program);
2958
+ registerCredentialCommands(program);
2959
+ registerIntegrationCommands(program);
2960
+ registerConfigCommands(program);
2961
+ registerSkillsCommands(program);
2962
+ registerDoctorCommand(program);
2963
+ program.addHelpText(
2964
+ "after",
2965
+ `
2966
+ Typical agent flow:
2967
+ 1. geni integration list -q "<keyword>" find a service
2968
+ 2. geni integration operations <service> -q "..." find an operation
2969
+ 3. geni integration operation <opId> read its reference (env vars, auth header, example). ALWAYS do this before writing a curl
2970
+ 4. geni credential list --service <service> find a connected credential
2971
+ 5. geni exec bash --cred <id> --reason "..." -- '<command>'
2972
+
2973
+ Discovery (steps 1-4) is read-only and free; only step 5 resolves
2974
+ credentials and runs code, audit-logged with the reason you supplied.
2975
+
2976
+ First time? geni login then geni config get to verify the API URL.
2977
+ Run any command with --help for its full reference.`
2978
+ );
2979
+ if (shouldRunPreflight(process.argv)) {
2980
+ requireRuntimeDeps();
2981
+ }
2982
+ function shouldRunPreflight(argv) {
2983
+ const firstArg = argv[2];
2984
+ if (!firstArg) return false;
2985
+ if (firstArg === "doctor") return false;
2986
+ if (firstArg === "--help" || firstArg === "-h") return false;
2987
+ if (firstArg === "--version" || firstArg === "-v") return false;
2988
+ return true;
2989
+ }
2990
+ try {
2991
+ await program.parseAsync(process.argv);
2992
+ } catch (err) {
2993
+ if (err instanceof ApiError && err.status === 426) {
2994
+ printError(err.message);
2995
+ exit(ExitCode.UpgradeRequired);
2996
+ }
2997
+ throw err;
2998
+ }
2999
+ //# sourceMappingURL=cli.js.map