@keygraph/shannon 1.0.0-beta.1
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/LICENSE +661 -0
- package/dist/index.mjs +1711 -0
- package/infra/compose.yml +50 -0
- package/infra/router-config.json +31 -0
- package/package.json +50 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
9
|
+
import { watch } from "chokidar";
|
|
10
|
+
import * as p from "@clack/prompts";
|
|
11
|
+
import { parse, stringify } from "smol-toml";
|
|
12
|
+
import dotenv from "dotenv";
|
|
13
|
+
//#region src/mode.ts
|
|
14
|
+
let cachedMode;
|
|
15
|
+
function getMode() {
|
|
16
|
+
if (cachedMode !== void 0) return cachedMode;
|
|
17
|
+
cachedMode = process.env.SHANNON_LOCAL === "1" ? "local" : "npx";
|
|
18
|
+
return cachedMode;
|
|
19
|
+
}
|
|
20
|
+
function isLocal() {
|
|
21
|
+
return getMode() === "local";
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/docker.ts
|
|
25
|
+
/**
|
|
26
|
+
* Docker orchestration — compose lifecycle, network, image pull/build, worker spawning.
|
|
27
|
+
*
|
|
28
|
+
* Local mode: builds locally, uses docker-compose.yml from repo root, mounts prompts.
|
|
29
|
+
* NPX mode: pulls from Docker Hub, uses bundled compose.yml.
|
|
30
|
+
*/
|
|
31
|
+
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const NPX_IMAGE_REPO = "keygraph/shannon";
|
|
33
|
+
const DEV_IMAGE = "shannon-worker";
|
|
34
|
+
function getWorkerImage(version) {
|
|
35
|
+
return getMode() === "local" ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
|
36
|
+
}
|
|
37
|
+
function getComposeFile() {
|
|
38
|
+
return getMode() === "local" ? path.resolve("docker-compose.yml") : path.resolve(__dirname$1, "..", "infra", "compose.yml");
|
|
39
|
+
}
|
|
40
|
+
/** Generate an 8-char random hex suffix for container/queue names. */
|
|
41
|
+
function randomSuffix() {
|
|
42
|
+
return crypto.randomBytes(4).toString("hex");
|
|
43
|
+
}
|
|
44
|
+
/** Run a command silently, return true if it succeeds. */
|
|
45
|
+
function runQuiet(cmd, args) {
|
|
46
|
+
try {
|
|
47
|
+
execFileSync(cmd, args, { stdio: "pipe" });
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Run a command and return stdout, or empty string on failure. */
|
|
54
|
+
function runOutput(cmd, args) {
|
|
55
|
+
try {
|
|
56
|
+
return execFileSync(cmd, args, {
|
|
57
|
+
stdio: "pipe",
|
|
58
|
+
encoding: "utf-8"
|
|
59
|
+
}).trim();
|
|
60
|
+
} catch {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if Temporal is running and healthy.
|
|
66
|
+
*/
|
|
67
|
+
function isTemporalReady() {
|
|
68
|
+
return runOutput("docker", [
|
|
69
|
+
"exec",
|
|
70
|
+
"shannon-temporal",
|
|
71
|
+
"temporal",
|
|
72
|
+
"operator",
|
|
73
|
+
"cluster",
|
|
74
|
+
"health",
|
|
75
|
+
"--address",
|
|
76
|
+
"localhost:7233"
|
|
77
|
+
]).includes("SERVING");
|
|
78
|
+
}
|
|
79
|
+
/** Check if the router container is running and healthy. */
|
|
80
|
+
function isRouterReady() {
|
|
81
|
+
return runOutput("docker", [
|
|
82
|
+
"inspect",
|
|
83
|
+
"--format",
|
|
84
|
+
"{{.State.Health.Status}}",
|
|
85
|
+
"shannon-router"
|
|
86
|
+
]) === "healthy";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Ensure Temporal (and optionally router) are running via compose.
|
|
90
|
+
* If Temporal is already up but router is needed and missing, starts router only.
|
|
91
|
+
*/
|
|
92
|
+
async function ensureInfra(useRouter) {
|
|
93
|
+
const temporalReady = isTemporalReady();
|
|
94
|
+
const routerNeeded = useRouter && !isRouterReady();
|
|
95
|
+
if (temporalReady && !routerNeeded) return;
|
|
96
|
+
const composeArgs = [
|
|
97
|
+
"compose",
|
|
98
|
+
"-f",
|
|
99
|
+
getComposeFile()
|
|
100
|
+
];
|
|
101
|
+
if (useRouter) composeArgs.push("--profile", "router");
|
|
102
|
+
composeArgs.push("up", "-d");
|
|
103
|
+
if (temporalReady && routerNeeded) console.log("Starting router...");
|
|
104
|
+
else console.log("Starting Shannon infrastructure...");
|
|
105
|
+
execFileSync("docker", composeArgs, { stdio: "inherit" });
|
|
106
|
+
if (!temporalReady) {
|
|
107
|
+
console.log("Waiting for Temporal to be ready...");
|
|
108
|
+
for (let i = 0; i < 30; i++) {
|
|
109
|
+
if (isTemporalReady()) {
|
|
110
|
+
console.log("Temporal is ready!");
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (i === 29) {
|
|
114
|
+
console.error("Timeout waiting for Temporal");
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
await setTimeout$1(2e3);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (routerNeeded) {
|
|
121
|
+
console.log("Waiting for router to be ready...");
|
|
122
|
+
for (let i = 0; i < 15; i++) {
|
|
123
|
+
if (isRouterReady()) {
|
|
124
|
+
console.log("Router is ready!");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await setTimeout$1(2e3);
|
|
128
|
+
}
|
|
129
|
+
console.error("Timeout waiting for router");
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the worker image locally (local mode only).
|
|
135
|
+
*/
|
|
136
|
+
function buildImage(noCache) {
|
|
137
|
+
console.log(`Building ${DEV_IMAGE}...`);
|
|
138
|
+
const args = ["build"];
|
|
139
|
+
if (noCache) args.push("--no-cache");
|
|
140
|
+
args.push("-t", DEV_IMAGE, ".");
|
|
141
|
+
execFileSync("docker", args, { stdio: "inherit" });
|
|
142
|
+
console.log(`Build complete: ${DEV_IMAGE}`);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Ensure the worker image is available.
|
|
146
|
+
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
|
|
147
|
+
*/
|
|
148
|
+
function ensureImage(version) {
|
|
149
|
+
const image = getWorkerImage(version);
|
|
150
|
+
if (runQuiet("docker", [
|
|
151
|
+
"image",
|
|
152
|
+
"inspect",
|
|
153
|
+
image
|
|
154
|
+
])) return;
|
|
155
|
+
if (getMode() === "local") {
|
|
156
|
+
console.log("Worker image not found, building...");
|
|
157
|
+
buildImage(false);
|
|
158
|
+
} else {
|
|
159
|
+
console.log(`Pulling ${image}...`);
|
|
160
|
+
try {
|
|
161
|
+
execFileSync("docker", ["pull", image], { stdio: "inherit" });
|
|
162
|
+
} catch {
|
|
163
|
+
console.error(`\nERROR: Failed to pull ${image}`);
|
|
164
|
+
console.error("The image may not be available for your platform yet.");
|
|
165
|
+
console.error("Check https://hub.docker.com/r/keygraph/shannon for available tags.");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
pruneOldImages(version);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Detect if --add-host is needed (Linux without Podman).
|
|
173
|
+
* macOS has host.docker.internal built in.
|
|
174
|
+
*/
|
|
175
|
+
function addHostFlag() {
|
|
176
|
+
if (os.platform() === "linux") {
|
|
177
|
+
if (!runQuiet("which", ["podman"])) return ["--add-host", "host.docker.internal:host-gateway"];
|
|
178
|
+
}
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Spawn the worker container in detached mode and return the process.
|
|
183
|
+
*/
|
|
184
|
+
function spawnWorker(opts) {
|
|
185
|
+
const args = [
|
|
186
|
+
"run",
|
|
187
|
+
"-d",
|
|
188
|
+
"--rm",
|
|
189
|
+
"--name",
|
|
190
|
+
opts.containerName,
|
|
191
|
+
"--network",
|
|
192
|
+
"shannon-net"
|
|
193
|
+
];
|
|
194
|
+
args.push(...addHostFlag());
|
|
195
|
+
if (os.platform() === "linux" && process.getuid && process.getgid) args.push("-e", `SHANNON_HOST_UID=${process.getuid()}`, "-e", `SHANNON_HOST_GID=${process.getgid()}`);
|
|
196
|
+
args.push("-v", `${opts.workspacesDir}:/app/workspaces`);
|
|
197
|
+
args.push("-v", `${opts.repo.hostPath}:${opts.repo.containerPath}`);
|
|
198
|
+
if (opts.promptsDir) args.push("-v", `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
|
|
199
|
+
if (opts.config) args.push("-v", `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
|
|
200
|
+
if (opts.outputDir) args.push("-v", `${opts.outputDir}:/app/output`);
|
|
201
|
+
if (opts.credentialsDir) args.push("-v", `${opts.credentialsDir}:/app/credentials:ro`);
|
|
202
|
+
else if (opts.credentials) args.push("-v", `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
|
|
203
|
+
args.push(...opts.envFlags);
|
|
204
|
+
args.push("--shm-size", "2gb", "--ipc", "host", "--security-opt", "seccomp=unconfined");
|
|
205
|
+
args.push(getWorkerImage(opts.version));
|
|
206
|
+
args.push("node", "apps/worker/dist/temporal/worker.js", opts.url, opts.repo.containerPath);
|
|
207
|
+
args.push("--task-queue", opts.taskQueue);
|
|
208
|
+
if (opts.config) args.push("--config", opts.config.containerPath);
|
|
209
|
+
if (opts.outputDir) args.push("--output", "/app/output");
|
|
210
|
+
if (opts.workspace) args.push("--workspace", opts.workspace);
|
|
211
|
+
if (opts.pipelineTesting) args.push("--pipeline-testing");
|
|
212
|
+
return spawn("docker", args, {
|
|
213
|
+
stdio: "pipe",
|
|
214
|
+
...os.platform() === "win32" && { env: {
|
|
215
|
+
...process.env,
|
|
216
|
+
MSYS_NO_PATHCONV: "1"
|
|
217
|
+
} }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Stop all running shannon-worker-* containers.
|
|
222
|
+
*/
|
|
223
|
+
function stopWorkers() {
|
|
224
|
+
const workers = runOutput("docker", [
|
|
225
|
+
"ps",
|
|
226
|
+
"-q",
|
|
227
|
+
"--filter",
|
|
228
|
+
"name=shannon-worker-"
|
|
229
|
+
]);
|
|
230
|
+
if (!workers) return;
|
|
231
|
+
const ids = workers.split("\n").filter(Boolean);
|
|
232
|
+
console.log("Stopping worker containers...");
|
|
233
|
+
execFileSync("docker", ["stop", ...ids], { stdio: "inherit" });
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Tear down the compose stack.
|
|
237
|
+
*/
|
|
238
|
+
function stopInfra(clean) {
|
|
239
|
+
const args = [
|
|
240
|
+
"compose",
|
|
241
|
+
"-f",
|
|
242
|
+
getComposeFile(),
|
|
243
|
+
"--profile",
|
|
244
|
+
"router",
|
|
245
|
+
"down"
|
|
246
|
+
];
|
|
247
|
+
if (clean) args.push("-v");
|
|
248
|
+
execFileSync("docker", args, { stdio: "inherit" });
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Pull the worker image matching the current CLI version.
|
|
252
|
+
*/
|
|
253
|
+
function pullImage(version) {
|
|
254
|
+
const image = getWorkerImage(version);
|
|
255
|
+
console.log(`Pulling ${image}...`);
|
|
256
|
+
try {
|
|
257
|
+
execFileSync("docker", ["pull", image], { stdio: "inherit" });
|
|
258
|
+
} catch {
|
|
259
|
+
console.error(`\nERROR: Failed to pull ${image}`);
|
|
260
|
+
console.error("Check https://hub.docker.com/r/keygraph/shannon for available tags.");
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
pruneOldImages(version);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Remove old keygraph/shannon images that don't match the current version.
|
|
267
|
+
*/
|
|
268
|
+
function pruneOldImages(currentVersion) {
|
|
269
|
+
const output = runOutput("docker", [
|
|
270
|
+
"images",
|
|
271
|
+
NPX_IMAGE_REPO,
|
|
272
|
+
"--format",
|
|
273
|
+
"{{.Tag}}"
|
|
274
|
+
]);
|
|
275
|
+
if (!output) return;
|
|
276
|
+
const currentTag = currentVersion;
|
|
277
|
+
const stale = output.split("\n").filter((tag) => tag && tag !== currentTag);
|
|
278
|
+
for (const tag of stale) runQuiet("docker", ["rmi", `${NPX_IMAGE_REPO}:${tag}`]);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* List running worker containers.
|
|
282
|
+
*/
|
|
283
|
+
function listRunningWorkers() {
|
|
284
|
+
return runOutput("docker", [
|
|
285
|
+
"ps",
|
|
286
|
+
"--filter",
|
|
287
|
+
"name=shannon-worker-",
|
|
288
|
+
"--format",
|
|
289
|
+
"table {{.Names}} {{.Status}} {{.RunningFor}}"
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/commands/build.ts
|
|
294
|
+
/**
|
|
295
|
+
* `shannon build` command — build the worker Docker image locally.
|
|
296
|
+
* Only available in local mode (running from cloned repository).
|
|
297
|
+
*/
|
|
298
|
+
function build(noCache) {
|
|
299
|
+
if (!isLocal()) {
|
|
300
|
+
console.error("ERROR: Build is only available when running from the Shannon repository");
|
|
301
|
+
console.error(" (Dockerfile not found in current directory)");
|
|
302
|
+
console.error("");
|
|
303
|
+
console.error("For npx usage, run: shannon update");
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
buildImage(noCache);
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/home.ts
|
|
310
|
+
/**
|
|
311
|
+
* Shannon state directory management.
|
|
312
|
+
*
|
|
313
|
+
* Local mode (cloned repo): uses ./workspaces/, ./credentials/
|
|
314
|
+
* NPX mode: uses ~/.shannon/workspaces/, ~/.shannon/
|
|
315
|
+
*/
|
|
316
|
+
const SHANNON_HOME$2 = path.join(os.homedir(), ".shannon");
|
|
317
|
+
function getConfigFile() {
|
|
318
|
+
return path.join(SHANNON_HOME$2, "config.toml");
|
|
319
|
+
}
|
|
320
|
+
function getWorkspacesDir() {
|
|
321
|
+
return getMode() === "local" ? path.resolve("workspaces") : path.join(SHANNON_HOME$2, "workspaces");
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Resolve the Vertex credentials file path.
|
|
325
|
+
*
|
|
326
|
+
* Checks GOOGLE_APPLICATION_CREDENTIALS env var first (may be set by TOML resolver),
|
|
327
|
+
* then falls back to mode-appropriate default location.
|
|
328
|
+
*/
|
|
329
|
+
function getCredentialsPath() {
|
|
330
|
+
const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
331
|
+
if (envPath && fs.existsSync(envPath)) return path.resolve(envPath);
|
|
332
|
+
if (getMode() === "local") return path.resolve("credentials", "google-sa-key.json");
|
|
333
|
+
return path.join(SHANNON_HOME$2, "google-sa-key.json");
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* In dev mode, return the credentials directory if it exists and has files.
|
|
337
|
+
* In npx mode, there is no credentials directory (single file mount instead).
|
|
338
|
+
*/
|
|
339
|
+
function getCredentialsDir() {
|
|
340
|
+
if (getMode() !== "local") return void 0;
|
|
341
|
+
const dir = path.resolve("credentials");
|
|
342
|
+
if (!fs.existsSync(dir)) return void 0;
|
|
343
|
+
return fs.readdirSync(dir).length > 0 ? dir : void 0;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Initialize state directories.
|
|
347
|
+
* Local mode: creates ./workspaces/ and ./credentials/
|
|
348
|
+
* NPX mode: creates ~/.shannon/workspaces/
|
|
349
|
+
*/
|
|
350
|
+
function initHome() {
|
|
351
|
+
if (getMode() === "local") {
|
|
352
|
+
fs.mkdirSync(path.resolve("workspaces"), { recursive: true });
|
|
353
|
+
fs.mkdirSync(path.resolve("credentials"), { recursive: true });
|
|
354
|
+
} else fs.mkdirSync(path.join(SHANNON_HOME$2, "workspaces"), { recursive: true });
|
|
355
|
+
}
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region src/commands/logs.ts
|
|
358
|
+
/**
|
|
359
|
+
* `shannon logs` command — tail a workspace's workflow log.
|
|
360
|
+
*
|
|
361
|
+
* Uses chokidar for reliable cross-platform file watching and
|
|
362
|
+
* bounded synchronous reads to prevent duplicate output.
|
|
363
|
+
*/
|
|
364
|
+
const COMPLETION_PATTERN = /^Workflow (COMPLETED|FAILED)$/m;
|
|
365
|
+
/** Read a byte range from a file and return it as a UTF-8 string. */
|
|
366
|
+
function readRange(filePath, start, end) {
|
|
367
|
+
const length = end - start;
|
|
368
|
+
const buffer = Buffer.alloc(length);
|
|
369
|
+
const fd = fs.openSync(filePath, "r");
|
|
370
|
+
try {
|
|
371
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
372
|
+
} finally {
|
|
373
|
+
fs.closeSync(fd);
|
|
374
|
+
}
|
|
375
|
+
return buffer.toString("utf-8");
|
|
376
|
+
}
|
|
377
|
+
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
|
|
378
|
+
function resolveLogFile(workspaceId) {
|
|
379
|
+
const workspacesDir = getWorkspacesDir();
|
|
380
|
+
const directPath = path.join(workspacesDir, workspaceId, "workflow.log");
|
|
381
|
+
if (fs.existsSync(directPath)) return directPath;
|
|
382
|
+
const resumeBase = workspaceId.replace(/_resume_\d+$/, "");
|
|
383
|
+
if (resumeBase !== workspaceId) {
|
|
384
|
+
const resumePath = path.join(workspacesDir, resumeBase, "workflow.log");
|
|
385
|
+
if (fs.existsSync(resumePath)) return resumePath;
|
|
386
|
+
}
|
|
387
|
+
const namedBase = workspaceId.replace(/_shannon-\d+$/, "");
|
|
388
|
+
if (namedBase !== workspaceId) {
|
|
389
|
+
const namedPath = path.join(workspacesDir, namedBase, "workflow.log");
|
|
390
|
+
if (fs.existsSync(namedPath)) return namedPath;
|
|
391
|
+
}
|
|
392
|
+
console.error(`ERROR: Workflow log not found for: ${workspaceId}`);
|
|
393
|
+
console.error("");
|
|
394
|
+
console.error("Possible causes:");
|
|
395
|
+
console.error(" - Workflow hasn't started yet");
|
|
396
|
+
console.error(" - Workspace ID is incorrect");
|
|
397
|
+
console.error("");
|
|
398
|
+
console.error("Check the Temporal Web UI at http://localhost:8233 for workflow details");
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
function logs(workspaceId) {
|
|
402
|
+
const logFile = resolveLogFile(workspaceId);
|
|
403
|
+
let position = 0;
|
|
404
|
+
/**
|
|
405
|
+
* Output any new content appended since the last read.
|
|
406
|
+
* Returns true when the workflow completion marker is detected.
|
|
407
|
+
*/
|
|
408
|
+
function flush() {
|
|
409
|
+
try {
|
|
410
|
+
const { size } = fs.statSync(logFile);
|
|
411
|
+
if (size <= position) return false;
|
|
412
|
+
const data = readRange(logFile, position, size);
|
|
413
|
+
process.stdout.write(data);
|
|
414
|
+
position = size;
|
|
415
|
+
return COMPLETION_PATTERN.test(data);
|
|
416
|
+
} catch {
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
console.log(`Tailing workflow log: ${logFile}`);
|
|
421
|
+
if (flush()) process.exit(0);
|
|
422
|
+
const watcher = watch(logFile, { persistent: true });
|
|
423
|
+
const shutdown = () => {
|
|
424
|
+
watcher.close().finally(() => process.exit(0));
|
|
425
|
+
setTimeout(() => process.exit(0), 1e3).unref();
|
|
426
|
+
};
|
|
427
|
+
watcher.on("change", () => {
|
|
428
|
+
if (flush()) shutdown();
|
|
429
|
+
});
|
|
430
|
+
process.on("SIGINT", shutdown);
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/config/writer.ts
|
|
434
|
+
/** TOML config writer for ~/.shannon/config.toml. */
|
|
435
|
+
/** Write the config to ~/.shannon/config.toml with 0o600 permissions. */
|
|
436
|
+
function saveConfig(config) {
|
|
437
|
+
const configPath = getConfigFile();
|
|
438
|
+
const dir = path.dirname(configPath);
|
|
439
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
440
|
+
const content = stringify(config);
|
|
441
|
+
fs.writeFileSync(configPath, content, { mode: 384 });
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/commands/setup.ts
|
|
445
|
+
/**
|
|
446
|
+
* `shn setup` — interactive TUI wizard for one-time credential configuration.
|
|
447
|
+
*
|
|
448
|
+
* Walks the user through selecting a provider and entering credentials,
|
|
449
|
+
* then persists everything to ~/.shannon/config.toml with 0o600 permissions.
|
|
450
|
+
*/
|
|
451
|
+
const SHANNON_HOME$1 = path.join(os.homedir(), ".shannon");
|
|
452
|
+
async function setup() {
|
|
453
|
+
p.intro("Shannon Setup");
|
|
454
|
+
const provider = await p.select({
|
|
455
|
+
message: "Select your AI provider",
|
|
456
|
+
options: [
|
|
457
|
+
{
|
|
458
|
+
value: "anthropic",
|
|
459
|
+
label: "Claude Direct",
|
|
460
|
+
hint: "recommended"
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
value: "custom_base_url",
|
|
464
|
+
label: "Custom Base URL",
|
|
465
|
+
hint: "proxies, gateways"
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
value: "bedrock",
|
|
469
|
+
label: "Claude via AWS Bedrock"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
value: "vertex",
|
|
473
|
+
label: "Claude via Google Vertex AI"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
value: "router",
|
|
477
|
+
label: "Router",
|
|
478
|
+
hint: "experimental"
|
|
479
|
+
}
|
|
480
|
+
]
|
|
481
|
+
});
|
|
482
|
+
if (p.isCancel(provider)) return cancelAndExit();
|
|
483
|
+
saveConfig(await setupProvider(provider));
|
|
484
|
+
const configPath = path.join(SHANNON_HOME$1, "config.toml");
|
|
485
|
+
p.log.success(`Configuration saved to ${configPath}`);
|
|
486
|
+
p.outro("Run `npx @keygraph/shannon start` to begin a scan.");
|
|
487
|
+
}
|
|
488
|
+
async function setupProvider(provider) {
|
|
489
|
+
switch (provider) {
|
|
490
|
+
case "anthropic": return setupAnthropic();
|
|
491
|
+
case "custom_base_url": return setupCustomBaseUrl();
|
|
492
|
+
case "bedrock": return setupBedrock();
|
|
493
|
+
case "vertex": return setupVertex();
|
|
494
|
+
case "router": return setupRouter();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function setupAnthropic() {
|
|
498
|
+
const authMethod = await p.select({
|
|
499
|
+
message: "Authentication method",
|
|
500
|
+
options: [{
|
|
501
|
+
value: "api_key",
|
|
502
|
+
label: "API Key"
|
|
503
|
+
}, {
|
|
504
|
+
value: "oauth",
|
|
505
|
+
label: "OAuth Token"
|
|
506
|
+
}]
|
|
507
|
+
});
|
|
508
|
+
if (p.isCancel(authMethod)) return cancelAndExit();
|
|
509
|
+
const config = {};
|
|
510
|
+
if (authMethod === "oauth") config.anthropic = { oauth_token: await promptSecret("Enter your OAuth token") };
|
|
511
|
+
else config.anthropic = { api_key: await promptSecret("Enter your Anthropic API key") };
|
|
512
|
+
const customizeModels = await p.confirm({
|
|
513
|
+
message: "Do you want to change the default models?\n Small - claude-haiku-4-5-20251001\n Medium - claude-sonnet-4-6\n Large - claude-opus-4-6",
|
|
514
|
+
initialValue: false
|
|
515
|
+
});
|
|
516
|
+
if (p.isCancel(customizeModels)) return cancelAndExit();
|
|
517
|
+
if (customizeModels) {
|
|
518
|
+
const small = await p.text({
|
|
519
|
+
message: "Small model ID",
|
|
520
|
+
initialValue: "claude-haiku-4-5-20251001",
|
|
521
|
+
validate: required("Small model ID is required")
|
|
522
|
+
});
|
|
523
|
+
if (p.isCancel(small)) return cancelAndExit();
|
|
524
|
+
const medium = await p.text({
|
|
525
|
+
message: "Medium model ID",
|
|
526
|
+
initialValue: "claude-sonnet-4-6",
|
|
527
|
+
validate: required("Medium model ID is required")
|
|
528
|
+
});
|
|
529
|
+
if (p.isCancel(medium)) return cancelAndExit();
|
|
530
|
+
const large = await p.text({
|
|
531
|
+
message: "Large model ID",
|
|
532
|
+
initialValue: "claude-opus-4-6",
|
|
533
|
+
validate: required("Large model ID is required")
|
|
534
|
+
});
|
|
535
|
+
if (p.isCancel(large)) return cancelAndExit();
|
|
536
|
+
config.models = {
|
|
537
|
+
small,
|
|
538
|
+
medium,
|
|
539
|
+
large
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
return config;
|
|
543
|
+
}
|
|
544
|
+
async function setupCustomBaseUrl() {
|
|
545
|
+
const baseUrl = await p.text({
|
|
546
|
+
message: "Endpoint URL",
|
|
547
|
+
placeholder: "https://your-proxy.example.com",
|
|
548
|
+
validate: (value) => {
|
|
549
|
+
if (!value) return "Endpoint URL is required";
|
|
550
|
+
try {
|
|
551
|
+
new URL(value);
|
|
552
|
+
} catch {
|
|
553
|
+
return "Must be a valid URL";
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
if (p.isCancel(baseUrl)) return cancelAndExit();
|
|
558
|
+
const config = { custom_base_url: {
|
|
559
|
+
base_url: baseUrl,
|
|
560
|
+
auth_token: await promptSecret("Enter the auth token for the custom endpoint")
|
|
561
|
+
} };
|
|
562
|
+
const customizeModels = await p.confirm({
|
|
563
|
+
message: "Do you want to change the default models?\n Small - claude-haiku-4-5-20251001\n Medium - claude-sonnet-4-6\n Large - claude-opus-4-6",
|
|
564
|
+
initialValue: false
|
|
565
|
+
});
|
|
566
|
+
if (p.isCancel(customizeModels)) return cancelAndExit();
|
|
567
|
+
if (customizeModels) {
|
|
568
|
+
const small = await p.text({
|
|
569
|
+
message: "Small model ID",
|
|
570
|
+
initialValue: "claude-haiku-4-5-20251001",
|
|
571
|
+
validate: required("Small model ID is required")
|
|
572
|
+
});
|
|
573
|
+
if (p.isCancel(small)) return cancelAndExit();
|
|
574
|
+
const medium = await p.text({
|
|
575
|
+
message: "Medium model ID",
|
|
576
|
+
initialValue: "claude-sonnet-4-6",
|
|
577
|
+
validate: required("Medium model ID is required")
|
|
578
|
+
});
|
|
579
|
+
if (p.isCancel(medium)) return cancelAndExit();
|
|
580
|
+
const large = await p.text({
|
|
581
|
+
message: "Large model ID",
|
|
582
|
+
initialValue: "claude-opus-4-6",
|
|
583
|
+
validate: required("Large model ID is required")
|
|
584
|
+
});
|
|
585
|
+
if (p.isCancel(large)) return cancelAndExit();
|
|
586
|
+
config.models = {
|
|
587
|
+
small,
|
|
588
|
+
medium,
|
|
589
|
+
large
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return config;
|
|
593
|
+
}
|
|
594
|
+
async function setupBedrock() {
|
|
595
|
+
const region = await p.text({
|
|
596
|
+
message: "AWS Region",
|
|
597
|
+
placeholder: "us-east-1",
|
|
598
|
+
validate: required("AWS Region is required")
|
|
599
|
+
});
|
|
600
|
+
if (p.isCancel(region)) return cancelAndExit();
|
|
601
|
+
const token = await promptSecret("Enter your AWS Bearer Token");
|
|
602
|
+
const small = await p.text({
|
|
603
|
+
message: "Small model ID",
|
|
604
|
+
placeholder: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
605
|
+
validate: required("Small model ID is required")
|
|
606
|
+
});
|
|
607
|
+
if (p.isCancel(small)) return cancelAndExit();
|
|
608
|
+
const medium = await p.text({
|
|
609
|
+
message: "Medium model ID",
|
|
610
|
+
placeholder: "us.anthropic.claude-sonnet-4-6",
|
|
611
|
+
validate: required("Medium model ID is required")
|
|
612
|
+
});
|
|
613
|
+
if (p.isCancel(medium)) return cancelAndExit();
|
|
614
|
+
const large = await p.text({
|
|
615
|
+
message: "Large model ID",
|
|
616
|
+
placeholder: "us.anthropic.claude-opus-4-6",
|
|
617
|
+
validate: required("Large model ID is required")
|
|
618
|
+
});
|
|
619
|
+
if (p.isCancel(large)) return cancelAndExit();
|
|
620
|
+
return {
|
|
621
|
+
bedrock: {
|
|
622
|
+
use: true,
|
|
623
|
+
region,
|
|
624
|
+
token
|
|
625
|
+
},
|
|
626
|
+
models: {
|
|
627
|
+
small,
|
|
628
|
+
medium,
|
|
629
|
+
large
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
async function setupVertex() {
|
|
634
|
+
const region = await p.text({
|
|
635
|
+
message: "Google Cloud region",
|
|
636
|
+
placeholder: "us-east5",
|
|
637
|
+
validate: required("Region is required")
|
|
638
|
+
});
|
|
639
|
+
if (p.isCancel(region)) return cancelAndExit();
|
|
640
|
+
const projectId = await p.text({
|
|
641
|
+
message: "GCP Project ID",
|
|
642
|
+
validate: required("Project ID is required")
|
|
643
|
+
});
|
|
644
|
+
if (p.isCancel(projectId)) return cancelAndExit();
|
|
645
|
+
p.log.info("Select the path to your GCP Service Account JSON key file.");
|
|
646
|
+
const keySourcePath = await p.path({
|
|
647
|
+
message: "Service Account JSON key file",
|
|
648
|
+
validate: (value) => {
|
|
649
|
+
if (!value) return "Path is required";
|
|
650
|
+
if (!fs.existsSync(value)) return "File not found";
|
|
651
|
+
if (!value.endsWith(".json")) return "Must be a .json file";
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
if (p.isCancel(keySourcePath)) return cancelAndExit();
|
|
655
|
+
const destPath = path.join(SHANNON_HOME$1, "google-sa-key.json");
|
|
656
|
+
fs.mkdirSync(SHANNON_HOME$1, { recursive: true });
|
|
657
|
+
fs.copyFileSync(keySourcePath, destPath);
|
|
658
|
+
fs.chmodSync(destPath, 384);
|
|
659
|
+
p.log.success(`Key copied to ${destPath} (permissions: 0600)`);
|
|
660
|
+
const models = await p.group({
|
|
661
|
+
small: () => p.text({
|
|
662
|
+
message: "Small model ID",
|
|
663
|
+
placeholder: "claude-haiku-4-5@20251001",
|
|
664
|
+
validate: required("Small model ID is required")
|
|
665
|
+
}),
|
|
666
|
+
medium: () => p.text({
|
|
667
|
+
message: "Medium model ID",
|
|
668
|
+
placeholder: "claude-sonnet-4-6",
|
|
669
|
+
validate: required("Medium model ID is required")
|
|
670
|
+
}),
|
|
671
|
+
large: () => p.text({
|
|
672
|
+
message: "Large model ID",
|
|
673
|
+
placeholder: "claude-opus-4-6",
|
|
674
|
+
validate: required("Large model ID is required")
|
|
675
|
+
})
|
|
676
|
+
});
|
|
677
|
+
if (p.isCancel(models)) return cancelAndExit();
|
|
678
|
+
return {
|
|
679
|
+
vertex: {
|
|
680
|
+
use: true,
|
|
681
|
+
region,
|
|
682
|
+
project_id: projectId,
|
|
683
|
+
key_path: destPath
|
|
684
|
+
},
|
|
685
|
+
models: {
|
|
686
|
+
small: models.small,
|
|
687
|
+
medium: models.medium,
|
|
688
|
+
large: models.large
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
async function setupRouter() {
|
|
693
|
+
const routerProvider = await p.select({
|
|
694
|
+
message: "Router provider",
|
|
695
|
+
options: [{
|
|
696
|
+
value: "openai",
|
|
697
|
+
label: "OpenAI"
|
|
698
|
+
}, {
|
|
699
|
+
value: "openrouter",
|
|
700
|
+
label: "OpenRouter"
|
|
701
|
+
}]
|
|
702
|
+
});
|
|
703
|
+
if (p.isCancel(routerProvider)) return cancelAndExit();
|
|
704
|
+
const apiKey = await promptSecret(routerProvider === "openai" ? "Enter your OpenAI API key" : "Enter your OpenRouter API key");
|
|
705
|
+
let defaultModel;
|
|
706
|
+
if (routerProvider === "openai") {
|
|
707
|
+
const model = await p.select({
|
|
708
|
+
message: "Default model",
|
|
709
|
+
options: [{
|
|
710
|
+
value: "gpt-5.2",
|
|
711
|
+
label: "GPT-5.2"
|
|
712
|
+
}, {
|
|
713
|
+
value: "gpt-5-mini",
|
|
714
|
+
label: "GPT-5 Mini"
|
|
715
|
+
}]
|
|
716
|
+
});
|
|
717
|
+
if (p.isCancel(model)) return cancelAndExit();
|
|
718
|
+
defaultModel = `openai,${model}`;
|
|
719
|
+
} else {
|
|
720
|
+
const model = await p.select({
|
|
721
|
+
message: "Default model",
|
|
722
|
+
options: [{
|
|
723
|
+
value: "google/gemini-3-flash-preview",
|
|
724
|
+
label: "Google Gemini 3 Flash Preview"
|
|
725
|
+
}]
|
|
726
|
+
});
|
|
727
|
+
if (p.isCancel(model)) return cancelAndExit();
|
|
728
|
+
defaultModel = `openrouter,${model}`;
|
|
729
|
+
}
|
|
730
|
+
const router = { default: defaultModel };
|
|
731
|
+
if (routerProvider === "openai") router.openai_key = apiKey;
|
|
732
|
+
else router.openrouter_key = apiKey;
|
|
733
|
+
return { router };
|
|
734
|
+
}
|
|
735
|
+
async function promptSecret(message) {
|
|
736
|
+
const value = await p.password({
|
|
737
|
+
message,
|
|
738
|
+
validate: required(`${message.replace(/^Enter /, "")} is required`)
|
|
739
|
+
});
|
|
740
|
+
if (p.isCancel(value)) return cancelAndExit();
|
|
741
|
+
return value;
|
|
742
|
+
}
|
|
743
|
+
function required(errorMessage) {
|
|
744
|
+
return (value) => {
|
|
745
|
+
if (!value) return errorMessage;
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function cancelAndExit() {
|
|
749
|
+
p.cancel("Setup cancelled.");
|
|
750
|
+
process.exit(0);
|
|
751
|
+
}
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/config/resolver.ts
|
|
754
|
+
/**
|
|
755
|
+
* Configuration resolver with environment-first, TOML-fallback precedence.
|
|
756
|
+
*
|
|
757
|
+
* Priority: process.env > ~/.shannon/config.toml
|
|
758
|
+
* Env var names match .env.example exactly; TOML uses nested sections.
|
|
759
|
+
*/
|
|
760
|
+
/** Maps every supported env var to its TOML path (section.key) and expected type. */
|
|
761
|
+
const CONFIG_MAP = [
|
|
762
|
+
{
|
|
763
|
+
env: "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
|
764
|
+
toml: "core.max_tokens",
|
|
765
|
+
type: "number"
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
env: "ANTHROPIC_API_KEY",
|
|
769
|
+
toml: "anthropic.api_key",
|
|
770
|
+
type: "string"
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
env: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
774
|
+
toml: "anthropic.oauth_token",
|
|
775
|
+
type: "string"
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
env: "CLAUDE_CODE_USE_BEDROCK",
|
|
779
|
+
toml: "bedrock.use",
|
|
780
|
+
type: "boolean"
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
env: "AWS_REGION",
|
|
784
|
+
toml: "bedrock.region",
|
|
785
|
+
type: "string"
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
env: "AWS_BEARER_TOKEN_BEDROCK",
|
|
789
|
+
toml: "bedrock.token",
|
|
790
|
+
type: "string"
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
env: "CLAUDE_CODE_USE_VERTEX",
|
|
794
|
+
toml: "vertex.use",
|
|
795
|
+
type: "boolean"
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
env: "CLOUD_ML_REGION",
|
|
799
|
+
toml: "vertex.region",
|
|
800
|
+
type: "string"
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
env: "ANTHROPIC_VERTEX_PROJECT_ID",
|
|
804
|
+
toml: "vertex.project_id",
|
|
805
|
+
type: "string"
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
env: "GOOGLE_APPLICATION_CREDENTIALS",
|
|
809
|
+
toml: "vertex.key_path",
|
|
810
|
+
type: "string"
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
env: "ANTHROPIC_BASE_URL",
|
|
814
|
+
toml: "custom_base_url.base_url",
|
|
815
|
+
type: "string"
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
env: "ANTHROPIC_AUTH_TOKEN",
|
|
819
|
+
toml: "custom_base_url.auth_token",
|
|
820
|
+
type: "string"
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
env: "ROUTER_DEFAULT",
|
|
824
|
+
toml: "router.default",
|
|
825
|
+
type: "string"
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
env: "OPENAI_API_KEY",
|
|
829
|
+
toml: "router.openai_key",
|
|
830
|
+
type: "string"
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
env: "OPENROUTER_API_KEY",
|
|
834
|
+
toml: "router.openrouter_key",
|
|
835
|
+
type: "string"
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
env: "ANTHROPIC_SMALL_MODEL",
|
|
839
|
+
toml: "models.small",
|
|
840
|
+
type: "string"
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
env: "ANTHROPIC_MEDIUM_MODEL",
|
|
844
|
+
toml: "models.medium",
|
|
845
|
+
type: "string"
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
env: "ANTHROPIC_LARGE_MODEL",
|
|
849
|
+
toml: "models.large",
|
|
850
|
+
type: "string"
|
|
851
|
+
}
|
|
852
|
+
];
|
|
853
|
+
/** Read a nested TOML value by dotted path (e.g. "anthropic.api_key"). */
|
|
854
|
+
function getTomlValue(config, path) {
|
|
855
|
+
const [section, key] = path.split(".");
|
|
856
|
+
if (!section || !key) return void 0;
|
|
857
|
+
const sectionObj = config[section];
|
|
858
|
+
if (!sectionObj || typeof sectionObj !== "object") return void 0;
|
|
859
|
+
const value = sectionObj[key];
|
|
860
|
+
if (value === void 0 || value === null) return void 0;
|
|
861
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
862
|
+
return String(value);
|
|
863
|
+
}
|
|
864
|
+
/** Parse the global TOML config file, returning null if it doesn't exist. */
|
|
865
|
+
function loadTOML() {
|
|
866
|
+
const configPath = getConfigFile();
|
|
867
|
+
if (!fs.existsSync(configPath)) return null;
|
|
868
|
+
if (process.platform !== "win32") {
|
|
869
|
+
const mode = fs.statSync(configPath).mode;
|
|
870
|
+
if (mode & 63) {
|
|
871
|
+
const actual = (mode & 511).toString(8).padStart(3, "0");
|
|
872
|
+
console.error(`\nInsecure permissions (${actual}) on ${configPath}. Run: chmod 600 ${configPath}\n`);
|
|
873
|
+
process.exit(1);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
return parse(fs.readFileSync(configPath, "utf-8"));
|
|
878
|
+
} catch (err) {
|
|
879
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
880
|
+
console.error(`\nFailed to parse ${configPath}: ${message}`);
|
|
881
|
+
console.error(`\nRun 'npx @keygraph/shannon setup' to reconfigure.\n`);
|
|
882
|
+
process.exit(1);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/** Build a lookup of allowed keys per section from CONFIG_MAP. */
|
|
886
|
+
function buildSchema() {
|
|
887
|
+
const schema = /* @__PURE__ */ new Map();
|
|
888
|
+
for (const mapping of CONFIG_MAP) {
|
|
889
|
+
const [section, key] = mapping.toml.split(".");
|
|
890
|
+
if (!section || !key) continue;
|
|
891
|
+
let keys = schema.get(section);
|
|
892
|
+
if (!keys) {
|
|
893
|
+
keys = /* @__PURE__ */ new Map();
|
|
894
|
+
schema.set(section, keys);
|
|
895
|
+
}
|
|
896
|
+
keys.set(key, mapping.type);
|
|
897
|
+
}
|
|
898
|
+
return schema;
|
|
899
|
+
}
|
|
900
|
+
/** Check that a provider section has all required fields and dependencies. */
|
|
901
|
+
function validateProviderFields(config, provider, errors) {
|
|
902
|
+
const section = config[provider];
|
|
903
|
+
if (!section) return;
|
|
904
|
+
const keys = Object.keys(section);
|
|
905
|
+
switch (provider) {
|
|
906
|
+
case "anthropic":
|
|
907
|
+
if (!keys.includes("api_key") && !keys.includes("oauth_token")) errors.push("[anthropic] requires either api_key or oauth_token");
|
|
908
|
+
break;
|
|
909
|
+
case "custom_base_url": {
|
|
910
|
+
const missing = ["base_url", "auth_token"].filter((k) => !keys.includes(k));
|
|
911
|
+
if (missing.length > 0) errors.push(`[custom_base_url] missing required keys: ${missing.join(", ")}`);
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case "bedrock": {
|
|
915
|
+
const missing = [
|
|
916
|
+
"use",
|
|
917
|
+
"region",
|
|
918
|
+
"token"
|
|
919
|
+
].filter((k) => !keys.includes(k));
|
|
920
|
+
if (missing.length > 0) errors.push(`[bedrock] missing required keys: ${missing.join(", ")}`);
|
|
921
|
+
validateModelTiers(config, "bedrock", errors);
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
case "vertex": {
|
|
925
|
+
const missing = [
|
|
926
|
+
"use",
|
|
927
|
+
"region",
|
|
928
|
+
"project_id",
|
|
929
|
+
"key_path"
|
|
930
|
+
].filter((k) => !keys.includes(k));
|
|
931
|
+
if (missing.length > 0) errors.push(`[vertex] missing required keys: ${missing.join(", ")}`);
|
|
932
|
+
validateModelTiers(config, "vertex", errors);
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
case "router": {
|
|
936
|
+
if (!keys.includes("default")) errors.push("[router] missing required key: default");
|
|
937
|
+
if (!keys.includes("openai_key") && !keys.includes("openrouter_key")) errors.push("[router] requires either openai_key or openrouter_key");
|
|
938
|
+
const models = config.models;
|
|
939
|
+
if (models && typeof models === "object" && Object.keys(models).length > 0) errors.push("[models] is not supported with [router]");
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
/** Bedrock and Vertex require a [models] section with all three tiers. */
|
|
945
|
+
function validateModelTiers(config, provider, errors) {
|
|
946
|
+
const models = config.models;
|
|
947
|
+
if (!models || typeof models !== "object") {
|
|
948
|
+
errors.push(`[${provider}] requires a [models] section with small, medium, and large`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const missing = [
|
|
952
|
+
"small",
|
|
953
|
+
"medium",
|
|
954
|
+
"large"
|
|
955
|
+
].filter((k) => !Object.keys(models).includes(k));
|
|
956
|
+
if (missing.length > 0) errors.push(`[models] missing required keys for ${provider}: ${missing.join(", ")}`);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Validate a parsed TOML config against the known schema.
|
|
960
|
+
* Returns an array of human-readable error messages (empty = valid).
|
|
961
|
+
*/
|
|
962
|
+
function validateConfig(config) {
|
|
963
|
+
const schema = buildSchema();
|
|
964
|
+
const errors = [];
|
|
965
|
+
for (const [section, sectionObj] of Object.entries(config)) {
|
|
966
|
+
const allowedKeys = schema.get(section);
|
|
967
|
+
if (!allowedKeys) {
|
|
968
|
+
const known = [...schema.keys()].join(", ");
|
|
969
|
+
errors.push(`Unknown section [${section}]. Valid sections: ${known}`);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
if (!sectionObj || typeof sectionObj !== "object") {
|
|
973
|
+
errors.push(`[${section}] must be a table, got ${typeof sectionObj}`);
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
for (const [key, value] of Object.entries(sectionObj)) {
|
|
977
|
+
const expectedType = allowedKeys.get(key);
|
|
978
|
+
if (!expectedType) {
|
|
979
|
+
const known = [...allowedKeys.keys()].join(", ");
|
|
980
|
+
errors.push(`Unknown key "${key}" in [${section}]. Valid keys: ${known}`);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (typeof value !== expectedType) {
|
|
984
|
+
errors.push(`[${section}].${key} must be ${expectedType}, got ${typeof value}`);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (typeof value === "string" && value.trim() === "") errors.push(`[${section}].${key} must not be empty`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const present = [
|
|
991
|
+
"anthropic",
|
|
992
|
+
"custom_base_url",
|
|
993
|
+
"bedrock",
|
|
994
|
+
"vertex",
|
|
995
|
+
"router"
|
|
996
|
+
].filter((s) => {
|
|
997
|
+
const section = config[s];
|
|
998
|
+
return section && typeof section === "object" && Object.keys(section).length > 0;
|
|
999
|
+
});
|
|
1000
|
+
if (present.length > 1) errors.push(`Multiple providers configured: [${present.join("], [")}]. Only one provider section is allowed at a time`);
|
|
1001
|
+
const singleProvider = present.length === 1 ? present[0] : void 0;
|
|
1002
|
+
if (singleProvider) validateProviderFields(config, singleProvider, errors);
|
|
1003
|
+
return errors;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Resolve all config values into process.env (npx mode only).
|
|
1007
|
+
*
|
|
1008
|
+
* For each mapped variable: if not already set in the environment,
|
|
1009
|
+
* look it up in ~/.shannon/config.toml and inject it into process.env.
|
|
1010
|
+
* Local mode uses .env exclusively — TOML is skipped.
|
|
1011
|
+
* Exits with an error if the TOML contains unknown or invalid keys.
|
|
1012
|
+
*/
|
|
1013
|
+
function resolveConfig$1() {
|
|
1014
|
+
if (getMode() === "local") return;
|
|
1015
|
+
const toml = loadTOML();
|
|
1016
|
+
if (!toml) return;
|
|
1017
|
+
const errors = validateConfig(toml);
|
|
1018
|
+
if (errors.length > 0) {
|
|
1019
|
+
console.error("\nInvalid configuration:");
|
|
1020
|
+
for (const err of errors) console.error(` - ${err}`);
|
|
1021
|
+
console.error(`\nRun 'shn setup' to reconfigure.\n`);
|
|
1022
|
+
process.exit(1);
|
|
1023
|
+
}
|
|
1024
|
+
for (const mapping of CONFIG_MAP) {
|
|
1025
|
+
if (process.env[mapping.env]) continue;
|
|
1026
|
+
const value = getTomlValue(toml, mapping.toml);
|
|
1027
|
+
if (value) process.env[mapping.env] = value;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/env.ts
|
|
1032
|
+
/**
|
|
1033
|
+
* Environment variable loading and credential validation.
|
|
1034
|
+
*
|
|
1035
|
+
* Local mode: loads ./.env via dotenv.
|
|
1036
|
+
* NPX mode: fills gaps from ~/.shannon/config.toml (no .env).
|
|
1037
|
+
*/
|
|
1038
|
+
/** Environment variables forwarded to worker containers. */
|
|
1039
|
+
const FORWARD_VARS = [
|
|
1040
|
+
"ANTHROPIC_API_KEY",
|
|
1041
|
+
"ANTHROPIC_BASE_URL",
|
|
1042
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
1043
|
+
"ROUTER_DEFAULT",
|
|
1044
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
1045
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
1046
|
+
"AWS_REGION",
|
|
1047
|
+
"AWS_BEARER_TOKEN_BEDROCK",
|
|
1048
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
1049
|
+
"CLOUD_ML_REGION",
|
|
1050
|
+
"ANTHROPIC_VERTEX_PROJECT_ID",
|
|
1051
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
1052
|
+
"ANTHROPIC_SMALL_MODEL",
|
|
1053
|
+
"ANTHROPIC_MEDIUM_MODEL",
|
|
1054
|
+
"ANTHROPIC_LARGE_MODEL",
|
|
1055
|
+
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
|
1056
|
+
"OPENAI_API_KEY",
|
|
1057
|
+
"OPENROUTER_API_KEY"
|
|
1058
|
+
];
|
|
1059
|
+
/**
|
|
1060
|
+
* Load credentials into process.env.
|
|
1061
|
+
* Local mode: loads ./.env via dotenv.
|
|
1062
|
+
* NPX mode: fills gaps from ~/.shannon/config.toml.
|
|
1063
|
+
* Exported env vars always take precedence in both modes.
|
|
1064
|
+
*/
|
|
1065
|
+
function loadEnv() {
|
|
1066
|
+
if (getMode() === "local") dotenv.config({
|
|
1067
|
+
path: ".env",
|
|
1068
|
+
quiet: true
|
|
1069
|
+
});
|
|
1070
|
+
else resolveConfig$1();
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Build `-e KEY=VALUE` flags for docker run, only for set variables.
|
|
1074
|
+
*/
|
|
1075
|
+
function buildEnvFlags() {
|
|
1076
|
+
const flags = ["-e", "TEMPORAL_ADDRESS=shannon-temporal:7233"];
|
|
1077
|
+
for (const key of FORWARD_VARS) {
|
|
1078
|
+
const value = process.env[key];
|
|
1079
|
+
if (value) flags.push("-e", `${key}=${value}`);
|
|
1080
|
+
}
|
|
1081
|
+
return flags;
|
|
1082
|
+
}
|
|
1083
|
+
/** Check if router credentials are present in the environment. */
|
|
1084
|
+
function isRouterConfigured() {
|
|
1085
|
+
return !!(process.env.ROUTER_DEFAULT && (process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY));
|
|
1086
|
+
}
|
|
1087
|
+
/** Check if a custom Anthropic-compatible base URL is configured. */
|
|
1088
|
+
function isCustomBaseUrlConfigured() {
|
|
1089
|
+
return !!(process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN);
|
|
1090
|
+
}
|
|
1091
|
+
/** Detect which providers are configured via environment variables. */
|
|
1092
|
+
function detectProviders() {
|
|
1093
|
+
const providers = [];
|
|
1094
|
+
if (process.env.ANTHROPIC_API_KEY) providers.push("Anthropic API key");
|
|
1095
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) providers.push("Anthropic OAuth");
|
|
1096
|
+
if (isCustomBaseUrlConfigured()) providers.push("Custom Base URL");
|
|
1097
|
+
if (process.env.CLAUDE_CODE_USE_BEDROCK === "1") providers.push("AWS Bedrock");
|
|
1098
|
+
if (process.env.CLAUDE_CODE_USE_VERTEX === "1") providers.push("Google Vertex");
|
|
1099
|
+
if (isRouterConfigured()) providers.push("Router");
|
|
1100
|
+
return providers;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Validate that exactly one authentication method is configured.
|
|
1104
|
+
*/
|
|
1105
|
+
function validateCredentials() {
|
|
1106
|
+
const providers = detectProviders();
|
|
1107
|
+
if (providers.length > 1) return {
|
|
1108
|
+
valid: false,
|
|
1109
|
+
mode: "api-key",
|
|
1110
|
+
error: `Multiple providers detected: ${providers.join(", ")}. Only one provider can be active at a time.`
|
|
1111
|
+
};
|
|
1112
|
+
if (process.env.ANTHROPIC_API_KEY) return {
|
|
1113
|
+
valid: true,
|
|
1114
|
+
mode: "api-key"
|
|
1115
|
+
};
|
|
1116
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return {
|
|
1117
|
+
valid: true,
|
|
1118
|
+
mode: "oauth"
|
|
1119
|
+
};
|
|
1120
|
+
if (isCustomBaseUrlConfigured()) {
|
|
1121
|
+
process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_AUTH_TOKEN;
|
|
1122
|
+
return {
|
|
1123
|
+
valid: true,
|
|
1124
|
+
mode: "custom-base-url"
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
if (process.env.CLAUDE_CODE_USE_BEDROCK === "1") {
|
|
1128
|
+
const missing = [];
|
|
1129
|
+
if (!process.env.AWS_REGION) missing.push("AWS_REGION");
|
|
1130
|
+
if (!process.env.AWS_BEARER_TOKEN_BEDROCK) missing.push("AWS_BEARER_TOKEN_BEDROCK");
|
|
1131
|
+
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push("ANTHROPIC_SMALL_MODEL");
|
|
1132
|
+
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push("ANTHROPIC_MEDIUM_MODEL");
|
|
1133
|
+
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push("ANTHROPIC_LARGE_MODEL");
|
|
1134
|
+
if (missing.length > 0) return {
|
|
1135
|
+
valid: false,
|
|
1136
|
+
mode: "bedrock",
|
|
1137
|
+
error: `Bedrock mode requires: ${missing.join(", ")}`
|
|
1138
|
+
};
|
|
1139
|
+
return {
|
|
1140
|
+
valid: true,
|
|
1141
|
+
mode: "bedrock"
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
if (process.env.CLAUDE_CODE_USE_VERTEX === "1") {
|
|
1145
|
+
const missing = [];
|
|
1146
|
+
if (!process.env.CLOUD_ML_REGION) missing.push("CLOUD_ML_REGION");
|
|
1147
|
+
if (!process.env.ANTHROPIC_VERTEX_PROJECT_ID) missing.push("ANTHROPIC_VERTEX_PROJECT_ID");
|
|
1148
|
+
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push("ANTHROPIC_SMALL_MODEL");
|
|
1149
|
+
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push("ANTHROPIC_MEDIUM_MODEL");
|
|
1150
|
+
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push("ANTHROPIC_LARGE_MODEL");
|
|
1151
|
+
if (missing.length > 0) return {
|
|
1152
|
+
valid: false,
|
|
1153
|
+
mode: "vertex",
|
|
1154
|
+
error: `Vertex AI mode requires: ${missing.join(", ")}`
|
|
1155
|
+
};
|
|
1156
|
+
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) return {
|
|
1157
|
+
valid: false,
|
|
1158
|
+
mode: "vertex",
|
|
1159
|
+
error: "Vertex AI mode requires GOOGLE_APPLICATION_CREDENTIALS"
|
|
1160
|
+
};
|
|
1161
|
+
return {
|
|
1162
|
+
valid: true,
|
|
1163
|
+
mode: "vertex"
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
if (isRouterConfigured()) {
|
|
1167
|
+
process.env.ANTHROPIC_API_KEY = "router-mode";
|
|
1168
|
+
return {
|
|
1169
|
+
valid: true,
|
|
1170
|
+
mode: "router"
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
return {
|
|
1174
|
+
valid: false,
|
|
1175
|
+
mode: "api-key",
|
|
1176
|
+
error: getMode() === "local" ? `No credentials found. Set ANTHROPIC_API_KEY in .env or export it.` : `Authentication not configured. Export variables or run 'npx @keygraph/shannon setup'.`
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
//#endregion
|
|
1180
|
+
//#region src/paths.ts
|
|
1181
|
+
/**
|
|
1182
|
+
* Path resolution for --repo and --config arguments.
|
|
1183
|
+
*
|
|
1184
|
+
* Local mode supports bare repo names (e.g. "my-repo" → ./repos/my-repo).
|
|
1185
|
+
* Both modes resolve relative paths against CWD.
|
|
1186
|
+
*/
|
|
1187
|
+
/**
|
|
1188
|
+
* Resolve --repo to absolute path and container mount.
|
|
1189
|
+
* Dev mode: bare names (no / or . prefix) check ./repos/<name> first.
|
|
1190
|
+
*/
|
|
1191
|
+
function resolveRepo(repoArg) {
|
|
1192
|
+
let hostPath;
|
|
1193
|
+
if (isLocal() && !repoArg.startsWith("/") && !repoArg.startsWith(".")) {
|
|
1194
|
+
const barePath = path.resolve("repos", repoArg);
|
|
1195
|
+
if (fs.existsSync(barePath)) hostPath = barePath;
|
|
1196
|
+
else {
|
|
1197
|
+
console.error(`ERROR: Repository not found at ./repos/${repoArg}`);
|
|
1198
|
+
console.error("");
|
|
1199
|
+
console.error("Place your target repository under the ./repos/ directory,");
|
|
1200
|
+
console.error("or pass an absolute/relative path: -r /path/to/repo");
|
|
1201
|
+
process.exit(1);
|
|
1202
|
+
}
|
|
1203
|
+
} else hostPath = path.resolve(repoArg);
|
|
1204
|
+
if (!fs.existsSync(hostPath)) {
|
|
1205
|
+
console.error(`ERROR: Repository not found: ${hostPath}`);
|
|
1206
|
+
process.exit(1);
|
|
1207
|
+
}
|
|
1208
|
+
if (!fs.statSync(hostPath).isDirectory()) {
|
|
1209
|
+
console.error(`ERROR: Not a directory: ${hostPath}`);
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
const basename = path.basename(hostPath);
|
|
1213
|
+
return {
|
|
1214
|
+
hostPath,
|
|
1215
|
+
containerPath: `/repos/${basename}`
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Resolve --config to absolute path and container mount.
|
|
1220
|
+
*/
|
|
1221
|
+
function resolveConfig(configArg) {
|
|
1222
|
+
const hostPath = path.resolve(configArg);
|
|
1223
|
+
if (!fs.existsSync(hostPath)) {
|
|
1224
|
+
console.error(`ERROR: Config file not found: ${hostPath}`);
|
|
1225
|
+
process.exit(1);
|
|
1226
|
+
}
|
|
1227
|
+
if (!fs.statSync(hostPath).isFile()) {
|
|
1228
|
+
console.error(`ERROR: Not a file: ${hostPath}`);
|
|
1229
|
+
process.exit(1);
|
|
1230
|
+
}
|
|
1231
|
+
return {
|
|
1232
|
+
hostPath,
|
|
1233
|
+
containerPath: `/app/configs/${path.basename(hostPath)}`
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Ensure the deliverables directory exists and is writable by the container user.
|
|
1238
|
+
*/
|
|
1239
|
+
function ensureDeliverables(repoHostPath) {
|
|
1240
|
+
const deliverables = path.join(repoHostPath, "deliverables");
|
|
1241
|
+
fs.mkdirSync(deliverables, { recursive: true });
|
|
1242
|
+
fs.chmodSync(deliverables, 511);
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/splash.ts
|
|
1246
|
+
/**
|
|
1247
|
+
* Splash screen display — pure terminal output, no npm dependencies.
|
|
1248
|
+
*/
|
|
1249
|
+
function displaySplash(version) {
|
|
1250
|
+
const GOLD = "\x1B[38;2;244;197;66m";
|
|
1251
|
+
const CYAN = "\x1B[36;1m";
|
|
1252
|
+
const WHITE = "\x1B[1;37m";
|
|
1253
|
+
const GRAY = "\x1B[0;37m";
|
|
1254
|
+
const YELLOW = "\x1B[1;33m";
|
|
1255
|
+
const RESET = "\x1B[0m";
|
|
1256
|
+
const B = `${CYAN}\u2551${RESET}`;
|
|
1257
|
+
const S67 = " ".repeat(67);
|
|
1258
|
+
const HR = "═".repeat(67);
|
|
1259
|
+
const lines = [
|
|
1260
|
+
"",
|
|
1261
|
+
` ${CYAN}\u2554${HR}\u2557${RESET}`,
|
|
1262
|
+
` ${B}${S67}${B}`,
|
|
1263
|
+
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557${RESET} ${B}`,
|
|
1264
|
+
` ${B} ${GOLD}\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
|
|
1265
|
+
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
|
|
1266
|
+
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551${RESET} ${B}`,
|
|
1267
|
+
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551${RESET} ${B}`,
|
|
1268
|
+
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D${RESET} ${B}`,
|
|
1269
|
+
` ${B}${S67}${B}`,
|
|
1270
|
+
` ${B} ${CYAN}\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557${RESET} ${B}`,
|
|
1271
|
+
` ${B} ${CYAN}\u2551${RESET} ${WHITE}AI Penetration Testing Framework${RESET} ${CYAN}\u2551${RESET} ${B}`,
|
|
1272
|
+
` ${B} ${CYAN}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D${RESET} ${B}`,
|
|
1273
|
+
` ${B}${S67}${B}`
|
|
1274
|
+
];
|
|
1275
|
+
if (version) {
|
|
1276
|
+
const verStr = `v${version}`;
|
|
1277
|
+
const verPadLeft = Math.floor((67 - verStr.length) / 2);
|
|
1278
|
+
const verPadRight = 67 - verStr.length - verPadLeft;
|
|
1279
|
+
lines.push(` ${B}${" ".repeat(verPadLeft)}${GRAY}${verStr}${RESET}${" ".repeat(verPadRight)}${B}`);
|
|
1280
|
+
}
|
|
1281
|
+
lines.push(` ${B}${S67}${B}`, ` ${B} ${YELLOW}\uD83D\uDD10 DEFENSIVE SECURITY ONLY \uD83D\uDD10${RESET} ${B}`, ` ${B}${S67}${B}`, ` ${CYAN}\u255A${HR}\u255D${RESET}`, "");
|
|
1282
|
+
console.log(lines.join("\n"));
|
|
1283
|
+
}
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region src/commands/start.ts
|
|
1286
|
+
/**
|
|
1287
|
+
* `shannon start` command — launch a pentest scan.
|
|
1288
|
+
*
|
|
1289
|
+
* Handles both local mode (local build, ./workspaces/, mounted prompts)
|
|
1290
|
+
* and npx mode (Docker Hub pull, ~/.shannon/).
|
|
1291
|
+
*/
|
|
1292
|
+
async function start(args) {
|
|
1293
|
+
initHome();
|
|
1294
|
+
loadEnv();
|
|
1295
|
+
const creds = validateCredentials();
|
|
1296
|
+
if (!creds.valid) {
|
|
1297
|
+
console.error(`ERROR: ${creds.error}`);
|
|
1298
|
+
process.exit(1);
|
|
1299
|
+
}
|
|
1300
|
+
const useRouter = args.router || isRouterConfigured();
|
|
1301
|
+
const repo = resolveRepo(args.repo);
|
|
1302
|
+
const config = args.config ? resolveConfig(args.config) : void 0;
|
|
1303
|
+
ensureDeliverables(repo.hostPath);
|
|
1304
|
+
const workspacesDir = getWorkspacesDir();
|
|
1305
|
+
fs.mkdirSync(workspacesDir, { recursive: true });
|
|
1306
|
+
fs.chmodSync(workspacesDir, 511);
|
|
1307
|
+
if (useRouter) {
|
|
1308
|
+
process.env.ANTHROPIC_BASE_URL = "http://shannon-router:3456";
|
|
1309
|
+
process.env.ANTHROPIC_AUTH_TOKEN = "shannon-router-key";
|
|
1310
|
+
}
|
|
1311
|
+
ensureImage(args.version);
|
|
1312
|
+
await ensureInfra(useRouter);
|
|
1313
|
+
const suffix = randomSuffix();
|
|
1314
|
+
const taskQueue = `shannon-${suffix}`;
|
|
1315
|
+
const containerName = `shannon-worker-${suffix}`;
|
|
1316
|
+
const workspace = args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, "-")}_shannon-${Date.now()}`;
|
|
1317
|
+
const credentialsDir = getCredentialsDir();
|
|
1318
|
+
const credentialsPath = getCredentialsPath();
|
|
1319
|
+
const hasCredentials = !credentialsDir && fs.existsSync(credentialsPath);
|
|
1320
|
+
const outputDir = args.output ? path.resolve(args.output) : void 0;
|
|
1321
|
+
if (outputDir) fs.mkdirSync(outputDir, { recursive: true });
|
|
1322
|
+
const promptsDir = isLocal() ? path.resolve("apps/worker/prompts") : void 0;
|
|
1323
|
+
displaySplash(isLocal() ? void 0 : args.version);
|
|
1324
|
+
spawnWorker({
|
|
1325
|
+
version: args.version,
|
|
1326
|
+
url: args.url,
|
|
1327
|
+
repo,
|
|
1328
|
+
workspacesDir,
|
|
1329
|
+
taskQueue,
|
|
1330
|
+
containerName,
|
|
1331
|
+
envFlags: buildEnvFlags(),
|
|
1332
|
+
...config && { config },
|
|
1333
|
+
...credentialsDir && { credentialsDir },
|
|
1334
|
+
...hasCredentials && { credentials: credentialsPath },
|
|
1335
|
+
...promptsDir && { promptsDir },
|
|
1336
|
+
...outputDir && { outputDir },
|
|
1337
|
+
...workspace && { workspace },
|
|
1338
|
+
...args.pipelineTesting && { pipelineTesting: true }
|
|
1339
|
+
}).on("error", (err) => {
|
|
1340
|
+
console.error(`Failed to start worker: ${err.message}`);
|
|
1341
|
+
process.exit(1);
|
|
1342
|
+
});
|
|
1343
|
+
const sessionJson = path.join(workspacesDir, workspace, "session.json");
|
|
1344
|
+
const isResume = fs.existsSync(sessionJson);
|
|
1345
|
+
let initialResumeCount = 0;
|
|
1346
|
+
if (isResume) try {
|
|
1347
|
+
initialResumeCount = JSON.parse(fs.readFileSync(sessionJson, "utf-8")).session?.resumeAttempts?.length ?? 0;
|
|
1348
|
+
} catch {}
|
|
1349
|
+
process.stdout.write("Waiting for workflow to start...");
|
|
1350
|
+
let workflowId = "";
|
|
1351
|
+
let started = false;
|
|
1352
|
+
let attempts = 0;
|
|
1353
|
+
const pollInterval = setInterval(() => {
|
|
1354
|
+
attempts++;
|
|
1355
|
+
if (attempts > 60) {
|
|
1356
|
+
clearInterval(pollInterval);
|
|
1357
|
+
process.stdout.write("\n");
|
|
1358
|
+
console.error("Timeout waiting for workflow to start");
|
|
1359
|
+
process.exit(1);
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
const session = JSON.parse(fs.readFileSync(sessionJson, "utf-8"));
|
|
1363
|
+
const resumeAttempts = session.session?.resumeAttempts ?? [];
|
|
1364
|
+
if (isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId) {
|
|
1365
|
+
clearInterval(pollInterval);
|
|
1366
|
+
started = true;
|
|
1367
|
+
workflowId = resumeAttempts.at(-1)?.workflowId ?? session.session?.originalWorkflowId ?? "";
|
|
1368
|
+
process.stdout.write("\r\x1B[K");
|
|
1369
|
+
printInfo(args, useRouter, workspace, workflowId, repo.hostPath, workspacesDir);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
} catch {}
|
|
1373
|
+
process.stdout.write(".");
|
|
1374
|
+
}, 2e3);
|
|
1375
|
+
let cleaned = false;
|
|
1376
|
+
const cleanup = () => {
|
|
1377
|
+
if (cleaned || started) return;
|
|
1378
|
+
cleaned = true;
|
|
1379
|
+
clearInterval(pollInterval);
|
|
1380
|
+
console.log(`\nStopping worker ${containerName}...`);
|
|
1381
|
+
try {
|
|
1382
|
+
execFileSync("docker", ["stop", containerName], { stdio: "pipe" });
|
|
1383
|
+
} catch {}
|
|
1384
|
+
};
|
|
1385
|
+
process.on("SIGINT", () => {
|
|
1386
|
+
cleanup();
|
|
1387
|
+
process.exit(0);
|
|
1388
|
+
});
|
|
1389
|
+
process.on("SIGTERM", () => {
|
|
1390
|
+
cleanup();
|
|
1391
|
+
process.exit(0);
|
|
1392
|
+
});
|
|
1393
|
+
process.on("exit", cleanup);
|
|
1394
|
+
}
|
|
1395
|
+
function printInfo(args, routerActive, workspace, workflowId, repoPath, workspacesDir) {
|
|
1396
|
+
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
|
|
1397
|
+
const reportsPath = path.join(workspacesDir, workspace);
|
|
1398
|
+
console.log(` Target: ${args.url}`);
|
|
1399
|
+
console.log(` Repository: ${repoPath}`);
|
|
1400
|
+
console.log(` Workspace: ${workspace}`);
|
|
1401
|
+
if (args.config) console.log(` Config: ${path.resolve(args.config)}`);
|
|
1402
|
+
if (args.pipelineTesting) console.log(" Mode: Pipeline Testing");
|
|
1403
|
+
if (routerActive) console.log(" Router: Enabled");
|
|
1404
|
+
console.log("");
|
|
1405
|
+
console.log(" Monitor:");
|
|
1406
|
+
if (workflowId) console.log(` Web UI: http://localhost:8233/namespaces/default/workflows/${workflowId}`);
|
|
1407
|
+
else console.log(" Web UI: http://localhost:8233");
|
|
1408
|
+
console.log(` Logs: ${logsCmd}`);
|
|
1409
|
+
console.log("");
|
|
1410
|
+
console.log(" Output:");
|
|
1411
|
+
console.log(` Reports: ${reportsPath}/`);
|
|
1412
|
+
console.log("");
|
|
1413
|
+
}
|
|
1414
|
+
//#endregion
|
|
1415
|
+
//#region src/commands/status.ts
|
|
1416
|
+
/**
|
|
1417
|
+
* `shannon status` command — show running workers and Temporal health.
|
|
1418
|
+
*/
|
|
1419
|
+
function status() {
|
|
1420
|
+
const temporalUp = isTemporalReady();
|
|
1421
|
+
console.log(`Temporal: ${temporalUp ? "running" : "not running"}`);
|
|
1422
|
+
if (temporalUp) console.log(" Web UI: http://localhost:8233");
|
|
1423
|
+
console.log("");
|
|
1424
|
+
const workers = listRunningWorkers();
|
|
1425
|
+
if (workers) {
|
|
1426
|
+
console.log("Workers:");
|
|
1427
|
+
console.log(workers);
|
|
1428
|
+
} else console.log("Workers: none running");
|
|
1429
|
+
}
|
|
1430
|
+
//#endregion
|
|
1431
|
+
//#region src/commands/stop.ts
|
|
1432
|
+
/**
|
|
1433
|
+
* `shannon stop` command — stop workers and infrastructure.
|
|
1434
|
+
*/
|
|
1435
|
+
async function stop(clean) {
|
|
1436
|
+
if (clean) {
|
|
1437
|
+
const confirmed = await p.confirm({ message: "This will stop all running scans and remove the Temporal data. Continue?" });
|
|
1438
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
1439
|
+
p.cancel("Aborted.");
|
|
1440
|
+
process.exit(0);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
stopWorkers();
|
|
1444
|
+
stopInfra(clean);
|
|
1445
|
+
}
|
|
1446
|
+
//#endregion
|
|
1447
|
+
//#region src/commands/uninstall.ts
|
|
1448
|
+
/**
|
|
1449
|
+
* `shn uninstall` command — remove ~/.shannon/ after confirmation (npx only).
|
|
1450
|
+
*/
|
|
1451
|
+
const SHANNON_HOME = path.join(os.homedir(), ".shannon");
|
|
1452
|
+
async function uninstall() {
|
|
1453
|
+
p.intro("Shannon Uninstall");
|
|
1454
|
+
if (!fs.existsSync(SHANNON_HOME)) {
|
|
1455
|
+
p.log.info("Nothing to remove. Shannon is not configured on this machine.");
|
|
1456
|
+
p.outro("Done.");
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const confirmed = await p.confirm({ message: "This will permanently remove all past scan data, saved configurations, and API keys. Continue?" });
|
|
1460
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
1461
|
+
p.cancel("Aborted.");
|
|
1462
|
+
process.exit(0);
|
|
1463
|
+
}
|
|
1464
|
+
stopWorkers();
|
|
1465
|
+
stopInfra(false);
|
|
1466
|
+
fs.rmSync(SHANNON_HOME, {
|
|
1467
|
+
recursive: true,
|
|
1468
|
+
force: true
|
|
1469
|
+
});
|
|
1470
|
+
p.log.success("All Shannon data has been removed.");
|
|
1471
|
+
p.outro("Shannon has been uninstalled. Run `npx @keygraph/shannon setup` to start fresh.");
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/commands/update.ts
|
|
1475
|
+
/**
|
|
1476
|
+
* `shannon update` command — pull the worker image matching the current CLI version.
|
|
1477
|
+
*/
|
|
1478
|
+
function update(version) {
|
|
1479
|
+
pullImage(version);
|
|
1480
|
+
console.log("Update complete.");
|
|
1481
|
+
}
|
|
1482
|
+
//#endregion
|
|
1483
|
+
//#region src/commands/workspaces.ts
|
|
1484
|
+
/**
|
|
1485
|
+
* `shannon workspaces` command — list all workspaces.
|
|
1486
|
+
*/
|
|
1487
|
+
function workspaces(version) {
|
|
1488
|
+
const workspacesDir = getWorkspacesDir();
|
|
1489
|
+
const image = getWorkerImage(version);
|
|
1490
|
+
try {
|
|
1491
|
+
execFileSync("docker", [
|
|
1492
|
+
"run",
|
|
1493
|
+
"--rm",
|
|
1494
|
+
"-v",
|
|
1495
|
+
`${workspacesDir}:/app/workspaces`,
|
|
1496
|
+
"-e",
|
|
1497
|
+
"WORKSPACES_DIR=/app/workspaces",
|
|
1498
|
+
image,
|
|
1499
|
+
"node",
|
|
1500
|
+
"apps/worker/dist/temporal/workspaces.js"
|
|
1501
|
+
], {
|
|
1502
|
+
stdio: "inherit",
|
|
1503
|
+
...os.platform() === "win32" && { env: {
|
|
1504
|
+
...process.env,
|
|
1505
|
+
MSYS_NO_PATHCONV: "1"
|
|
1506
|
+
} }
|
|
1507
|
+
});
|
|
1508
|
+
} catch {
|
|
1509
|
+
console.error("ERROR: Failed to list workspaces. Is the Docker image available?");
|
|
1510
|
+
console.error(` Run: docker pull ${image}`);
|
|
1511
|
+
process.exit(1);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
//#endregion
|
|
1515
|
+
//#region src/index.ts
|
|
1516
|
+
/**
|
|
1517
|
+
* Shannon CLI — AI Penetration Testing Framework
|
|
1518
|
+
*
|
|
1519
|
+
* Unified CLI supporting two modes:
|
|
1520
|
+
* Local mode: Run from cloned repo — builds locally, mounts prompts, uses ./workspaces/
|
|
1521
|
+
* NPX mode: Run via npx — pulls from Docker Hub, uses ~/.shannon/
|
|
1522
|
+
*
|
|
1523
|
+
* Mode is auto-detected based on presence of Dockerfile + docker-compose.yml + prompts/
|
|
1524
|
+
* in the current working directory.
|
|
1525
|
+
*/
|
|
1526
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
1527
|
+
function getVersion() {
|
|
1528
|
+
try {
|
|
1529
|
+
const pkgPath = path.join(__dirname, "..", "package.json");
|
|
1530
|
+
return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version || "1.0.0";
|
|
1531
|
+
} catch {
|
|
1532
|
+
return "1.0.0";
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
function showHelp() {
|
|
1536
|
+
const mode = getMode();
|
|
1537
|
+
const prefix = mode === "local" ? "./shannon" : "npx @keygraph/shannon";
|
|
1538
|
+
console.log(`
|
|
1539
|
+
Shannon - AI Penetration Testing Framework
|
|
1540
|
+
|
|
1541
|
+
Usage:${mode === "local" ? "" : `
|
|
1542
|
+
${prefix} setup Configure credentials`}
|
|
1543
|
+
${prefix} start --url <url> --repo <path> [options] Start a pentest scan
|
|
1544
|
+
${prefix} stop [--clean] Stop all containers
|
|
1545
|
+
${prefix} workspaces List all workspaces
|
|
1546
|
+
${prefix} logs <workspace> Tail workflow log
|
|
1547
|
+
${prefix} status Show running workers${mode === "local" ? `
|
|
1548
|
+
${prefix} build [--no-cache] Build worker image` : `
|
|
1549
|
+
${prefix} update Pull latest image
|
|
1550
|
+
${prefix} uninstall Remove ~/.shannon/ and all data`}
|
|
1551
|
+
${prefix} info Show splash screen
|
|
1552
|
+
${prefix} help Show this help
|
|
1553
|
+
|
|
1554
|
+
Options for 'start':
|
|
1555
|
+
-u, --url <url> Target URL (required)
|
|
1556
|
+
-r, --repo <path> Repository path${mode === "local" ? " or bare name" : ""} (required)
|
|
1557
|
+
-c, --config <path> Configuration file (YAML)
|
|
1558
|
+
-o, --output <path> Copy deliverables to this directory after run
|
|
1559
|
+
-w, --workspace <name> Named workspace (auto-resumes if exists)
|
|
1560
|
+
--pipeline-testing Use minimal prompts for fast testing
|
|
1561
|
+
--router Route requests through claude-code-router
|
|
1562
|
+
|
|
1563
|
+
Examples:
|
|
1564
|
+
${prefix} start -u https://example.com -r ${mode === "local" ? "my-repo" : "./my-repo"}
|
|
1565
|
+
${prefix} start -u https://example.com -r /path/to/repo -c config.yaml -w q1-audit
|
|
1566
|
+
${prefix} logs q1-audit
|
|
1567
|
+
${prefix} stop --clean
|
|
1568
|
+
${mode === "local" ? `
|
|
1569
|
+
State directory: ./workspaces/` : `
|
|
1570
|
+
State directory: ~/.shannon/`}
|
|
1571
|
+
Monitor workflows at http://localhost:8233
|
|
1572
|
+
`);
|
|
1573
|
+
}
|
|
1574
|
+
function parseStartArgs(argv) {
|
|
1575
|
+
let url = "";
|
|
1576
|
+
let repo = "";
|
|
1577
|
+
let config;
|
|
1578
|
+
let workspace;
|
|
1579
|
+
let output;
|
|
1580
|
+
let pipelineTesting = false;
|
|
1581
|
+
let router = false;
|
|
1582
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1583
|
+
const arg = argv[i];
|
|
1584
|
+
const next = argv[i + 1];
|
|
1585
|
+
switch (arg) {
|
|
1586
|
+
case "-u":
|
|
1587
|
+
case "--url":
|
|
1588
|
+
if (next && !next.startsWith("-")) {
|
|
1589
|
+
url = next;
|
|
1590
|
+
i++;
|
|
1591
|
+
}
|
|
1592
|
+
break;
|
|
1593
|
+
case "-r":
|
|
1594
|
+
case "--repo":
|
|
1595
|
+
if (next && !next.startsWith("-")) {
|
|
1596
|
+
repo = next;
|
|
1597
|
+
i++;
|
|
1598
|
+
}
|
|
1599
|
+
break;
|
|
1600
|
+
case "-c":
|
|
1601
|
+
case "--config":
|
|
1602
|
+
if (next && !next.startsWith("-")) {
|
|
1603
|
+
config = next;
|
|
1604
|
+
i++;
|
|
1605
|
+
}
|
|
1606
|
+
break;
|
|
1607
|
+
case "-w":
|
|
1608
|
+
case "--workspace":
|
|
1609
|
+
if (next && !next.startsWith("-")) {
|
|
1610
|
+
workspace = next;
|
|
1611
|
+
i++;
|
|
1612
|
+
}
|
|
1613
|
+
break;
|
|
1614
|
+
case "-o":
|
|
1615
|
+
case "--output":
|
|
1616
|
+
if (next && !next.startsWith("-")) {
|
|
1617
|
+
output = next;
|
|
1618
|
+
i++;
|
|
1619
|
+
}
|
|
1620
|
+
break;
|
|
1621
|
+
case "--pipeline-testing":
|
|
1622
|
+
pipelineTesting = true;
|
|
1623
|
+
break;
|
|
1624
|
+
case "--router":
|
|
1625
|
+
router = true;
|
|
1626
|
+
break;
|
|
1627
|
+
default:
|
|
1628
|
+
console.error(`Unknown option: ${arg}`);
|
|
1629
|
+
console.error(`Run "${getMode() === "local" ? "./shannon" : "npx @keygraph/shannon"} help" for usage`);
|
|
1630
|
+
process.exit(1);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (!url || !repo) {
|
|
1634
|
+
console.error("ERROR: --url and --repo are required");
|
|
1635
|
+
console.error(`Usage: ${getMode() === "local" ? "./shannon" : "npx @keygraph/shannon"} start -u <url> -r <path>`);
|
|
1636
|
+
process.exit(1);
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
url,
|
|
1640
|
+
repo,
|
|
1641
|
+
pipelineTesting,
|
|
1642
|
+
router,
|
|
1643
|
+
...config && { config },
|
|
1644
|
+
...workspace && { workspace },
|
|
1645
|
+
...output && { output }
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
const args = process.argv.slice(2);
|
|
1649
|
+
const command = args[0];
|
|
1650
|
+
switch (command) {
|
|
1651
|
+
case "start":
|
|
1652
|
+
await start({
|
|
1653
|
+
...parseStartArgs(args.slice(1)),
|
|
1654
|
+
version: getVersion()
|
|
1655
|
+
});
|
|
1656
|
+
break;
|
|
1657
|
+
case "stop":
|
|
1658
|
+
stop(args.includes("--clean"));
|
|
1659
|
+
break;
|
|
1660
|
+
case "logs": {
|
|
1661
|
+
const workspaceId = args[1];
|
|
1662
|
+
if (!workspaceId) {
|
|
1663
|
+
console.error("ERROR: Workspace ID is required");
|
|
1664
|
+
console.error(`Usage: ${getMode() === "local" ? "./shannon" : "npx @keygraph/shannon"} logs <workspace>`);
|
|
1665
|
+
process.exit(1);
|
|
1666
|
+
}
|
|
1667
|
+
logs(workspaceId);
|
|
1668
|
+
break;
|
|
1669
|
+
}
|
|
1670
|
+
case "workspaces":
|
|
1671
|
+
workspaces(getVersion());
|
|
1672
|
+
break;
|
|
1673
|
+
case "status":
|
|
1674
|
+
status();
|
|
1675
|
+
break;
|
|
1676
|
+
case "setup":
|
|
1677
|
+
if (getMode() === "local") {
|
|
1678
|
+
console.error("ERROR: setup is only available in npx mode. In local mode, use .env");
|
|
1679
|
+
process.exit(1);
|
|
1680
|
+
}
|
|
1681
|
+
setup();
|
|
1682
|
+
break;
|
|
1683
|
+
case "build":
|
|
1684
|
+
build(args.includes("--no-cache"));
|
|
1685
|
+
break;
|
|
1686
|
+
case "update":
|
|
1687
|
+
update(getVersion());
|
|
1688
|
+
break;
|
|
1689
|
+
case "uninstall":
|
|
1690
|
+
if (getMode() === "local") {
|
|
1691
|
+
console.error("ERROR: uninstall is only available in npx mode.");
|
|
1692
|
+
process.exit(1);
|
|
1693
|
+
}
|
|
1694
|
+
uninstall();
|
|
1695
|
+
break;
|
|
1696
|
+
case "info":
|
|
1697
|
+
displaySplash(getMode() === "local" ? void 0 : getVersion());
|
|
1698
|
+
break;
|
|
1699
|
+
case "help":
|
|
1700
|
+
case "--help":
|
|
1701
|
+
case "-h":
|
|
1702
|
+
case void 0:
|
|
1703
|
+
showHelp();
|
|
1704
|
+
break;
|
|
1705
|
+
default:
|
|
1706
|
+
console.error(`Unknown command: ${command}`);
|
|
1707
|
+
showHelp();
|
|
1708
|
+
process.exit(1);
|
|
1709
|
+
}
|
|
1710
|
+
//#endregion
|
|
1711
|
+
export {};
|