@zleap-ai/sag-cli 0.2.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/LICENSE +21 -0
- package/README.md +160 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3109 -0
- package/dist/cli.js.map +1 -0
- package/package.json +78 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { confirm, password, select } from "@inquirer/prompts";
|
|
5
|
+
|
|
6
|
+
// package.json
|
|
7
|
+
var package_default = {
|
|
8
|
+
name: "@zleap-ai/sag-cli",
|
|
9
|
+
version: "0.2.0",
|
|
10
|
+
description: "Command-line client and diagnostics for SAG knowledge bases",
|
|
11
|
+
type: "module",
|
|
12
|
+
bin: {
|
|
13
|
+
sag: "dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
files: [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
scripts: {
|
|
21
|
+
build: "tsup",
|
|
22
|
+
dev: "tsx src/cli.ts",
|
|
23
|
+
test: "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"test:coverage": "vitest run --coverage",
|
|
26
|
+
typecheck: "tsc --noEmit",
|
|
27
|
+
lint: "eslint .",
|
|
28
|
+
format: "prettier --write .",
|
|
29
|
+
"format:check": "prettier --check .",
|
|
30
|
+
check: "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build",
|
|
31
|
+
prepare: "husky",
|
|
32
|
+
commitlint: "commitlint",
|
|
33
|
+
changeset: "changeset",
|
|
34
|
+
"version-packages": "changeset version",
|
|
35
|
+
"release:check": "bash scripts/release.sh",
|
|
36
|
+
release: "bash scripts/release.sh --publish"
|
|
37
|
+
},
|
|
38
|
+
engines: {
|
|
39
|
+
node: ">=20.19.0"
|
|
40
|
+
},
|
|
41
|
+
publishConfig: {
|
|
42
|
+
access: "public"
|
|
43
|
+
},
|
|
44
|
+
license: "MIT",
|
|
45
|
+
dependencies: {
|
|
46
|
+
"@inquirer/prompts": "^8.5.2",
|
|
47
|
+
"@modelcontextprotocol/client": "^2.0.0",
|
|
48
|
+
commander: "^14.0.3",
|
|
49
|
+
"proper-lockfile": "^4.1.2",
|
|
50
|
+
yaml: "^2.9.0",
|
|
51
|
+
zod: "^4.4.3"
|
|
52
|
+
},
|
|
53
|
+
optionalDependencies: {
|
|
54
|
+
"@napi-rs/keyring": "^1.3.0"
|
|
55
|
+
},
|
|
56
|
+
devDependencies: {
|
|
57
|
+
"@changesets/cli": "^2.31.1",
|
|
58
|
+
"@commitlint/cli": "^20.0.0",
|
|
59
|
+
"@commitlint/config-conventional": "^20.0.0",
|
|
60
|
+
"@eslint/js": "^10.0.1",
|
|
61
|
+
"@types/node": "^20.19.43",
|
|
62
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
63
|
+
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
|
64
|
+
"@typescript-eslint/parser": "^8.65.0",
|
|
65
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
66
|
+
eslint: "^10.8.0",
|
|
67
|
+
husky: "^9.1.7",
|
|
68
|
+
"lint-staged": "^16.2.7",
|
|
69
|
+
prettier: "^3.9.6",
|
|
70
|
+
tsup: "^8.5.1",
|
|
71
|
+
tsx: "^4.23.1",
|
|
72
|
+
typescript: "^6.0.3",
|
|
73
|
+
vitest: "^4.1.10"
|
|
74
|
+
},
|
|
75
|
+
"lint-staged": {
|
|
76
|
+
"*.{ts,js,mjs,cjs}": [
|
|
77
|
+
"eslint --fix",
|
|
78
|
+
"prettier --write"
|
|
79
|
+
],
|
|
80
|
+
"*.{json,md,yml,yaml}": [
|
|
81
|
+
"prettier --write"
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// src/agents/claude-code.ts
|
|
87
|
+
import { readFile } from "fs/promises";
|
|
88
|
+
import os from "os";
|
|
89
|
+
import path from "path";
|
|
90
|
+
import { z } from "zod";
|
|
91
|
+
|
|
92
|
+
// src/core/errors.ts
|
|
93
|
+
var CliError = class extends Error {
|
|
94
|
+
code;
|
|
95
|
+
exitCode;
|
|
96
|
+
hint;
|
|
97
|
+
cause;
|
|
98
|
+
constructor(code, message, options) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = "CliError";
|
|
101
|
+
this.code = code;
|
|
102
|
+
this.exitCode = options.exitCode;
|
|
103
|
+
this.hint = options.hint;
|
|
104
|
+
this.cause = options.cause;
|
|
105
|
+
}
|
|
106
|
+
toShape() {
|
|
107
|
+
return {
|
|
108
|
+
code: this.code,
|
|
109
|
+
message: this.message,
|
|
110
|
+
...this.hint ? { hint: this.hint } : {}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
var exitCodes = {
|
|
115
|
+
success: 0,
|
|
116
|
+
invalidArgument: 2,
|
|
117
|
+
networkUnreachable: 10,
|
|
118
|
+
serviceNotReady: 11,
|
|
119
|
+
authRequired: 20,
|
|
120
|
+
permissionDenied: 21,
|
|
121
|
+
resourceNotFound: 30,
|
|
122
|
+
documentNotReady: 31,
|
|
123
|
+
mcpFailed: 40,
|
|
124
|
+
hostNotFound: 41,
|
|
125
|
+
hostConfigFailed: 42,
|
|
126
|
+
configConflict: 50,
|
|
127
|
+
dependencyMissing: 60,
|
|
128
|
+
internalError: 70
|
|
129
|
+
};
|
|
130
|
+
function toCliError(error) {
|
|
131
|
+
if (error instanceof CliError) {
|
|
132
|
+
return error;
|
|
133
|
+
}
|
|
134
|
+
return new CliError("INTERNAL_ERROR", "Unexpected internal error", {
|
|
135
|
+
exitCode: exitCodes.internalError,
|
|
136
|
+
cause: error,
|
|
137
|
+
hint: "Run again with SAG_DEBUG=1 for technical details."
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/agents/adapter.ts
|
|
142
|
+
import { createHash } from "crypto";
|
|
143
|
+
function canonicalize(value) {
|
|
144
|
+
if (Array.isArray(value)) {
|
|
145
|
+
return value.map(canonicalize);
|
|
146
|
+
}
|
|
147
|
+
if (value && typeof value === "object") {
|
|
148
|
+
return Object.fromEntries(
|
|
149
|
+
Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, nested]) => [key, canonicalize(nested)])
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function fingerprintValue(value) {
|
|
155
|
+
const canonical = JSON.stringify(canonicalize(value));
|
|
156
|
+
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
|
|
157
|
+
}
|
|
158
|
+
function fingerprintConnection(spec) {
|
|
159
|
+
return fingerprintValue(spec);
|
|
160
|
+
}
|
|
161
|
+
function connectionKey(agent, scope, serverName) {
|
|
162
|
+
return `${agent}:${scope}:${serverName}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/agents/claude-code.ts
|
|
166
|
+
var claudeConfigSchema = z.object({
|
|
167
|
+
mcpServers: z.record(z.string(), z.unknown()).optional()
|
|
168
|
+
});
|
|
169
|
+
var claudeEntrySchema = z.object({
|
|
170
|
+
type: z.literal("stdio").optional(),
|
|
171
|
+
command: z.string().min(1),
|
|
172
|
+
args: z.array(z.string()).optional(),
|
|
173
|
+
env: z.record(z.string(), z.string()).optional()
|
|
174
|
+
});
|
|
175
|
+
function invalidSpec() {
|
|
176
|
+
return new CliError(
|
|
177
|
+
"INVALID_ARGUMENT",
|
|
178
|
+
"Claude Code v0.2 supports Docker stdio MCP connections only",
|
|
179
|
+
{
|
|
180
|
+
exitCode: exitCodes.invalidArgument
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
function hostFailure(message, cause) {
|
|
185
|
+
return new CliError("HOST_CONFIG_FAILED", message, {
|
|
186
|
+
exitCode: exitCodes.hostConfigFailed,
|
|
187
|
+
...cause ? { cause } : {},
|
|
188
|
+
hint: "Run `claude mcp list` to inspect the current Claude Code MCP configuration."
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function ensureSuccess(result, action) {
|
|
192
|
+
if (result.exitCode !== 0) {
|
|
193
|
+
throw hostFailure(`Claude Code failed to ${action}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
var ClaudeCodeAdapter = class {
|
|
197
|
+
constructor(runner, options = {}) {
|
|
198
|
+
this.runner = runner;
|
|
199
|
+
const configPath = options.configPath ?? path.join(os.homedir(), ".claude.json");
|
|
200
|
+
this.readConfig = options.readConfig ?? (() => readFile(configPath, "utf8").catch((error) => {
|
|
201
|
+
if (error.code === "ENOENT") return "{}";
|
|
202
|
+
throw error;
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
runner;
|
|
206
|
+
id = "claude-code";
|
|
207
|
+
readConfig;
|
|
208
|
+
async detect() {
|
|
209
|
+
try {
|
|
210
|
+
const result = await this.runner.run({
|
|
211
|
+
command: "claude",
|
|
212
|
+
args: ["--version"],
|
|
213
|
+
timeoutMs: 5e3
|
|
214
|
+
});
|
|
215
|
+
if (result.exitCode !== 0) {
|
|
216
|
+
throw new Error("Claude Code returned an error");
|
|
217
|
+
}
|
|
218
|
+
return { id: this.id, version: result.stdout.trim() };
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
throw new CliError("HOST_NOT_FOUND", "Claude Code CLI was not found", {
|
|
221
|
+
exitCode: exitCodes.hostNotFound,
|
|
222
|
+
cause,
|
|
223
|
+
hint: "Install Claude Code and ensure `claude` is available on PATH."
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async read(name, scope) {
|
|
228
|
+
try {
|
|
229
|
+
const config = claudeConfigSchema.parse(JSON.parse(await this.readConfig()));
|
|
230
|
+
const rawEntry = config.mcpServers?.[name];
|
|
231
|
+
if (!rawEntry) return null;
|
|
232
|
+
const entry = claudeEntrySchema.parse(rawEntry);
|
|
233
|
+
const spec = {
|
|
234
|
+
transport: "stdio",
|
|
235
|
+
command: entry.command,
|
|
236
|
+
args: entry.args ?? [],
|
|
237
|
+
env: entry.env ?? {}
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
agent: this.id,
|
|
241
|
+
name,
|
|
242
|
+
scope,
|
|
243
|
+
spec,
|
|
244
|
+
fingerprint: fingerprintConnection(spec)
|
|
245
|
+
};
|
|
246
|
+
} catch (cause) {
|
|
247
|
+
throw hostFailure("Claude Code user MCP configuration is invalid", cause);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async add(name, scope, spec) {
|
|
251
|
+
if (spec.transport !== "stdio" || Object.keys(spec.env).length) {
|
|
252
|
+
throw invalidSpec();
|
|
253
|
+
}
|
|
254
|
+
ensureSuccess(
|
|
255
|
+
await this.runner.run({
|
|
256
|
+
command: "claude",
|
|
257
|
+
args: ["mcp", "add", "--scope", scope, name, "--", spec.command, ...spec.args],
|
|
258
|
+
timeoutMs: 15e3
|
|
259
|
+
}),
|
|
260
|
+
"add the MCP server"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
async remove(name, scope) {
|
|
264
|
+
ensureSuccess(
|
|
265
|
+
await this.runner.run({
|
|
266
|
+
command: "claude",
|
|
267
|
+
args: ["mcp", "remove", "--scope", scope, name],
|
|
268
|
+
timeoutMs: 15e3
|
|
269
|
+
}),
|
|
270
|
+
"remove the MCP server"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// src/agents/codex.ts
|
|
276
|
+
import { z as z2 } from "zod";
|
|
277
|
+
var codexEntrySchema = z2.object({
|
|
278
|
+
name: z2.string().optional(),
|
|
279
|
+
enabled: z2.boolean(),
|
|
280
|
+
disabled_reason: z2.unknown().optional(),
|
|
281
|
+
transport: z2.object({
|
|
282
|
+
type: z2.literal("stdio"),
|
|
283
|
+
command: z2.string().min(1),
|
|
284
|
+
args: z2.array(z2.string()).optional(),
|
|
285
|
+
env: z2.record(z2.string(), z2.string()).nullish()
|
|
286
|
+
}).passthrough(),
|
|
287
|
+
startup_timeout_sec: z2.number().nullish(),
|
|
288
|
+
tool_timeout_sec: z2.number().nullish(),
|
|
289
|
+
auth_status: z2.unknown().optional()
|
|
290
|
+
}).passthrough();
|
|
291
|
+
var CODEX_STATUS_FIELDS = /* @__PURE__ */ new Set(["name", "disabled_reason", "auth_status"]);
|
|
292
|
+
function invalidSpec2() {
|
|
293
|
+
return new CliError(
|
|
294
|
+
"INVALID_ARGUMENT",
|
|
295
|
+
"Codex v0.2 supports Docker stdio MCP connections only",
|
|
296
|
+
{
|
|
297
|
+
exitCode: exitCodes.invalidArgument
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
function hostFailure2(message, cause) {
|
|
302
|
+
return new CliError("HOST_CONFIG_FAILED", message, {
|
|
303
|
+
exitCode: exitCodes.hostConfigFailed,
|
|
304
|
+
...cause ? { cause } : {},
|
|
305
|
+
hint: "Run `codex mcp list` to inspect the current Codex MCP configuration."
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function ensureSuccess2(result, action) {
|
|
309
|
+
if (result.exitCode !== 0) {
|
|
310
|
+
throw hostFailure2(`Codex failed to ${action}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
var CodexAdapter = class {
|
|
314
|
+
constructor(runner) {
|
|
315
|
+
this.runner = runner;
|
|
316
|
+
}
|
|
317
|
+
runner;
|
|
318
|
+
id = "codex";
|
|
319
|
+
async detect() {
|
|
320
|
+
try {
|
|
321
|
+
const result = await this.runner.run({
|
|
322
|
+
command: "codex",
|
|
323
|
+
args: ["--version"],
|
|
324
|
+
timeoutMs: 5e3
|
|
325
|
+
});
|
|
326
|
+
if (result.exitCode !== 0) throw new Error("Codex returned an error");
|
|
327
|
+
return { id: this.id, version: result.stdout.trim() };
|
|
328
|
+
} catch (cause) {
|
|
329
|
+
throw new CliError("HOST_NOT_FOUND", "Codex CLI was not found", {
|
|
330
|
+
exitCode: exitCodes.hostNotFound,
|
|
331
|
+
cause,
|
|
332
|
+
hint: "Install Codex CLI and ensure `codex` is available on PATH."
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async read(name, scope) {
|
|
337
|
+
const result = await this.runner.run({
|
|
338
|
+
command: "codex",
|
|
339
|
+
args: ["mcp", "get", name, "--json"],
|
|
340
|
+
timeoutMs: 15e3
|
|
341
|
+
});
|
|
342
|
+
if (result.exitCode !== 0) {
|
|
343
|
+
if (/not found|does not exist|no mcp server/iu.test(result.stderr)) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
throw hostFailure2("Codex failed to read the MCP configuration");
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const entry = codexEntrySchema.parse(JSON.parse(result.stdout));
|
|
350
|
+
const spec = {
|
|
351
|
+
transport: "stdio",
|
|
352
|
+
command: entry.transport.command,
|
|
353
|
+
args: entry.transport.args ?? [],
|
|
354
|
+
env: entry.transport.env ?? {}
|
|
355
|
+
};
|
|
356
|
+
const configuration = Object.fromEntries(
|
|
357
|
+
Object.entries(entry).filter(([key]) => !CODEX_STATUS_FIELDS.has(key))
|
|
358
|
+
);
|
|
359
|
+
return {
|
|
360
|
+
agent: this.id,
|
|
361
|
+
name,
|
|
362
|
+
scope,
|
|
363
|
+
spec,
|
|
364
|
+
fingerprint: fingerprintValue(configuration)
|
|
365
|
+
};
|
|
366
|
+
} catch (cause) {
|
|
367
|
+
throw hostFailure2("Codex returned an invalid MCP configuration", cause);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async add(name, scope, spec) {
|
|
371
|
+
if (scope !== "user" || spec.transport !== "stdio" || Object.keys(spec.env).length) {
|
|
372
|
+
throw invalidSpec2();
|
|
373
|
+
}
|
|
374
|
+
ensureSuccess2(
|
|
375
|
+
await this.runner.run({
|
|
376
|
+
command: "codex",
|
|
377
|
+
args: ["mcp", "add", name, "--", spec.command, ...spec.args],
|
|
378
|
+
timeoutMs: 15e3
|
|
379
|
+
}),
|
|
380
|
+
"add the MCP server"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
async remove(name, scope) {
|
|
384
|
+
if (scope !== "user") {
|
|
385
|
+
throw invalidSpec2();
|
|
386
|
+
}
|
|
387
|
+
ensureSuccess2(
|
|
388
|
+
await this.runner.run({
|
|
389
|
+
command: "codex",
|
|
390
|
+
args: ["mcp", "remove", name],
|
|
391
|
+
timeoutMs: 15e3
|
|
392
|
+
}),
|
|
393
|
+
"remove the MCP server"
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/agents/state.ts
|
|
399
|
+
import { chmod, mkdir, readFile as readFile2, rename, unlink, writeFile } from "fs/promises";
|
|
400
|
+
import path2 from "path";
|
|
401
|
+
import { lock } from "proper-lockfile";
|
|
402
|
+
import { parse, stringify } from "yaml";
|
|
403
|
+
import { z as z3 } from "zod";
|
|
404
|
+
var managedConnectionSchema = z3.strictObject({
|
|
405
|
+
agent: z3.enum(["codex", "claude-code"]),
|
|
406
|
+
provider: z3.literal("docker-stdio"),
|
|
407
|
+
profile: z3.string().min(1),
|
|
408
|
+
serverName: z3.string().min(1),
|
|
409
|
+
docker: z3.strictObject({
|
|
410
|
+
composeProject: z3.string().min(1).optional(),
|
|
411
|
+
composeService: z3.string().min(1).optional(),
|
|
412
|
+
containerName: z3.string().min(1)
|
|
413
|
+
}),
|
|
414
|
+
sourceId: z3.string().min(1).nullable(),
|
|
415
|
+
scope: z3.literal("user"),
|
|
416
|
+
fingerprint: z3.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
|
417
|
+
createdAt: z3.string().min(1)
|
|
418
|
+
});
|
|
419
|
+
var managedConnectionsSchema = z3.strictObject({
|
|
420
|
+
version: z3.literal(1),
|
|
421
|
+
connections: z3.record(z3.string(), managedConnectionSchema)
|
|
422
|
+
});
|
|
423
|
+
function emptyState() {
|
|
424
|
+
return { version: 1, connections: {} };
|
|
425
|
+
}
|
|
426
|
+
function invalidState(cause, filePath) {
|
|
427
|
+
return new CliError("CONFIG_CONFLICT", "Managed connection state is invalid", {
|
|
428
|
+
exitCode: exitCodes.configConflict,
|
|
429
|
+
cause,
|
|
430
|
+
hint: `Review or restore ${filePath}.`
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
var ManagedConnectionStore = class {
|
|
434
|
+
constructor(filePath) {
|
|
435
|
+
this.filePath = filePath;
|
|
436
|
+
}
|
|
437
|
+
filePath;
|
|
438
|
+
async #withLock(operation) {
|
|
439
|
+
await mkdir(path2.dirname(this.filePath), { recursive: true });
|
|
440
|
+
let release;
|
|
441
|
+
try {
|
|
442
|
+
release = await lock(this.filePath, {
|
|
443
|
+
realpath: false,
|
|
444
|
+
stale: 1e4,
|
|
445
|
+
update: 2e3,
|
|
446
|
+
retries: {
|
|
447
|
+
retries: 40,
|
|
448
|
+
factor: 1,
|
|
449
|
+
minTimeout: 25,
|
|
450
|
+
maxTimeout: 100
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
} catch (cause) {
|
|
454
|
+
throw new CliError("CONFIG_CONFLICT", "Managed connection state is busy", {
|
|
455
|
+
exitCode: exitCodes.configConflict,
|
|
456
|
+
cause,
|
|
457
|
+
hint: "Wait for the other SAG CLI Agent command to finish and retry."
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
return await operation();
|
|
462
|
+
} finally {
|
|
463
|
+
await release();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async load() {
|
|
467
|
+
let raw;
|
|
468
|
+
try {
|
|
469
|
+
raw = await readFile2(this.filePath, "utf8");
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (error.code === "ENOENT") {
|
|
472
|
+
return emptyState();
|
|
473
|
+
}
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
return managedConnectionsSchema.parse(parse(raw));
|
|
478
|
+
} catch (cause) {
|
|
479
|
+
throw invalidState(cause, this.filePath);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async #saveUnlocked(state) {
|
|
483
|
+
let validated;
|
|
484
|
+
try {
|
|
485
|
+
validated = managedConnectionsSchema.parse(state);
|
|
486
|
+
} catch (cause) {
|
|
487
|
+
throw invalidState(cause, this.filePath);
|
|
488
|
+
}
|
|
489
|
+
const directory = path2.dirname(this.filePath);
|
|
490
|
+
await mkdir(directory, { recursive: true });
|
|
491
|
+
const temporaryPath = path2.join(
|
|
492
|
+
directory,
|
|
493
|
+
`.${path2.basename(this.filePath)}.${process.pid}.${Date.now()}.tmp`
|
|
494
|
+
);
|
|
495
|
+
try {
|
|
496
|
+
await writeFile(temporaryPath, stringify(validated), {
|
|
497
|
+
encoding: "utf8",
|
|
498
|
+
mode: 384
|
|
499
|
+
});
|
|
500
|
+
await rename(temporaryPath, this.filePath);
|
|
501
|
+
await chmod(this.filePath, 384);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
await unlink(temporaryPath).catch(() => void 0);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async save(state) {
|
|
508
|
+
await this.#withLock(() => this.#saveUnlocked(state));
|
|
509
|
+
}
|
|
510
|
+
async get(key) {
|
|
511
|
+
return (await this.load()).connections[key] ?? null;
|
|
512
|
+
}
|
|
513
|
+
async set(connection) {
|
|
514
|
+
const validated = (() => {
|
|
515
|
+
try {
|
|
516
|
+
return managedConnectionSchema.parse(connection);
|
|
517
|
+
} catch (cause) {
|
|
518
|
+
throw invalidState(cause, this.filePath);
|
|
519
|
+
}
|
|
520
|
+
})();
|
|
521
|
+
await this.#withLock(async () => {
|
|
522
|
+
const state = await this.load();
|
|
523
|
+
const key = connectionKey(validated.agent, validated.scope, validated.serverName);
|
|
524
|
+
state.connections[key] = validated;
|
|
525
|
+
await this.#saveUnlocked(state);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
async delete(key) {
|
|
529
|
+
await this.#withLock(async () => {
|
|
530
|
+
const state = await this.load();
|
|
531
|
+
delete state.connections[key];
|
|
532
|
+
await this.#saveUnlocked(state);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
async list(input) {
|
|
536
|
+
return Object.values((await this.load()).connections).filter(
|
|
537
|
+
(connection) => (!input?.agent || connection.agent === input.agent) && (!input?.scope || connection.scope === input.scope)
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// src/config/store.ts
|
|
543
|
+
import { mkdir as mkdir2, readFile as readFile3, rename as rename2, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
|
|
544
|
+
import path3 from "path";
|
|
545
|
+
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
546
|
+
|
|
547
|
+
// src/config/resolve.ts
|
|
548
|
+
var PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/u;
|
|
549
|
+
var STRIPPED_PATHS = /* @__PURE__ */ new Set(["/", "/api/v1", "/api/v1/", "/mcp", "/mcp/"]);
|
|
550
|
+
function validateProfileName(name) {
|
|
551
|
+
if (!PROFILE_NAME.test(name)) {
|
|
552
|
+
throw new CliError("INVALID_ARGUMENT", `Invalid profile name: ${name}`, {
|
|
553
|
+
exitCode: exitCodes.invalidArgument,
|
|
554
|
+
hint: "Use 1-64 letters, numbers, hyphens, or underscores."
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return name;
|
|
558
|
+
}
|
|
559
|
+
function normalizeOrigin(input) {
|
|
560
|
+
let url;
|
|
561
|
+
try {
|
|
562
|
+
url = new URL(input);
|
|
563
|
+
} catch (cause) {
|
|
564
|
+
throw new CliError("INVALID_ARGUMENT", `Invalid SAG URL: ${input}`, {
|
|
565
|
+
exitCode: exitCodes.invalidArgument,
|
|
566
|
+
cause,
|
|
567
|
+
hint: "Use an absolute HTTP or HTTPS URL such as http://localhost:8000."
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
571
|
+
throw new CliError("INVALID_ARGUMENT", "SAG URL must use HTTP or HTTPS", {
|
|
572
|
+
exitCode: exitCodes.invalidArgument
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (url.username || url.password) {
|
|
576
|
+
throw new CliError("INVALID_ARGUMENT", "SAG URL must not contain credentials", {
|
|
577
|
+
exitCode: exitCodes.invalidArgument
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
if (url.search || url.hash) {
|
|
581
|
+
throw new CliError(
|
|
582
|
+
"INVALID_ARGUMENT",
|
|
583
|
+
"SAG URL must not contain a query string or fragment",
|
|
584
|
+
{ exitCode: exitCodes.invalidArgument }
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
if (!STRIPPED_PATHS.has(url.pathname)) {
|
|
588
|
+
throw new CliError(
|
|
589
|
+
"INVALID_ARGUMENT",
|
|
590
|
+
`Unsupported path in SAG URL: ${url.pathname}`,
|
|
591
|
+
{
|
|
592
|
+
exitCode: exitCodes.invalidArgument,
|
|
593
|
+
hint: "Store only the SAG origin; /api/v1 and /mcp/ are added automatically."
|
|
594
|
+
}
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return url.origin;
|
|
598
|
+
}
|
|
599
|
+
function profileOrThrow(config, name) {
|
|
600
|
+
validateProfileName(name);
|
|
601
|
+
const profile = config.profiles[name];
|
|
602
|
+
if (!profile) {
|
|
603
|
+
throw new CliError("RESOURCE_NOT_FOUND", `Profile not found: ${name}`, {
|
|
604
|
+
exitCode: exitCodes.resourceNotFound,
|
|
605
|
+
hint: "Run `sag profile list` to view configured profiles."
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return profile;
|
|
609
|
+
}
|
|
610
|
+
function withOptionalFields(base, profileName, profile, environmentToken) {
|
|
611
|
+
return {
|
|
612
|
+
...base,
|
|
613
|
+
...profileName ? { profileName } : {},
|
|
614
|
+
...profile ? { credentialRef: profile.credentialRef } : {},
|
|
615
|
+
...environmentToken ? { environmentToken } : {}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function resolveConnection(options, environment, config) {
|
|
619
|
+
const environmentToken = environment.SAG_TOKEN?.trim() || void 0;
|
|
620
|
+
if (options.url) {
|
|
621
|
+
const profileName = options.profile ? validateProfileName(options.profile) : void 0;
|
|
622
|
+
const profile = profileName ? profileOrThrow(config, profileName) : void 0;
|
|
623
|
+
return withOptionalFields(
|
|
624
|
+
{ url: normalizeOrigin(options.url), source: "explicit-url" },
|
|
625
|
+
profileName,
|
|
626
|
+
profile,
|
|
627
|
+
environmentToken
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
if (options.profile) {
|
|
631
|
+
const profileName = validateProfileName(options.profile);
|
|
632
|
+
const profile = profileOrThrow(config, profileName);
|
|
633
|
+
return withOptionalFields(
|
|
634
|
+
{ url: normalizeOrigin(profile.url), source: "explicit-profile" },
|
|
635
|
+
profileName,
|
|
636
|
+
profile,
|
|
637
|
+
environmentToken
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
if (environment.SAG_URL) {
|
|
641
|
+
return withOptionalFields(
|
|
642
|
+
{
|
|
643
|
+
url: normalizeOrigin(environment.SAG_URL),
|
|
644
|
+
source: "environment-url"
|
|
645
|
+
},
|
|
646
|
+
void 0,
|
|
647
|
+
void 0,
|
|
648
|
+
environmentToken
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (environment.SAG_PROFILE) {
|
|
652
|
+
const profileName = validateProfileName(environment.SAG_PROFILE);
|
|
653
|
+
const profile = profileOrThrow(config, profileName);
|
|
654
|
+
return withOptionalFields(
|
|
655
|
+
{ url: normalizeOrigin(profile.url), source: "environment-profile" },
|
|
656
|
+
profileName,
|
|
657
|
+
profile,
|
|
658
|
+
environmentToken
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (config.currentProfile) {
|
|
662
|
+
const profileName = config.currentProfile;
|
|
663
|
+
const profile = profileOrThrow(config, profileName);
|
|
664
|
+
return withOptionalFields(
|
|
665
|
+
{ url: normalizeOrigin(profile.url), source: "current-profile" },
|
|
666
|
+
profileName,
|
|
667
|
+
profile,
|
|
668
|
+
environmentToken
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return withOptionalFields(
|
|
672
|
+
{ url: "http://127.0.0.1:8000", source: "local-probe" },
|
|
673
|
+
void 0,
|
|
674
|
+
void 0,
|
|
675
|
+
environmentToken
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/config/schema.ts
|
|
680
|
+
import { z as z4 } from "zod";
|
|
681
|
+
var profileSchema = z4.object({
|
|
682
|
+
url: z4.string(),
|
|
683
|
+
credentialRef: z4.string().min(1),
|
|
684
|
+
defaults: z4.object({
|
|
685
|
+
output: z4.enum(["human", "json"]).optional()
|
|
686
|
+
}).optional()
|
|
687
|
+
});
|
|
688
|
+
var cliConfigSchema = z4.object({
|
|
689
|
+
version: z4.literal(1),
|
|
690
|
+
currentProfile: z4.string().optional(),
|
|
691
|
+
profiles: z4.record(z4.string(), profileSchema)
|
|
692
|
+
});
|
|
693
|
+
var emptyConfig = () => ({
|
|
694
|
+
version: 1,
|
|
695
|
+
profiles: {}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// src/config/store.ts
|
|
699
|
+
var ConfigStore = class {
|
|
700
|
+
constructor(filePath) {
|
|
701
|
+
this.filePath = filePath;
|
|
702
|
+
}
|
|
703
|
+
filePath;
|
|
704
|
+
async load() {
|
|
705
|
+
let raw;
|
|
706
|
+
try {
|
|
707
|
+
raw = await readFile3(this.filePath, "utf8");
|
|
708
|
+
} catch (error) {
|
|
709
|
+
if (error.code === "ENOENT") {
|
|
710
|
+
return emptyConfig();
|
|
711
|
+
}
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
return cliConfigSchema.parse(parse2(raw));
|
|
716
|
+
} catch (cause) {
|
|
717
|
+
throw new CliError("CONFIG_CONFLICT", "CLI config is invalid", {
|
|
718
|
+
exitCode: exitCodes.configConflict,
|
|
719
|
+
cause,
|
|
720
|
+
hint: `Review or restore ${this.filePath}.`
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async save(config) {
|
|
725
|
+
const validated = cliConfigSchema.parse(config);
|
|
726
|
+
const directory = path3.dirname(this.filePath);
|
|
727
|
+
await mkdir2(directory, { recursive: true });
|
|
728
|
+
const temporaryPath = path3.join(
|
|
729
|
+
directory,
|
|
730
|
+
`.${path3.basename(this.filePath)}.${process.pid}.${Date.now()}.tmp`
|
|
731
|
+
);
|
|
732
|
+
try {
|
|
733
|
+
await writeFile2(temporaryPath, stringify2(validated), {
|
|
734
|
+
encoding: "utf8",
|
|
735
|
+
mode: 384
|
|
736
|
+
});
|
|
737
|
+
await rename2(temporaryPath, this.filePath);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
await unlink2(temporaryPath).catch(() => void 0);
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
async addProfile(name, url) {
|
|
744
|
+
validateProfileName(name);
|
|
745
|
+
const normalizedUrl = normalizeOrigin(url);
|
|
746
|
+
const config = await this.load();
|
|
747
|
+
const existing = config.profiles[name];
|
|
748
|
+
if (existing && existing.url !== normalizedUrl) {
|
|
749
|
+
throw new CliError(
|
|
750
|
+
"CONFIG_CONFLICT",
|
|
751
|
+
`Profile already points to a different SAG origin: ${name}`,
|
|
752
|
+
{
|
|
753
|
+
exitCode: exitCodes.configConflict,
|
|
754
|
+
hint: "Remove the profile first or choose another name."
|
|
755
|
+
}
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
const profile = existing ?? {
|
|
759
|
+
url: normalizedUrl,
|
|
760
|
+
credentialRef: `sag-cli/${name}`
|
|
761
|
+
};
|
|
762
|
+
config.profiles[name] = profile;
|
|
763
|
+
config.currentProfile ??= name;
|
|
764
|
+
await this.save(config);
|
|
765
|
+
return {
|
|
766
|
+
name,
|
|
767
|
+
current: config.currentProfile === name,
|
|
768
|
+
...profile
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
async listProfiles() {
|
|
772
|
+
const config = await this.load();
|
|
773
|
+
return Object.entries(config.profiles).sort(([left], [right]) => left.localeCompare(right)).map(([name, profile]) => ({
|
|
774
|
+
name,
|
|
775
|
+
current: config.currentProfile === name,
|
|
776
|
+
...profile
|
|
777
|
+
}));
|
|
778
|
+
}
|
|
779
|
+
async getProfile(name) {
|
|
780
|
+
const config = await this.load();
|
|
781
|
+
const selectedName = name ?? config.currentProfile;
|
|
782
|
+
if (!selectedName || !config.profiles[selectedName]) {
|
|
783
|
+
throw new CliError("RESOURCE_NOT_FOUND", "Profile not found", {
|
|
784
|
+
exitCode: exitCodes.resourceNotFound,
|
|
785
|
+
hint: "Run `sag profile add <name> <url>` first."
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
name: selectedName,
|
|
790
|
+
current: config.currentProfile === selectedName,
|
|
791
|
+
...config.profiles[selectedName]
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
async useProfile(name) {
|
|
795
|
+
const config = await this.load();
|
|
796
|
+
validateProfileName(name);
|
|
797
|
+
const profile = config.profiles[name];
|
|
798
|
+
if (!profile) {
|
|
799
|
+
throw new CliError("RESOURCE_NOT_FOUND", `Profile not found: ${name}`, {
|
|
800
|
+
exitCode: exitCodes.resourceNotFound
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
config.currentProfile = name;
|
|
804
|
+
await this.save(config);
|
|
805
|
+
return { name, current: true, ...profile };
|
|
806
|
+
}
|
|
807
|
+
async removeProfile(name) {
|
|
808
|
+
const config = await this.load();
|
|
809
|
+
validateProfileName(name);
|
|
810
|
+
if (!config.profiles[name]) {
|
|
811
|
+
throw new CliError("RESOURCE_NOT_FOUND", `Profile not found: ${name}`, {
|
|
812
|
+
exitCode: exitCodes.resourceNotFound
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
delete config.profiles[name];
|
|
816
|
+
if (config.currentProfile === name) {
|
|
817
|
+
config.currentProfile = Object.keys(config.profiles).sort()[0];
|
|
818
|
+
if (!config.currentProfile) {
|
|
819
|
+
delete config.currentProfile;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
await this.save(config);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
// src/config/paths.ts
|
|
827
|
+
import os2 from "os";
|
|
828
|
+
import path4 from "path";
|
|
829
|
+
function defaultConfigPath(options = {}) {
|
|
830
|
+
const platform = options.platform ?? process.platform;
|
|
831
|
+
const homeDirectory = options.homeDirectory ?? os2.homedir();
|
|
832
|
+
const environment = options.environment ?? process.env;
|
|
833
|
+
if (platform === "darwin") {
|
|
834
|
+
return path4.join(
|
|
835
|
+
homeDirectory,
|
|
836
|
+
"Library",
|
|
837
|
+
"Application Support",
|
|
838
|
+
"sag-cli",
|
|
839
|
+
"config.yaml"
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (platform === "win32") {
|
|
843
|
+
const appData = environment.APPDATA;
|
|
844
|
+
return path4.join(
|
|
845
|
+
appData || path4.join(homeDirectory, "AppData", "Roaming"),
|
|
846
|
+
"sag-cli",
|
|
847
|
+
"config.yaml"
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const xdgConfig = environment.XDG_CONFIG_HOME;
|
|
851
|
+
return path4.join(
|
|
852
|
+
xdgConfig || path4.join(homeDirectory, ".config"),
|
|
853
|
+
"sag-cli",
|
|
854
|
+
"config.yaml"
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
function defaultManagedConnectionsPath(options = {}) {
|
|
858
|
+
return path4.join(
|
|
859
|
+
path4.dirname(defaultConfigPath(options)),
|
|
860
|
+
"managed-connections.yaml"
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/credentials/memory.ts
|
|
865
|
+
var MemoryCredentialStore = class {
|
|
866
|
+
kind = "memory";
|
|
867
|
+
#values = /* @__PURE__ */ new Map();
|
|
868
|
+
async get(reference) {
|
|
869
|
+
return this.#values.get(reference) ?? null;
|
|
870
|
+
}
|
|
871
|
+
async set(reference, value) {
|
|
872
|
+
this.#values.set(reference, value);
|
|
873
|
+
}
|
|
874
|
+
async delete(reference) {
|
|
875
|
+
this.#values.delete(reference);
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// src/credentials/keychain.ts
|
|
880
|
+
var SERVICE_NAME = "@zleap-ai/sag-cli";
|
|
881
|
+
var KeychainCredentialStore = class {
|
|
882
|
+
constructor(entryFactory) {
|
|
883
|
+
this.entryFactory = entryFactory;
|
|
884
|
+
}
|
|
885
|
+
entryFactory;
|
|
886
|
+
kind = "keychain";
|
|
887
|
+
async get(reference) {
|
|
888
|
+
return this.entryFactory(SERVICE_NAME, reference).getPassword();
|
|
889
|
+
}
|
|
890
|
+
async set(reference, value) {
|
|
891
|
+
this.entryFactory(SERVICE_NAME, reference).setPassword(value);
|
|
892
|
+
}
|
|
893
|
+
async delete(reference) {
|
|
894
|
+
this.entryFactory(SERVICE_NAME, reference).deletePassword();
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
async function createKeychainCredentialStore() {
|
|
898
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
899
|
+
return new KeychainCredentialStore((service, account) => new Entry(service, account));
|
|
900
|
+
}
|
|
901
|
+
async function createCredentialStore(loadKeychain = createKeychainCredentialStore) {
|
|
902
|
+
try {
|
|
903
|
+
return { store: await loadKeychain() };
|
|
904
|
+
} catch {
|
|
905
|
+
return {
|
|
906
|
+
store: new MemoryCredentialStore(),
|
|
907
|
+
warning: "System Keychain is unavailable; credentials will remain in memory only and must be provided again next time."
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// src/docker/schemas.ts
|
|
913
|
+
import { z as z5 } from "zod";
|
|
914
|
+
var dockerVersionSchema = z5.object({
|
|
915
|
+
Platform: z5.object({
|
|
916
|
+
Name: z5.string().min(1)
|
|
917
|
+
})
|
|
918
|
+
});
|
|
919
|
+
var dockerPsLineSchema = z5.object({
|
|
920
|
+
ID: z5.string().min(1),
|
|
921
|
+
Names: z5.string().min(1),
|
|
922
|
+
Image: z5.string(),
|
|
923
|
+
Labels: z5.string()
|
|
924
|
+
});
|
|
925
|
+
var dockerInspectSchema = z5.array(
|
|
926
|
+
z5.object({
|
|
927
|
+
Id: z5.string().min(1),
|
|
928
|
+
Name: z5.string().min(1),
|
|
929
|
+
Config: z5.object({
|
|
930
|
+
Labels: z5.record(z5.string(), z5.string()).nullish()
|
|
931
|
+
}),
|
|
932
|
+
State: z5.object({
|
|
933
|
+
Running: z5.boolean(),
|
|
934
|
+
Health: z5.object({
|
|
935
|
+
Status: z5.enum(["healthy", "unhealthy", "starting"])
|
|
936
|
+
}).optional()
|
|
937
|
+
})
|
|
938
|
+
})
|
|
939
|
+
).length(1);
|
|
940
|
+
|
|
941
|
+
// src/docker/client.ts
|
|
942
|
+
var DOCKER_TIMEOUT_MS = 5e3;
|
|
943
|
+
function dependencyFailure(message, cause) {
|
|
944
|
+
return new CliError("DEPENDENCY_MISSING", message, {
|
|
945
|
+
exitCode: exitCodes.dependencyMissing,
|
|
946
|
+
...cause ? { cause } : {},
|
|
947
|
+
hint: "Check that Docker Desktop or Docker Engine is running and accessible."
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
function invalidDockerResponse(cause) {
|
|
951
|
+
return new CliError("INVALID_RESPONSE", "Docker returned an invalid response", {
|
|
952
|
+
exitCode: exitCodes.internalError,
|
|
953
|
+
cause,
|
|
954
|
+
hint: "Check the installed Docker CLI version and try again."
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
function parseLabels(value) {
|
|
958
|
+
const labels = {};
|
|
959
|
+
for (const pair of value.split(",")) {
|
|
960
|
+
if (!pair) continue;
|
|
961
|
+
const separator = pair.indexOf("=");
|
|
962
|
+
if (separator === -1) continue;
|
|
963
|
+
labels[pair.slice(0, separator)] = pair.slice(separator + 1);
|
|
964
|
+
}
|
|
965
|
+
return labels;
|
|
966
|
+
}
|
|
967
|
+
var DockerClient = class {
|
|
968
|
+
constructor(runner) {
|
|
969
|
+
this.runner = runner;
|
|
970
|
+
}
|
|
971
|
+
runner;
|
|
972
|
+
async version() {
|
|
973
|
+
const result = await this.runner.run({
|
|
974
|
+
command: "docker",
|
|
975
|
+
args: ["version", "--format", "{{json .Server}}"],
|
|
976
|
+
timeoutMs: DOCKER_TIMEOUT_MS
|
|
977
|
+
});
|
|
978
|
+
if (result.exitCode !== 0) {
|
|
979
|
+
throw dependencyFailure("Docker daemon is unavailable");
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
const parsed = dockerVersionSchema.parse(JSON.parse(result.stdout));
|
|
983
|
+
return { platformName: parsed.Platform.Name };
|
|
984
|
+
} catch (cause) {
|
|
985
|
+
throw invalidDockerResponse(cause);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async listRunningContainers() {
|
|
989
|
+
const result = await this.runner.run({
|
|
990
|
+
command: "docker",
|
|
991
|
+
args: ["ps", "--format", "{{json .}}"],
|
|
992
|
+
timeoutMs: DOCKER_TIMEOUT_MS
|
|
993
|
+
});
|
|
994
|
+
if (result.exitCode !== 0) {
|
|
995
|
+
throw dependencyFailure("Unable to list Docker containers");
|
|
996
|
+
}
|
|
997
|
+
if (!result.stdout.trim()) return [];
|
|
998
|
+
try {
|
|
999
|
+
return result.stdout.trim().split(/\r?\n/u).map((line) => dockerPsLineSchema.parse(JSON.parse(line))).map((container) => ({
|
|
1000
|
+
id: container.ID,
|
|
1001
|
+
name: container.Names,
|
|
1002
|
+
image: container.Image,
|
|
1003
|
+
labels: parseLabels(container.Labels)
|
|
1004
|
+
}));
|
|
1005
|
+
} catch (cause) {
|
|
1006
|
+
throw invalidDockerResponse(cause);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async inspect(container) {
|
|
1010
|
+
const result = await this.runner.run({
|
|
1011
|
+
command: "docker",
|
|
1012
|
+
args: ["inspect", container],
|
|
1013
|
+
timeoutMs: DOCKER_TIMEOUT_MS
|
|
1014
|
+
});
|
|
1015
|
+
if (result.exitCode !== 0) {
|
|
1016
|
+
if (/no such (?:object|container)/iu.test(result.stderr)) {
|
|
1017
|
+
throw new CliError("RESOURCE_NOT_FOUND", "Docker container was not found", {
|
|
1018
|
+
exitCode: exitCodes.resourceNotFound,
|
|
1019
|
+
hint: "Check the container name or omit `--container` to auto-discover SAG."
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
throw dependencyFailure("Unable to inspect the Docker container");
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
const inspection = dockerInspectSchema.parse(JSON.parse(result.stdout))[0];
|
|
1026
|
+
return {
|
|
1027
|
+
id: inspection.Id,
|
|
1028
|
+
name: inspection.Name.replace(/^\/+/u, ""),
|
|
1029
|
+
running: inspection.State.Running,
|
|
1030
|
+
health: inspection.State.Health?.Status ?? "none",
|
|
1031
|
+
labels: inspection.Config.Labels ?? {}
|
|
1032
|
+
};
|
|
1033
|
+
} catch (cause) {
|
|
1034
|
+
throw invalidDockerResponse(cause);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
async canImportMcp(container) {
|
|
1038
|
+
const result = await this.runner.run({
|
|
1039
|
+
command: "docker",
|
|
1040
|
+
args: ["exec", container, "python", "-c", "import sag_api.mcp.server"],
|
|
1041
|
+
timeoutMs: DOCKER_TIMEOUT_MS
|
|
1042
|
+
});
|
|
1043
|
+
return result.exitCode === 0;
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
// src/mcp/verifier.ts
|
|
1048
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
1049
|
+
import {
|
|
1050
|
+
getDefaultEnvironment,
|
|
1051
|
+
StdioClientTransport
|
|
1052
|
+
} from "@modelcontextprotocol/client/stdio";
|
|
1053
|
+
|
|
1054
|
+
// src/mcp/failure.ts
|
|
1055
|
+
function localMcpFailure(stage, message, options = {}) {
|
|
1056
|
+
const failureCause = {
|
|
1057
|
+
stage,
|
|
1058
|
+
...options.cause ? { cause: options.cause } : {}
|
|
1059
|
+
};
|
|
1060
|
+
return new CliError("MCP_FAILED", message, {
|
|
1061
|
+
exitCode: exitCodes.mcpFailed,
|
|
1062
|
+
cause: failureCause,
|
|
1063
|
+
hint: options.hint ?? "Check the SAG API container logs and retry `sag mcp test`."
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
function localMcpFailureStage(error) {
|
|
1067
|
+
if (error.code !== "MCP_FAILED" || typeof error.cause !== "object") {
|
|
1068
|
+
return void 0;
|
|
1069
|
+
}
|
|
1070
|
+
const stage = error.cause?.stage;
|
|
1071
|
+
return stage === "entrypoint" || stage === "protocol" || stage === "tools" || stage === "knowledge" ? stage : void 0;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/mcp/verifier.ts
|
|
1075
|
+
var REQUIRED_SAG_TOOLS = [
|
|
1076
|
+
"list_sources",
|
|
1077
|
+
"list_documents",
|
|
1078
|
+
"outline",
|
|
1079
|
+
"search",
|
|
1080
|
+
"grep",
|
|
1081
|
+
"get_chunk",
|
|
1082
|
+
"read",
|
|
1083
|
+
"get_entity"
|
|
1084
|
+
];
|
|
1085
|
+
var McpVerifier = class {
|
|
1086
|
+
constructor(cliVersion) {
|
|
1087
|
+
this.cliVersion = cliVersion;
|
|
1088
|
+
}
|
|
1089
|
+
cliVersion;
|
|
1090
|
+
async verify(spec, options) {
|
|
1091
|
+
if (spec.transport !== "stdio") {
|
|
1092
|
+
throw new CliError(
|
|
1093
|
+
"INVALID_ARGUMENT",
|
|
1094
|
+
"HTTP MCP is not supported in SAG CLI v0.2",
|
|
1095
|
+
{
|
|
1096
|
+
exitCode: exitCodes.invalidArgument,
|
|
1097
|
+
hint: "Use a local Docker stdio connection."
|
|
1098
|
+
}
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
const client = new Client(
|
|
1102
|
+
{
|
|
1103
|
+
name: "@zleap-ai/sag-cli",
|
|
1104
|
+
version: this.cliVersion
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
versionNegotiation: {
|
|
1108
|
+
mode: "auto",
|
|
1109
|
+
probe: {
|
|
1110
|
+
timeoutMs: Math.min(options.timeoutMs, 250),
|
|
1111
|
+
maxRetries: 0
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
);
|
|
1116
|
+
const transport = new StdioClientTransport({
|
|
1117
|
+
command: spec.command,
|
|
1118
|
+
args: spec.args,
|
|
1119
|
+
env: {
|
|
1120
|
+
...getDefaultEnvironment(),
|
|
1121
|
+
...spec.env
|
|
1122
|
+
},
|
|
1123
|
+
stderr: "pipe"
|
|
1124
|
+
});
|
|
1125
|
+
transport.stderr?.on("data", () => void 0);
|
|
1126
|
+
try {
|
|
1127
|
+
await client.connect(transport, { timeout: options.timeoutMs });
|
|
1128
|
+
const era = client.getProtocolEra();
|
|
1129
|
+
const protocolVersion = client.getNegotiatedProtocolVersion();
|
|
1130
|
+
if (!era || !protocolVersion) {
|
|
1131
|
+
throw localMcpFailure(
|
|
1132
|
+
"protocol",
|
|
1133
|
+
"MCP protocol negotiation returned no version"
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
const listed = await client.listTools(void 0, {
|
|
1137
|
+
timeout: options.timeoutMs,
|
|
1138
|
+
cacheMode: "refresh"
|
|
1139
|
+
}).catch((cause) => {
|
|
1140
|
+
throw localMcpFailure("tools", "Unable to list SAG MCP tools", {
|
|
1141
|
+
cause
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
const tools = listed.tools.map((tool) => tool.name);
|
|
1145
|
+
const missingTools = REQUIRED_SAG_TOOLS.filter(
|
|
1146
|
+
(required) => !tools.includes(required)
|
|
1147
|
+
);
|
|
1148
|
+
if (missingTools.length) {
|
|
1149
|
+
throw localMcpFailure(
|
|
1150
|
+
"tools",
|
|
1151
|
+
`SAG MCP is missing required tools: ${missingTools.join(", ")}`
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
const listSources2 = await client.callTool(
|
|
1155
|
+
{
|
|
1156
|
+
name: "list_sources",
|
|
1157
|
+
arguments: {}
|
|
1158
|
+
},
|
|
1159
|
+
{ timeout: options.timeoutMs }
|
|
1160
|
+
).catch((cause) => {
|
|
1161
|
+
throw localMcpFailure("knowledge", "Unable to call SAG MCP list_sources", {
|
|
1162
|
+
cause
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
if (listSources2.isError) {
|
|
1166
|
+
throw localMcpFailure("knowledge", "SAG MCP list_sources returned an error");
|
|
1167
|
+
}
|
|
1168
|
+
const server = client.getServerVersion();
|
|
1169
|
+
return {
|
|
1170
|
+
transport: "stdio",
|
|
1171
|
+
era,
|
|
1172
|
+
protocolVersion,
|
|
1173
|
+
...server ? { server } : {},
|
|
1174
|
+
tools,
|
|
1175
|
+
missingTools,
|
|
1176
|
+
listSourcesCallable: true
|
|
1177
|
+
};
|
|
1178
|
+
} catch (cause) {
|
|
1179
|
+
if (cause instanceof CliError) {
|
|
1180
|
+
throw cause;
|
|
1181
|
+
}
|
|
1182
|
+
throw localMcpFailure("protocol", "Unable to verify SAG MCP", { cause });
|
|
1183
|
+
} finally {
|
|
1184
|
+
await client.close().catch(() => void 0);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
// src/process/runner.ts
|
|
1190
|
+
import { spawn } from "child_process";
|
|
1191
|
+
var NodeProcessRunner = class {
|
|
1192
|
+
async run(input) {
|
|
1193
|
+
return new Promise((resolve, reject) => {
|
|
1194
|
+
const child = spawn(input.command, input.args, {
|
|
1195
|
+
shell: false,
|
|
1196
|
+
env: input.env ?? process.env,
|
|
1197
|
+
stdio: [input.stdin === "pipe" ? "pipe" : "ignore", "pipe", "pipe"]
|
|
1198
|
+
});
|
|
1199
|
+
const childStdout = child.stdout;
|
|
1200
|
+
const childStderr = child.stderr;
|
|
1201
|
+
let stdout = "";
|
|
1202
|
+
let stderr = "";
|
|
1203
|
+
let timedOut = false;
|
|
1204
|
+
let forceKill;
|
|
1205
|
+
const timeout = setTimeout(() => {
|
|
1206
|
+
timedOut = true;
|
|
1207
|
+
child.kill("SIGTERM");
|
|
1208
|
+
forceKill = setTimeout(() => {
|
|
1209
|
+
child.kill("SIGKILL");
|
|
1210
|
+
}, 250);
|
|
1211
|
+
}, input.timeoutMs);
|
|
1212
|
+
childStdout?.setEncoding("utf8");
|
|
1213
|
+
childStderr?.setEncoding("utf8");
|
|
1214
|
+
childStdout?.on("data", (chunk) => {
|
|
1215
|
+
stdout += chunk;
|
|
1216
|
+
});
|
|
1217
|
+
childStderr?.on("data", (chunk) => {
|
|
1218
|
+
stderr += chunk;
|
|
1219
|
+
});
|
|
1220
|
+
child.on("error", (cause) => {
|
|
1221
|
+
clearTimeout(timeout);
|
|
1222
|
+
if (forceKill) clearTimeout(forceKill);
|
|
1223
|
+
reject(
|
|
1224
|
+
new CliError("DEPENDENCY_MISSING", `Unable to start ${input.command}`, {
|
|
1225
|
+
exitCode: exitCodes.dependencyMissing,
|
|
1226
|
+
cause,
|
|
1227
|
+
hint: `Install ${input.command} and ensure it is available on PATH.`
|
|
1228
|
+
})
|
|
1229
|
+
);
|
|
1230
|
+
});
|
|
1231
|
+
child.on("close", (exitCode) => {
|
|
1232
|
+
clearTimeout(timeout);
|
|
1233
|
+
if (forceKill) clearTimeout(forceKill);
|
|
1234
|
+
if (timedOut) {
|
|
1235
|
+
reject(
|
|
1236
|
+
new CliError("DEPENDENCY_MISSING", `${input.command} timed out`, {
|
|
1237
|
+
exitCode: exitCodes.dependencyMissing,
|
|
1238
|
+
hint: `Check that ${input.command} is responsive and try again.`
|
|
1239
|
+
})
|
|
1240
|
+
);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
resolve({
|
|
1244
|
+
exitCode: exitCode ?? 1,
|
|
1245
|
+
stdout,
|
|
1246
|
+
stderr
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
// src/program.ts
|
|
1254
|
+
import { Command, CommanderError, Option } from "commander";
|
|
1255
|
+
|
|
1256
|
+
// src/api/schemas.ts
|
|
1257
|
+
import { z as z6 } from "zod";
|
|
1258
|
+
var dateTime = z6.string().min(1);
|
|
1259
|
+
var rootSchema = z6.object({
|
|
1260
|
+
name: z6.string().min(1),
|
|
1261
|
+
version: z6.string().min(1),
|
|
1262
|
+
docs: z6.string()
|
|
1263
|
+
}).passthrough();
|
|
1264
|
+
var readySchema = z6.object({
|
|
1265
|
+
status: z6.string(),
|
|
1266
|
+
db: z6.boolean()
|
|
1267
|
+
}).passthrough();
|
|
1268
|
+
var capabilitiesSchema = z6.record(z6.string(), z6.unknown());
|
|
1269
|
+
var userSchema = z6.object({
|
|
1270
|
+
id: z6.string().min(1),
|
|
1271
|
+
email: z6.string(),
|
|
1272
|
+
name: z6.string(),
|
|
1273
|
+
created_at: dateTime.optional()
|
|
1274
|
+
}).passthrough();
|
|
1275
|
+
var sourceSchema = z6.object({
|
|
1276
|
+
id: z6.string().min(1),
|
|
1277
|
+
name: z6.string(),
|
|
1278
|
+
description: z6.string(),
|
|
1279
|
+
source_type: z6.enum(["document", "web", "message", "audio"]),
|
|
1280
|
+
connector_kind: z6.string(),
|
|
1281
|
+
status: z6.enum(["active", "paused", "error"]),
|
|
1282
|
+
document_count: z6.number().int().nonnegative(),
|
|
1283
|
+
chunk_count: z6.number().int().nonnegative(),
|
|
1284
|
+
event_count: z6.number().int().nonnegative(),
|
|
1285
|
+
created_at: dateTime,
|
|
1286
|
+
updated_at: dateTime
|
|
1287
|
+
}).passthrough();
|
|
1288
|
+
var documentStatusSchema = z6.enum([
|
|
1289
|
+
"pending",
|
|
1290
|
+
"loading",
|
|
1291
|
+
"extracting",
|
|
1292
|
+
"paused",
|
|
1293
|
+
"ready",
|
|
1294
|
+
"failed"
|
|
1295
|
+
]);
|
|
1296
|
+
var documentSchema = z6.object({
|
|
1297
|
+
id: z6.string().min(1),
|
|
1298
|
+
source_id: z6.string().min(1),
|
|
1299
|
+
filename: z6.string(),
|
|
1300
|
+
content_type: z6.string(),
|
|
1301
|
+
size_bytes: z6.number().int().nonnegative(),
|
|
1302
|
+
status: documentStatusSchema,
|
|
1303
|
+
chunk_count: z6.number().int().nonnegative(),
|
|
1304
|
+
event_count: z6.number().int().nonnegative(),
|
|
1305
|
+
progress: z6.number().int().min(0).max(100),
|
|
1306
|
+
token_usage: z6.number().int().nonnegative(),
|
|
1307
|
+
error: z6.string().nullable(),
|
|
1308
|
+
created_at: dateTime,
|
|
1309
|
+
updated_at: dateTime
|
|
1310
|
+
}).passthrough();
|
|
1311
|
+
var searchSectionSchema = z6.object({
|
|
1312
|
+
chunk_id: z6.string().nullable(),
|
|
1313
|
+
heading: z6.string(),
|
|
1314
|
+
content: z6.string(),
|
|
1315
|
+
score: z6.number(),
|
|
1316
|
+
rank: z6.number().int(),
|
|
1317
|
+
source_id: z6.string().nullable(),
|
|
1318
|
+
source_name: z6.string().nullable().optional()
|
|
1319
|
+
}).passthrough();
|
|
1320
|
+
var searchEventSchema = z6.object({
|
|
1321
|
+
id: z6.string(),
|
|
1322
|
+
title: z6.string(),
|
|
1323
|
+
summary: z6.string(),
|
|
1324
|
+
rank: z6.number().int(),
|
|
1325
|
+
score: z6.number()
|
|
1326
|
+
}).passthrough();
|
|
1327
|
+
var sourceHitSchema = z6.object({
|
|
1328
|
+
source_id: z6.string(),
|
|
1329
|
+
source_name: z6.string().nullable().optional(),
|
|
1330
|
+
event_hits: z6.number().int(),
|
|
1331
|
+
max_score: z6.number(),
|
|
1332
|
+
latest_event_time: z6.string().nullable().optional()
|
|
1333
|
+
}).passthrough();
|
|
1334
|
+
var searchResponseSchema = z6.object({
|
|
1335
|
+
query: z6.string(),
|
|
1336
|
+
sections: z6.array(searchSectionSchema),
|
|
1337
|
+
events: z6.array(searchEventSchema),
|
|
1338
|
+
entities: z6.array(z6.record(z6.string(), z6.unknown())),
|
|
1339
|
+
relations: z6.array(z6.record(z6.string(), z6.unknown())),
|
|
1340
|
+
source_hits: z6.array(sourceHitSchema),
|
|
1341
|
+
summary: z6.string(),
|
|
1342
|
+
exploration_id: z6.string().nullable(),
|
|
1343
|
+
stats: z6.record(z6.string(), z6.unknown())
|
|
1344
|
+
}).passthrough();
|
|
1345
|
+
var mcpDescriptorSchema = z6.object({
|
|
1346
|
+
name: z6.string(),
|
|
1347
|
+
scope: z6.string(),
|
|
1348
|
+
source_count: z6.number().int().nonnegative(),
|
|
1349
|
+
tools: z6.array(z6.string()),
|
|
1350
|
+
http: z6.object({
|
|
1351
|
+
transport: z6.string(),
|
|
1352
|
+
url: z6.string().url(),
|
|
1353
|
+
headers: z6.record(z6.string(), z6.string())
|
|
1354
|
+
}).passthrough()
|
|
1355
|
+
}).passthrough();
|
|
1356
|
+
|
|
1357
|
+
// src/api/client.ts
|
|
1358
|
+
var SagClient = class {
|
|
1359
|
+
#origin;
|
|
1360
|
+
#token;
|
|
1361
|
+
#timeoutMs;
|
|
1362
|
+
#locale;
|
|
1363
|
+
#fetch;
|
|
1364
|
+
constructor(options) {
|
|
1365
|
+
this.#origin = normalizeOrigin(options.origin);
|
|
1366
|
+
this.#token = options.token;
|
|
1367
|
+
this.#timeoutMs = options.timeoutMs ?? 1e4;
|
|
1368
|
+
this.#locale = options.locale ?? "zh-CN";
|
|
1369
|
+
this.#fetch = options.fetchImplementation ?? fetch;
|
|
1370
|
+
}
|
|
1371
|
+
root() {
|
|
1372
|
+
return this.#request("/", rootSchema, { authenticated: false });
|
|
1373
|
+
}
|
|
1374
|
+
ready() {
|
|
1375
|
+
return this.#request("/api/v1/system/ready", readySchema, {
|
|
1376
|
+
authenticated: false
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
capabilities() {
|
|
1380
|
+
return this.#request("/api/v1/system/capabilities", capabilitiesSchema, {
|
|
1381
|
+
authenticated: false
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
me() {
|
|
1385
|
+
return this.#request("/api/v1/auth/me", userSchema);
|
|
1386
|
+
}
|
|
1387
|
+
mcpDescriptor() {
|
|
1388
|
+
return this.#request("/api/v1/system/mcp", mcpDescriptorSchema);
|
|
1389
|
+
}
|
|
1390
|
+
listSources() {
|
|
1391
|
+
return this.#request("/api/v1/sources", sourceSchema.array());
|
|
1392
|
+
}
|
|
1393
|
+
getSource(sourceId) {
|
|
1394
|
+
return this.#request(
|
|
1395
|
+
`/api/v1/sources/${encodeURIComponent(sourceId)}`,
|
|
1396
|
+
sourceSchema
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
listDocuments(sourceId) {
|
|
1400
|
+
return this.#request(
|
|
1401
|
+
`/api/v1/sources/${encodeURIComponent(sourceId)}/documents`,
|
|
1402
|
+
documentSchema.array()
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
getDocument(sourceId, documentId) {
|
|
1406
|
+
return this.#request(
|
|
1407
|
+
`/api/v1/sources/${encodeURIComponent(sourceId)}/documents/${encodeURIComponent(documentId)}`,
|
|
1408
|
+
documentSchema
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
search(input) {
|
|
1412
|
+
return this.#request("/api/v1/search", searchResponseSchema, {
|
|
1413
|
+
method: "POST",
|
|
1414
|
+
body: input
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
async #request(path5, schema, options = {}) {
|
|
1418
|
+
const authenticated = options.authenticated ?? true;
|
|
1419
|
+
if (authenticated && !this.#token) {
|
|
1420
|
+
throw new CliError("AUTH_REQUIRED", "No SAG token is configured", {
|
|
1421
|
+
exitCode: exitCodes.authRequired,
|
|
1422
|
+
hint: "Run `sag auth login` or set SAG_TOKEN."
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
const headers = new Headers({
|
|
1426
|
+
Accept: "application/json",
|
|
1427
|
+
"Accept-Language": this.#locale
|
|
1428
|
+
});
|
|
1429
|
+
if (authenticated && this.#token) {
|
|
1430
|
+
headers.set("Authorization", `Bearer ${this.#token}`);
|
|
1431
|
+
}
|
|
1432
|
+
if (options.body !== void 0) {
|
|
1433
|
+
headers.set("Content-Type", "application/json");
|
|
1434
|
+
}
|
|
1435
|
+
let response;
|
|
1436
|
+
try {
|
|
1437
|
+
response = await this.#fetch(new URL(path5, `${this.#origin}/`), {
|
|
1438
|
+
method: options.method ?? "GET",
|
|
1439
|
+
headers,
|
|
1440
|
+
...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {},
|
|
1441
|
+
signal: AbortSignal.timeout(this.#timeoutMs)
|
|
1442
|
+
});
|
|
1443
|
+
} catch (cause) {
|
|
1444
|
+
throw new CliError("NETWORK_UNREACHABLE", `Cannot reach SAG at ${this.#origin}`, {
|
|
1445
|
+
exitCode: exitCodes.networkUnreachable,
|
|
1446
|
+
cause,
|
|
1447
|
+
hint: "Check that SAG is running and the URL is reachable."
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
const payload = await this.#readPayload(response);
|
|
1451
|
+
if (!response.ok) {
|
|
1452
|
+
throw this.#httpError(response.status, payload);
|
|
1453
|
+
}
|
|
1454
|
+
const parsed = schema.safeParse(payload);
|
|
1455
|
+
if (!parsed.success) {
|
|
1456
|
+
throw new CliError("INVALID_RESPONSE", "SAG returned an incompatible response", {
|
|
1457
|
+
exitCode: exitCodes.internalError,
|
|
1458
|
+
cause: parsed.error,
|
|
1459
|
+
hint: "Check the SAG/CLI compatibility matrix."
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
return parsed.data;
|
|
1463
|
+
}
|
|
1464
|
+
async #readPayload(response) {
|
|
1465
|
+
try {
|
|
1466
|
+
return await response.json();
|
|
1467
|
+
} catch (cause) {
|
|
1468
|
+
throw new CliError("INVALID_RESPONSE", "SAG returned invalid JSON", {
|
|
1469
|
+
exitCode: exitCodes.internalError,
|
|
1470
|
+
cause
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
#httpError(status2, payload) {
|
|
1475
|
+
const serverMessage = payload && typeof payload === "object" && "error" in payload && payload.error && typeof payload.error === "object" && "message" in payload.error && typeof payload.error.message === "string" ? payload.error.message : `SAG request failed with HTTP ${status2}`;
|
|
1476
|
+
if (status2 === 401) {
|
|
1477
|
+
return new CliError("AUTH_REQUIRED", serverMessage, {
|
|
1478
|
+
exitCode: exitCodes.authRequired,
|
|
1479
|
+
hint: "Run `sag auth login` to refresh the token."
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
if (status2 === 403) {
|
|
1483
|
+
return new CliError("PERMISSION_DENIED", serverMessage, {
|
|
1484
|
+
exitCode: exitCodes.permissionDenied
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
if (status2 === 404) {
|
|
1488
|
+
return new CliError("RESOURCE_NOT_FOUND", serverMessage, {
|
|
1489
|
+
exitCode: exitCodes.resourceNotFound
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
if (status2 === 409) {
|
|
1493
|
+
return new CliError("DOCUMENT_NOT_READY", serverMessage, {
|
|
1494
|
+
exitCode: exitCodes.documentNotReady
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
if (status2 >= 500) {
|
|
1498
|
+
return new CliError("SERVICE_NOT_READY", serverMessage, {
|
|
1499
|
+
exitCode: exitCodes.serviceNotReady
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
return new CliError("INVALID_RESPONSE", serverMessage, {
|
|
1503
|
+
exitCode: exitCodes.internalError
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
// src/agents/orchestrator.ts
|
|
1509
|
+
var SERVER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u;
|
|
1510
|
+
function validateServerName(serverName) {
|
|
1511
|
+
if (!SERVER_NAME_PATTERN.test(serverName)) {
|
|
1512
|
+
throw new CliError("INVALID_ARGUMENT", "Invalid MCP server name", {
|
|
1513
|
+
exitCode: exitCodes.invalidArgument,
|
|
1514
|
+
hint: "Use 1-64 letters, numbers, dots, underscores, or dashes."
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
function conflict(message) {
|
|
1519
|
+
return new CliError("CONFIG_CONFLICT", message, {
|
|
1520
|
+
exitCode: exitCodes.configConflict,
|
|
1521
|
+
hint: "Choose another `--name` or restore the SAG CLI managed entry."
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
function hostFailure3(message, cause, hint) {
|
|
1525
|
+
return new CliError("HOST_CONFIG_FAILED", message, {
|
|
1526
|
+
exitCode: exitCodes.hostConfigFailed,
|
|
1527
|
+
...cause ? { cause } : {},
|
|
1528
|
+
hint: hint ?? "Inspect the Agent MCP configuration and run the command again."
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
function determinePlan(input) {
|
|
1532
|
+
let action;
|
|
1533
|
+
if (!input.hostFingerprint || !input.hostSpecFingerprint) {
|
|
1534
|
+
if (input.managed) {
|
|
1535
|
+
throw conflict("Managed state exists but the Agent MCP entry is missing");
|
|
1536
|
+
}
|
|
1537
|
+
action = "create";
|
|
1538
|
+
} else if (!input.managed) {
|
|
1539
|
+
throw conflict("The Agent MCP name is already owned by the user");
|
|
1540
|
+
} else if (input.managed.fingerprint !== input.hostFingerprint) {
|
|
1541
|
+
throw conflict("The managed Agent MCP entry was changed outside SAG CLI");
|
|
1542
|
+
} else if (input.hostSpecFingerprint === input.desiredFingerprint) {
|
|
1543
|
+
action = "unchanged";
|
|
1544
|
+
} else {
|
|
1545
|
+
action = "update";
|
|
1546
|
+
}
|
|
1547
|
+
return {
|
|
1548
|
+
action,
|
|
1549
|
+
agent: input.agent,
|
|
1550
|
+
serverName: input.serverName,
|
|
1551
|
+
scope: input.scope,
|
|
1552
|
+
provider: "docker-stdio",
|
|
1553
|
+
target: input.target.name,
|
|
1554
|
+
sourceId: input.sourceId ?? null,
|
|
1555
|
+
...input.hostSpecFingerprint ? { currentFingerprint: input.hostSpecFingerprint } : {},
|
|
1556
|
+
desiredFingerprint: input.desiredFingerprint
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
function managedConnection(input, resolved, fingerprint, existing) {
|
|
1560
|
+
return {
|
|
1561
|
+
agent: input.agent,
|
|
1562
|
+
provider: "docker-stdio",
|
|
1563
|
+
profile: input.profile,
|
|
1564
|
+
serverName: input.serverName,
|
|
1565
|
+
docker: {
|
|
1566
|
+
...resolved.target.composeProject ? { composeProject: resolved.target.composeProject } : {},
|
|
1567
|
+
...resolved.target.composeService ? { composeService: resolved.target.composeService } : {},
|
|
1568
|
+
containerName: resolved.target.name
|
|
1569
|
+
},
|
|
1570
|
+
sourceId: input.sourceId ?? null,
|
|
1571
|
+
scope: input.scope,
|
|
1572
|
+
fingerprint,
|
|
1573
|
+
createdAt: existing?.createdAt ?? input.now().toISOString()
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
async function rollbackHost(input) {
|
|
1577
|
+
if (input.previousSpec) {
|
|
1578
|
+
try {
|
|
1579
|
+
await input.adapter.remove(input.serverName, input.scope);
|
|
1580
|
+
} catch {
|
|
1581
|
+
}
|
|
1582
|
+
await input.adapter.add(input.serverName, input.scope, input.previousSpec);
|
|
1583
|
+
const restored = await input.adapter.read(input.serverName, input.scope);
|
|
1584
|
+
if (!restored || restored.fingerprint !== (input.previousFingerprint ?? fingerprintConnection(input.previousSpec))) {
|
|
1585
|
+
throw new Error("Previous Agent MCP entry was not restored");
|
|
1586
|
+
}
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
await input.adapter.remove(input.serverName, input.scope);
|
|
1590
|
+
}
|
|
1591
|
+
async function connectAgent(input) {
|
|
1592
|
+
validateServerName(input.serverName);
|
|
1593
|
+
const resolved = await input.resolveConnection();
|
|
1594
|
+
await input.verifier.verify(resolved.spec, { timeoutMs: input.timeoutMs });
|
|
1595
|
+
await input.adapter.detect();
|
|
1596
|
+
const host = await input.adapter.read(input.serverName, input.scope);
|
|
1597
|
+
const key = connectionKey(input.agent, input.scope, input.serverName);
|
|
1598
|
+
const managed = await input.state.get(key);
|
|
1599
|
+
const desiredFingerprint = fingerprintConnection(resolved.spec);
|
|
1600
|
+
const plan = determinePlan({
|
|
1601
|
+
agent: input.agent,
|
|
1602
|
+
serverName: input.serverName,
|
|
1603
|
+
scope: input.scope,
|
|
1604
|
+
target: resolved.target,
|
|
1605
|
+
...input.sourceId ? { sourceId: input.sourceId } : {},
|
|
1606
|
+
desiredFingerprint,
|
|
1607
|
+
...host ? { hostFingerprint: host.fingerprint } : {},
|
|
1608
|
+
...host ? { hostSpecFingerprint: fingerprintConnection(host.spec) } : {},
|
|
1609
|
+
managed
|
|
1610
|
+
});
|
|
1611
|
+
if (plan.action === "unchanged") {
|
|
1612
|
+
return {
|
|
1613
|
+
agent: input.agent,
|
|
1614
|
+
serverName: input.serverName,
|
|
1615
|
+
scope: input.scope,
|
|
1616
|
+
action: "unchanged",
|
|
1617
|
+
changed: false,
|
|
1618
|
+
verified: true,
|
|
1619
|
+
dryRun: false,
|
|
1620
|
+
plan
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
if (input.dryRun) {
|
|
1624
|
+
return {
|
|
1625
|
+
agent: input.agent,
|
|
1626
|
+
serverName: input.serverName,
|
|
1627
|
+
scope: input.scope,
|
|
1628
|
+
action: plan.action,
|
|
1629
|
+
changed: false,
|
|
1630
|
+
verified: true,
|
|
1631
|
+
dryRun: true,
|
|
1632
|
+
plan
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
if (!input.yes && !await input.confirm(
|
|
1636
|
+
`${plan.action === "create" ? "Add" : "Update"} ${input.serverName} in ${input.agent}?`
|
|
1637
|
+
)) {
|
|
1638
|
+
return {
|
|
1639
|
+
agent: input.agent,
|
|
1640
|
+
serverName: input.serverName,
|
|
1641
|
+
scope: input.scope,
|
|
1642
|
+
action: "cancelled",
|
|
1643
|
+
changed: false,
|
|
1644
|
+
verified: true,
|
|
1645
|
+
dryRun: false,
|
|
1646
|
+
plan
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
try {
|
|
1650
|
+
if (plan.action === "update") {
|
|
1651
|
+
await input.adapter.remove(input.serverName, input.scope);
|
|
1652
|
+
}
|
|
1653
|
+
await input.adapter.add(input.serverName, input.scope, resolved.spec);
|
|
1654
|
+
const readBack = await input.adapter.read(input.serverName, input.scope);
|
|
1655
|
+
if (!readBack || fingerprintConnection(readBack.spec) !== desiredFingerprint) {
|
|
1656
|
+
throw hostFailure3("Agent MCP read-back did not match the requested connection");
|
|
1657
|
+
}
|
|
1658
|
+
await input.verifier.verify(readBack.spec, { timeoutMs: input.timeoutMs });
|
|
1659
|
+
await input.state.set(
|
|
1660
|
+
managedConnection(input, resolved, readBack.fingerprint, managed)
|
|
1661
|
+
);
|
|
1662
|
+
} catch (cause) {
|
|
1663
|
+
try {
|
|
1664
|
+
await rollbackHost({
|
|
1665
|
+
adapter: input.adapter,
|
|
1666
|
+
serverName: input.serverName,
|
|
1667
|
+
scope: input.scope,
|
|
1668
|
+
...host ? { previousSpec: host.spec } : {},
|
|
1669
|
+
...host ? { previousFingerprint: host.fingerprint } : {}
|
|
1670
|
+
});
|
|
1671
|
+
} catch {
|
|
1672
|
+
throw hostFailure3(
|
|
1673
|
+
"Agent MCP configuration failed and rollback also failed",
|
|
1674
|
+
cause,
|
|
1675
|
+
`Inspect the ${input.agent} MCP entry \`${input.serverName}\` before retrying.`
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
throw hostFailure3(
|
|
1679
|
+
"Agent MCP configuration failed; changes were rolled back",
|
|
1680
|
+
cause
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
return {
|
|
1684
|
+
agent: input.agent,
|
|
1685
|
+
serverName: input.serverName,
|
|
1686
|
+
scope: input.scope,
|
|
1687
|
+
action: plan.action,
|
|
1688
|
+
changed: true,
|
|
1689
|
+
verified: true,
|
|
1690
|
+
dryRun: false,
|
|
1691
|
+
plan
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
async function disconnectAgent(input) {
|
|
1695
|
+
validateServerName(input.serverName);
|
|
1696
|
+
await input.adapter.detect();
|
|
1697
|
+
const key = connectionKey(input.agent, input.scope, input.serverName);
|
|
1698
|
+
const managed = await input.state.get(key);
|
|
1699
|
+
if (!managed) {
|
|
1700
|
+
throw conflict("The Agent MCP entry is not managed by SAG CLI");
|
|
1701
|
+
}
|
|
1702
|
+
const host = await input.adapter.read(input.serverName, input.scope);
|
|
1703
|
+
if (!host) {
|
|
1704
|
+
throw conflict("Managed state exists but the Agent MCP entry is missing");
|
|
1705
|
+
}
|
|
1706
|
+
if (host.fingerprint !== managed.fingerprint) {
|
|
1707
|
+
throw conflict("The managed Agent MCP entry was changed outside SAG CLI");
|
|
1708
|
+
}
|
|
1709
|
+
const plan = {
|
|
1710
|
+
action: "unchanged",
|
|
1711
|
+
agent: input.agent,
|
|
1712
|
+
serverName: input.serverName,
|
|
1713
|
+
scope: input.scope,
|
|
1714
|
+
provider: "docker-stdio",
|
|
1715
|
+
target: managed.docker.containerName,
|
|
1716
|
+
sourceId: managed.sourceId,
|
|
1717
|
+
currentFingerprint: host.fingerprint,
|
|
1718
|
+
desiredFingerprint: host.fingerprint
|
|
1719
|
+
};
|
|
1720
|
+
if (!input.yes && !await input.confirm(`Remove ${input.serverName} from ${input.agent}?`)) {
|
|
1721
|
+
return {
|
|
1722
|
+
agent: input.agent,
|
|
1723
|
+
serverName: input.serverName,
|
|
1724
|
+
scope: input.scope,
|
|
1725
|
+
action: "cancelled",
|
|
1726
|
+
changed: false,
|
|
1727
|
+
verified: true,
|
|
1728
|
+
dryRun: false,
|
|
1729
|
+
plan
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
await input.adapter.remove(input.serverName, input.scope);
|
|
1733
|
+
try {
|
|
1734
|
+
await input.state.delete(key);
|
|
1735
|
+
} catch (cause) {
|
|
1736
|
+
try {
|
|
1737
|
+
await input.adapter.add(input.serverName, input.scope, host.spec);
|
|
1738
|
+
} catch {
|
|
1739
|
+
throw hostFailure3(
|
|
1740
|
+
"Managed state cleanup failed and the Agent entry could not be restored",
|
|
1741
|
+
cause
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
throw hostFailure3(
|
|
1745
|
+
"Managed state cleanup failed; the Agent entry was restored",
|
|
1746
|
+
cause
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
return {
|
|
1750
|
+
agent: input.agent,
|
|
1751
|
+
serverName: input.serverName,
|
|
1752
|
+
scope: input.scope,
|
|
1753
|
+
action: "disconnect",
|
|
1754
|
+
changed: true,
|
|
1755
|
+
verified: true,
|
|
1756
|
+
dryRun: false,
|
|
1757
|
+
plan
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// src/mcp/docker-stdio-provider.ts
|
|
1762
|
+
var SOURCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
|
|
1763
|
+
var DockerStdioProvider = class {
|
|
1764
|
+
id = "docker-stdio";
|
|
1765
|
+
async inspect(input) {
|
|
1766
|
+
return {
|
|
1767
|
+
provider: this.id,
|
|
1768
|
+
ready: true,
|
|
1769
|
+
target: input.target.name
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
async createSpec(input) {
|
|
1773
|
+
if (input.sourceId && !SOURCE_ID_PATTERN.test(input.sourceId)) {
|
|
1774
|
+
throw new CliError("INVALID_ARGUMENT", "Invalid SAG source id", {
|
|
1775
|
+
exitCode: exitCodes.invalidArgument,
|
|
1776
|
+
hint: "Use 1-128 letters, numbers, underscores, or dashes."
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
const sourceArguments = input.sourceId ? ["-e", `SAG_MCP_SOURCE_ID=${input.sourceId}`] : [];
|
|
1780
|
+
return {
|
|
1781
|
+
transport: "stdio",
|
|
1782
|
+
command: "docker",
|
|
1783
|
+
args: [
|
|
1784
|
+
"exec",
|
|
1785
|
+
"-i",
|
|
1786
|
+
...sourceArguments,
|
|
1787
|
+
input.target.name,
|
|
1788
|
+
"python",
|
|
1789
|
+
"-m",
|
|
1790
|
+
"sag_api.mcp.server"
|
|
1791
|
+
],
|
|
1792
|
+
env: {}
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
// src/docker/discovery.ts
|
|
1798
|
+
var CONTAINER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u;
|
|
1799
|
+
function validateContainer(value) {
|
|
1800
|
+
if (!CONTAINER_PATTERN.test(value)) {
|
|
1801
|
+
throw new CliError("INVALID_ARGUMENT", "Invalid Docker container name or id", {
|
|
1802
|
+
exitCode: exitCodes.invalidArgument,
|
|
1803
|
+
hint: "Use 1-128 letters, numbers, dots, underscores, or dashes."
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
function toTarget(inspection) {
|
|
1808
|
+
const composeProject = inspection.labels["com.docker.compose.project"] || void 0;
|
|
1809
|
+
const composeService = inspection.labels["com.docker.compose.service"] || void 0;
|
|
1810
|
+
return {
|
|
1811
|
+
id: inspection.id,
|
|
1812
|
+
name: inspection.name,
|
|
1813
|
+
...composeProject ? { composeProject } : {},
|
|
1814
|
+
...composeService ? { composeService } : {},
|
|
1815
|
+
health: inspection.health
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
async function validateTarget(docker, container) {
|
|
1819
|
+
const inspection = await docker.inspect(container);
|
|
1820
|
+
if (!inspection.running || inspection.health === "unhealthy" || inspection.health === "starting") {
|
|
1821
|
+
throw new CliError("SERVICE_NOT_READY", "SAG API container is not ready", {
|
|
1822
|
+
exitCode: exitCodes.serviceNotReady,
|
|
1823
|
+
hint: "Check the SAG API container health and logs."
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
if (!await docker.canImportMcp(inspection.name)) {
|
|
1827
|
+
throw localMcpFailure(
|
|
1828
|
+
"entrypoint",
|
|
1829
|
+
"SAG API container does not provide the stdio MCP entrypoint",
|
|
1830
|
+
{
|
|
1831
|
+
hint: "Use a compatible SAG image that includes sag_api.mcp.server."
|
|
1832
|
+
}
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
return toTarget(inspection);
|
|
1836
|
+
}
|
|
1837
|
+
async function discoverSagContainer(input) {
|
|
1838
|
+
await input.docker.version();
|
|
1839
|
+
if (input.container) {
|
|
1840
|
+
validateContainer(input.container);
|
|
1841
|
+
return validateTarget(input.docker, input.container);
|
|
1842
|
+
}
|
|
1843
|
+
const containers = await input.docker.listRunningContainers();
|
|
1844
|
+
const apiCandidates = containers.filter(
|
|
1845
|
+
(container) => container.labels["com.docker.compose.service"] === "api"
|
|
1846
|
+
);
|
|
1847
|
+
if (!apiCandidates.length) {
|
|
1848
|
+
throw new CliError("RESOURCE_NOT_FOUND", "No SAG API container was found", {
|
|
1849
|
+
exitCode: exitCodes.resourceNotFound,
|
|
1850
|
+
hint: "Start SAG Docker or pass `--container <name-or-id>`."
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
const targets = [];
|
|
1854
|
+
let firstFailure;
|
|
1855
|
+
let preferredFailure;
|
|
1856
|
+
for (const candidate of apiCandidates) {
|
|
1857
|
+
try {
|
|
1858
|
+
targets.push(await validateTarget(input.docker, candidate.id));
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
firstFailure ??= error;
|
|
1861
|
+
if (candidate.labels["com.docker.compose.project"] === "sag") {
|
|
1862
|
+
preferredFailure ??= error;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
if (!targets.length) {
|
|
1867
|
+
throw preferredFailure ?? firstFailure;
|
|
1868
|
+
}
|
|
1869
|
+
const preferredTargets = targets.filter((target) => target.composeProject === "sag");
|
|
1870
|
+
const rankedTargets = preferredTargets.length ? preferredTargets : targets;
|
|
1871
|
+
if (rankedTargets.length === 1) {
|
|
1872
|
+
return rankedTargets[0];
|
|
1873
|
+
}
|
|
1874
|
+
if (!input.interactive || !input.select) {
|
|
1875
|
+
throw new CliError("CONFIG_CONFLICT", "Multiple SAG API containers were found", {
|
|
1876
|
+
exitCode: exitCodes.configConflict,
|
|
1877
|
+
hint: "Pass `--container <name-or-id>` to select one."
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
const selectedId = await input.select(rankedTargets);
|
|
1881
|
+
const selected = rankedTargets.find(
|
|
1882
|
+
(target) => target.id === selectedId || target.name === selectedId
|
|
1883
|
+
);
|
|
1884
|
+
if (!selected) {
|
|
1885
|
+
throw new CliError("INVALID_ARGUMENT", "Invalid Docker container selection", {
|
|
1886
|
+
exitCode: exitCodes.invalidArgument
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
return selected;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// src/commands/agent.ts
|
|
1893
|
+
async function connectLocalAgent(runtime2, input) {
|
|
1894
|
+
return connectAgent({
|
|
1895
|
+
agent: input.agent,
|
|
1896
|
+
serverName: input.serverName,
|
|
1897
|
+
scope: "user",
|
|
1898
|
+
profile: input.profile,
|
|
1899
|
+
...input.sourceId ? { sourceId: input.sourceId } : {},
|
|
1900
|
+
timeoutMs: input.timeoutMs,
|
|
1901
|
+
dryRun: input.dryRun,
|
|
1902
|
+
yes: input.yes,
|
|
1903
|
+
resolveConnection: async () => {
|
|
1904
|
+
const target = await discoverSagContainer({
|
|
1905
|
+
docker: runtime2.docker,
|
|
1906
|
+
...input.container ? { container: input.container } : {},
|
|
1907
|
+
interactive: runtime2.interactive,
|
|
1908
|
+
select: runtime2.selectContainer
|
|
1909
|
+
});
|
|
1910
|
+
const spec = await new DockerStdioProvider().createSpec({
|
|
1911
|
+
target,
|
|
1912
|
+
...input.sourceId ? { sourceId: input.sourceId } : {}
|
|
1913
|
+
});
|
|
1914
|
+
return { target, spec };
|
|
1915
|
+
},
|
|
1916
|
+
verifier: runtime2.verifier,
|
|
1917
|
+
adapter: runtime2.adapters[input.agent],
|
|
1918
|
+
state: runtime2.state,
|
|
1919
|
+
confirm: input.confirm,
|
|
1920
|
+
now: input.now
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
async function agentStatus(runtime2, agent, serverName) {
|
|
1924
|
+
const adapter = runtime2.adapters[agent];
|
|
1925
|
+
let version;
|
|
1926
|
+
try {
|
|
1927
|
+
version = (await adapter.detect()).version;
|
|
1928
|
+
} catch {
|
|
1929
|
+
return {
|
|
1930
|
+
agent,
|
|
1931
|
+
installed: false,
|
|
1932
|
+
serverName,
|
|
1933
|
+
status: "unavailable",
|
|
1934
|
+
managed: false,
|
|
1935
|
+
verified: false
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
const key = connectionKey(agent, "user", serverName);
|
|
1939
|
+
const [managed, host] = await Promise.all([
|
|
1940
|
+
runtime2.state.get(key),
|
|
1941
|
+
adapter.read(serverName, "user")
|
|
1942
|
+
]);
|
|
1943
|
+
if (!host) {
|
|
1944
|
+
return {
|
|
1945
|
+
agent,
|
|
1946
|
+
installed: true,
|
|
1947
|
+
version,
|
|
1948
|
+
serverName,
|
|
1949
|
+
status: managed ? "drifted" : "disconnected",
|
|
1950
|
+
managed: Boolean(managed),
|
|
1951
|
+
verified: false
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
if (!managed) {
|
|
1955
|
+
return {
|
|
1956
|
+
agent,
|
|
1957
|
+
installed: true,
|
|
1958
|
+
version,
|
|
1959
|
+
serverName,
|
|
1960
|
+
status: "unmanaged",
|
|
1961
|
+
managed: false,
|
|
1962
|
+
verified: false
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
if (host.fingerprint !== managed.fingerprint) {
|
|
1966
|
+
return {
|
|
1967
|
+
agent,
|
|
1968
|
+
installed: true,
|
|
1969
|
+
version,
|
|
1970
|
+
serverName,
|
|
1971
|
+
status: "drifted",
|
|
1972
|
+
managed: true,
|
|
1973
|
+
verified: false
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
try {
|
|
1977
|
+
await runtime2.verifier.verify(host.spec, { timeoutMs: 15e3 });
|
|
1978
|
+
return {
|
|
1979
|
+
agent,
|
|
1980
|
+
installed: true,
|
|
1981
|
+
version,
|
|
1982
|
+
serverName,
|
|
1983
|
+
status: "connected",
|
|
1984
|
+
managed: true,
|
|
1985
|
+
verified: true
|
|
1986
|
+
};
|
|
1987
|
+
} catch {
|
|
1988
|
+
return {
|
|
1989
|
+
agent,
|
|
1990
|
+
installed: true,
|
|
1991
|
+
version,
|
|
1992
|
+
serverName,
|
|
1993
|
+
status: "unavailable",
|
|
1994
|
+
managed: true,
|
|
1995
|
+
verified: false
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
async function statusLocalAgents(runtime2, input) {
|
|
2000
|
+
const agents = input.agent ? [input.agent] : ["codex", "claude-code"];
|
|
2001
|
+
return Promise.all(
|
|
2002
|
+
agents.map((agent) => agentStatus(runtime2, agent, input.serverName))
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
async function listAgentHosts(runtime2) {
|
|
2006
|
+
return Promise.all(
|
|
2007
|
+
["codex", "claude-code"].map(async (agent) => {
|
|
2008
|
+
const managedConnections = (await runtime2.state.list({ agent })).length;
|
|
2009
|
+
try {
|
|
2010
|
+
const installation = await runtime2.adapters[agent].detect();
|
|
2011
|
+
return {
|
|
2012
|
+
agent,
|
|
2013
|
+
installed: true,
|
|
2014
|
+
version: installation.version,
|
|
2015
|
+
managedConnections
|
|
2016
|
+
};
|
|
2017
|
+
} catch {
|
|
2018
|
+
return { agent, installed: false, managedConnections };
|
|
2019
|
+
}
|
|
2020
|
+
})
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
async function disconnectLocalAgent(runtime2, input) {
|
|
2024
|
+
return disconnectAgent({
|
|
2025
|
+
agent: input.agent,
|
|
2026
|
+
serverName: input.serverName,
|
|
2027
|
+
scope: "user",
|
|
2028
|
+
adapter: runtime2.adapters[input.agent],
|
|
2029
|
+
state: runtime2.state,
|
|
2030
|
+
yes: input.yes,
|
|
2031
|
+
confirm: input.confirm
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// src/commands/auth.ts
|
|
2036
|
+
async function login(input) {
|
|
2037
|
+
const token = input.environmentToken?.trim() || await input.promptToken();
|
|
2038
|
+
if (!token.trim()) {
|
|
2039
|
+
throw new CliError("AUTH_REQUIRED", "A SAG token is required", {
|
|
2040
|
+
exitCode: exitCodes.authRequired,
|
|
2041
|
+
hint: "Paste a token when prompted or set SAG_TOKEN."
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
const user = await input.validate(token);
|
|
2045
|
+
await input.store.set(input.credentialRef, token);
|
|
2046
|
+
return {
|
|
2047
|
+
authenticated: true,
|
|
2048
|
+
credentialRef: input.credentialRef,
|
|
2049
|
+
credentialStore: input.store.kind,
|
|
2050
|
+
user
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
async function status(input) {
|
|
2054
|
+
const token = input.environmentToken?.trim() || await input.store.get(input.credentialRef);
|
|
2055
|
+
if (!token) {
|
|
2056
|
+
return {
|
|
2057
|
+
authenticated: false,
|
|
2058
|
+
credentialRef: input.credentialRef,
|
|
2059
|
+
credentialStore: input.store.kind
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
const user = await input.validate(token);
|
|
2063
|
+
return {
|
|
2064
|
+
authenticated: true,
|
|
2065
|
+
credentialRef: input.credentialRef,
|
|
2066
|
+
credentialStore: input.store.kind,
|
|
2067
|
+
user
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
async function logout(input) {
|
|
2071
|
+
await input.store.delete(input.credentialRef);
|
|
2072
|
+
return {
|
|
2073
|
+
authenticated: false,
|
|
2074
|
+
credentialRef: input.credentialRef
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// src/commands/document.ts
|
|
2079
|
+
function requiredIdentifier(value, label) {
|
|
2080
|
+
const normalized = value.trim();
|
|
2081
|
+
if (!normalized) {
|
|
2082
|
+
throw new CliError("INVALID_ARGUMENT", `${label} is required`, {
|
|
2083
|
+
exitCode: exitCodes.invalidArgument
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
return normalized;
|
|
2087
|
+
}
|
|
2088
|
+
function summarizeDocuments(documents) {
|
|
2089
|
+
const statuses = {
|
|
2090
|
+
pending: 0,
|
|
2091
|
+
loading: 0,
|
|
2092
|
+
extracting: 0,
|
|
2093
|
+
paused: 0,
|
|
2094
|
+
ready: 0,
|
|
2095
|
+
failed: 0
|
|
2096
|
+
};
|
|
2097
|
+
for (const document of documents) {
|
|
2098
|
+
statuses[document.status] += 1;
|
|
2099
|
+
}
|
|
2100
|
+
return {
|
|
2101
|
+
total: documents.length,
|
|
2102
|
+
statuses,
|
|
2103
|
+
searchable: documents.length > 0 && statuses.ready === documents.length
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
async function listDocuments(client, sourceId) {
|
|
2107
|
+
return client.listDocuments(requiredIdentifier(sourceId, "Source ID"));
|
|
2108
|
+
}
|
|
2109
|
+
async function getDocument(client, sourceId, documentId) {
|
|
2110
|
+
return client.getDocument(
|
|
2111
|
+
requiredIdentifier(sourceId, "Source ID"),
|
|
2112
|
+
requiredIdentifier(documentId, "Document ID")
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
async function documentStatus(client, sourceId, documentId) {
|
|
2116
|
+
const normalizedSourceId = requiredIdentifier(sourceId, "Source ID");
|
|
2117
|
+
if (documentId !== void 0) {
|
|
2118
|
+
const document = await getDocument(client, normalizedSourceId, documentId);
|
|
2119
|
+
return {
|
|
2120
|
+
mode: "document",
|
|
2121
|
+
document,
|
|
2122
|
+
searchable: document.status === "ready"
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
const summary = summarizeDocuments(await client.listDocuments(normalizedSourceId));
|
|
2126
|
+
return {
|
|
2127
|
+
mode: "source",
|
|
2128
|
+
sourceId: normalizedSourceId,
|
|
2129
|
+
total: summary.total,
|
|
2130
|
+
statuses: summary.statuses,
|
|
2131
|
+
searchable: summary.searchable
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// src/commands/mcp.ts
|
|
2136
|
+
async function testLocalMcp(runtime2, input) {
|
|
2137
|
+
const container = await discoverSagContainer({
|
|
2138
|
+
docker: runtime2.docker,
|
|
2139
|
+
...input.container ? { container: input.container } : {},
|
|
2140
|
+
interactive: runtime2.interactive,
|
|
2141
|
+
select: runtime2.selectContainer
|
|
2142
|
+
});
|
|
2143
|
+
const spec = await new DockerStdioProvider().createSpec({
|
|
2144
|
+
target: container,
|
|
2145
|
+
...input.sourceId ? { sourceId: input.sourceId } : {}
|
|
2146
|
+
});
|
|
2147
|
+
const verification = await runtime2.verifier.verify(spec, {
|
|
2148
|
+
timeoutMs: input.timeoutMs
|
|
2149
|
+
});
|
|
2150
|
+
return {
|
|
2151
|
+
provider: "docker-stdio",
|
|
2152
|
+
container,
|
|
2153
|
+
sourceId: input.sourceId ?? null,
|
|
2154
|
+
...verification
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
function skipped(name, detail) {
|
|
2158
|
+
return {
|
|
2159
|
+
name,
|
|
2160
|
+
status: "warn",
|
|
2161
|
+
code: "CHECK_SKIPPED",
|
|
2162
|
+
detail
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
async function localMcpDoctorChecks(runtime2, input) {
|
|
2166
|
+
try {
|
|
2167
|
+
const report = await testLocalMcp(runtime2, input);
|
|
2168
|
+
return [
|
|
2169
|
+
{
|
|
2170
|
+
name: "docker",
|
|
2171
|
+
status: "pass",
|
|
2172
|
+
code: "DOCKER_READY",
|
|
2173
|
+
detail: "Docker daemon is available"
|
|
2174
|
+
},
|
|
2175
|
+
{
|
|
2176
|
+
name: "sagContainer",
|
|
2177
|
+
status: "pass",
|
|
2178
|
+
code: "SAG_CONTAINER_READY",
|
|
2179
|
+
detail: `${report.container.name} is ${report.container.health}`
|
|
2180
|
+
},
|
|
2181
|
+
{
|
|
2182
|
+
name: "mcpEntrypoint",
|
|
2183
|
+
status: "pass",
|
|
2184
|
+
code: "MCP_ENTRYPOINT_READY",
|
|
2185
|
+
detail: "sag_api.mcp.server is importable"
|
|
2186
|
+
},
|
|
2187
|
+
{
|
|
2188
|
+
name: "mcpProtocol",
|
|
2189
|
+
status: "pass",
|
|
2190
|
+
code: "MCP_PROTOCOL_READY",
|
|
2191
|
+
detail: `${report.protocolVersion} ${report.era}`
|
|
2192
|
+
},
|
|
2193
|
+
{
|
|
2194
|
+
name: "mcpTools",
|
|
2195
|
+
status: "pass",
|
|
2196
|
+
code: "MCP_TOOLS_READY",
|
|
2197
|
+
detail: `${report.tools.length} tools`
|
|
2198
|
+
},
|
|
2199
|
+
{
|
|
2200
|
+
name: "mcpKnowledge",
|
|
2201
|
+
status: "pass",
|
|
2202
|
+
code: "MCP_KNOWLEDGE_READY",
|
|
2203
|
+
detail: "list_sources callable"
|
|
2204
|
+
}
|
|
2205
|
+
];
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
const cliError = toCliError(error);
|
|
2208
|
+
const mcpStage = localMcpFailureStage(cliError);
|
|
2209
|
+
const failedName = cliError.code === "DEPENDENCY_MISSING" ? "docker" : cliError.code === "RESOURCE_NOT_FOUND" || cliError.code === "SERVICE_NOT_READY" ? "sagContainer" : mcpStage === "entrypoint" ? "mcpEntrypoint" : mcpStage === "tools" ? "mcpTools" : mcpStage === "knowledge" ? "mcpKnowledge" : "mcpProtocol";
|
|
2210
|
+
const names = [
|
|
2211
|
+
"docker",
|
|
2212
|
+
"sagContainer",
|
|
2213
|
+
"mcpEntrypoint",
|
|
2214
|
+
"mcpProtocol",
|
|
2215
|
+
"mcpTools",
|
|
2216
|
+
"mcpKnowledge"
|
|
2217
|
+
];
|
|
2218
|
+
const failedIndex = names.indexOf(failedName);
|
|
2219
|
+
return names.map((name, index) => {
|
|
2220
|
+
if (index < failedIndex) {
|
|
2221
|
+
return {
|
|
2222
|
+
name,
|
|
2223
|
+
status: "pass",
|
|
2224
|
+
code: "CHECK_COMPLETED",
|
|
2225
|
+
detail: "Prerequisite check passed"
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
if (index === failedIndex) {
|
|
2229
|
+
return {
|
|
2230
|
+
name,
|
|
2231
|
+
status: "fail",
|
|
2232
|
+
code: cliError.code,
|
|
2233
|
+
detail: cliError.message,
|
|
2234
|
+
...cliError.hint ? { hint: cliError.hint } : {}
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
return skipped(name, `Skipped because ${failedName} failed`);
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// src/commands/search.ts
|
|
2243
|
+
function invalid(message) {
|
|
2244
|
+
throw new CliError("INVALID_ARGUMENT", message, {
|
|
2245
|
+
exitCode: exitCodes.invalidArgument
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
async function searchKnowledge(client, query, options = {}) {
|
|
2249
|
+
const normalizedQuery = query.trim();
|
|
2250
|
+
if (!normalizedQuery) {
|
|
2251
|
+
invalid("Query is required");
|
|
2252
|
+
}
|
|
2253
|
+
if (normalizedQuery.length > 4e3) {
|
|
2254
|
+
invalid("Query must not exceed 4000 characters");
|
|
2255
|
+
}
|
|
2256
|
+
if (options.topK !== void 0 && (!Number.isInteger(options.topK) || options.topK < 1 || options.topK > 50)) {
|
|
2257
|
+
invalid("top-k must be between 1 and 50");
|
|
2258
|
+
}
|
|
2259
|
+
if (options.strategy !== void 0 && !["vector", "multi"].includes(options.strategy)) {
|
|
2260
|
+
invalid("strategy must be vector or multi");
|
|
2261
|
+
}
|
|
2262
|
+
const sourceIds = [
|
|
2263
|
+
...new Set(
|
|
2264
|
+
(options.sourceIds ?? []).map((sourceId) => sourceId.trim()).filter(Boolean)
|
|
2265
|
+
)
|
|
2266
|
+
];
|
|
2267
|
+
return client.search({
|
|
2268
|
+
query: normalizedQuery,
|
|
2269
|
+
...sourceIds.length ? { source_ids: sourceIds } : {},
|
|
2270
|
+
...options.topK !== void 0 ? { top_k: options.topK } : {},
|
|
2271
|
+
...options.strategy !== void 0 ? { strategy: options.strategy } : {},
|
|
2272
|
+
save_exploration: false
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// src/commands/source.ts
|
|
2277
|
+
function requiredSourceId(sourceId) {
|
|
2278
|
+
const normalized = sourceId.trim();
|
|
2279
|
+
if (!normalized) {
|
|
2280
|
+
throw new CliError("INVALID_ARGUMENT", "Source ID is required", {
|
|
2281
|
+
exitCode: exitCodes.invalidArgument
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
return normalized;
|
|
2285
|
+
}
|
|
2286
|
+
function listSources(client) {
|
|
2287
|
+
return client.listSources();
|
|
2288
|
+
}
|
|
2289
|
+
async function getSource(client, sourceId) {
|
|
2290
|
+
return client.getSource(requiredSourceId(sourceId));
|
|
2291
|
+
}
|
|
2292
|
+
async function sourceStatus(client, sourceId) {
|
|
2293
|
+
if (sourceId === void 0) {
|
|
2294
|
+
const sources = await client.listSources();
|
|
2295
|
+
const documentSummaries = await Promise.all(
|
|
2296
|
+
sources.map(
|
|
2297
|
+
async (source2) => summarizeDocuments(await client.listDocuments(source2.id))
|
|
2298
|
+
)
|
|
2299
|
+
);
|
|
2300
|
+
return {
|
|
2301
|
+
total: sources.length,
|
|
2302
|
+
statuses: {
|
|
2303
|
+
active: sources.filter(({ status: status2 }) => status2 === "active").length,
|
|
2304
|
+
paused: sources.filter(({ status: status2 }) => status2 === "paused").length,
|
|
2305
|
+
error: sources.filter(({ status: status2 }) => status2 === "error").length
|
|
2306
|
+
},
|
|
2307
|
+
documents: {
|
|
2308
|
+
total: documentSummaries.reduce((total, summary2) => total + summary2.total, 0),
|
|
2309
|
+
ready: documentSummaries.reduce(
|
|
2310
|
+
(total, summary2) => total + summary2.statuses.ready,
|
|
2311
|
+
0
|
|
2312
|
+
),
|
|
2313
|
+
pending: documentSummaries.reduce(
|
|
2314
|
+
(total, summary2) => total + summary2.statuses.pending,
|
|
2315
|
+
0
|
|
2316
|
+
),
|
|
2317
|
+
loading: documentSummaries.reduce(
|
|
2318
|
+
(total, summary2) => total + summary2.statuses.loading,
|
|
2319
|
+
0
|
|
2320
|
+
),
|
|
2321
|
+
extracting: documentSummaries.reduce(
|
|
2322
|
+
(total, summary2) => total + summary2.statuses.extracting,
|
|
2323
|
+
0
|
|
2324
|
+
),
|
|
2325
|
+
paused: documentSummaries.reduce(
|
|
2326
|
+
(total, summary2) => total + summary2.statuses.paused,
|
|
2327
|
+
0
|
|
2328
|
+
),
|
|
2329
|
+
failed: documentSummaries.reduce(
|
|
2330
|
+
(total, summary2) => total + summary2.statuses.failed,
|
|
2331
|
+
0
|
|
2332
|
+
)
|
|
2333
|
+
},
|
|
2334
|
+
searchableSources: documentSummaries.filter(({ searchable }) => searchable).length
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
const normalizedSourceId = requiredSourceId(sourceId);
|
|
2338
|
+
const [source, documents] = await Promise.all([
|
|
2339
|
+
client.getSource(normalizedSourceId),
|
|
2340
|
+
client.listDocuments(normalizedSourceId)
|
|
2341
|
+
]);
|
|
2342
|
+
const summary = summarizeDocuments(documents);
|
|
2343
|
+
return {
|
|
2344
|
+
source,
|
|
2345
|
+
documents: {
|
|
2346
|
+
total: summary.total,
|
|
2347
|
+
ready: summary.statuses.ready,
|
|
2348
|
+
pending: summary.statuses.pending,
|
|
2349
|
+
loading: summary.statuses.loading,
|
|
2350
|
+
extracting: summary.statuses.extracting,
|
|
2351
|
+
paused: summary.statuses.paused,
|
|
2352
|
+
failed: summary.statuses.failed,
|
|
2353
|
+
searchable: summary.searchable
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// src/core/context.ts
|
|
2359
|
+
import { createHash as createHash2 } from "crypto";
|
|
2360
|
+
function directCredentialRef(url) {
|
|
2361
|
+
const fingerprint = createHash2("sha256").update(url).digest("hex").slice(0, 16);
|
|
2362
|
+
return `sag-cli/direct-${fingerprint}`;
|
|
2363
|
+
}
|
|
2364
|
+
async function createRuntimeContext(input) {
|
|
2365
|
+
const config = await input.configStore.load();
|
|
2366
|
+
const connection = resolveConnection(input.options, input.environment, config);
|
|
2367
|
+
const credentialRef = connection.credentialRef ?? directCredentialRef(connection.url);
|
|
2368
|
+
const token = connection.environmentToken ?? await input.credentialStore.get(credentialRef) ?? void 0;
|
|
2369
|
+
return {
|
|
2370
|
+
connection,
|
|
2371
|
+
credentialRef,
|
|
2372
|
+
...token ? { token } : {},
|
|
2373
|
+
client: input.createClient({
|
|
2374
|
+
origin: connection.url,
|
|
2375
|
+
...token ? { token } : {}
|
|
2376
|
+
})
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// src/core/result.ts
|
|
2381
|
+
var OUTPUT_SCHEMA = "sag.cli.v1";
|
|
2382
|
+
function success(data) {
|
|
2383
|
+
return {
|
|
2384
|
+
schema: OUTPUT_SCHEMA,
|
|
2385
|
+
ok: true,
|
|
2386
|
+
data
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
function failure(error) {
|
|
2390
|
+
return {
|
|
2391
|
+
schema: OUTPUT_SCHEMA,
|
|
2392
|
+
ok: false,
|
|
2393
|
+
error: error.toShape()
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// src/diagnostics/doctor.ts
|
|
2398
|
+
function emptyDocumentStatuses() {
|
|
2399
|
+
return {
|
|
2400
|
+
pending: 0,
|
|
2401
|
+
loading: 0,
|
|
2402
|
+
extracting: 0,
|
|
2403
|
+
paused: 0,
|
|
2404
|
+
ready: 0,
|
|
2405
|
+
failed: 0
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
function errorCheck(name, error, fallbackHint) {
|
|
2409
|
+
const cliError = toCliError(error);
|
|
2410
|
+
const hint = cliError.hint ?? fallbackHint;
|
|
2411
|
+
return {
|
|
2412
|
+
name,
|
|
2413
|
+
status: "fail",
|
|
2414
|
+
code: cliError.code,
|
|
2415
|
+
detail: cliError.message,
|
|
2416
|
+
...hint ? { hint } : {}
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
function skippedCheck(name, reason) {
|
|
2420
|
+
return {
|
|
2421
|
+
name,
|
|
2422
|
+
status: "warn",
|
|
2423
|
+
code: "CHECK_SKIPPED",
|
|
2424
|
+
detail: reason
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
function overallStatus(checks) {
|
|
2428
|
+
if (checks.some((check) => check.status === "fail")) {
|
|
2429
|
+
return "fail";
|
|
2430
|
+
}
|
|
2431
|
+
if (checks.some((check) => check.status === "warn")) {
|
|
2432
|
+
return "warn";
|
|
2433
|
+
}
|
|
2434
|
+
return "pass";
|
|
2435
|
+
}
|
|
2436
|
+
function sagVersionCheck(name, version) {
|
|
2437
|
+
const match = /^(\d+)\.(\d+)(?:\.|$)/u.exec(version);
|
|
2438
|
+
if (!match) {
|
|
2439
|
+
return {
|
|
2440
|
+
name: "service",
|
|
2441
|
+
status: "warn",
|
|
2442
|
+
code: "SAG_VERSION_UNVERIFIED",
|
|
2443
|
+
detail: `${name} ${version}`,
|
|
2444
|
+
hint: "This SAG version could not be checked against >=1.4.0 <2.0.0."
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
const major = Number(match[1]);
|
|
2448
|
+
const minor = Number(match[2]);
|
|
2449
|
+
if (major !== 1 || minor < 4) {
|
|
2450
|
+
return {
|
|
2451
|
+
name: "service",
|
|
2452
|
+
status: "fail",
|
|
2453
|
+
code: "SAG_VERSION_INCOMPATIBLE",
|
|
2454
|
+
detail: `${name} ${version} is outside the supported range >=1.4.0 <2.0.0`,
|
|
2455
|
+
hint: "Use a compatible SAG version or upgrade SAG CLI."
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
return {
|
|
2459
|
+
name: "service",
|
|
2460
|
+
status: "pass",
|
|
2461
|
+
code: "SAG_REACHABLE",
|
|
2462
|
+
detail: `${name} ${version}`
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
function documentDetail(total, statuses) {
|
|
2466
|
+
const displayOrder = [
|
|
2467
|
+
"ready",
|
|
2468
|
+
"pending",
|
|
2469
|
+
"loading",
|
|
2470
|
+
"extracting",
|
|
2471
|
+
"paused",
|
|
2472
|
+
"failed"
|
|
2473
|
+
];
|
|
2474
|
+
const parts = displayOrder.filter((status2) => statuses[status2] > 0).map((status2) => `${statuses[status2]} ${status2}`);
|
|
2475
|
+
return `${total} ${total === 1 ? "document" : "documents"}${parts.length ? `: ${parts.join(", ")}` : ""}`;
|
|
2476
|
+
}
|
|
2477
|
+
async function runDoctor(client, input, localMcpChecks) {
|
|
2478
|
+
const checks = [];
|
|
2479
|
+
let sagVersion;
|
|
2480
|
+
let authenticated = false;
|
|
2481
|
+
let sources;
|
|
2482
|
+
const documentStatuses = emptyDocumentStatuses();
|
|
2483
|
+
let documentCount = 0;
|
|
2484
|
+
let mcpTools = 0;
|
|
2485
|
+
try {
|
|
2486
|
+
const root = await client.root();
|
|
2487
|
+
sagVersion = root.version;
|
|
2488
|
+
checks.push(sagVersionCheck(root.name, root.version));
|
|
2489
|
+
} catch (error) {
|
|
2490
|
+
checks.push(errorCheck("service", error));
|
|
2491
|
+
}
|
|
2492
|
+
try {
|
|
2493
|
+
const ready = await client.ready();
|
|
2494
|
+
if (!ready.db || ready.status !== "ready") {
|
|
2495
|
+
checks.push({
|
|
2496
|
+
name: "readiness",
|
|
2497
|
+
status: "fail",
|
|
2498
|
+
code: "SERVICE_NOT_READY",
|
|
2499
|
+
detail: "SAG database is not ready",
|
|
2500
|
+
hint: "Check SAG API and database logs."
|
|
2501
|
+
});
|
|
2502
|
+
} else {
|
|
2503
|
+
checks.push({
|
|
2504
|
+
name: "readiness",
|
|
2505
|
+
status: "pass",
|
|
2506
|
+
code: "SAG_READY",
|
|
2507
|
+
detail: "Database is ready"
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
checks.push(errorCheck("readiness", error));
|
|
2512
|
+
}
|
|
2513
|
+
try {
|
|
2514
|
+
const user = await client.me();
|
|
2515
|
+
authenticated = true;
|
|
2516
|
+
checks.push({
|
|
2517
|
+
name: "authentication",
|
|
2518
|
+
status: "pass",
|
|
2519
|
+
code: "AUTH_VALID",
|
|
2520
|
+
detail: `${user.name} <${user.email}>`
|
|
2521
|
+
});
|
|
2522
|
+
} catch (error) {
|
|
2523
|
+
checks.push(
|
|
2524
|
+
errorCheck("authentication", error, "Run `sag auth login` or set SAG_TOKEN.")
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
if (!authenticated) {
|
|
2528
|
+
const reason = "Skipped because authentication failed";
|
|
2529
|
+
checks.push(skippedCheck("sources", reason));
|
|
2530
|
+
checks.push(skippedCheck("documents", reason));
|
|
2531
|
+
checks.push(skippedCheck("mcp", reason));
|
|
2532
|
+
} else {
|
|
2533
|
+
try {
|
|
2534
|
+
sources = await client.listSources();
|
|
2535
|
+
checks.push({
|
|
2536
|
+
name: "sources",
|
|
2537
|
+
status: sources.length ? "pass" : "warn",
|
|
2538
|
+
code: sources.length ? "SOURCES_AVAILABLE" : "NO_SOURCES",
|
|
2539
|
+
detail: `${sources.length} ${sources.length === 1 ? "source" : "sources"}`,
|
|
2540
|
+
...!sources.length ? { hint: "Create and populate a knowledge source in SAG." } : {}
|
|
2541
|
+
});
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
checks.push(errorCheck("sources", error));
|
|
2544
|
+
}
|
|
2545
|
+
if (!sources) {
|
|
2546
|
+
checks.push(
|
|
2547
|
+
skippedCheck("documents", "Skipped because sources could not be listed")
|
|
2548
|
+
);
|
|
2549
|
+
} else {
|
|
2550
|
+
try {
|
|
2551
|
+
const documents = (await Promise.all(sources.map((source) => client.listDocuments(source.id)))).flat();
|
|
2552
|
+
documentCount = documents.length;
|
|
2553
|
+
for (const document of documents) {
|
|
2554
|
+
documentStatuses[document.status] += 1;
|
|
2555
|
+
}
|
|
2556
|
+
const hasUnready = documentStatuses.pending + documentStatuses.loading + documentStatuses.extracting + documentStatuses.paused + documentStatuses.failed > 0;
|
|
2557
|
+
checks.push({
|
|
2558
|
+
name: "documents",
|
|
2559
|
+
status: hasUnready || !documents.length ? "warn" : "pass",
|
|
2560
|
+
code: !documents.length ? "NO_DOCUMENTS" : hasUnready ? "DOCUMENTS_PROCESSING" : "DOCUMENTS_READY",
|
|
2561
|
+
detail: documentDetail(documentCount, documentStatuses),
|
|
2562
|
+
...hasUnready ? {
|
|
2563
|
+
hint: `Run \`sag document status --source ${sources[0]?.id ?? "<source-id>"}\` for details.`
|
|
2564
|
+
} : {}
|
|
2565
|
+
});
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
checks.push(errorCheck("documents", error));
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
try {
|
|
2571
|
+
const descriptor = await client.mcpDescriptor();
|
|
2572
|
+
mcpTools = descriptor.tools.length;
|
|
2573
|
+
checks.push({
|
|
2574
|
+
name: "mcp",
|
|
2575
|
+
status: "pass",
|
|
2576
|
+
code: "MCP_DESCRIPTOR_READY",
|
|
2577
|
+
detail: `${mcpTools} tools via ${descriptor.http.transport}`
|
|
2578
|
+
});
|
|
2579
|
+
} catch (error) {
|
|
2580
|
+
checks.push(errorCheck("mcp", error));
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
if (localMcpChecks) {
|
|
2584
|
+
try {
|
|
2585
|
+
checks.push(...await localMcpChecks());
|
|
2586
|
+
} catch (error) {
|
|
2587
|
+
checks.push(errorCheck("docker", error));
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
return {
|
|
2591
|
+
status: overallStatus(checks),
|
|
2592
|
+
url: input.url,
|
|
2593
|
+
...input.profileName ? { profileName: input.profileName } : {},
|
|
2594
|
+
...sagVersion ? { sagVersion } : {},
|
|
2595
|
+
checks,
|
|
2596
|
+
summary: {
|
|
2597
|
+
sources: sources?.length ?? 0,
|
|
2598
|
+
documents: documentCount,
|
|
2599
|
+
documentStatuses,
|
|
2600
|
+
mcpTools
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// src/output/human.ts
|
|
2606
|
+
function isRecord(value) {
|
|
2607
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2608
|
+
}
|
|
2609
|
+
function renderRows(rows) {
|
|
2610
|
+
if (!rows.length) return "No results.\n";
|
|
2611
|
+
const preferredColumns = [
|
|
2612
|
+
"current",
|
|
2613
|
+
"agent",
|
|
2614
|
+
"installed",
|
|
2615
|
+
"version",
|
|
2616
|
+
"serverName",
|
|
2617
|
+
"managed",
|
|
2618
|
+
"verified",
|
|
2619
|
+
"managedConnections",
|
|
2620
|
+
"id",
|
|
2621
|
+
"name",
|
|
2622
|
+
"filename",
|
|
2623
|
+
"status",
|
|
2624
|
+
"url",
|
|
2625
|
+
"document_count",
|
|
2626
|
+
"chunk_count",
|
|
2627
|
+
"progress"
|
|
2628
|
+
];
|
|
2629
|
+
const available = new Set(rows.flatMap((row) => Object.keys(row)));
|
|
2630
|
+
const columns = preferredColumns.filter((column) => available.has(column));
|
|
2631
|
+
const selected = columns.length ? columns : Object.keys(rows[0] ?? {});
|
|
2632
|
+
const widths = selected.map(
|
|
2633
|
+
(column) => Math.max(column.length, ...rows.map((row) => String(row[column] ?? "").length))
|
|
2634
|
+
);
|
|
2635
|
+
const line = (values) => values.map((value, index) => String(value ?? "").padEnd(widths[index] ?? 0)).join(" ").trimEnd();
|
|
2636
|
+
return `${[
|
|
2637
|
+
line(selected),
|
|
2638
|
+
line(widths.map((width) => "-".repeat(width))),
|
|
2639
|
+
...rows.map((row) => line(selected.map((column) => row[column])))
|
|
2640
|
+
].join("\n")}
|
|
2641
|
+
`;
|
|
2642
|
+
}
|
|
2643
|
+
function renderDoctor(report) {
|
|
2644
|
+
const lines = [
|
|
2645
|
+
`SAG doctor: ${report.status.toUpperCase()}`,
|
|
2646
|
+
`URL: ${report.url}`,
|
|
2647
|
+
...report.checks.map(
|
|
2648
|
+
(check) => `${check.status === "pass" ? "\u2713" : check.status === "warn" ? "!" : "\u2717"} ${check.name}: ${check.detail}${check.hint ? ` \u2014 ${check.hint}` : ""}`
|
|
2649
|
+
)
|
|
2650
|
+
];
|
|
2651
|
+
return `${lines.join("\n")}
|
|
2652
|
+
`;
|
|
2653
|
+
}
|
|
2654
|
+
function renderSearch(value) {
|
|
2655
|
+
const summary = typeof value.summary === "string" ? value.summary : "";
|
|
2656
|
+
const sections = Array.isArray(value.sections) ? value.sections.filter(isRecord) : [];
|
|
2657
|
+
const lines = [
|
|
2658
|
+
summary,
|
|
2659
|
+
...sections.map((section) => {
|
|
2660
|
+
const source = section.source_name || section.source_id || "Unknown source";
|
|
2661
|
+
const heading = section.heading || "Result";
|
|
2662
|
+
const content = section.content || "";
|
|
2663
|
+
return `[${source}] ${heading}
|
|
2664
|
+
${content}`;
|
|
2665
|
+
})
|
|
2666
|
+
].filter(Boolean);
|
|
2667
|
+
return `${lines.join("\n\n")}
|
|
2668
|
+
`;
|
|
2669
|
+
}
|
|
2670
|
+
function renderLocalMcp(value) {
|
|
2671
|
+
const container = isRecord(value.container) ? value.container : {};
|
|
2672
|
+
const tools = Array.isArray(value.tools) ? value.tools : [];
|
|
2673
|
+
return `${[
|
|
2674
|
+
"SAG MCP: PASS",
|
|
2675
|
+
`Container: ${String(container.name ?? "unknown")}`,
|
|
2676
|
+
`Health: ${String(container.health ?? "unknown")}`,
|
|
2677
|
+
`Protocol: ${String(value.protocolVersion ?? "unknown")} (${String(value.era ?? "unknown")})`,
|
|
2678
|
+
`Tools: ${tools.length}`,
|
|
2679
|
+
`Knowledge: ${value.listSourcesCallable ? "list_sources callable" : "unavailable"}`
|
|
2680
|
+
].join("\n")}
|
|
2681
|
+
`;
|
|
2682
|
+
}
|
|
2683
|
+
function renderHuman(value, quiet = false) {
|
|
2684
|
+
if (quiet) {
|
|
2685
|
+
if (Array.isArray(value)) {
|
|
2686
|
+
return `${value.map(
|
|
2687
|
+
(item) => isRecord(item) ? item.id ?? item.name ?? JSON.stringify(item) : item
|
|
2688
|
+
).join("\n")}
|
|
2689
|
+
`;
|
|
2690
|
+
}
|
|
2691
|
+
if (isRecord(value) && typeof value.summary === "string") {
|
|
2692
|
+
return `${value.summary}
|
|
2693
|
+
`;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
if (Array.isArray(value) && value.every(isRecord)) {
|
|
2697
|
+
return renderRows(value);
|
|
2698
|
+
}
|
|
2699
|
+
if (isRecord(value) && Array.isArray(value.checks) && "summary" in value) {
|
|
2700
|
+
return renderDoctor(value);
|
|
2701
|
+
}
|
|
2702
|
+
if (isRecord(value) && Array.isArray(value.sections) && "query" in value) {
|
|
2703
|
+
return renderSearch(value);
|
|
2704
|
+
}
|
|
2705
|
+
if (isRecord(value) && value.provider === "docker-stdio" && "protocolVersion" in value && "container" in value) {
|
|
2706
|
+
return renderLocalMcp(value);
|
|
2707
|
+
}
|
|
2708
|
+
if (typeof value === "string") {
|
|
2709
|
+
return `${value}
|
|
2710
|
+
`;
|
|
2711
|
+
}
|
|
2712
|
+
return `${JSON.stringify(value, null, 2)}
|
|
2713
|
+
`;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
// src/output/redaction.ts
|
|
2717
|
+
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
2718
|
+
"authorization",
|
|
2719
|
+
"token",
|
|
2720
|
+
"access_token",
|
|
2721
|
+
"password",
|
|
2722
|
+
"secret",
|
|
2723
|
+
"secretkey",
|
|
2724
|
+
"secret_key"
|
|
2725
|
+
]);
|
|
2726
|
+
var BEARER_TOKEN = /\bBearer\s+[^\s"',}\]]+/giu;
|
|
2727
|
+
function redactString(value) {
|
|
2728
|
+
return value.replace(BEARER_TOKEN, "Bearer [REDACTED]");
|
|
2729
|
+
}
|
|
2730
|
+
function redact(value) {
|
|
2731
|
+
if (typeof value === "string") {
|
|
2732
|
+
return redactString(value);
|
|
2733
|
+
}
|
|
2734
|
+
if (Array.isArray(value)) {
|
|
2735
|
+
return value.map((item) => redact(item));
|
|
2736
|
+
}
|
|
2737
|
+
if (value && typeof value === "object") {
|
|
2738
|
+
return Object.fromEntries(
|
|
2739
|
+
Object.entries(value).map(([key, item]) => {
|
|
2740
|
+
const normalizedKey = key.toLowerCase();
|
|
2741
|
+
if (normalizedKey === "authorization" && typeof item === "string") {
|
|
2742
|
+
return [key, redactString(item)];
|
|
2743
|
+
}
|
|
2744
|
+
return [key, SENSITIVE_KEYS.has(normalizedKey) ? "[REDACTED]" : redact(item)];
|
|
2745
|
+
})
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
return value;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// src/output/json.ts
|
|
2752
|
+
function renderJson(result) {
|
|
2753
|
+
return `${JSON.stringify(redact(result))}
|
|
2754
|
+
`;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// src/program.ts
|
|
2758
|
+
function parseInteger(value) {
|
|
2759
|
+
return Number.parseInt(value, 10);
|
|
2760
|
+
}
|
|
2761
|
+
function parsePositiveInteger(value) {
|
|
2762
|
+
const parsed = Number.parseInt(value, 10);
|
|
2763
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
2764
|
+
throw new CliError("INVALID_ARGUMENT", "Expected a positive integer", {
|
|
2765
|
+
exitCode: exitCodes.invalidArgument
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
return parsed;
|
|
2769
|
+
}
|
|
2770
|
+
function parseAgent(value) {
|
|
2771
|
+
if (value === "codex" || value === "claude-code") {
|
|
2772
|
+
return value;
|
|
2773
|
+
}
|
|
2774
|
+
throw new CliError("INVALID_ARGUMENT", `Unsupported Agent: ${value}`, {
|
|
2775
|
+
exitCode: exitCodes.invalidArgument,
|
|
2776
|
+
hint: "Choose codex or claude-code."
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
function commandOptions(program) {
|
|
2780
|
+
return program.opts();
|
|
2781
|
+
}
|
|
2782
|
+
function emit(program, dependencies2, data) {
|
|
2783
|
+
const options = commandOptions(program);
|
|
2784
|
+
dependencies2.writeStdout(
|
|
2785
|
+
options.json ? renderJson(success(data)) : renderHuman(data, options.quiet ?? false)
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
async function runtime(program, dependencies2) {
|
|
2789
|
+
const options = commandOptions(program);
|
|
2790
|
+
return createRuntimeContext({
|
|
2791
|
+
options: {
|
|
2792
|
+
...options.url ? { url: options.url } : {},
|
|
2793
|
+
...options.profile ? { profile: options.profile } : {}
|
|
2794
|
+
},
|
|
2795
|
+
environment: dependencies2.environment,
|
|
2796
|
+
configStore: dependencies2.configStore,
|
|
2797
|
+
credentialStore: dependencies2.credentialStore,
|
|
2798
|
+
createClient: dependencies2.createClient
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
function localMcpRuntime(dependencies2) {
|
|
2802
|
+
if (!dependencies2.localMcp) {
|
|
2803
|
+
throw new CliError("DEPENDENCY_MISSING", "Local MCP runtime is unavailable", {
|
|
2804
|
+
exitCode: exitCodes.dependencyMissing
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
return dependencies2.localMcp;
|
|
2808
|
+
}
|
|
2809
|
+
async function selectedProfileName(program, dependencies2) {
|
|
2810
|
+
const options = commandOptions(program);
|
|
2811
|
+
if (options.profile) {
|
|
2812
|
+
await dependencies2.configStore.getProfile(options.profile);
|
|
2813
|
+
return options.profile;
|
|
2814
|
+
}
|
|
2815
|
+
return (await dependencies2.configStore.load()).currentProfile ?? "local";
|
|
2816
|
+
}
|
|
2817
|
+
async function selectedServerName(program, dependencies2, explicitName) {
|
|
2818
|
+
const profile = await selectedProfileName(program, dependencies2);
|
|
2819
|
+
return {
|
|
2820
|
+
profile,
|
|
2821
|
+
serverName: explicitName ?? `sag-knowledge-${profile}`
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
function addProfileCommands(program, dependencies2) {
|
|
2825
|
+
const profile = program.command("profile").description("Manage SAG profiles");
|
|
2826
|
+
profile.command("add").argument("<name>").argument("<url>").action(async (name, url) => {
|
|
2827
|
+
emit(program, dependencies2, await dependencies2.configStore.addProfile(name, url));
|
|
2828
|
+
});
|
|
2829
|
+
profile.command("list").action(async () => {
|
|
2830
|
+
emit(program, dependencies2, await dependencies2.configStore.listProfiles());
|
|
2831
|
+
});
|
|
2832
|
+
profile.command("use").argument("<name>").action(async (name) => {
|
|
2833
|
+
emit(program, dependencies2, await dependencies2.configStore.useProfile(name));
|
|
2834
|
+
});
|
|
2835
|
+
profile.command("show").argument("[name]").action(async (name) => {
|
|
2836
|
+
emit(program, dependencies2, await dependencies2.configStore.getProfile(name));
|
|
2837
|
+
});
|
|
2838
|
+
profile.command("remove").argument("<name>").action(async (name) => {
|
|
2839
|
+
if (!commandOptions(program).yes && !await dependencies2.confirm(`Remove profile ${name}?`)) {
|
|
2840
|
+
emit(program, dependencies2, { removed: null, cancelled: true });
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
await dependencies2.configStore.removeProfile(name);
|
|
2844
|
+
emit(program, dependencies2, { removed: name });
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
function addAuthCommands(program, dependencies2) {
|
|
2848
|
+
const auth = program.command("auth").description("Manage SAG credentials");
|
|
2849
|
+
auth.command("login").action(async () => {
|
|
2850
|
+
const context = await runtime(program, dependencies2);
|
|
2851
|
+
const result = await login({
|
|
2852
|
+
credentialRef: context.credentialRef,
|
|
2853
|
+
...context.connection.environmentToken ? { environmentToken: context.connection.environmentToken } : {},
|
|
2854
|
+
promptToken: dependencies2.promptToken,
|
|
2855
|
+
validate: async (token) => dependencies2.createClient({ origin: context.connection.url, token }).me(),
|
|
2856
|
+
store: dependencies2.credentialStore
|
|
2857
|
+
});
|
|
2858
|
+
emit(program, dependencies2, result);
|
|
2859
|
+
});
|
|
2860
|
+
auth.command("status").action(async () => {
|
|
2861
|
+
const context = await runtime(program, dependencies2);
|
|
2862
|
+
const result = await status({
|
|
2863
|
+
credentialRef: context.credentialRef,
|
|
2864
|
+
...context.connection.environmentToken ? { environmentToken: context.connection.environmentToken } : {},
|
|
2865
|
+
validate: async (token) => dependencies2.createClient({ origin: context.connection.url, token }).me(),
|
|
2866
|
+
store: dependencies2.credentialStore
|
|
2867
|
+
});
|
|
2868
|
+
emit(program, dependencies2, result);
|
|
2869
|
+
});
|
|
2870
|
+
auth.command("logout").action(async () => {
|
|
2871
|
+
const context = await runtime(program, dependencies2);
|
|
2872
|
+
emit(
|
|
2873
|
+
program,
|
|
2874
|
+
dependencies2,
|
|
2875
|
+
await logout({
|
|
2876
|
+
credentialRef: context.credentialRef,
|
|
2877
|
+
store: dependencies2.credentialStore
|
|
2878
|
+
})
|
|
2879
|
+
);
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
function addKnowledgeCommands(program, dependencies2) {
|
|
2883
|
+
program.command("doctor").action(async () => {
|
|
2884
|
+
const context = await runtime(program, dependencies2);
|
|
2885
|
+
emit(
|
|
2886
|
+
program,
|
|
2887
|
+
dependencies2,
|
|
2888
|
+
await runDoctor(
|
|
2889
|
+
context.client,
|
|
2890
|
+
{
|
|
2891
|
+
url: context.connection.url,
|
|
2892
|
+
...context.connection.profileName ? { profileName: context.connection.profileName } : {}
|
|
2893
|
+
},
|
|
2894
|
+
dependencies2.localMcp ? () => localMcpDoctorChecks(dependencies2.localMcp, {
|
|
2895
|
+
timeoutMs: 15e3
|
|
2896
|
+
}) : void 0
|
|
2897
|
+
)
|
|
2898
|
+
);
|
|
2899
|
+
});
|
|
2900
|
+
const source = program.command("source").description("Inspect SAG sources");
|
|
2901
|
+
source.command("list").action(async () => {
|
|
2902
|
+
const context = await runtime(program, dependencies2);
|
|
2903
|
+
emit(program, dependencies2, await listSources(context.client));
|
|
2904
|
+
});
|
|
2905
|
+
source.command("get").argument("<source-id>").action(async (sourceId) => {
|
|
2906
|
+
const context = await runtime(program, dependencies2);
|
|
2907
|
+
emit(program, dependencies2, await getSource(context.client, sourceId));
|
|
2908
|
+
});
|
|
2909
|
+
source.command("status").argument("[source-id]").action(async (sourceId) => {
|
|
2910
|
+
const context = await runtime(program, dependencies2);
|
|
2911
|
+
emit(program, dependencies2, await sourceStatus(context.client, sourceId));
|
|
2912
|
+
});
|
|
2913
|
+
const document = program.command("document").description("Inspect SAG documents");
|
|
2914
|
+
document.command("list").requiredOption("--source <source-id>").action(async (options) => {
|
|
2915
|
+
const context = await runtime(program, dependencies2);
|
|
2916
|
+
emit(program, dependencies2, await listDocuments(context.client, options.source));
|
|
2917
|
+
});
|
|
2918
|
+
document.command("get").argument("<document-id>").requiredOption("--source <source-id>").action(async (documentId, options) => {
|
|
2919
|
+
const context = await runtime(program, dependencies2);
|
|
2920
|
+
emit(
|
|
2921
|
+
program,
|
|
2922
|
+
dependencies2,
|
|
2923
|
+
await getDocument(context.client, options.source, documentId)
|
|
2924
|
+
);
|
|
2925
|
+
});
|
|
2926
|
+
document.command("status").argument("[document-id]").requiredOption("--source <source-id>").action(async (documentId, options) => {
|
|
2927
|
+
const context = await runtime(program, dependencies2);
|
|
2928
|
+
emit(
|
|
2929
|
+
program,
|
|
2930
|
+
dependencies2,
|
|
2931
|
+
await documentStatus(context.client, options.source, documentId)
|
|
2932
|
+
);
|
|
2933
|
+
});
|
|
2934
|
+
program.command("search").argument("<query>").option("--source <source-id...>").option("--top-k <number>", "Maximum result count", parseInteger).addOption(new Option("--strategy <strategy>").choices(["vector", "multi"])).action(
|
|
2935
|
+
async (query, options) => {
|
|
2936
|
+
const context = await runtime(program, dependencies2);
|
|
2937
|
+
emit(
|
|
2938
|
+
program,
|
|
2939
|
+
dependencies2,
|
|
2940
|
+
await searchKnowledge(context.client, query, {
|
|
2941
|
+
...options.source ? { sourceIds: options.source } : {},
|
|
2942
|
+
...options.topK !== void 0 ? { topK: options.topK } : {},
|
|
2943
|
+
...options.strategy ? { strategy: options.strategy } : {}
|
|
2944
|
+
})
|
|
2945
|
+
);
|
|
2946
|
+
}
|
|
2947
|
+
);
|
|
2948
|
+
}
|
|
2949
|
+
function addLocalMcpCommands(program, dependencies2) {
|
|
2950
|
+
const mcp = program.command("mcp").description("Verify local SAG MCP");
|
|
2951
|
+
mcp.command("test").option("--source-id <source-id>").option("--container <name-or-id>").option("--timeout <milliseconds>", "MCP timeout", parsePositiveInteger, 15e3).action(
|
|
2952
|
+
async (options) => {
|
|
2953
|
+
emit(
|
|
2954
|
+
program,
|
|
2955
|
+
dependencies2,
|
|
2956
|
+
await testLocalMcp(localMcpRuntime(dependencies2), {
|
|
2957
|
+
...options.sourceId ? { sourceId: options.sourceId } : {},
|
|
2958
|
+
...options.container ? { container: options.container } : {},
|
|
2959
|
+
timeoutMs: options.timeout
|
|
2960
|
+
})
|
|
2961
|
+
);
|
|
2962
|
+
}
|
|
2963
|
+
);
|
|
2964
|
+
}
|
|
2965
|
+
function addAgentCommands(program, dependencies2) {
|
|
2966
|
+
const agent = program.command("agent").description("Manage Agent MCP connections");
|
|
2967
|
+
agent.command("list").action(async () => {
|
|
2968
|
+
emit(program, dependencies2, await listAgentHosts(localMcpRuntime(dependencies2)));
|
|
2969
|
+
});
|
|
2970
|
+
agent.command("connect").argument("<agent>", "codex or claude-code", parseAgent).option("--source-id <source-id>").option("--container <name-or-id>").option("--name <server-name>").option("--timeout <milliseconds>", "MCP timeout", parsePositiveInteger, 15e3).option("--dry-run", "Show the plan without changing configuration").action(
|
|
2971
|
+
async (agentId, options) => {
|
|
2972
|
+
const selection = await selectedServerName(program, dependencies2, options.name);
|
|
2973
|
+
emit(
|
|
2974
|
+
program,
|
|
2975
|
+
dependencies2,
|
|
2976
|
+
await connectLocalAgent(localMcpRuntime(dependencies2), {
|
|
2977
|
+
agent: agentId,
|
|
2978
|
+
serverName: selection.serverName,
|
|
2979
|
+
profile: selection.profile,
|
|
2980
|
+
...options.sourceId ? { sourceId: options.sourceId } : {},
|
|
2981
|
+
...options.container ? { container: options.container } : {},
|
|
2982
|
+
timeoutMs: options.timeout,
|
|
2983
|
+
dryRun: options.dryRun ?? false,
|
|
2984
|
+
yes: commandOptions(program).yes ?? false,
|
|
2985
|
+
confirm: dependencies2.confirm,
|
|
2986
|
+
now: () => /* @__PURE__ */ new Date()
|
|
2987
|
+
})
|
|
2988
|
+
);
|
|
2989
|
+
}
|
|
2990
|
+
);
|
|
2991
|
+
agent.command("status").argument("[agent]", "codex or claude-code", parseAgent).option("--name <server-name>").action(async (agentId, options) => {
|
|
2992
|
+
const selection = await selectedServerName(program, dependencies2, options.name);
|
|
2993
|
+
emit(
|
|
2994
|
+
program,
|
|
2995
|
+
dependencies2,
|
|
2996
|
+
await statusLocalAgents(localMcpRuntime(dependencies2), {
|
|
2997
|
+
...agentId ? { agent: agentId } : {},
|
|
2998
|
+
serverName: selection.serverName
|
|
2999
|
+
})
|
|
3000
|
+
);
|
|
3001
|
+
});
|
|
3002
|
+
agent.command("disconnect").argument("<agent>", "codex or claude-code", parseAgent).option("--name <server-name>").action(async (agentId, options) => {
|
|
3003
|
+
const selection = await selectedServerName(program, dependencies2, options.name);
|
|
3004
|
+
emit(
|
|
3005
|
+
program,
|
|
3006
|
+
dependencies2,
|
|
3007
|
+
await disconnectLocalAgent(localMcpRuntime(dependencies2), {
|
|
3008
|
+
agent: agentId,
|
|
3009
|
+
serverName: selection.serverName,
|
|
3010
|
+
yes: commandOptions(program).yes ?? false,
|
|
3011
|
+
confirm: dependencies2.confirm
|
|
3012
|
+
})
|
|
3013
|
+
);
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
function createProgram(dependencies2) {
|
|
3017
|
+
const program = new Command();
|
|
3018
|
+
program.name("sag").description("Command-line client and diagnostics for SAG").showHelpAfterError().exitOverride().option("--json", "Output a stable JSON envelope").option("--quiet", "Output only essential values").option("--profile <name>", "Use a configured SAG profile").option("--url <origin>", "Use a SAG origin without saving it").option("--yes", "Confirm safe local configuration changes").configureOutput({
|
|
3019
|
+
writeOut: dependencies2.writeStdout,
|
|
3020
|
+
writeErr: dependencies2.writeStderr
|
|
3021
|
+
});
|
|
3022
|
+
program.command("version").action(() => {
|
|
3023
|
+
const options = commandOptions(program);
|
|
3024
|
+
if (options.json) {
|
|
3025
|
+
dependencies2.writeStdout(
|
|
3026
|
+
renderJson(success({ name: "sag", version: dependencies2.version }))
|
|
3027
|
+
);
|
|
3028
|
+
} else {
|
|
3029
|
+
dependencies2.writeStdout(`sag ${dependencies2.version}
|
|
3030
|
+
`);
|
|
3031
|
+
}
|
|
3032
|
+
});
|
|
3033
|
+
addProfileCommands(program, dependencies2);
|
|
3034
|
+
addAuthCommands(program, dependencies2);
|
|
3035
|
+
addKnowledgeCommands(program, dependencies2);
|
|
3036
|
+
addLocalMcpCommands(program, dependencies2);
|
|
3037
|
+
addAgentCommands(program, dependencies2);
|
|
3038
|
+
return program;
|
|
3039
|
+
}
|
|
3040
|
+
async function runCli(arguments_, dependencies2) {
|
|
3041
|
+
const json = arguments_.includes("--json");
|
|
3042
|
+
try {
|
|
3043
|
+
await createProgram(dependencies2).parseAsync(arguments_);
|
|
3044
|
+
return exitCodes.success;
|
|
3045
|
+
} catch (error) {
|
|
3046
|
+
if (error instanceof CommanderError && error.exitCode === 0) {
|
|
3047
|
+
return 0;
|
|
3048
|
+
}
|
|
3049
|
+
const cliError = error instanceof CommanderError ? new CliError("INVALID_ARGUMENT", error.message, {
|
|
3050
|
+
exitCode: exitCodes.invalidArgument
|
|
3051
|
+
}) : toCliError(error);
|
|
3052
|
+
if (json) {
|
|
3053
|
+
dependencies2.writeStdout(renderJson(failure(cliError)));
|
|
3054
|
+
} else {
|
|
3055
|
+
dependencies2.writeStderr(
|
|
3056
|
+
`Error [${cliError.code}]: ${cliError.message}${cliError.hint ? `
|
|
3057
|
+
Hint: ${cliError.hint}` : ""}
|
|
3058
|
+
`
|
|
3059
|
+
);
|
|
3060
|
+
if (dependencies2.environment.SAG_DEBUG === "1" && cliError.cause instanceof Error) {
|
|
3061
|
+
dependencies2.writeStderr(`${cliError.cause.stack ?? cliError.cause.message}
|
|
3062
|
+
`);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
return cliError.exitCode;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
var defaultClientFactory = (options) => new SagClient(options);
|
|
3069
|
+
|
|
3070
|
+
// src/cli.ts
|
|
3071
|
+
var credentialSelection = await createCredentialStore();
|
|
3072
|
+
if (credentialSelection.warning) {
|
|
3073
|
+
process.stderr.write(`Warning: ${credentialSelection.warning}
|
|
3074
|
+
`);
|
|
3075
|
+
}
|
|
3076
|
+
var processRunner = new NodeProcessRunner();
|
|
3077
|
+
var dependencies = {
|
|
3078
|
+
version: package_default.version,
|
|
3079
|
+
environment: process.env,
|
|
3080
|
+
configStore: new ConfigStore(defaultConfigPath()),
|
|
3081
|
+
credentialStore: credentialSelection.store,
|
|
3082
|
+
createClient: defaultClientFactory,
|
|
3083
|
+
promptToken: () => password({
|
|
3084
|
+
message: "SAG Token",
|
|
3085
|
+
mask: "*"
|
|
3086
|
+
}),
|
|
3087
|
+
confirm: (message) => confirm({ message, default: false }),
|
|
3088
|
+
writeStdout: (value) => process.stdout.write(value),
|
|
3089
|
+
writeStderr: (value) => process.stderr.write(value),
|
|
3090
|
+
localMcp: {
|
|
3091
|
+
docker: new DockerClient(processRunner),
|
|
3092
|
+
verifier: new McpVerifier(package_default.version),
|
|
3093
|
+
state: new ManagedConnectionStore(defaultManagedConnectionsPath()),
|
|
3094
|
+
adapters: {
|
|
3095
|
+
codex: new CodexAdapter(processRunner),
|
|
3096
|
+
"claude-code": new ClaudeCodeAdapter(processRunner)
|
|
3097
|
+
},
|
|
3098
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
3099
|
+
selectContainer: (candidates) => select({
|
|
3100
|
+
message: "Select the SAG API container",
|
|
3101
|
+
choices: candidates.map((candidate) => ({
|
|
3102
|
+
name: `${candidate.name} (${candidate.composeProject ?? "no Compose project"})`,
|
|
3103
|
+
value: candidate.id
|
|
3104
|
+
}))
|
|
3105
|
+
})
|
|
3106
|
+
}
|
|
3107
|
+
};
|
|
3108
|
+
process.exitCode = await runCli(process.argv, dependencies);
|
|
3109
|
+
//# sourceMappingURL=cli.js.map
|