@datasynx/agentic-ai-cartography 1.1.1 → 2.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/dist/cli.js CHANGED
@@ -1,27 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ CartographyDB,
4
+ buildCartographyToolHandlers,
5
+ createCartographyTools,
6
+ deriveSessionName,
7
+ diffTopology,
8
+ getRuleset,
9
+ isPersonalHost,
10
+ listRulesets,
11
+ loadOrgKey,
12
+ normalizeTenant,
13
+ pseudonymize,
14
+ pseudonymizeString,
15
+ redactValue,
16
+ reversePseudonym,
17
+ rotateOrgKey,
18
+ runDrift,
19
+ runLocalDiscovery,
20
+ stableStringify,
21
+ startMcp,
22
+ stripSensitive
23
+ } from "./chunk-BNDCY2RI.js";
24
+ import {
25
+ ConfigFileSchema,
26
+ CostEntrySchema,
3
27
  DOMAIN_COLORS,
4
28
  DOMAIN_PALETTE,
5
- EDGE_RELATIONSHIPS,
6
- NODE_TYPES,
29
+ DriftConfigSchema,
30
+ NODE_TYPE_GROUPS,
31
+ SharingLevelSchema,
32
+ centralDbFromEnv,
7
33
  defaultConfig
8
- } from "./chunk-WJR63RWY.js";
34
+ } from "./chunk-WCR47QA2.js";
9
35
  import {
10
- HOME,
11
- IS_LINUX,
12
36
  IS_MAC,
13
37
  IS_WIN,
14
38
  PLATFORM,
39
+ checkReadOnly,
15
40
  cleanupTempFiles,
16
- commandExists,
17
- dbScanDirs,
18
- findFiles,
41
+ logDebug,
42
+ logError,
43
+ logInfo,
44
+ logWarn,
19
45
  run,
20
- scanAllBookmarks,
21
- scanAllHistory,
22
- scanWindowsDbServices,
23
- scanWindowsPrograms
24
- } from "./chunk-QKNYI3SU.js";
46
+ setVerbose
47
+ } from "./chunk-2SZ5QHGH.js";
25
48
 
26
49
  // src/cli.ts
27
50
  import { Command } from "commander";
@@ -42,12 +65,35 @@ function isOAuthLoggedIn() {
42
65
  return false;
43
66
  }
44
67
  }
45
- function checkPrerequisites() {
68
+ function checkPrerequisites(provider = "claude") {
69
+ if (provider === "openai") {
70
+ checkOpenAIPrerequisites();
71
+ return;
72
+ }
73
+ if (provider === "ollama") {
74
+ process.stderr.write(
75
+ `\u2713 Ollama provider selected (host: ${process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434"})
76
+ `
77
+ );
78
+ return;
79
+ }
80
+ checkClaudePrerequisites();
81
+ }
82
+ function checkOpenAIPrerequisites() {
83
+ if (!process.env.OPENAI_API_KEY) {
84
+ process.stderr.write(
85
+ "\n\u274C OpenAI provider selected but OPENAI_API_KEY is not set.\n\n Set your key:\n export OPENAI_API_KEY=sk-...\n\n Install the SDK if needed:\n npm install openai\n\n Tip: pass a non-Claude model, e.g. --model gpt-4.1\n\n"
86
+ );
87
+ process.exitCode = 1;
88
+ throw new Error("OPENAI_API_KEY not set");
89
+ }
90
+ }
91
+ function checkClaudePrerequisites() {
46
92
  try {
47
93
  execSync("claude --version", { stdio: "pipe" });
48
94
  } catch {
49
95
  process.stderr.write(
50
- "\n\u274C Claude CLI nicht gefunden.\n Datasynx Cartography braucht die Claude CLI als Runtime-Dependency.\n\n Installieren:\n npm install -g @anthropic-ai/claude-code\n # oder\n curl -fsSL https://claude.ai/install.sh | bash\n\n Danach: claude login\n\n"
96
+ "\n\u274C Claude CLI not found.\n Datasynx Cartography requires the Claude CLI as a runtime dependency.\n\n Install:\n npm install -g @anthropic-ai/claude-code\n # or\n curl -fsSL https://claude.ai/install.sh | bash\n\n Then: claude login\n\n"
51
97
  );
52
98
  process.exitCode = 1;
53
99
  throw new Error("Claude CLI not found");
@@ -56,1078 +102,503 @@ function checkPrerequisites() {
56
102
  const hasOAuth = isOAuthLoggedIn();
57
103
  if (!hasApiKey && !hasOAuth) {
58
104
  process.stderr.write(
59
- "\u26A0 Keine Authentifizierung gefunden. Bitte eine der folgenden Optionen:\n\n Option A \u2014 claude.ai Subscription (empfohlen):\n claude login\n\n Option B \u2014 API Key:\n export ANTHROPIC_API_KEY=sk-ant-...\n\n"
105
+ "\u26A0 No authentication found. Please choose one of the following options:\n\n Option A \u2014 claude.ai Subscription (recommended):\n claude login\n\n Option B \u2014 API Key:\n export ANTHROPIC_API_KEY=sk-ant-...\n\n"
60
106
  );
61
107
  } else if (hasOAuth && !hasApiKey) {
62
- process.stderr.write("\u2713 Eingeloggt via claude login (Subscription)\n");
108
+ process.stderr.write("\u2713 Logged in via claude login (Subscription)\n");
63
109
  }
64
110
  }
65
111
 
66
- // src/db.ts
67
- import Database from "better-sqlite3";
68
- import { mkdirSync } from "fs";
69
- import { dirname } from "path";
70
- import { z } from "zod";
71
- var SessionRowSchema = z.object({
72
- id: z.string(),
73
- mode: z.literal("discover"),
74
- started_at: z.string(),
75
- completed_at: z.string().nullable().optional(),
76
- config: z.string()
77
- });
78
- var NodeRowSchema = z.object({
79
- id: z.string(),
80
- session_id: z.string(),
81
- type: z.enum(NODE_TYPES),
82
- name: z.string(),
83
- discovered_via: z.string().nullable().optional(),
84
- discovered_at: z.string(),
85
- path_id: z.string().nullable().optional(),
86
- depth: z.number().default(0),
87
- confidence: z.number().default(0.5),
88
- metadata: z.string().default("{}"),
89
- tags: z.string().default("[]"),
90
- domain: z.string().nullable().optional(),
91
- sub_domain: z.string().nullable().optional(),
92
- quality_score: z.number().nullable().optional()
93
- });
94
- var EdgeRowSchema = z.object({
95
- id: z.string(),
96
- session_id: z.string(),
97
- source_id: z.string(),
98
- target_id: z.string(),
99
- relationship: z.enum(EDGE_RELATIONSHIPS),
100
- evidence: z.string().nullable().optional(),
101
- confidence: z.number().default(0.5),
102
- discovered_at: z.string()
103
- });
104
- var EventRowSchema = z.object({
105
- id: z.string(),
106
- session_id: z.string(),
107
- task_id: z.string().nullable().optional(),
108
- timestamp: z.string(),
109
- event_type: z.string(),
110
- process: z.string(),
111
- pid: z.number(),
112
- target: z.string().nullable().optional(),
113
- target_type: z.string().nullable().optional(),
114
- port: z.number().nullable().optional(),
115
- duration_ms: z.number().nullable().optional()
116
- });
117
- var TaskRowSchema = z.object({
118
- id: z.string(),
119
- session_id: z.string(),
120
- description: z.string().nullable().optional(),
121
- started_at: z.string(),
122
- completed_at: z.string().nullable().optional(),
123
- steps: z.string().default("[]"),
124
- involved_services: z.string().default("[]"),
125
- status: z.enum(["active", "completed", "cancelled"])
126
- });
127
- var WorkflowRowSchema = z.object({
128
- id: z.string(),
129
- session_id: z.string(),
130
- name: z.string().nullable().optional(),
131
- pattern: z.string(),
132
- task_ids: z.string().default("[]"),
133
- occurrences: z.number().default(1),
134
- first_seen: z.string(),
135
- last_seen: z.string(),
136
- avg_duration_ms: z.number().nullable().optional(),
137
- involved_services: z.string().default("[]")
138
- });
139
- var ConnectionRowSchema = z.object({
140
- id: z.string(),
141
- session_id: z.string(),
142
- source_asset_id: z.string(),
143
- target_asset_id: z.string(),
144
- type: z.string().nullable().optional(),
145
- created_at: z.string()
146
- });
147
- var SCHEMA = `
148
- PRAGMA journal_mode = WAL;
149
- PRAGMA foreign_keys = ON;
150
- PRAGMA busy_timeout = 5000;
151
-
152
- CREATE TABLE IF NOT EXISTS sessions (
153
- id TEXT PRIMARY KEY,
154
- mode TEXT NOT NULL CHECK (mode IN ('discover')),
155
- started_at TEXT NOT NULL,
156
- completed_at TEXT,
157
- config TEXT NOT NULL DEFAULT '{}'
158
- );
159
-
160
- CREATE TABLE IF NOT EXISTS nodes (
161
- id TEXT NOT NULL,
162
- session_id TEXT NOT NULL REFERENCES sessions(id),
163
- type TEXT NOT NULL,
164
- name TEXT NOT NULL,
165
- discovered_via TEXT,
166
- discovered_at TEXT NOT NULL,
167
- path_id TEXT,
168
- depth INTEGER DEFAULT 0,
169
- confidence REAL DEFAULT 0.5,
170
- metadata TEXT NOT NULL DEFAULT '{}',
171
- tags TEXT NOT NULL DEFAULT '[]',
172
- domain TEXT,
173
- sub_domain TEXT,
174
- quality_score REAL,
175
- PRIMARY KEY (id, session_id)
176
- );
177
-
178
- CREATE TABLE IF NOT EXISTS connections (
179
- id TEXT PRIMARY KEY,
180
- session_id TEXT NOT NULL REFERENCES sessions(id),
181
- source_asset_id TEXT NOT NULL,
182
- target_asset_id TEXT NOT NULL,
183
- type TEXT,
184
- created_at TEXT NOT NULL
185
- );
186
-
187
- CREATE TABLE IF NOT EXISTS edges (
188
- id TEXT PRIMARY KEY,
189
- session_id TEXT NOT NULL REFERENCES sessions(id),
190
- source_id TEXT NOT NULL,
191
- target_id TEXT NOT NULL,
192
- relationship TEXT NOT NULL,
193
- evidence TEXT,
194
- confidence REAL DEFAULT 0.5,
195
- discovered_at TEXT NOT NULL
196
- );
197
-
198
- CREATE TABLE IF NOT EXISTS activity_events (
199
- id TEXT PRIMARY KEY,
200
- session_id TEXT NOT NULL REFERENCES sessions(id),
201
- task_id TEXT,
202
- timestamp TEXT NOT NULL,
203
- event_type TEXT NOT NULL,
204
- process TEXT NOT NULL,
205
- pid INTEGER NOT NULL,
206
- target TEXT,
207
- target_type TEXT,
208
- port INTEGER,
209
- duration_ms INTEGER
210
- );
211
-
212
- CREATE TABLE IF NOT EXISTS tasks (
213
- id TEXT PRIMARY KEY,
214
- session_id TEXT NOT NULL REFERENCES sessions(id),
215
- description TEXT,
216
- started_at TEXT NOT NULL,
217
- completed_at TEXT,
218
- steps TEXT NOT NULL DEFAULT '[]',
219
- involved_services TEXT NOT NULL DEFAULT '[]',
220
- status TEXT DEFAULT 'active' CHECK (status IN ('active','completed','cancelled'))
221
- );
222
-
223
- CREATE TABLE IF NOT EXISTS workflows (
224
- id TEXT PRIMARY KEY,
225
- session_id TEXT NOT NULL REFERENCES sessions(id),
226
- name TEXT,
227
- pattern TEXT NOT NULL,
228
- task_ids TEXT NOT NULL DEFAULT '[]',
229
- occurrences INTEGER DEFAULT 1,
230
- first_seen TEXT NOT NULL,
231
- last_seen TEXT NOT NULL,
232
- avg_duration_ms INTEGER,
233
- involved_services TEXT NOT NULL DEFAULT '[]'
234
- );
235
-
236
- CREATE TABLE IF NOT EXISTS node_approvals (
237
- pattern TEXT PRIMARY KEY,
238
- action TEXT NOT NULL CHECK (action IN ('save','ignore','auto')),
239
- created_at TEXT NOT NULL
240
- );
241
-
242
- CREATE INDEX IF NOT EXISTS idx_nodes_session ON nodes(session_id);
243
- CREATE INDEX IF NOT EXISTS idx_edges_session ON edges(session_id);
244
- CREATE INDEX IF NOT EXISTS idx_events_session ON activity_events(session_id);
245
- CREATE INDEX IF NOT EXISTS idx_events_task ON activity_events(task_id);
246
- CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id);
247
- CREATE INDEX IF NOT EXISTS idx_connections_session ON connections(session_id);
248
- `;
249
- var CartographyDB = class {
250
- db;
251
- constructor(dbPath) {
252
- mkdirSync(dirname(dbPath), { recursive: true });
253
- this.db = new Database(dbPath);
254
- this.db.pragma("journal_mode = WAL");
255
- this.db.pragma("foreign_keys = ON");
256
- this.db.pragma("busy_timeout = 5000");
257
- this.migrate();
258
- }
259
- migrate() {
260
- const version = this.db.pragma("user_version", { simple: true });
261
- if (version === 0) {
262
- this.db.exec(SCHEMA);
263
- this.db.pragma("user_version = 2");
264
- } else if (version === 1) {
265
- const cols = this.db.prepare("PRAGMA table_info(nodes)").all().map((c) => c.name);
266
- if (!cols.includes("domain")) this.db.exec("ALTER TABLE nodes ADD COLUMN domain TEXT");
267
- if (!cols.includes("sub_domain")) this.db.exec("ALTER TABLE nodes ADD COLUMN sub_domain TEXT");
268
- if (!cols.includes("quality_score")) this.db.exec("ALTER TABLE nodes ADD COLUMN quality_score REAL");
269
- this.db.exec(`
270
- CREATE TABLE IF NOT EXISTS connections (
271
- id TEXT PRIMARY KEY,
272
- session_id TEXT NOT NULL REFERENCES sessions(id),
273
- source_asset_id TEXT NOT NULL,
274
- target_asset_id TEXT NOT NULL,
275
- type TEXT,
276
- created_at TEXT NOT NULL
277
- );
278
- CREATE INDEX IF NOT EXISTS idx_connections_session ON connections(session_id);
279
- `);
280
- this.db.pragma("user_version = 2");
281
- }
282
- }
283
- close() {
284
- this.db.pragma("optimize");
285
- this.db.close();
286
- }
287
- // ── Sessions ────────────────────────────
288
- createSession(mode, config) {
289
- const id = crypto.randomUUID();
290
- this.db.prepare(
291
- "INSERT INTO sessions (id, mode, started_at, config) VALUES (?, ?, ?, ?)"
292
- ).run(id, mode, (/* @__PURE__ */ new Date()).toISOString(), JSON.stringify(config));
293
- return id;
294
- }
295
- endSession(id) {
296
- this.db.prepare("UPDATE sessions SET completed_at = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), id);
112
+ // src/providers/types.ts
113
+ var ProviderRegistry = class {
114
+ factories = /* @__PURE__ */ new Map();
115
+ register(name, factory) {
116
+ this.factories.set(name, factory);
297
117
  }
298
- getSession(id) {
299
- const row = this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
300
- return row ? this.mapSession(row) : void 0;
118
+ has(name) {
119
+ return this.factories.has(name);
301
120
  }
302
- getLatestSession(mode) {
303
- const row = mode ? this.db.prepare("SELECT * FROM sessions WHERE mode = ? ORDER BY rowid DESC LIMIT 1").get(mode) : this.db.prepare("SELECT * FROM sessions ORDER BY rowid DESC LIMIT 1").get();
304
- return row ? this.mapSession(row) : void 0;
305
- }
306
- getSessions() {
307
- const rows = this.db.prepare("SELECT * FROM sessions ORDER BY rowid DESC").all();
308
- return rows.map((r) => this.mapSession(r));
309
- }
310
- mapSession(r) {
311
- const v = SessionRowSchema.parse(r);
312
- return {
313
- id: v.id,
314
- mode: v.mode,
315
- startedAt: v.started_at,
316
- completedAt: v.completed_at ?? void 0,
317
- config: v.config
318
- };
121
+ resolve(name) {
122
+ const f = this.factories.get(name);
123
+ if (!f) throw new Error(`Unknown provider "${name}". Available: ${this.names().join(", ")}`);
124
+ return f();
319
125
  }
320
- // ── Nodes ───────────────────────────────
321
- upsertNode(sessionId, node, depth = 0) {
322
- this.db.prepare(`
323
- INSERT OR REPLACE INTO nodes
324
- (id, session_id, type, name, discovered_via, discovered_at, depth, confidence, metadata, tags,
325
- domain, sub_domain, quality_score)
326
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
327
- `).run(
328
- node.id,
329
- sessionId,
330
- node.type,
331
- node.name,
332
- node.discoveredVia,
333
- (/* @__PURE__ */ new Date()).toISOString(),
334
- depth,
335
- node.confidence,
336
- JSON.stringify(node.metadata ?? {}),
337
- JSON.stringify(node.tags ?? []),
338
- node.domain ?? null,
339
- node.subDomain ?? null,
340
- node.qualityScore ?? null
341
- );
126
+ names() {
127
+ return [...this.factories.keys()];
342
128
  }
343
- getNodes(sessionId) {
344
- const rows = this.db.prepare("SELECT * FROM nodes WHERE session_id = ?").all(sessionId);
345
- return rows.map((r) => this.mapNode(r));
129
+ };
130
+
131
+ // src/safety.ts
132
+ var safetyHook = async (input, _toolUseID, _options) => {
133
+ if (!("tool_name" in input)) return {};
134
+ if (input.tool_name !== "Bash") return {};
135
+ const cmd = (input.tool_input?.command ?? "").trim();
136
+ if (!cmd) {
137
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } };
346
138
  }
347
- mapNode(r) {
348
- const v = NodeRowSchema.parse(r);
139
+ const decision = checkReadOnly(cmd);
140
+ if (!decision.allowed) {
349
141
  return {
350
- id: v.id,
351
- sessionId: v.session_id,
352
- type: v.type,
353
- name: v.name,
354
- discoveredVia: v.discovered_via ?? "",
355
- discoveredAt: v.discovered_at,
356
- depth: v.depth,
357
- confidence: v.confidence,
358
- metadata: JSON.parse(v.metadata),
359
- tags: JSON.parse(v.tags),
360
- pathId: v.path_id ?? void 0,
361
- domain: v.domain ?? void 0,
362
- subDomain: v.sub_domain ?? void 0,
363
- qualityScore: v.quality_score ?? void 0
142
+ hookSpecificOutput: {
143
+ hookEventName: "PreToolUse",
144
+ permissionDecision: "deny",
145
+ permissionDecisionReason: `BLOCKED: ${decision.reason} \u2014 read-only allowlist policy`
146
+ }
364
147
  };
365
148
  }
366
- deleteNode(sessionId, nodeId) {
367
- this.db.prepare("DELETE FROM nodes WHERE session_id = ? AND id = ?").run(sessionId, nodeId);
368
- this.db.prepare(
369
- "DELETE FROM edges WHERE session_id = ? AND (source_id = ? OR target_id = ?)"
370
- ).run(sessionId, nodeId, nodeId);
371
- }
372
- // ── Edges ───────────────────────────────
373
- insertEdge(sessionId, edge) {
374
- const id = crypto.randomUUID();
375
- this.db.prepare(`
376
- INSERT OR IGNORE INTO edges
377
- (id, session_id, source_id, target_id, relationship, evidence, confidence, discovered_at)
378
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
379
- `).run(
380
- id,
381
- sessionId,
382
- edge.sourceId,
383
- edge.targetId,
384
- edge.relationship,
385
- edge.evidence,
386
- edge.confidence,
387
- (/* @__PURE__ */ new Date()).toISOString()
388
- );
389
- }
390
- getEdges(sessionId) {
391
- const rows = this.db.prepare("SELECT * FROM edges WHERE session_id = ?").all(sessionId);
392
- return rows.map((r) => {
393
- const v = EdgeRowSchema.parse(r);
394
- return {
395
- id: v.id,
396
- sessionId: v.session_id,
397
- sourceId: v.source_id,
398
- targetId: v.target_id,
399
- relationship: v.relationship,
400
- evidence: v.evidence ?? "",
401
- confidence: v.confidence,
402
- discoveredAt: v.discovered_at
403
- };
404
- });
405
- }
406
- // ── Events ──────────────────────────────
407
- insertEvent(sessionId, event, taskId) {
408
- const id = crypto.randomUUID();
409
- this.db.prepare(`
410
- INSERT INTO activity_events
411
- (id, session_id, task_id, timestamp, event_type, process, pid, target, target_type, port)
412
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
413
- `).run(
414
- id,
415
- sessionId,
416
- taskId ?? null,
417
- (/* @__PURE__ */ new Date()).toISOString(),
418
- event.eventType,
419
- event.process,
420
- event.pid,
421
- event.target ?? null,
422
- event.targetType ?? null,
423
- event.port ?? null
424
- );
425
- }
426
- getEvents(sessionId, since) {
427
- const rows = since ? this.db.prepare("SELECT * FROM activity_events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp").all(sessionId, since) : this.db.prepare("SELECT * FROM activity_events WHERE session_id = ? ORDER BY timestamp").all(sessionId);
428
- return rows.map((r) => {
429
- const v = EventRowSchema.parse(r);
430
- return {
431
- id: v.id,
432
- sessionId: v.session_id,
433
- taskId: v.task_id ?? void 0,
434
- timestamp: v.timestamp,
435
- eventType: v.event_type,
436
- process: v.process,
437
- pid: v.pid,
438
- target: v.target ?? void 0,
439
- targetType: v.target_type ?? void 0,
440
- port: v.port ?? void 0,
441
- durationMs: v.duration_ms ?? void 0
442
- };
443
- });
149
+ return {
150
+ hookSpecificOutput: {
151
+ hookEventName: "PreToolUse",
152
+ permissionDecision: "allow"
153
+ }
154
+ };
155
+ };
156
+
157
+ // src/audit.ts
158
+ function createAuditHook(db, sessionId) {
159
+ return async (input) => {
160
+ try {
161
+ if (!("tool_name" in input)) return {};
162
+ const i = input;
163
+ const command = i.tool_input?.command ?? JSON.stringify(i.tool_input ?? {}).slice(0, 2e3);
164
+ const response = typeof i.tool_response === "string" ? i.tool_response : JSON.stringify(i.tool_response ?? "");
165
+ db.insertEvent(sessionId, {
166
+ eventType: "tool_executed",
167
+ process: i.tool_name,
168
+ pid: process.pid,
169
+ command,
170
+ resultBytes: Buffer.byteLength(response)
171
+ });
172
+ } catch (err) {
173
+ logDebug(`audit hook failed to record event: ${String(err)}`);
174
+ }
175
+ return {};
176
+ };
177
+ }
178
+
179
+ // src/providers/claude.ts
180
+ function createClaudeProvider() {
181
+ return {
182
+ name: "claude",
183
+ async ensureAvailable(_config) {
184
+ try {
185
+ await import("@anthropic-ai/claude-agent-sdk");
186
+ } catch {
187
+ throw new Error(
188
+ "Claude provider unavailable: the @anthropic-ai/claude-agent-sdk package is not installed.\n Install: npm install @anthropic-ai/claude-agent-sdk"
189
+ );
190
+ }
191
+ },
192
+ async *run(ctx) {
193
+ const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
194
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
195
+ const tools = await createCartographyTools(db, sessionId, {
196
+ onAskUser,
197
+ maxResponseBytes: config.maxToolResponseBytes
198
+ });
199
+ let turnCount = 0;
200
+ for await (const msg of query({
201
+ prompt: initialPrompt,
202
+ options: {
203
+ model: config.models.lead,
204
+ maxTurns: config.maxTurns,
205
+ systemPrompt,
206
+ mcpServers: { cartography: tools },
207
+ allowedTools: [
208
+ "Bash",
209
+ "mcp__cartography__save_node",
210
+ "mcp__cartography__save_edge",
211
+ "mcp__cartography__get_catalog",
212
+ "mcp__cartography__scan_bookmarks",
213
+ "mcp__cartography__scan_browser_history",
214
+ "mcp__cartography__scan_installed_apps",
215
+ "mcp__cartography__scan_local_databases",
216
+ "mcp__cartography__scan_k8s_resources",
217
+ "mcp__cartography__scan_aws_resources",
218
+ "mcp__cartography__scan_gcp_resources",
219
+ "mcp__cartography__scan_azure_resources",
220
+ "mcp__cartography__ask_user"
221
+ ],
222
+ hooks: {
223
+ PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }],
224
+ PostToolUse: [{ hooks: [createAuditHook(db, sessionId)] }]
225
+ },
226
+ permissionMode: "bypassPermissions"
227
+ }
228
+ })) {
229
+ if (Date.now() > deadlineMs) {
230
+ yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
231
+ yield { kind: "done" };
232
+ return;
233
+ }
234
+ if (msg.type === "assistant") {
235
+ turnCount++;
236
+ yield { kind: "turn", turn: turnCount };
237
+ for (const block of msg.message.content) {
238
+ if (block.type === "text") {
239
+ yield { kind: "thinking", text: block.text };
240
+ }
241
+ if (block.type === "tool_use") {
242
+ yield {
243
+ kind: "tool_call",
244
+ tool: block.name,
245
+ input: block.input
246
+ };
247
+ }
248
+ }
249
+ }
250
+ if (msg.type === "user") {
251
+ const content = msg.message?.content;
252
+ if (Array.isArray(content)) {
253
+ for (const block of content) {
254
+ if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
255
+ const tb = block;
256
+ const text = typeof tb.content === "string" ? tb.content : "";
257
+ yield { kind: "tool_result", tool: tb.tool_use_id ?? "", output: text };
258
+ }
259
+ }
260
+ }
261
+ }
262
+ if (msg.type === "result") {
263
+ yield { kind: "done" };
264
+ return;
265
+ }
266
+ }
267
+ }
268
+ };
269
+ }
270
+
271
+ // src/providers/shell.ts
272
+ import { z } from "zod";
273
+ function createBashTool() {
274
+ const shell = IS_WIN ? "powershell" : "posix";
275
+ return {
276
+ name: "Bash",
277
+ description: "Run a read-only shell command (inspect ports, processes, config). Mutating or destructive commands are blocked by the read-only allowlist.",
278
+ inputShape: { command: z.string().describe("The read-only shell command to run") },
279
+ annotations: { readOnlyHint: true, openWorldHint: true },
280
+ handler: async (args) => {
281
+ const command = String(args["command"] ?? "").trim();
282
+ if (!command) return { content: [{ type: "text", text: "" }] };
283
+ const decision = checkReadOnly(command, { shell });
284
+ if (!decision.allowed) {
285
+ return {
286
+ content: [
287
+ { type: "text", text: `BLOCKED: ${decision.reason ?? "not read-only"} \u2014 read-only allowlist policy` }
288
+ ]
289
+ };
290
+ }
291
+ const output = run(command) || "(no output)";
292
+ return { content: [{ type: "text", text: output }] };
293
+ }
294
+ };
295
+ }
296
+
297
+ // src/providers/zod-schema.ts
298
+ function unwrap(schema) {
299
+ let current = schema;
300
+ let required = true;
301
+ let description = current.description;
302
+ for (; ; ) {
303
+ const def = current.def;
304
+ const typeName = def?.type;
305
+ if (typeName === "optional" || typeName === "default") {
306
+ required = false;
307
+ const inner = def?.innerType;
308
+ if (!inner) break;
309
+ current = inner;
310
+ description = description ?? current.description;
311
+ continue;
312
+ }
313
+ if (typeName === "nullable") {
314
+ const inner = def?.innerType;
315
+ if (!inner) break;
316
+ current = inner;
317
+ description = description ?? current.description;
318
+ continue;
319
+ }
320
+ break;
444
321
  }
445
- // ── Tasks ───────────────────────────────
446
- startTask(sessionId, description) {
447
- const id = crypto.randomUUID();
448
- this.db.prepare(`
449
- INSERT INTO tasks (id, session_id, description, started_at, steps, involved_services, status)
450
- VALUES (?, ?, ?, ?, '[]', '[]', 'active')
451
- `).run(id, sessionId, description ?? null, (/* @__PURE__ */ new Date()).toISOString());
452
- return id;
453
- }
454
- endCurrentTask(sessionId) {
455
- this.db.prepare(`
456
- UPDATE tasks SET status = 'completed', completed_at = ?
457
- WHERE session_id = ? AND status = 'active'
458
- `).run((/* @__PURE__ */ new Date()).toISOString(), sessionId);
459
- }
460
- updateTaskDescription(sessionId, description) {
461
- this.db.prepare(`
462
- UPDATE tasks SET description = ?
463
- WHERE session_id = ? AND status = 'active'
464
- `).run(description, sessionId);
465
- }
466
- getActiveTask(sessionId) {
467
- const row = this.db.prepare(
468
- "SELECT * FROM tasks WHERE session_id = ? AND status = 'active' LIMIT 1"
469
- ).get(sessionId);
470
- return row ? this.mapTask(row) : void 0;
471
- }
472
- getTasks(sessionId) {
473
- const rows = this.db.prepare("SELECT * FROM tasks WHERE session_id = ? ORDER BY started_at").all(sessionId);
474
- return rows.map((r) => this.mapTask(r));
475
- }
476
- mapTask(r) {
477
- const v = TaskRowSchema.parse(r);
478
- return {
479
- id: v.id,
480
- sessionId: v.session_id,
481
- description: v.description ?? void 0,
482
- startedAt: v.started_at,
483
- completedAt: v.completed_at ?? void 0,
484
- steps: v.steps,
485
- involvedServices: v.involved_services,
486
- status: v.status
487
- };
322
+ return { schema: current, required, description };
323
+ }
324
+ function convert(schema, field) {
325
+ const def = schema.def;
326
+ const typeName = def?.["type"];
327
+ switch (typeName) {
328
+ case "string":
329
+ return { type: "string" };
330
+ case "number": {
331
+ const out = { type: "number" };
332
+ const checks = def?.["checks"] ?? [];
333
+ for (const c of checks) {
334
+ const cd = c?._zod?.def;
335
+ if (cd?.check === "greater_than") out["minimum"] = cd.value;
336
+ if (cd?.check === "less_than") out["maximum"] = cd.value;
337
+ }
338
+ return out;
339
+ }
340
+ case "boolean":
341
+ return { type: "boolean" };
342
+ case "enum": {
343
+ const entries = def?.["entries"];
344
+ const values = entries ? Object.values(entries) : [];
345
+ return { type: "string", enum: values };
346
+ }
347
+ case "array": {
348
+ const element = def?.["element"];
349
+ return { type: "array", items: element ? convert(unwrap(element).schema, field) : {} };
350
+ }
351
+ case "record":
352
+ return { type: "object", additionalProperties: true };
353
+ default:
354
+ throw new Error(
355
+ `zod-schema: unsupported zod construct "${typeName ?? "unknown"}" on field "${field}". Extend src/providers/zod-schema.ts to support it.`
356
+ );
488
357
  }
489
- // ── Workflows ───────────────────────────
490
- insertWorkflow(sessionId, data) {
491
- const id = crypto.randomUUID();
492
- this.db.prepare(`
493
- INSERT INTO workflows
494
- (id, session_id, name, pattern, task_ids, occurrences,
495
- first_seen, last_seen, avg_duration_ms, involved_services)
496
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
497
- `).run(
498
- id,
499
- sessionId,
500
- data.name ?? null,
501
- data.pattern,
502
- data.taskIds,
503
- data.occurrences,
504
- data.firstSeen,
505
- data.lastSeen,
506
- data.avgDurationMs,
507
- data.involvedServices
508
- );
358
+ }
359
+ function shapeToJsonSchema(shape) {
360
+ const properties = {};
361
+ const required = [];
362
+ for (const [key, raw] of Object.entries(shape)) {
363
+ const { schema, required: isRequired, description } = unwrap(raw);
364
+ const prop = convert(schema, key);
365
+ if (description) prop["description"] = description;
366
+ properties[key] = prop;
367
+ if (isRequired) required.push(key);
509
368
  }
510
- getWorkflows(sessionId) {
511
- const rows = this.db.prepare("SELECT * FROM workflows WHERE session_id = ?").all(sessionId);
512
- return rows.map((r) => {
513
- const v = WorkflowRowSchema.parse(r);
514
- return {
515
- id: v.id,
516
- sessionId: v.session_id,
517
- name: v.name ?? void 0,
518
- pattern: v.pattern,
519
- taskIds: v.task_ids,
520
- occurrences: v.occurrences,
521
- firstSeen: v.first_seen,
522
- lastSeen: v.last_seen,
523
- avgDurationMs: v.avg_duration_ms ?? 0,
524
- involvedServices: v.involved_services
525
- };
369
+ return { type: "object", properties, required, additionalProperties: false };
370
+ }
371
+
372
+ // src/providers/audit.ts
373
+ function recordToolEvent(db, sessionId, evt) {
374
+ try {
375
+ db.insertEvent(sessionId, {
376
+ eventType: "tool_executed",
377
+ process: evt.tool,
378
+ pid: process.pid,
379
+ command: evt.command,
380
+ resultBytes: Buffer.byteLength(evt.response)
526
381
  });
382
+ } catch (err) {
383
+ logDebug(`audit writer failed to record event: ${String(err)}`);
527
384
  }
528
- // ── Connections (user-created hex map links) ─────────────────────────────
529
- upsertConnection(sessionId, conn) {
530
- const existing = this.db.prepare(
531
- "SELECT id FROM connections WHERE session_id = ? AND source_asset_id = ? AND target_asset_id = ?"
532
- ).get(sessionId, conn.sourceAssetId, conn.targetAssetId);
533
- if (existing) return existing.id;
534
- const id = crypto.randomUUID();
535
- this.db.prepare(`
536
- INSERT INTO connections (id, session_id, source_asset_id, target_asset_id, type, created_at)
537
- VALUES (?, ?, ?, ?, ?, ?)
538
- `).run(id, sessionId, conn.sourceAssetId, conn.targetAssetId, conn.type ?? null, (/* @__PURE__ */ new Date()).toISOString());
539
- return id;
540
- }
541
- getConnections(sessionId) {
542
- const rows = this.db.prepare("SELECT * FROM connections WHERE session_id = ?").all(sessionId);
543
- return rows.map((r) => {
544
- const v = ConnectionRowSchema.parse(r);
545
- return {
546
- id: v.id,
547
- sessionId: v.session_id,
548
- sourceAssetId: v.source_asset_id,
549
- targetAssetId: v.target_asset_id,
550
- type: v.type ?? void 0,
551
- createdAt: v.created_at
552
- };
553
- });
385
+ }
386
+
387
+ // src/providers/loop.ts
388
+ async function dispatchTool(call, tools, db, sessionId) {
389
+ const tool = tools.find((t) => t.name === call.name);
390
+ if (!tool) {
391
+ const text = `ERROR: unknown tool "${call.name}"`;
392
+ recordToolEvent(db, sessionId, { tool: call.name, command: JSON.stringify(call.args).slice(0, 2e3), response: text });
393
+ return text;
554
394
  }
555
- deleteConnection(sessionId, connectionId) {
556
- this.db.prepare("DELETE FROM connections WHERE session_id = ? AND id = ?").run(sessionId, connectionId);
557
- }
558
- // ── Approvals ───────────────────────────
559
- setApproval(pattern, action) {
560
- this.db.prepare(`
561
- INSERT OR REPLACE INTO node_approvals (pattern, action, created_at) VALUES (?, ?, ?)
562
- `).run(pattern, action, (/* @__PURE__ */ new Date()).toISOString());
563
- }
564
- getApproval(pattern) {
565
- const row = this.db.prepare("SELECT action FROM node_approvals WHERE pattern = ?").get(pattern);
566
- return row?.action;
567
- }
568
- // ── Pruning ──────────────────────────────
569
- /**
570
- * Delete a session and all its associated data (nodes, edges, events, tasks, workflows, connections).
571
- */
572
- deleteSession(sessionId) {
573
- this.db.prepare("DELETE FROM connections WHERE session_id = ?").run(sessionId);
574
- this.db.prepare("DELETE FROM workflows WHERE session_id = ?").run(sessionId);
575
- this.db.prepare("DELETE FROM activity_events WHERE session_id = ?").run(sessionId);
576
- this.db.prepare("DELETE FROM tasks WHERE session_id = ?").run(sessionId);
577
- this.db.prepare("DELETE FROM edges WHERE session_id = ?").run(sessionId);
578
- this.db.prepare("DELETE FROM nodes WHERE session_id = ?").run(sessionId);
579
- this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
580
- }
581
- /**
582
- * Prune sessions older than the given ISO date string. Returns count of deleted sessions.
583
- */
584
- pruneSessions(olderThan) {
585
- const rows = this.db.prepare(
586
- "SELECT id FROM sessions WHERE started_at < ?"
587
- ).all(olderThan);
588
- for (const row of rows) {
589
- this.deleteSession(row.id);
590
- }
591
- return rows.length;
592
- }
593
- // ── Stats ───────────────────────────────
594
- getStats(sessionId) {
595
- const nodes = this.db.prepare("SELECT COUNT(*) as c FROM nodes WHERE session_id = ?").get(sessionId).c;
596
- const edges = this.db.prepare("SELECT COUNT(*) as c FROM edges WHERE session_id = ?").get(sessionId).c;
597
- const events = this.db.prepare("SELECT COUNT(*) as c FROM activity_events WHERE session_id = ?").get(sessionId).c;
598
- const tasks = this.db.prepare("SELECT COUNT(*) as c FROM tasks WHERE session_id = ?").get(sessionId).c;
599
- return { nodes, edges, events, tasks };
395
+ let output;
396
+ try {
397
+ const result = await tool.handler(call.args);
398
+ output = result.content.map((c) => c.text).join("\n");
399
+ } catch (err) {
400
+ output = `ERROR: ${err instanceof Error ? err.message : String(err)}`;
600
401
  }
601
- };
602
-
603
- // src/tools.ts
604
- import { z as z2 } from "zod";
605
- function createScanRunner(runFn, opts = {}) {
606
- const threshold = opts.threshold ?? 3;
607
- let consecutiveFailures = 0;
608
- let tripped = false;
609
- return (cmd) => {
610
- if (tripped) return "(skipped \u2014 circuit breaker: too many consecutive failures)";
611
- const result = runFn(cmd, { timeout: opts.timeout ?? 2e4, env: opts.env });
612
- if (!result) {
613
- consecutiveFailures++;
614
- if (consecutiveFailures >= threshold) tripped = true;
615
- return "(error or not available)";
616
- }
617
- consecutiveFailures = 0;
618
- return result;
619
- };
402
+ const command = call.name === "Bash" ? String(call.args["command"] ?? "") : JSON.stringify(call.args).slice(0, 2e3);
403
+ recordToolEvent(db, sessionId, { tool: call.name, command, response: output });
404
+ return output;
620
405
  }
621
- function stripSensitive(target) {
406
+ async function* runToolLoop(opts, chat) {
407
+ const { db, sessionId, tools, maxTurns, deadlineMs } = opts;
408
+ let outcomes = [];
409
+ let turn = 0;
622
410
  try {
623
- const url = new URL(target.startsWith("http") ? target : `tcp://${target}`);
624
- return `${url.hostname}${url.port ? ":" + url.port : ""}`;
625
- } catch {
626
- return target.replace(/\/.*$/, "").replace(/\?.*$/, "").replace(/@.*:/, ":");
627
- }
628
- }
629
- async function createCartographyTools(db, sessionId, opts = {}) {
630
- const { tool, createSdkMcpServer } = await import("@anthropic-ai/claude-agent-sdk");
631
- const tools = [
632
- tool("save_node", "Save an infrastructure node to the catalog", {
633
- id: z2.string(),
634
- type: z2.enum(NODE_TYPES),
635
- name: z2.string(),
636
- discoveredVia: z2.string(),
637
- confidence: z2.number().min(0).max(1),
638
- metadata: z2.record(z2.string(), z2.unknown()).optional(),
639
- tags: z2.array(z2.string()).optional(),
640
- domain: z2.string().optional().describe('Business domain, e.g. "Marketing", "Finance"'),
641
- subDomain: z2.string().optional().describe('Sub-domain, e.g. "Forecast client orders"'),
642
- qualityScore: z2.number().min(0).max(100).optional().describe("Data quality score 0\u2013100")
643
- }, async (args) => {
644
- const node = {
645
- id: stripSensitive(args["id"]),
646
- type: args["type"],
647
- name: args["name"],
648
- discoveredVia: args["discoveredVia"],
649
- confidence: args["confidence"],
650
- metadata: args["metadata"] ?? {},
651
- tags: args["tags"] ?? [],
652
- domain: args["domain"],
653
- subDomain: args["subDomain"],
654
- qualityScore: args["qualityScore"]
655
- };
656
- db.upsertNode(sessionId, node);
657
- return { content: [{ type: "text", text: `\u2713 Node: ${node.id}` }] };
658
- }),
659
- tool("save_edge", "Save a relationship (edge) between two nodes \u2014 ALWAYS save edges when connections are clear", {
660
- sourceId: z2.string(),
661
- targetId: z2.string(),
662
- relationship: z2.enum(EDGE_RELATIONSHIPS),
663
- evidence: z2.string(),
664
- confidence: z2.number().min(0).max(1)
665
- }, async (args) => {
666
- db.insertEdge(sessionId, {
667
- sourceId: args["sourceId"],
668
- targetId: args["targetId"],
669
- relationship: args["relationship"],
670
- evidence: args["evidence"],
671
- confidence: args["confidence"]
672
- });
673
- return { content: [{ type: "text", text: `\u2713 ${args["sourceId"]}\u2192${args["targetId"]}` }] };
674
- }),
675
- tool("get_catalog", "Get the current catalog \u2014 use before save_node to avoid duplicates", {
676
- includeEdges: z2.boolean().default(true)
677
- }, async (args) => {
678
- const nodes = db.getNodes(sessionId);
679
- const edges = args["includeEdges"] ? db.getEdges(sessionId) : [];
680
- return {
681
- content: [{
682
- type: "text",
683
- text: JSON.stringify({
684
- count: { nodes: nodes.length, edges: edges.length },
685
- nodeIds: nodes.map((n) => n.id)
686
- })
687
- }]
688
- };
689
- }),
690
- tool("ask_user", "Ask the user a question \u2014 for clarifications, missing context, or consent (e.g. before scanning browser history)", {
691
- question: z2.string().describe("The question for the user (clear and specific)"),
692
- context: z2.string().optional().describe("Optional context explaining why this is relevant")
693
- }, async (args) => {
694
- const question = args["question"];
695
- const context = args["context"];
696
- if (opts.onAskUser) {
697
- const answer = await opts.onAskUser(question, context);
698
- return { content: [{ type: "text", text: answer }] };
699
- }
700
- return {
701
- content: [{ type: "text", text: "(Non-interactive mode \u2014 please continue without this information)" }]
702
- };
703
- }),
704
- tool("scan_bookmarks", "Scan all browser bookmarks \u2014 hostnames only, no personal data (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)", {
705
- minConfidence: z2.number().min(0).max(1).default(0.5).optional()
706
- }, async () => {
707
- const hosts = await scanAllBookmarks();
708
- return {
709
- content: [{
710
- type: "text",
711
- text: JSON.stringify({
712
- count: hosts.length,
713
- hosts: hosts.map((h) => ({
714
- hostname: h.hostname,
715
- port: h.port,
716
- protocol: h.protocol,
717
- source: h.source
718
- })),
719
- note: "Hostnames only \u2014 no paths, no personal data. Classify each as a business tool (save_node) or ignore (social media, news, shopping)."
720
- })
721
- }]
722
- };
723
- }),
724
- tool("scan_browser_history", "Scan browser history \u2014 anonymized hostnames + visit frequency. ALWAYS call ask_user for consent before using this tool.", {
725
- minVisits: z2.number().min(1).default(3).optional().describe("Minimum visit count to include a host (filters rarely-visited sites)")
726
- }, async (args) => {
727
- const minVisits = args["minVisits"] ?? 3;
728
- const hosts = await scanAllHistory();
729
- const filtered = hosts.filter((h) => h.visitCount >= minVisits);
730
- return {
731
- content: [{
732
- type: "text",
733
- text: JSON.stringify({
734
- count: filtered.length,
735
- note: "Anonymized \u2014 hostnames only, no URLs, no paths, no personal data. Classify business tools as saas_tool nodes.",
736
- hosts: filtered.map((h) => ({
737
- hostname: h.hostname,
738
- visitCount: h.visitCount,
739
- protocol: h.protocol,
740
- source: h.source
741
- }))
742
- })
743
- }]
744
- };
745
- }),
746
- tool("scan_local_databases", "Scan for local database files and running DB servers \u2014 PostgreSQL databases, MySQL, SQLite files from installed apps", {
747
- deep: z2.boolean().default(false).optional().describe("Also search home directory recursively for SQLite/DB files (slower)")
748
- }, async (args) => {
749
- const deep = args["deep"] ?? false;
750
- const results = {};
751
- results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
752
- if (IS_WIN) {
753
- results["DB_SERVICES"] = scanWindowsDbServices() || "(no database services found)";
754
- }
755
- if (commandExists("psql")) {
756
- if (IS_WIN) {
757
- results["POSTGRES_DATABASES"] = run("psql -lqt", { timeout: 1e4 }) || "(psql found but not running or requires auth)";
758
- } else {
759
- results["POSTGRES_DATABASES"] = run(`psql -lqt 2>/dev/null | grep -v "template0\\|template1" | awk '{print $1}' | grep -v "^$\\|^|"`) || "(psql not running or not available)";
760
- results["POSTGRES_CLUSTERS"] = run("pg_lsclusters 2>/dev/null") || "(pg_lsclusters not available)";
761
- }
762
- } else {
763
- results["POSTGRES_DATABASES"] = "(psql not installed)";
411
+ while (turn < maxTurns) {
412
+ if (Date.now() > deadlineMs) {
413
+ yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
414
+ yield { kind: "done" };
415
+ return;
764
416
  }
765
- if (commandExists("mysql")) {
766
- if (IS_WIN) {
767
- results["MYSQL_DATABASES"] = run('mysql --connect-timeout=3 -e "SHOW DATABASES;"', { timeout: 1e4 }) || "(mysql not running or requires auth)";
768
- } else {
769
- results["MYSQL_DATABASES"] = run('mysql --connect-timeout=3 -e "SHOW DATABASES;" 2>/dev/null') || "(mysql not running or requires auth)";
770
- }
771
- } else {
772
- results["MYSQL_DATABASES"] = "(mysql not installed)";
417
+ const result = await chat(outcomes);
418
+ turn++;
419
+ yield { kind: "turn", turn };
420
+ if (result.text) yield { kind: "thinking", text: result.text };
421
+ if (result.toolCalls.length === 0) {
422
+ yield { kind: "done" };
423
+ return;
773
424
  }
774
- if (commandExists("mongosh")) {
775
- if (IS_WIN) {
776
- results["MONGODB_DATABASES"] = run(`mongosh --quiet --eval "db.adminCommand({listDatabases:1}).databases.map(d=>d.name).join('\\n')"`, { timeout: 1e4 }) || "(mongosh not available)";
777
- } else {
778
- results["MONGODB_DATABASES"] = run(`mongosh --quiet --eval "db.adminCommand({listDatabases:1}).databases.map(d=>d.name).join('\\n')" 2>/dev/null`) || "(mongosh not available)";
779
- }
780
- } else {
781
- results["MONGODB_DATABASES"] = "(mongosh not installed)";
425
+ const nextOutcomes = [];
426
+ for (const call of result.toolCalls) {
427
+ yield { kind: "tool_call", tool: call.name, input: call.args };
428
+ const output = await dispatchTool(call, tools, db, sessionId);
429
+ yield { kind: "tool_result", tool: call.name, output };
430
+ nextOutcomes.push({ id: call.id, name: call.name, output });
782
431
  }
783
- if (commandExists("redis-cli")) {
784
- if (IS_WIN) {
785
- results["REDIS_INFO"] = run("redis-cli info server", { timeout: 1e4 }).split("\n").slice(0, 5).join("\n") || "(redis-cli not available)";
786
- } else {
787
- results["REDIS_INFO"] = run("redis-cli info server 2>/dev/null | head -5") || "(redis-cli not available)";
788
- }
789
- } else {
790
- results["REDIS_INFO"] = "(redis-cli not installed)";
791
- }
792
- const appDirs = dbScanDirs();
793
- if (appDirs.length > 0) {
794
- results["SQLITE_APP_FILES"] = findFiles(appDirs, ["*.sqlite", "*.sqlite3", "*.db"], 4, 80) || "(none found)";
795
- }
796
- if (deep) {
797
- if (IS_WIN) {
798
- results["SQLITE_DEEP_SCAN"] = run(
799
- `Get-ChildItem -Path '${HOME}' -Recurse -Depth 6 -Include '*.sqlite','*.sqlite3','*.db' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch 'node_modules|\\.git' } | Select-Object -First 100 -ExpandProperty FullName`,
800
- { timeout: 3e4 }
801
- ) || "(none found)";
802
- } else {
803
- results["SQLITE_DEEP_SCAN"] = run(`find "${HOME}" -maxdepth 6 \\( -name "*.sqlite" -o -name "*.sqlite3" -o -name "*.db" \\) -not -path "*/node_modules/*" -not -path "*/.git/*" 2>/dev/null | head -100`) || "(none found)";
804
- }
432
+ outcomes = nextOutcomes;
433
+ }
434
+ yield { kind: "done" };
435
+ } catch (err) {
436
+ yield { kind: "error", text: `Discovery error: ${err instanceof Error ? err.message : String(err)}` };
437
+ yield { kind: "done" };
438
+ }
439
+ }
440
+
441
+ // src/providers/openai.ts
442
+ function toOpenAITools(tools) {
443
+ return tools.map((t) => ({
444
+ type: "function",
445
+ function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
446
+ }));
447
+ }
448
+ function createOpenAIProvider() {
449
+ return {
450
+ name: "openai",
451
+ async ensureAvailable(_config) {
452
+ try {
453
+ await import("openai");
454
+ } catch {
455
+ throw new Error(
456
+ "OpenAI provider unavailable: the `openai` package is not installed.\n Install: npm install openai"
457
+ );
805
458
  }
806
- if (IS_WIN) {
807
- results["DB_CONFIG_FILES"] = run(
808
- `Get-ChildItem -Path '${HOME}' -Recurse -Depth 4 -Include '.env','.env.local','database.yml','database.json','docker-compose.yml' -ErrorAction SilentlyContinue | Select-Object -First 20 -ExpandProperty FullName`,
809
- { timeout: 15e3 }
810
- ) || "(none found)";
811
- } else {
812
- results["DB_CONFIG_FILES"] = run(`find "${HOME}" -maxdepth 4 \\( -name ".env" -o -name ".env.local" -o -name "database.yml" -o -name "database.json" -o -name "docker-compose.yml" \\) 2>/dev/null | head -20`) || "(none found)";
813
- }
814
- const out = Object.entries(results).map(([k, v]) => `=== ${k} ===
815
- ${v}`).join("\n\n");
816
- return { content: [{ type: "text", text: out }] };
817
- }),
818
- tool("scan_k8s_resources", "Scan Kubernetes cluster via kubectl \u2014 100% readonly (get, describe)", {
819
- namespace: z2.string().optional().describe("Filter by namespace \u2014 empty = all namespaces")
820
- }, async (args) => {
821
- const ns = args["namespace"];
822
- const nsFlag = ns ? `-n ${ns}` : "--all-namespaces";
823
- const runK = createScanRunner(run, { timeout: 15e3, threshold: 3 });
824
- const sections = IS_WIN ? [
825
- ["CONTEXT", "kubectl config current-context"],
826
- ["NODES", "kubectl get nodes -o wide"],
827
- ["NAMESPACES", "kubectl get namespaces"],
828
- ["SERVICES", `kubectl get services ${nsFlag}`],
829
- ["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
830
- ["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
831
- ["INGRESSES", `kubectl get ingress ${nsFlag}`],
832
- ["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running`],
833
- ["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system"]
834
- ] : [
835
- ["CONTEXT", 'kubectl config current-context 2>/dev/null || echo "(no context set)"'],
836
- ["NODES", "kubectl get nodes -o wide"],
837
- ["NAMESPACES", "kubectl get namespaces"],
838
- ["SERVICES", `kubectl get services ${nsFlag}`],
839
- ["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
840
- ["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
841
- ["INGRESSES", `kubectl get ingress ${nsFlag} 2>/dev/null || echo "(none)"`],
842
- ["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running 2>/dev/null | head -60`],
843
- ["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system 2>/dev/null | head -30"]
844
- ];
845
- const out = sections.map(([l, c]) => `=== ${l} ===
846
- ${runK(c)}`).join("\n\n");
847
- return { content: [{ type: "text", text: out }] };
848
- }),
849
- tool("scan_aws_resources", "Scan AWS infrastructure via AWS CLI \u2014 100% readonly (describe, list)", {
850
- region: z2.string().optional().describe("AWS Region \u2014 default: AWS_DEFAULT_REGION or profile"),
851
- profile: z2.string().optional().describe("AWS CLI profile")
852
- }, async (args) => {
853
- const region = args["region"];
854
- const profile = args["profile"];
855
- const env = { ...process.env };
856
- if (region) env["AWS_DEFAULT_REGION"] = region;
857
- const pf = profile ? `--profile ${profile}` : "";
858
- const runAws = createScanRunner(run, { timeout: 2e4, env, threshold: 3 });
859
- const sections = [
860
- ["IDENTITY", `aws sts get-caller-identity ${pf} --output json`],
861
- ["EC2", `aws ec2 describe-instances ${pf} --query "Reservations[*].Instances[*].[InstanceId,InstanceType,State.Name,PublicIpAddress,PrivateIpAddress]" --output table`],
862
- ["RDS", `aws rds describe-db-instances ${pf} --query "DBInstances[*].[DBInstanceIdentifier,Engine,DBInstanceStatus,Endpoint.Address,Endpoint.Port]" --output table`],
863
- ["ELB_V2", `aws elbv2 describe-load-balancers ${pf} --query "LoadBalancers[*].[LoadBalancerName,DNSName,Type,State.Code]" --output table`],
864
- ["EKS", `aws eks list-clusters ${pf} --output json`],
865
- ["ELASTICACHE", `aws elasticache describe-cache-clusters ${pf} --query "CacheClusters[*].[CacheClusterId,Engine,CacheClusterStatus]" --output table`],
866
- ["S3", `aws s3 ls ${pf}`],
867
- ["VPC", `aws ec2 describe-vpcs ${pf} --query "Vpcs[*].[VpcId,CidrBlock,IsDefault]" --output table`]
868
- ];
869
- const out = sections.map(([l, c]) => `=== ${l} ===
870
- ${runAws(c)}`).join("\n\n");
871
- return { content: [{ type: "text", text: out }] };
872
- }),
873
- tool("scan_gcp_resources", "Scan Google Cloud Platform via gcloud CLI \u2014 100% readonly (list, describe)", {
874
- project: z2.string().optional().describe("GCP Project ID \u2014 default: current gcloud project")
875
- }, async (args) => {
876
- const project = args["project"];
877
- const pf = project ? `--project ${project}` : "";
878
- const runGcp = createScanRunner(run, { timeout: 2e4, threshold: 3 });
879
- const sections = [
880
- ["IDENTITY", `gcloud config list account --format="value(core.account)"`],
881
- ["COMPUTE_INSTANCES", `gcloud compute instances list ${pf}`],
882
- ["SQL_INSTANCES", `gcloud sql instances list ${pf}`],
883
- ["GKE_CLUSTERS", `gcloud container clusters list ${pf}`],
884
- ["CLOUD_RUN", `gcloud run services list ${pf} --platform managed`],
885
- ["CLOUD_FUNCTIONS", `gcloud functions list ${pf}`],
886
- ["REDIS", `gcloud redis instances list ${pf} --regions=-`],
887
- ["PUBSUB", `gcloud pubsub topics list ${pf}`],
888
- ["SPANNER", `gcloud spanner instances list ${pf}`]
889
- ];
890
- const out = sections.map(([l, c]) => `=== ${l} ===
891
- ${runGcp(c)}`).join("\n\n");
892
- return { content: [{ type: "text", text: out }] };
893
- }),
894
- tool("scan_azure_resources", "Scan Azure infrastructure via az CLI \u2014 100% readonly (list, show)", {
895
- subscription: z2.string().optional().describe("Azure Subscription ID"),
896
- resourceGroup: z2.string().optional().describe("Filter by resource group")
897
- }, async (args) => {
898
- const sub = args["subscription"];
899
- const rg = args["resourceGroup"];
900
- const sf = sub ? `--subscription ${sub}` : "";
901
- const rf = rg ? `--resource-group ${rg}` : "";
902
- const runAz = createScanRunner(run, { timeout: 2e4, threshold: 3 });
903
- const sections = [
904
- ["IDENTITY", `az account show --output json ${sf}`],
905
- ["VMS", `az vm list ${sf} ${rf} --output table`],
906
- ["AKS", `az aks list ${sf} ${rf} --output table`],
907
- ["SQL_SERVERS", `az sql server list ${sf} ${rf} --output table`],
908
- ["POSTGRES", `az postgres server list ${sf} ${rf} --output table`],
909
- ["REDIS", `az redis list ${sf} ${rf} --output table`],
910
- ["WEBAPPS", `az webapp list ${sf} ${rf} --output table`],
911
- ["CONTAINER_APPS", `az containerapp list ${sf} ${rf} --output table`],
912
- ["FUNCTIONS", `az functionapp list ${sf} ${rf} --output table`]
913
- ];
914
- const out = sections.map(([l, c]) => `=== ${l} ===
915
- ${runAz(c)}`).join("\n\n");
916
- return { content: [{ type: "text", text: out }] };
917
- }),
918
- tool("scan_installed_apps", "Scan all installed apps and tools \u2014 IDEs, office, dev tools, business apps, databases", {
919
- searchHint: z2.string().optional().describe('Optional search term to find specific tools (e.g. "hubspot windsurf cursor")')
920
- }, async (args) => {
921
- const hint = args["searchHint"];
922
- const results = {};
923
- results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
924
- if (IS_MAC) {
925
- results["APPLICATIONS"] = run("ls /Applications/ 2>/dev/null | head -200") || "(empty)";
926
- results["USER_APPLICATIONS"] = run("ls ~/Applications/ 2>/dev/null | head -100") || "(empty)";
927
- results["BREW_CASKS"] = run("brew list --cask 2>/dev/null | head -100") || "(brew not installed)";
928
- results["BREW_FORMULAE"] = run("brew list --formula 2>/dev/null | head -150") || "(brew not installed)";
929
- results["SPOTLIGHT_APPS"] = run(`mdfind "kMDItemKind == 'Application'" 2>/dev/null | grep -v "^/System" | grep -v "^/Library/Apple" | head -100`) || "(Spotlight not available)";
930
- } else if (IS_LINUX) {
931
- results["DPKG"] = run("dpkg --list 2>/dev/null | awk '{print $2}' | head -200") || "(dpkg not available)";
932
- results["SNAP"] = run("snap list 2>/dev/null | head -50") || "(snap not available)";
933
- results["FLATPAK"] = run("flatpak list 2>/dev/null | head -50") || "(flatpak not available)";
934
- results["DESKTOP_FILES"] = run("ls /usr/share/applications/*.desktop ~/.local/share/applications/*.desktop 2>/dev/null | xargs -I{} basename {} .desktop 2>/dev/null | head -100") || "(no .desktop files)";
935
- results["RPM"] = run("rpm -qa 2>/dev/null | head -200") || "(rpm not available)";
936
- } else if (IS_WIN) {
937
- results["WINGET"] = run("winget list --accept-source-agreements", { timeout: 2e4 }) || "(winget not available)";
938
- results["INSTALLED_PROGRAMS"] = scanWindowsPrograms() || "(registry scan failed)";
939
- results["CHOCO"] = run("choco list --local-only", { timeout: 15e3 }) || "(chocolatey not installed)";
940
- results["SCOOP"] = run("scoop list", { timeout: 15e3 }) || "(scoop not installed)";
941
- }
942
- const knownTools = [
943
- // IDEs & Editors
944
- "code",
945
- "code-insiders",
946
- "cursor",
947
- "windsurf",
948
- "zed",
949
- "vim",
950
- "nvim",
951
- "emacs",
952
- "nano",
953
- "sublime_text",
954
- "atom",
955
- "idea",
956
- "webstorm",
957
- "pycharm",
958
- "goland",
959
- "datagrip",
960
- "clion",
961
- "rider",
962
- "phpstorm",
963
- "rubymine",
964
- "appcode",
965
- // Dev Tools
966
- "git",
967
- "gh",
968
- "docker",
969
- "docker-compose",
970
- "podman",
971
- "kubectl",
972
- "helm",
973
- "terraform",
974
- "ansible",
975
- "node",
976
- "npm",
977
- "npx",
978
- "yarn",
979
- "pnpm",
980
- "bun",
981
- "deno",
982
- "python",
983
- "python3",
984
- "pip",
985
- "pip3",
986
- "pipenv",
987
- "poetry",
988
- "conda",
989
- "ruby",
990
- "gem",
991
- "bundler",
992
- "rails",
993
- "java",
994
- "mvn",
995
- "gradle",
996
- "kotlin",
997
- "go",
998
- "cargo",
999
- "rustc",
1000
- "php",
1001
- "composer",
1002
- "dotnet",
1003
- // Databases
1004
- "psql",
1005
- "mysql",
1006
- "mysqladmin",
1007
- "mongo",
1008
- "mongosh",
1009
- "redis-cli",
1010
- "sqlite3",
1011
- "clickhouse-client",
1012
- // Cloud CLIs
1013
- "aws",
1014
- "gcloud",
1015
- "az",
1016
- "heroku",
1017
- "fly",
1018
- "vercel",
1019
- "netlify",
1020
- "wrangler",
1021
- // Infra
1022
- "vagrant",
1023
- "packer",
1024
- "consul",
1025
- "vault",
1026
- "nomad",
1027
- // Communication / SaaS
1028
- "slack",
1029
- "discord",
1030
- "zoom",
1031
- "teams",
1032
- "skype",
1033
- "telegram",
1034
- "signal",
1035
- // Browsers
1036
- "google-chrome",
1037
- "chromium",
1038
- "firefox",
1039
- "safari",
1040
- "brave",
1041
- "opera",
1042
- "edge",
1043
- // Windows-specific
1044
- ...IS_WIN ? ["pwsh", "powershell", "wsl", "winget", "choco", "scoop", "notepad++"] : [],
1045
- // Monitoring / Analytics
1046
- "datadog-agent",
1047
- "newrelic-agent",
1048
- "prometheus",
1049
- "grafana-cli",
1050
- // Other tools
1051
- "ngrok",
1052
- "stripe",
1053
- "supabase",
1054
- "neon"
459
+ if (!process.env["OPENAI_API_KEY"]) {
460
+ throw new Error(
461
+ "OpenAI provider unavailable: OPENAI_API_KEY is not set.\n Set it: export OPENAI_API_KEY=sk-..."
462
+ );
463
+ }
464
+ },
465
+ async *run(ctx) {
466
+ const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
467
+ const mod = await import("openai");
468
+ const apiKey = process.env["OPENAI_API_KEY"] ?? "";
469
+ const baseURL = process.env["OPENAI_BASE_URL"];
470
+ const client = new mod.default({ apiKey, ...baseURL ? { baseURL } : {} });
471
+ const handlers = await buildCartographyToolHandlers(db, sessionId, {
472
+ onAskUser,
473
+ maxResponseBytes: config.maxToolResponseBytes
474
+ });
475
+ const tools = [...handlers, createBashTool()];
476
+ const openaiTools = toOpenAITools(tools);
477
+ const messages = [
478
+ { role: "system", content: systemPrompt },
479
+ { role: "user", content: initialPrompt }
1055
480
  ];
1056
- const found = [];
1057
- const notFound = [];
1058
- for (const t of knownTools) {
1059
- const r = commandExists(t);
1060
- if (r) found.push(`${t}: ${r}`);
1061
- else notFound.push(t);
1062
- }
1063
- results["TOOLS_FOUND"] = found.join("\n") || "(none found)";
1064
- results["TOOLS_NOT_FOUND"] = notFound.join(", ");
1065
- if (hint) {
1066
- const terms = hint.split(/[\s,]+/).filter(Boolean);
1067
- const hintResults = [];
1068
- for (const term of terms) {
1069
- const safe = term.replace(/[^a-zA-Z0-9._-]/g, "");
1070
- if (!safe) continue;
1071
- const cmdPath = commandExists(safe);
1072
- if (cmdPath) {
1073
- hintResults.push(`${term}: ${cmdPath}`);
1074
- continue;
1075
- }
1076
- let fallback = "";
1077
- if (IS_WIN) {
1078
- fallback = run(
1079
- `Get-ChildItem -Path 'C:\\Program Files','C:\\Program Files (x86)','${HOME}\\AppData\\Local\\Programs' -Recurse -Depth 3 -Filter '*${safe}*' -ErrorAction SilentlyContinue | Select-Object -First 5 -ExpandProperty FullName`,
1080
- { timeout: 1e4 }
1081
- );
1082
- } else if (IS_MAC) {
1083
- fallback = run(`mdfind -name "${safe}" 2>/dev/null | head -5`);
1084
- } else {
1085
- fallback = run(`find /usr/bin /usr/local/bin /opt/homebrew/bin ~/.local/bin /Applications ~/Applications 2>/dev/null -iname "*${safe}*" -maxdepth 3 2>/dev/null | head -5`);
1086
- }
1087
- hintResults.push(fallback ? `${term}: ${fallback}` : `${term}: (not found)`);
481
+ const chat = async (outcomes) => {
482
+ for (const oc of outcomes) {
483
+ messages.push({ role: "tool", tool_call_id: oc.id, content: oc.output });
1088
484
  }
1089
- results["HINT_SEARCH"] = hintResults.join("\n");
1090
- }
1091
- const out = Object.entries(results).map(([k, v]) => `=== ${k} ===
1092
- ${v}`).join("\n\n");
1093
- return { content: [{ type: "text", text: out }] };
1094
- })
1095
- ];
1096
- return createSdkMcpServer({
1097
- name: "cartography",
1098
- version: "0.1.0",
1099
- tools
1100
- });
485
+ const completion = await client.chat.completions.create({
486
+ model: config.models.lead,
487
+ messages,
488
+ tools: openaiTools,
489
+ tool_choice: "auto"
490
+ });
491
+ const choice = completion.choices[0]?.message;
492
+ const text = choice?.content ?? "";
493
+ const toolCalls = choice?.tool_calls ?? [];
494
+ messages.push({ role: "assistant", content: text || null, ...toolCalls.length ? { tool_calls: toolCalls } : {} });
495
+ return {
496
+ text,
497
+ toolCalls: toolCalls.map((tc) => ({
498
+ id: tc.id,
499
+ name: tc.function.name,
500
+ args: parseArgs(tc.function.arguments)
501
+ }))
502
+ };
503
+ };
504
+ yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
505
+ }
506
+ };
1101
507
  }
1102
-
1103
- // src/safety.ts
1104
- var BLOCKED_CMDS = /\b(rm|mv|cp|dd|mkfs|chmod|chown|chgrp|kill|killall|pkill|reboot|shutdown|poweroff|halt|systemctl\s+(start|stop|restart|enable|disable)|service\s+(start|stop|restart)|docker\s+(rm|rmi|stop|kill|exec|run|build|push)|kubectl\s+(delete|apply|edit|exec|run|create|patch)|apt|yum|dnf|pacman|pip\s+install|npm\s+(install|uninstall)|curl\s+.*-X\s*(POST|PUT|DELETE|PATCH)|wget\s+-O|tee\s|Remove-Item|Move-Item|Copy-Item|Stop-Process|Stop-Service|Restart-Service|Start-Service|Set-Service|Invoke-WebRequest\s+.*-Method\s+(POST|PUT|DELETE|PATCH)|del\s|rmdir\s|Format-Volume|Clear-Disk|Stop-Computer|Restart-Computer|Uninstall-Package|Install-Package|Install-Module)\b/i;
1105
- var BLOCKED_REDIRECTS = />>|>[^>]|Out-File|Set-Content|Add-Content/;
1106
- var safetyHook = async (input, _toolUseID, _options) => {
1107
- if (!("tool_name" in input)) return {};
1108
- if (input.tool_name !== "Bash") return {};
1109
- const cmd = input.tool_input?.command ?? "";
1110
- if (BLOCKED_CMDS.test(cmd) || BLOCKED_REDIRECTS.test(cmd)) {
1111
- return {
1112
- hookSpecificOutput: {
1113
- hookEventName: "PreToolUse",
1114
- permissionDecision: "deny",
1115
- permissionDecisionReason: `BLOCKED: "${cmd}" \u2014 read-only policy`
1116
- }
1117
- };
508
+ function parseArgs(raw) {
509
+ try {
510
+ const parsed = JSON.parse(raw || "{}");
511
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
512
+ } catch {
513
+ return {};
1118
514
  }
515
+ }
516
+
517
+ // src/providers/ollama.ts
518
+ var DEFAULT_HOST = "http://127.0.0.1:11434";
519
+ function host() {
520
+ return (process.env["OLLAMA_HOST"] || DEFAULT_HOST).replace(/\/+$/, "");
521
+ }
522
+ function toOllamaTools(tools) {
523
+ return tools.map((t) => ({
524
+ type: "function",
525
+ function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
526
+ }));
527
+ }
528
+ function createOllamaProvider() {
1119
529
  return {
1120
- hookSpecificOutput: {
1121
- hookEventName: "PreToolUse",
1122
- permissionDecision: "allow"
530
+ name: "ollama",
531
+ async ensureAvailable(_config) {
532
+ const base = host();
533
+ try {
534
+ const res = await fetch(`${base}/api/tags`, { method: "GET" });
535
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
536
+ } catch {
537
+ throw new Error(
538
+ `Ollama provider unavailable: not reachable at ${base}.
539
+ Start it: ollama serve (or set OLLAMA_HOST=<url>)`
540
+ );
541
+ }
542
+ },
543
+ async *run(ctx) {
544
+ const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
545
+ const base = host();
546
+ const handlers = await buildCartographyToolHandlers(db, sessionId, {
547
+ onAskUser,
548
+ maxResponseBytes: config.maxToolResponseBytes
549
+ });
550
+ const tools = [...handlers, createBashTool()];
551
+ const ollamaTools = toOllamaTools(tools);
552
+ const messages = [
553
+ { role: "system", content: systemPrompt },
554
+ { role: "user", content: initialPrompt }
555
+ ];
556
+ const chat = async (outcomes) => {
557
+ for (const oc of outcomes) {
558
+ messages.push({ role: "tool", content: oc.output });
559
+ }
560
+ const res = await fetch(`${base}/api/chat`, {
561
+ method: "POST",
562
+ headers: { "content-type": "application/json" },
563
+ body: JSON.stringify({ model: config.models.lead, messages, tools: ollamaTools, stream: false })
564
+ });
565
+ if (!res.ok) {
566
+ throw new Error(`Ollama /api/chat returned HTTP ${res.status}`);
567
+ }
568
+ const data = await res.json();
569
+ const text = data.message?.content ?? "";
570
+ const toolCalls = data.message?.tool_calls ?? [];
571
+ messages.push({
572
+ role: "assistant",
573
+ content: text,
574
+ ...toolCalls.length ? { tool_calls: toolCalls } : {}
575
+ });
576
+ return {
577
+ text,
578
+ toolCalls: toolCalls.map((tc, i) => ({
579
+ id: `${tc.function.name}:${i}`,
580
+ name: tc.function.name,
581
+ args: tc.function.arguments ?? {}
582
+ }))
583
+ };
584
+ };
585
+ yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
1123
586
  }
1124
587
  };
1125
- };
588
+ }
589
+
590
+ // src/providers/registry.ts
591
+ function createDefaultRegistry() {
592
+ const r = new ProviderRegistry();
593
+ r.register("claude", createClaudeProvider);
594
+ r.register("openai", createOpenAIProvider);
595
+ r.register("ollama", createOllamaProvider);
596
+ return r;
597
+ }
598
+ var defaultProviderRegistry = createDefaultRegistry();
1126
599
 
1127
600
  // src/agent.ts
1128
601
  async function runDiscovery(config, db, sessionId, onEvent, onAskUser, hint) {
1129
- const { query } = await import("@anthropic-ai/claude-agent-sdk");
1130
- const tools = await createCartographyTools(db, sessionId, { onAskUser });
1131
602
  const hintSection = hint ? `
1132
603
  \u26A1 USER HINT (HIGH PRIORITY): The user wants to find these specific tools: "${hint}"
1133
604
  \u2192 Run scan_installed_apps(searchHint: "${hint}") IMMEDIATELY and save found tools as saas_tool nodes!
@@ -1227,6 +698,7 @@ RULES:
1227
698
  \u2022 metadata allowed: { description, category, port, version, path } \u2014 no passwords
1228
699
  \u2022 Call get_catalog before save_node \u2192 avoid duplicates
1229
700
  \u2022 Save edges whenever connections are clearly identifiable
701
+ \u2022 Max crawl depth: ${config.maxDepth} hops from an entry point \u2014 do not chase leads deeper than this
1230
702
 
1231
703
  Entry points: ${config.entryPoints.join(", ")}`;
1232
704
  const initialPrompt = hint ? `Start discovery with USER HINT: "${hint}".
@@ -1241,75 +713,28 @@ Then systematically scan local services, then config files.
1241
713
  Finally, map all edges (Step 8 \u2014 critical!) before finishing.
1242
714
  Use ask_user when you need context from the user.`;
1243
715
  const MAX_DISCOVERY_MS = 30 * 60 * 1e3;
1244
- let turnCount = 0;
716
+ const startTime = Date.now();
717
+ const deadlineMs = startTime + MAX_DISCOVERY_MS;
718
+ const provider = defaultProviderRegistry.resolve(config.provider ?? "claude");
719
+ await provider.ensureAvailable(config);
720
+ const ctx = {
721
+ config,
722
+ db,
723
+ sessionId,
724
+ systemPrompt,
725
+ initialPrompt,
726
+ onAskUser,
727
+ deadlineMs
728
+ };
1245
729
  try {
1246
- const startTime = Date.now();
1247
- for await (const msg of query({
1248
- prompt: initialPrompt,
1249
- options: {
1250
- model: config.agentModel,
1251
- maxTurns: config.maxTurns,
1252
- systemPrompt,
1253
- mcpServers: { cartography: tools },
1254
- allowedTools: [
1255
- "Bash",
1256
- "mcp__cartograph__save_node",
1257
- "mcp__cartograph__save_edge",
1258
- "mcp__cartograph__get_catalog",
1259
- "mcp__cartograph__scan_bookmarks",
1260
- "mcp__cartograph__scan_browser_history",
1261
- "mcp__cartograph__scan_installed_apps",
1262
- "mcp__cartograph__scan_local_databases",
1263
- "mcp__cartograph__scan_k8s_resources",
1264
- "mcp__cartograph__scan_aws_resources",
1265
- "mcp__cartograph__scan_gcp_resources",
1266
- "mcp__cartograph__scan_azure_resources",
1267
- "mcp__cartograph__ask_user"
1268
- ],
1269
- hooks: {
1270
- PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }]
1271
- },
1272
- permissionMode: "bypassPermissions"
1273
- }
1274
- })) {
1275
- if (Date.now() - startTime > MAX_DISCOVERY_MS) {
730
+ for await (const event of provider.run(ctx)) {
731
+ onEvent?.(event);
732
+ if (event.kind === "done") return;
733
+ if (Date.now() > deadlineMs) {
1276
734
  onEvent?.({ kind: "error", text: `Discovery timeout after ${MAX_DISCOVERY_MS / 6e4} minutes` });
1277
735
  onEvent?.({ kind: "done" });
1278
736
  return;
1279
737
  }
1280
- if (!onEvent) continue;
1281
- if (msg.type === "assistant") {
1282
- turnCount++;
1283
- onEvent({ kind: "turn", turn: turnCount });
1284
- for (const block of msg.message.content) {
1285
- if (block.type === "text") {
1286
- onEvent({ kind: "thinking", text: block.text });
1287
- }
1288
- if (block.type === "tool_use") {
1289
- onEvent({
1290
- kind: "tool_call",
1291
- tool: block.name,
1292
- input: block.input
1293
- });
1294
- }
1295
- }
1296
- }
1297
- if (msg.type === "user") {
1298
- const content = msg.message?.content;
1299
- if (Array.isArray(content)) {
1300
- for (const block of content) {
1301
- if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
1302
- const tb = block;
1303
- const text = typeof tb.content === "string" ? tb.content : "";
1304
- onEvent({ kind: "tool_result", tool: tb.tool_use_id ?? "", output: text });
1305
- }
1306
- }
1307
- }
1308
- }
1309
- if (msg.type === "result") {
1310
- onEvent({ kind: "done" });
1311
- return;
1312
- }
1313
738
  }
1314
739
  } catch (err) {
1315
740
  const message = err instanceof Error ? err.message : String(err);
@@ -1318,8 +743,184 @@ Use ask_user when you need context from the user.`;
1318
743
  }
1319
744
  }
1320
745
 
746
+ // src/config.ts
747
+ import { readFileSync as readFileSync2 } from "fs";
748
+ var ConfigError = class extends Error {
749
+ constructor(message) {
750
+ super(message);
751
+ this.name = "ConfigError";
752
+ }
753
+ };
754
+ function loadConfig(path) {
755
+ const file = readConfigFile(path);
756
+ const overrides = {};
757
+ if (file.organization) overrides.organization = file.organization;
758
+ const entryPoints = file.schedule?.entryPoints ?? file.entryPoints;
759
+ if (entryPoints) overrides.entryPoints = [...entryPoints];
760
+ const dbPath = file.schedule?.dbPath ?? file.dbPath;
761
+ if (dbPath) overrides.dbPath = dbPath;
762
+ if (file.schedule) overrides.schedule = file.schedule;
763
+ if (file.centralDb) {
764
+ const merged = { ...file.centralDb, ...centralDbFromEnv() };
765
+ overrides.centralDb = merged;
766
+ }
767
+ return defaultConfig(overrides);
768
+ }
769
+ function readConfigFile(path) {
770
+ let raw;
771
+ try {
772
+ raw = readFileSync2(path, "utf-8");
773
+ } catch (err) {
774
+ throw new ConfigError(
775
+ `Cannot read config file ${path}: ${err instanceof Error ? err.message : String(err)}`
776
+ );
777
+ }
778
+ let json;
779
+ try {
780
+ json = JSON.parse(raw);
781
+ } catch (err) {
782
+ throw new ConfigError(
783
+ `Invalid JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`
784
+ );
785
+ }
786
+ const parsed = ConfigFileSchema.safeParse(json);
787
+ if (!parsed.success) {
788
+ const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
789
+ throw new ConfigError(`Invalid config in ${path}: ${detail}`);
790
+ }
791
+ return parsed.data;
792
+ }
793
+
794
+ // src/schedule.ts
795
+ var FIELD_SPECS = [
796
+ { name: "minute", min: 0, max: 59 },
797
+ { name: "hour", min: 0, max: 23 },
798
+ { name: "dom", min: 1, max: 31 },
799
+ { name: "month", min: 1, max: 12 },
800
+ { name: "dow", min: 0, max: 7 }
801
+ // 7 and 0 both mean Sunday; normalized to 0 below
802
+ ];
803
+ function parseField(raw, spec) {
804
+ const out = /* @__PURE__ */ new Set();
805
+ const add = (n) => {
806
+ if (!Number.isInteger(n) || n < spec.min || n > spec.max) {
807
+ throw new RangeError(`Invalid value "${n}" in cron field "${spec.name}" (allowed ${spec.min}-${spec.max})`);
808
+ }
809
+ out.add(spec.name === "dow" && n === 7 ? 0 : n);
810
+ };
811
+ for (const part of raw.split(",")) {
812
+ if (part === "") {
813
+ throw new RangeError(`Empty term in cron field "${spec.name}"`);
814
+ }
815
+ const [rangePart, stepPart, ...rest] = part.split("/");
816
+ if (rest.length > 0) {
817
+ throw new RangeError(`Malformed step in cron field "${spec.name}": "${part}"`);
818
+ }
819
+ let step = 1;
820
+ if (stepPart !== void 0) {
821
+ step = Number(stepPart);
822
+ if (!Number.isInteger(step) || step < 1) {
823
+ throw new RangeError(`Invalid step "${stepPart}" in cron field "${spec.name}"`);
824
+ }
825
+ }
826
+ let lo;
827
+ let hi;
828
+ if (rangePart === "*") {
829
+ lo = spec.min;
830
+ hi = spec.max;
831
+ } else if (rangePart.includes("-")) {
832
+ const [a, b, ...extra] = rangePart.split("-");
833
+ if (extra.length > 0) {
834
+ throw new RangeError(`Malformed range in cron field "${spec.name}": "${rangePart}"`);
835
+ }
836
+ lo = Number(a);
837
+ hi = Number(b);
838
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
839
+ throw new RangeError(`Non-numeric range in cron field "${spec.name}": "${rangePart}"`);
840
+ }
841
+ if (lo > hi) {
842
+ throw new RangeError(`Descending range in cron field "${spec.name}": "${rangePart}"`);
843
+ }
844
+ } else {
845
+ const n = Number(rangePart);
846
+ if (!Number.isInteger(n)) {
847
+ throw new RangeError(`Non-numeric value in cron field "${spec.name}": "${rangePart}"`);
848
+ }
849
+ lo = n;
850
+ hi = stepPart !== void 0 ? spec.max : n;
851
+ }
852
+ for (let v = lo; v <= hi; v += step) add(v);
853
+ }
854
+ if (out.size === 0) {
855
+ throw new RangeError(`Cron field "${spec.name}" matched no values`);
856
+ }
857
+ return out;
858
+ }
859
+ function parseCron(expr) {
860
+ const fields = expr.trim().split(/\s+/);
861
+ if (fields.length !== 5) {
862
+ throw new RangeError(`Cron expression must have 5 fields (got ${fields.length}): "${expr}"`);
863
+ }
864
+ const [minute, hour, dom, month, dow] = FIELD_SPECS.map((spec, i) => parseField(fields[i], spec));
865
+ return { minute, hour, dom, month, dow };
866
+ }
867
+ function matches(fields, date) {
868
+ if (!fields.minute.has(date.getUTCMinutes())) return false;
869
+ if (!fields.hour.has(date.getUTCHours())) return false;
870
+ if (!fields.month.has(date.getUTCMonth() + 1)) return false;
871
+ const domRestricted = fields.dom.size !== 31;
872
+ const dowRestricted = fields.dow.size !== 7;
873
+ const domOk = fields.dom.has(date.getUTCDate());
874
+ const dowOk = fields.dow.has(date.getUTCDay());
875
+ if (domRestricted && dowRestricted) return domOk || dowOk;
876
+ if (domRestricted) return domOk;
877
+ if (dowRestricted) return dowOk;
878
+ return true;
879
+ }
880
+ var MAX_SEARCH_MINUTES = 4 * 366 * 24 * 60;
881
+ function nextRun(expr, after) {
882
+ const fields = parseCron(expr);
883
+ const cursor2 = new Date(after.getTime());
884
+ cursor2.setUTCSeconds(0, 0);
885
+ cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
886
+ for (let i = 0; i < MAX_SEARCH_MINUTES; i++) {
887
+ if (matches(fields, cursor2)) return new Date(cursor2.getTime());
888
+ cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
889
+ }
890
+ throw new RangeError(`No cron match for "${expr}" within ~4 years after ${after.toISOString()}`);
891
+ }
892
+ async function runOnce(cfg, db) {
893
+ const prior = db.getLatestSession("discover");
894
+ if (prior) {
895
+ const r = await runLocalDiscovery(db, prior.id, {
896
+ hint: cfg.entryPoints.join(","),
897
+ plugins: cfg.plugins,
898
+ mode: "update",
899
+ onProgress: (line) => logInfo(`scan: ${line}`)
900
+ });
901
+ const delta = r.delta ?? diffTopology({ nodes: [], edges: [] }, { nodes: [], edges: [] });
902
+ logInfo("scheduled run complete", { sessionId: prior.id, base: prior.id, ...delta.summary });
903
+ return { sessionId: prior.id, baseSessionId: prior.id, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
904
+ }
905
+ const sessionId = db.createSession("discover", cfg);
906
+ try {
907
+ const r = await runLocalDiscovery(db, sessionId, {
908
+ hint: cfg.entryPoints.join(","),
909
+ plugins: cfg.plugins,
910
+ mode: "replace",
911
+ onProgress: (line) => logInfo(`scan: ${line}`)
912
+ });
913
+ const current = { nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId) };
914
+ const delta = diffTopology({ nodes: [], edges: [] }, current);
915
+ logInfo("scheduled run complete", { sessionId, base: null, ...delta.summary });
916
+ return { sessionId, baseSessionId: void 0, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
917
+ } finally {
918
+ db.endSession(sessionId);
919
+ }
920
+ }
921
+
1321
922
  // src/exporter.ts
1322
- import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
923
+ import { mkdirSync, writeFileSync } from "fs";
1323
924
  import { join as join2 } from "path";
1324
925
 
1325
926
  // src/hex.ts
@@ -1427,6 +1028,11 @@ function layoutClusters(groups, hexSize) {
1427
1028
  }
1428
1029
  function findFreeOrigin(occupied, count, gap) {
1429
1030
  const key = (q, r) => `${q},${r}`;
1031
+ const parsedOccupied = [];
1032
+ for (const oKey of occupied) {
1033
+ const [oq, or] = oKey.split(",").map(Number);
1034
+ parsedOccupied.push({ q: oq, r: or });
1035
+ }
1430
1036
  for (let searchRadius = 1; searchRadius < 100; searchRadius++) {
1431
1037
  const candidates = hexSpiral({ q: 0, r: 0 }, 1 + 6 * searchRadius * (searchRadius + 1) / 2);
1432
1038
  for (const candidate of candidates) {
@@ -1437,9 +1043,8 @@ function findFreeOrigin(occupied, count, gap) {
1437
1043
  fits = false;
1438
1044
  break;
1439
1045
  }
1440
- for (const oKey of occupied) {
1441
- const [oq, or] = oKey.split(",").map(Number);
1442
- if (hexDistance(tp, { q: oq, r: or }) < gap) {
1046
+ for (const oc of parsedOccupied) {
1047
+ if (hexDistance(tp, oc) < gap) {
1443
1048
  fits = false;
1444
1049
  break;
1445
1050
  }
@@ -1537,12 +1142,9 @@ function buildMapData(nodes, edges, options) {
1537
1142
 
1538
1143
  // src/exporter.ts
1539
1144
  function nodeLayer(type) {
1540
- if (type === "saas_tool") return "saas";
1541
- if (["web_service", "api_endpoint"].includes(type)) return "web";
1542
- if (["database_server", "database", "table", "cache_server"].includes(type)) return "data";
1543
- if (["message_broker", "queue", "topic"].includes(type)) return "messaging";
1544
- if (["host", "container", "pod", "k8s_cluster"].includes(type)) return "infra";
1545
- if (type === "config_file") return "config";
1145
+ for (const [layer, types] of Object.entries(NODE_TYPE_GROUPS)) {
1146
+ if (types.includes(type)) return layer;
1147
+ }
1546
1148
  return "other";
1547
1149
  }
1548
1150
  var LAYER_LABELS = {
@@ -1683,6 +1285,66 @@ function generateDependencyMermaid(nodes, edges) {
1683
1285
  }
1684
1286
  return lines.join("\n");
1685
1287
  }
1288
+ var DIFF_CLASSES = {
1289
+ added: "fill:#0d3d0d,stroke:#22c55e,color:#86efac",
1290
+ removed: "fill:#3d0d0d,stroke:#ef4444,color:#fca5a5",
1291
+ changed: "fill:#3d2f0d,stroke:#f59e0b,color:#fcd34d",
1292
+ context: "fill:#1e1e1e,stroke:#555555,color:#999999"
1293
+ };
1294
+ function diffNodeLabel(node, suffix) {
1295
+ const icon = MERMAID_ICONS[node.type] ?? "?";
1296
+ const extra = suffix ? `<br/><small>\u0394 ${suffix}</small>` : "";
1297
+ return `["${icon} <b>${node.name}</b><br/><small>${node.type}</small>${extra}"]`;
1298
+ }
1299
+ function generateDiffMermaid(diff) {
1300
+ const total = diff.summary.nodesAdded + diff.summary.nodesRemoved + diff.summary.nodesChanged + diff.summary.edgesAdded + diff.summary.edgesRemoved;
1301
+ if (total === 0) return 'graph TB\n nodrift["\u2713 No drift between the two sessions"]';
1302
+ const lines = ["graph TB"];
1303
+ for (const [k, style] of Object.entries(DIFF_CLASSES)) lines.push(` classDef ${k} ${style}`);
1304
+ lines.push("");
1305
+ const rank = { added: 3, removed: 3, changed: 3, context: 0 };
1306
+ const entries = /* @__PURE__ */ new Map();
1307
+ const place = (node, cls, suffix) => {
1308
+ const prev = entries.get(node.id);
1309
+ if (prev && rank[prev.cls] >= rank[cls]) return;
1310
+ entries.set(node.id, { node, cls, suffix });
1311
+ };
1312
+ for (const n of diff.nodes.added) place(n, "added");
1313
+ for (const n of diff.nodes.removed) place(n, "removed");
1314
+ for (const c of diff.nodes.changed) place(c.after, "changed", c.changedFields.join(", "));
1315
+ const contextNode = (id) => ({
1316
+ id,
1317
+ type: "unknown",
1318
+ name: id,
1319
+ discoveredVia: "diff",
1320
+ confidence: 1,
1321
+ metadata: {},
1322
+ tags: [],
1323
+ sessionId: "",
1324
+ discoveredAt: "",
1325
+ depth: 0
1326
+ });
1327
+ const ensureEndpoint = (id) => {
1328
+ if (!entries.has(id)) place(contextNode(id), "context");
1329
+ };
1330
+ for (const e of [...diff.edges.added, ...diff.edges.removed]) {
1331
+ ensureEndpoint(e.sourceId);
1332
+ ensureEndpoint(e.targetId);
1333
+ }
1334
+ for (const { node, cls, suffix } of entries.values()) {
1335
+ lines.push(` ${sanitize(node.id)}${diffNodeLabel(node, suffix)}:::${cls}`);
1336
+ }
1337
+ lines.push("");
1338
+ for (const e of diff.edges.added) {
1339
+ const label = EDGE_LABELS[e.relationship] ?? e.relationship;
1340
+ lines.push(` ${sanitize(e.sourceId)} ==>|"+ ${label}"| ${sanitize(e.targetId)}`);
1341
+ }
1342
+ for (const e of diff.edges.removed) {
1343
+ const label = EDGE_LABELS[e.relationship] ?? e.relationship;
1344
+ lines.push(` ${sanitize(e.sourceId)} -.->|"- ${label}"| ${sanitize(e.targetId)}`);
1345
+ }
1346
+ return lines.join("\n");
1347
+ }
1686
1348
  function exportBackstageYAML(nodes, edges, org) {
1687
1349
  const owner = org ?? "unknown";
1688
1350
  const docs = [];
@@ -1702,7 +1364,7 @@ function exportBackstageYAML(nodes, edges, org) {
1702
1364
  `spec:`,
1703
1365
  ` type: ${node.type}`,
1704
1366
  ` lifecycle: production`,
1705
- ` owner: ${owner}`,
1367
+ ` owner: ${node.owner ?? owner}`,
1706
1368
  ...deps.length > 0 ? [" dependsOn:", ...deps] : []
1707
1369
  ].join("\n");
1708
1370
  docs.push(doc);
@@ -2823,8 +2485,82 @@ function exportJGF(nodes, edges) {
2823
2485
  };
2824
2486
  return JSON.stringify(jgf, null, 2);
2825
2487
  }
2488
+ function csvField(v) {
2489
+ let s = String(v);
2490
+ if (/^[=+\-@]/.test(s)) s = `'${s}`;
2491
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
2492
+ }
2493
+ function exportCostCSV(summary) {
2494
+ const rows = ["scope,key,currency,period,total,nodes"];
2495
+ for (const c of summary.costByDomain) {
2496
+ rows.push(["domain", c.domain, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
2497
+ }
2498
+ for (const c of summary.costByOwner) {
2499
+ rows.push(["owner", c.owner, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
2500
+ }
2501
+ return rows.join("\n") + "\n";
2502
+ }
2503
+ function exportCostSummary(summary) {
2504
+ return JSON.stringify({
2505
+ costByDomain: summary.costByDomain,
2506
+ costByOwner: summary.costByOwner,
2507
+ costCoverage: summary.costCoverage
2508
+ }, null, 2);
2509
+ }
2510
+ var SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
2511
+ function exportComplianceReport(report, format) {
2512
+ if (format === "json") return JSON.stringify(report, null, 2);
2513
+ if (format === "markdown") {
2514
+ const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
2515
+ const out = [
2516
+ `# Compliance \u2014 ${report.rulesetName} v${report.rulesetVersion}`,
2517
+ ``,
2518
+ `**Status:** ${report.status.toUpperCase()} \xB7 **Score:** ${scoreStr}`,
2519
+ ``,
2520
+ `| Controls | Count |`,
2521
+ `|----------|-------|`,
2522
+ `| Passed | ${report.totals.passed} |`,
2523
+ `| Failed | ${report.totals.failed} |`,
2524
+ `| Not applicable | ${report.totals.notApplicable} |`,
2525
+ `| Total | ${report.totals.rules} |`,
2526
+ ``,
2527
+ `| Severity | Failed | Passed |`,
2528
+ `|----------|--------|--------|`,
2529
+ ...["critical", "high", "medium", "low"].map(
2530
+ (s) => `| ${s} | ${report.bySeverity[s].failed} | ${report.bySeverity[s].passed} |`
2531
+ )
2532
+ ];
2533
+ if (report.gaps.length === 0) {
2534
+ out.push(``, `\u2713 No compliance gaps.`);
2535
+ } else {
2536
+ out.push(``, `## Gaps`);
2537
+ for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
2538
+ out.push(``, `### [${g.severity}] ${g.control} \u2014 ${g.title}`, ...g.nodeIds.map((id) => `- \`${id}\``));
2539
+ }
2540
+ }
2541
+ return out.join("\n");
2542
+ }
2543
+ const lines = [
2544
+ "graph TB",
2545
+ " classDef critical fill:#7f1d1d,stroke:#ef4444,color:#fff;",
2546
+ " classDef high fill:#7c2d12,stroke:#f97316,color:#fff;",
2547
+ " classDef medium fill:#713f12,stroke:#eab308,color:#fff;",
2548
+ " classDef low fill:#1e3a5f,stroke:#3b82f6,color:#fff;"
2549
+ ];
2550
+ if (report.gaps.length === 0) {
2551
+ lines.push(' ok["\u2713 No compliance gaps"]');
2552
+ return lines.join("\n");
2553
+ }
2554
+ const mmSafe = (s) => s.replace(/["\]\r\n]/g, "'");
2555
+ let i = 0;
2556
+ for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
2557
+ const gid = `g${i++}`;
2558
+ lines.push(` ${gid}["${mmSafe(g.control)}: ${mmSafe(g.title)} (${g.nodeIds.length})"]:::${g.severity}`);
2559
+ }
2560
+ return lines.join("\n");
2561
+ }
2826
2562
  function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery"]) {
2827
- mkdirSync2(outputDir, { recursive: true });
2563
+ mkdirSync(outputDir, { recursive: true });
2828
2564
  const nodes = db.getNodes(sessionId);
2829
2565
  const edges = db.getEdges(sessionId);
2830
2566
  const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
@@ -2842,36 +2578,774 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
2842
2578
  if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
2843
2579
  writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
2844
2580
  }
2581
+ if (formats.includes("cost")) {
2582
+ const summary = db.getGraphSummary(sessionId);
2583
+ writeFileSync(join2(outputDir, "cost-by-domain.csv"), exportCostCSV(summary));
2584
+ writeFileSync(join2(outputDir, "cost-summary.json"), exportCostSummary(summary));
2585
+ }
2845
2586
  }
2846
2587
 
2847
- // src/cli.ts
2848
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2588
+ // src/compliance/report.ts
2589
+ var NODE_CAP = 50;
2590
+ function formatComplianceText(report) {
2591
+ const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
2592
+ const lines = [
2593
+ `Compliance: ${report.rulesetName} v${report.rulesetVersion} \u2014 ${report.status.toUpperCase()} (score ${scoreStr})`,
2594
+ `Controls: ${report.totals.passed} passed, ${report.totals.failed} failed, ${report.totals.notApplicable} n/a (of ${report.totals.rules})`,
2595
+ "",
2596
+ "By severity (failed/passed):",
2597
+ ...["critical", "high", "medium", "low"].map(
2598
+ (s) => ` - ${s}: ${report.bySeverity[s].failed} failed / ${report.bySeverity[s].passed} passed`
2599
+ )
2600
+ ];
2601
+ if (report.gaps.length === 0) {
2602
+ lines.push("", "\u2713 No compliance gaps.");
2603
+ return lines.join("\n");
2604
+ }
2605
+ lines.push("", `Gaps (${report.gaps.length}):`);
2606
+ for (const g of report.gaps) {
2607
+ lines.push(` \u2717 [${g.severity}] ${g.control} \u2014 ${g.title}`);
2608
+ const shown = g.nodeIds.slice(0, NODE_CAP);
2609
+ for (const id of shown) lines.push(` ${id}`);
2610
+ if (g.nodeIds.length > NODE_CAP) lines.push(` \u2026 +${g.nodeIds.length - NODE_CAP} more`);
2611
+ }
2612
+ return lines.join("\n");
2613
+ }
2614
+
2615
+ // src/cost.ts
2616
+ import { readFileSync as readFileSync3 } from "fs";
2849
2617
  import { resolve } from "path";
2618
+ function splitCsvLine(line) {
2619
+ const out = [];
2620
+ let cur = "";
2621
+ let inQuotes = false;
2622
+ for (let i = 0; i < line.length; i++) {
2623
+ const ch = line[i];
2624
+ if (inQuotes) {
2625
+ if (ch === '"') {
2626
+ if (line[i + 1] === '"') {
2627
+ cur += '"';
2628
+ i++;
2629
+ } else {
2630
+ inQuotes = false;
2631
+ }
2632
+ } else cur += ch;
2633
+ } else if (ch === '"') {
2634
+ inQuotes = true;
2635
+ } else if (ch === ",") {
2636
+ out.push(cur);
2637
+ cur = "";
2638
+ } else cur += ch;
2639
+ }
2640
+ out.push(cur);
2641
+ return out.map((s) => s.trim());
2642
+ }
2643
+ function parseCostCsv(text) {
2644
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
2645
+ if (lines.length === 0) return [];
2646
+ const header = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
2647
+ const col = (name) => header.indexOf(name);
2648
+ const iNode = col("nodeid");
2649
+ const iOwner = col("owner");
2650
+ const iAmount = col("amount");
2651
+ const iCurrency = col("currency");
2652
+ const iPeriod = col("period");
2653
+ const iSource = col("source");
2654
+ if (iNode < 0) {
2655
+ logWarn('cost csv: missing required "nodeId" header column');
2656
+ return [];
2657
+ }
2658
+ const records = [];
2659
+ for (let r = 1; r < lines.length; r++) {
2660
+ const f = splitCsvLine(lines[r]);
2661
+ const nodeId = f[iNode];
2662
+ if (!nodeId) {
2663
+ logWarn(`cost csv: row ${r + 1} skipped (empty nodeId)`);
2664
+ continue;
2665
+ }
2666
+ const rec = { nodeId };
2667
+ if (iOwner >= 0 && f[iOwner]) rec.owner = f[iOwner];
2668
+ const amountRaw = iAmount >= 0 ? f[iAmount] : "";
2669
+ if (amountRaw) {
2670
+ const parsed = CostEntrySchema.safeParse({
2671
+ amount: Number(amountRaw),
2672
+ currency: iCurrency >= 0 ? f[iCurrency] : void 0,
2673
+ period: iPeriod >= 0 ? f[iPeriod] : void 0,
2674
+ ...iSource >= 0 && f[iSource] ? { source: f[iSource] } : {}
2675
+ });
2676
+ if (!parsed.success) {
2677
+ logWarn(`cost csv: row ${r + 1} skipped (invalid cost fields)`);
2678
+ if (!rec.owner) continue;
2679
+ } else {
2680
+ rec.cost = parsed.data;
2681
+ }
2682
+ }
2683
+ if (rec.owner || rec.cost) records.push(rec);
2684
+ }
2685
+ return records;
2686
+ }
2687
+ var CsvCostSource = class {
2688
+ constructor(opts) {
2689
+ this.opts = opts;
2690
+ const base = opts.filePath.split(/[\\/]/).pop() ?? opts.filePath;
2691
+ this.id = `csv:${base}`;
2692
+ }
2693
+ id;
2694
+ async fetch() {
2695
+ const text = readFileSync3(resolve(this.opts.filePath), "utf-8");
2696
+ const records = parseCostCsv(text);
2697
+ const match = this.opts.match ?? "nodeId";
2698
+ const out = /* @__PURE__ */ new Map();
2699
+ if (match === "nodeId") {
2700
+ for (const rec of records) out.set(rec.nodeId, rec);
2701
+ return out;
2702
+ }
2703
+ if (!this.opts.db || !this.opts.sessionId) {
2704
+ logWarn(`cost csv: match '${match}' requires db + sessionId; falling back to nodeId`);
2705
+ for (const rec of records) out.set(rec.nodeId, rec);
2706
+ return out;
2707
+ }
2708
+ const nodes = this.opts.db.getNodes(this.opts.sessionId);
2709
+ const index = /* @__PURE__ */ new Map();
2710
+ for (const n of nodes) {
2711
+ if (match === "name") index.set(n.name, n.id);
2712
+ else for (const t of n.tags) index.set(t, n.id);
2713
+ }
2714
+ for (const rec of records) {
2715
+ const resolved = index.get(rec.nodeId);
2716
+ out.set(resolved ?? rec.nodeId, { ...rec, nodeId: resolved ?? rec.nodeId });
2717
+ }
2718
+ return out;
2719
+ }
2720
+ };
2721
+ async function enrichCosts(db, sessionId, source) {
2722
+ const records = await source.fetch();
2723
+ let matched = 0;
2724
+ const unmatchedIds = [];
2725
+ for (const [nodeId, rec] of records) {
2726
+ const ok = db.enrichNodeAttribution(sessionId, nodeId, {
2727
+ owner: rec.owner ?? void 0,
2728
+ cost: rec.cost ?? void 0
2729
+ });
2730
+ if (ok) matched++;
2731
+ else unmatchedIds.push(nodeId);
2732
+ }
2733
+ return { source: source.id, total: records.size, matched, unmatched: unmatchedIds.length, unmatchedIds };
2734
+ }
2735
+
2736
+ // src/cli.ts
2737
+ import { readFileSync as readFileSync5, existsSync as existsSync3, writeFileSync as writeFileSync3 } from "fs";
2738
+ import { resolve as resolve2, dirname as dirname2 } from "path";
2739
+ import { fileURLToPath } from "url";
2850
2740
  import { createInterface } from "readline";
2851
2741
 
2852
- // src/logger.ts
2853
- var verboseMode = false;
2854
- function setVerbose(v) {
2855
- verboseMode = v;
2856
- }
2857
- function log(level, message, context) {
2858
- if (level === "DEBUG" && !verboseMode) return;
2859
- const entry = {
2860
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2861
- level,
2862
- message,
2863
- ...context && Object.keys(context).length > 0 ? { context } : {}
2742
+ // src/sharing.ts
2743
+ function wildcardCount(pattern) {
2744
+ return (pattern.match(/\*/g) ?? []).length;
2745
+ }
2746
+ function globMatch(pattern, id) {
2747
+ let re = "^";
2748
+ for (let i = 0; i < pattern.length; i++) {
2749
+ const c = pattern[i];
2750
+ if (c === "*") {
2751
+ if (pattern[i + 1] === "*") {
2752
+ re += ".*";
2753
+ i++;
2754
+ } else re += "[^:]*";
2755
+ } else {
2756
+ re += c.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2757
+ }
2758
+ }
2759
+ re += "$";
2760
+ return new RegExp(re).test(id);
2761
+ }
2762
+ function resolveSharingLevel(nodeId, policy) {
2763
+ const matches2 = policy.overrides.filter((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId)).sort(
2764
+ (a, b) => wildcardCount(a.pattern) - wildcardCount(b.pattern) || b.pattern.length - a.pattern.length
2765
+ );
2766
+ return matches2.length ? matches2[0].level : policy.defaultLevel;
2767
+ }
2768
+ function nodeHosts(node) {
2769
+ const out = [node.id, node.name];
2770
+ const meta = node.metadata ?? {};
2771
+ for (const k of ["host", "url", "domain"]) {
2772
+ const v = meta[k];
2773
+ if (typeof v === "string") out.push(v);
2774
+ }
2775
+ if (node.domain) out.push(node.domain);
2776
+ return out;
2777
+ }
2778
+ function resolveEffectiveLevel(node, policy) {
2779
+ if (nodeHosts(node).some((h) => isPersonalHost(h))) return "none";
2780
+ return resolveSharingLevel(node.id, policy);
2781
+ }
2782
+ function applySharingLevel(node, level, orgKey, db) {
2783
+ if (level === "none") return null;
2784
+ if (level === "full") return { ...node, metadata: { ...node.metadata ?? {} }, tags: [...node.tags ?? []] };
2785
+ return {
2786
+ ...node,
2787
+ id: pseudonymizeString(node.id, orgKey, db),
2788
+ name: pseudonymizeString(node.name, orgKey, db),
2789
+ metadata: pseudonymize(node.metadata ?? {}, orgKey, db),
2790
+ tags: (node.tags ?? []).map((t) => pseudonymizeString(t, orgKey, db))
2791
+ };
2792
+ }
2793
+ function previewShare(db, sessionId, orgKey, policy, opts = {}) {
2794
+ const persist = opts.persistReversal ? db : void 0;
2795
+ const nodes = db.getNodes(sessionId);
2796
+ const edges = db.getEdges(sessionId);
2797
+ const entries = [];
2798
+ const idMap = /* @__PURE__ */ new Map();
2799
+ const droppedNodeIds = [];
2800
+ for (const node of nodes) {
2801
+ const level = resolveEffectiveLevel(node, policy);
2802
+ const payload = applySharingLevel(node, level, orgKey, persist);
2803
+ entries.push({ node, level, payload });
2804
+ if (payload === null) {
2805
+ idMap.set(node.id, null);
2806
+ droppedNodeIds.push(node.id);
2807
+ } else {
2808
+ idMap.set(node.id, payload.id);
2809
+ }
2810
+ }
2811
+ const outEdges = [];
2812
+ for (const e of edges) {
2813
+ const src = idMap.get(e.sourceId);
2814
+ const tgt = idMap.get(e.targetId);
2815
+ if (src == null || tgt == null) continue;
2816
+ outEdges.push({ sourceId: src, targetId: tgt, relationship: e.relationship });
2817
+ }
2818
+ return { nodes: entries, edges: outEdges, droppedNodeIds };
2819
+ }
2820
+ function isRemembered(policy, nodeId) {
2821
+ const matched = policy.overrides.some((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId));
2822
+ return matched || policy.defaultLevel !== "none";
2823
+ }
2824
+
2825
+ // src/sync/hash.ts
2826
+ import { createHash } from "crypto";
2827
+ function shareHash(kind, payload) {
2828
+ return createHash("sha256").update(stableStringify({ kind, payload })).digest("hex");
2829
+ }
2830
+
2831
+ // src/sync/classify.ts
2832
+ function classify(input) {
2833
+ const { preview, policy, sharedHashes } = input;
2834
+ const result = { share: [], withhold: [], pending: [] };
2835
+ const sharedNodeIds = /* @__PURE__ */ new Set();
2836
+ for (const entry of preview.nodes) {
2837
+ if (entry.payload === null) {
2838
+ result.withhold.push({ contentHash: "", kind: "node", nodeId: entry.node.id, payload: null });
2839
+ continue;
2840
+ }
2841
+ const contentHash = shareHash("node", entry.payload);
2842
+ if (sharedHashes.has(contentHash)) continue;
2843
+ const item = { contentHash, kind: "node", nodeId: entry.node.id, payload: entry.payload };
2844
+ if (isRemembered(policy, entry.node.id)) {
2845
+ result.share.push(item);
2846
+ sharedNodeIds.add(entry.node.id);
2847
+ } else {
2848
+ result.pending.push(item);
2849
+ }
2850
+ }
2851
+ const sharedRemappedIds = /* @__PURE__ */ new Set();
2852
+ for (const entry of preview.nodes) {
2853
+ if (entry.payload !== null && sharedNodeIds.has(entry.node.id)) {
2854
+ sharedRemappedIds.add(entry.payload.id);
2855
+ }
2856
+ }
2857
+ for (const e of preview.edges) {
2858
+ const payload = { sourceId: e.sourceId, targetId: e.targetId, relationship: e.relationship };
2859
+ const contentHash = shareHash("edge", payload);
2860
+ const bothShared = sharedRemappedIds.has(e.sourceId) && sharedRemappedIds.has(e.targetId);
2861
+ if (!bothShared) {
2862
+ result.withhold.push({ contentHash: "", kind: "edge", payload });
2863
+ continue;
2864
+ }
2865
+ if (sharedHashes.has(contentHash)) continue;
2866
+ result.share.push({ contentHash, kind: "edge", payload });
2867
+ }
2868
+ return result;
2869
+ }
2870
+
2871
+ // src/sync/push.ts
2872
+ import { createHash as createHash2 } from "crypto";
2873
+ var PUSH_SCHEMA_VERSION = 1;
2874
+ var DEFAULT_BATCH = 100;
2875
+ var DEFAULT_RETRIES = 4;
2876
+ var DEFAULT_TIMEOUT_MS = 15e3;
2877
+ function defaultLog(line) {
2878
+ process.stderr.write(`[cartography-sync] ${line}
2879
+ `);
2880
+ }
2881
+ function defaultSleep(ms) {
2882
+ return new Promise((r) => setTimeout(r, ms));
2883
+ }
2884
+ function batchKey(items) {
2885
+ const hashes = items.map((i) => i.contentHash).sort();
2886
+ return createHash2("sha256").update(stableStringify(hashes)).digest("hex");
2887
+ }
2888
+ async function pushDeltas(config, items, opts = {}) {
2889
+ const central = config.centralDb;
2890
+ if (!central?.url || !central.token) {
2891
+ throw new Error("sync push: centralDb not configured (set centralDb.url + token)");
2892
+ }
2893
+ let parsed;
2894
+ try {
2895
+ parsed = new URL(central.url);
2896
+ } catch {
2897
+ throw new Error("sync push: centralDb.url is not a valid URL");
2898
+ }
2899
+ const insecureAllowed = process.env.CARTOGRAPHY_ALLOW_INSECURE_SYNC === "1";
2900
+ if (parsed.protocol !== "https:" && !insecureAllowed) {
2901
+ throw new Error(
2902
+ `sync push: refusing to send over insecure ${parsed.protocol}// \u2014 use https:// (or set CARTOGRAPHY_ALLOW_INSECURE_SYNC=1 for local testing only)`
2903
+ );
2904
+ }
2905
+ const log = opts.log ?? defaultLog;
2906
+ const sleep = opts.sleep ?? defaultSleep;
2907
+ const fetchImpl = opts.fetchImpl ?? fetch;
2908
+ const batchSize = Math.max(1, opts.batchSize ?? central.batchSize ?? DEFAULT_BATCH);
2909
+ const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_RETRIES);
2910
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2911
+ const safeUrl = stripSensitive(central.url);
2912
+ if (items.length === 0) {
2913
+ log("nothing to push (0 approved items)");
2914
+ return { sent: 0, batches: 0, failed: 0, sentHashes: [] };
2915
+ }
2916
+ const batches = [];
2917
+ for (let i = 0; i < items.length; i += batchSize) {
2918
+ batches.push(items.slice(i, i + batchSize).map((it) => ({ ...it, payload: redactValue(it.payload) })));
2919
+ }
2920
+ let sent = 0;
2921
+ let failed = 0;
2922
+ const sentHashes = [];
2923
+ for (const batch of batches) {
2924
+ const key = batchKey(batch);
2925
+ const body = JSON.stringify({
2926
+ schemaVersion: PUSH_SCHEMA_VERSION,
2927
+ ...central.org ? { org: central.org } : {},
2928
+ items: batch.map((b) => ({ contentHash: b.contentHash, kind: b.kind, payload: b.payload }))
2929
+ });
2930
+ if (opts.dryRun) {
2931
+ log(`dry-run: would POST ${batch.length} item(s) to ${safeUrl} (idempotency ${key.slice(0, 12)}\u2026)`);
2932
+ sent += batch.length;
2933
+ sentHashes.push(...batch.map((b) => b.contentHash));
2934
+ continue;
2935
+ }
2936
+ let ok = false;
2937
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2938
+ const controller = new AbortController();
2939
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2940
+ const startedAt = Date.now();
2941
+ try {
2942
+ const res = await fetchImpl(central.url, {
2943
+ method: "POST",
2944
+ headers: {
2945
+ "authorization": `Bearer ${central.token}`,
2946
+ "content-type": "application/json",
2947
+ "x-idempotency-key": key
2948
+ },
2949
+ body,
2950
+ signal: controller.signal
2951
+ });
2952
+ const elapsed = Date.now() - startedAt;
2953
+ if (res.ok) {
2954
+ log(`pushed ${batch.length} item(s) \u2192 ${safeUrl} [${res.status}] ${elapsed}ms (attempt ${attempt + 1})`);
2955
+ ok = true;
2956
+ break;
2957
+ }
2958
+ if (res.status >= 400 && res.status < 500) {
2959
+ log(`batch rejected \u2192 ${safeUrl} [${res.status}] (no retry)`);
2960
+ break;
2961
+ }
2962
+ log(`batch failed \u2192 ${safeUrl} [${res.status}] (attempt ${attempt + 1}/${maxRetries + 1})`);
2963
+ } catch (err) {
2964
+ const msg = err instanceof Error ? err.message : String(err);
2965
+ log(`batch error \u2192 ${safeUrl}: ${msg.replace(/https?:\/\/[^\s]+/g, (u) => stripSensitive(u))} (attempt ${attempt + 1}/${maxRetries + 1})`);
2966
+ } finally {
2967
+ clearTimeout(timer);
2968
+ }
2969
+ if (attempt < maxRetries) {
2970
+ const base = Math.min(2 ** attempt * 250, 4e3);
2971
+ await sleep(base + Math.floor(Math.random() * 100));
2972
+ }
2973
+ }
2974
+ if (ok) {
2975
+ sent += batch.length;
2976
+ sentHashes.push(...batch.map((b) => b.contentHash));
2977
+ } else {
2978
+ failed += batch.length;
2979
+ }
2980
+ }
2981
+ return { sent, batches: batches.length, failed, sentHashes };
2982
+ }
2983
+
2984
+ // src/sync/index.ts
2985
+ function runSyncClassify(db, sessionId, config, opts = {}) {
2986
+ if (!config.centralDb?.url) return { enqueued: 0, autoShared: 0, withheld: 0 };
2987
+ const orgKey = opts.orgKey ?? loadOrgKey({ organization: config.organization });
2988
+ const policy = db.getSharingPolicy();
2989
+ const preview = previewShare(db, sessionId, orgKey, policy, { persistReversal: true });
2990
+ const sharedHashes = db.getSharedHashes();
2991
+ const { share, pending, withhold } = classify({ preview, policy, sharedHashes });
2992
+ const writeAll = db.rawConnection().transaction(() => {
2993
+ for (const item of share) {
2994
+ db.enqueuePending({
2995
+ contentHash: item.contentHash,
2996
+ sessionId,
2997
+ nodeId: item.nodeId,
2998
+ kind: item.kind,
2999
+ payload: item.payload,
3000
+ status: "approved",
3001
+ decidedBy: "rule"
3002
+ });
3003
+ }
3004
+ for (const item of pending) {
3005
+ db.enqueuePending({
3006
+ contentHash: item.contentHash,
3007
+ sessionId,
3008
+ nodeId: item.nodeId,
3009
+ kind: item.kind,
3010
+ payload: item.payload,
3011
+ status: "pending"
3012
+ });
3013
+ }
3014
+ for (const item of withhold) {
3015
+ if (!item.contentHash) continue;
3016
+ db.enqueuePending({
3017
+ contentHash: item.contentHash,
3018
+ sessionId,
3019
+ nodeId: item.nodeId,
3020
+ kind: item.kind,
3021
+ payload: item.payload,
3022
+ status: "withheld",
3023
+ decidedBy: "rule"
3024
+ });
3025
+ }
3026
+ });
3027
+ writeAll();
3028
+ return { enqueued: pending.length, autoShared: share.length, withheld: withhold.length };
3029
+ }
3030
+
3031
+ // src/installer/format.ts
3032
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
3033
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3034
+ function parseConfig(text, format) {
3035
+ if (!text.trim()) return {};
3036
+ try {
3037
+ switch (format) {
3038
+ case "json":
3039
+ return JSON.parse(text);
3040
+ case "toml":
3041
+ return parseToml(text);
3042
+ case "yaml":
3043
+ return parseYaml(text) ?? {};
3044
+ }
3045
+ } catch (err) {
3046
+ const detail = err instanceof Error ? err.message : String(err);
3047
+ throw new Error(`Failed to parse existing ${format.toUpperCase()} config: ${detail}`);
3048
+ }
3049
+ }
3050
+ function serializeConfig(obj, format) {
3051
+ switch (format) {
3052
+ case "json":
3053
+ return JSON.stringify(obj, null, 2) + "\n";
3054
+ case "toml":
3055
+ return stringifyToml(obj) + "\n";
3056
+ case "yaml":
3057
+ return stringifyYaml(obj);
3058
+ }
3059
+ }
3060
+
3061
+ // src/installer/merge.ts
3062
+ function isPlainObject(v) {
3063
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3064
+ }
3065
+ function deepMerge(target, source) {
3066
+ const out = { ...target };
3067
+ for (const [key, value] of Object.entries(source)) {
3068
+ const existing = out[key];
3069
+ if (isPlainObject(existing) && isPlainObject(value)) {
3070
+ out[key] = deepMerge(existing, value);
3071
+ } else {
3072
+ out[key] = value;
3073
+ }
3074
+ }
3075
+ return out;
3076
+ }
3077
+
3078
+ // src/installer/shapes.ts
3079
+ function mcpServerObject(entry) {
3080
+ if (entry.url) {
3081
+ return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
3082
+ }
3083
+ return {
3084
+ command: entry.command,
3085
+ args: entry.args ?? [],
3086
+ ...entry.env ? { env: entry.env } : {}
3087
+ };
3088
+ }
3089
+
3090
+ // src/installer/entry.ts
3091
+ var PACKAGE_NAME = "@datasynx/agentic-ai-cartography";
3092
+ var MCP_BIN = "cartography-mcp";
3093
+ var DEFAULT_SERVER_NAME = "cartography";
3094
+ function defaultServerEntry(opts = {}) {
3095
+ if (opts.transport === "http") {
3096
+ return { url: opts.url ?? "http://127.0.0.1:3737/mcp", ...opts.env ? { env: opts.env } : {} };
3097
+ }
3098
+ const args = ["-y", "--package", PACKAGE_NAME, MCP_BIN, ...opts.packageArgs ?? []];
3099
+ return { command: "npx", args, ...opts.env ? { env: opts.env } : {} };
3100
+ }
3101
+
3102
+ // src/installer/install.ts
3103
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
3104
+ import { dirname } from "path";
3105
+ import { homedir } from "os";
3106
+ function currentOs() {
3107
+ if (process.platform === "win32") return "win";
3108
+ if (process.platform === "darwin") return "mac";
3109
+ return "linux";
3110
+ }
3111
+ function defaultContext(scope) {
3112
+ return { scope, os: currentOs(), home: homedir(), cwd: process.cwd(), env: process.env };
3113
+ }
3114
+ function planInstall(spec, ctx, opts) {
3115
+ const path = spec.path(ctx);
3116
+ if (!path) {
3117
+ throw new Error(`${spec.label} does not support the "${ctx.scope}" scope.`);
3118
+ }
3119
+ const fileExists = existsSync2(path);
3120
+ const before = fileExists ? readFileSync4(path, "utf8") : "";
3121
+ const existing = parseConfig(before, spec.format);
3122
+ const merged = spec.apply(existing, opts.serverName ?? DEFAULT_SERVER_NAME, opts.entry);
3123
+ const after = serializeConfig(merged, spec.format);
3124
+ return {
3125
+ client: spec.id,
3126
+ label: spec.label,
3127
+ path,
3128
+ format: spec.format,
3129
+ before,
3130
+ after,
3131
+ fileExists,
3132
+ changed: after !== before,
3133
+ ...spec.note ? { note: spec.note } : {}
3134
+ };
3135
+ }
3136
+ function applyInstall(plan) {
3137
+ mkdirSync2(dirname(plan.path), { recursive: true });
3138
+ writeFileSync2(plan.path, plan.after, "utf8");
3139
+ }
3140
+ function renderDiff(before, after) {
3141
+ if (before === after) return " (no changes)";
3142
+ const b = before.length ? before.split("\n") : [];
3143
+ const a = after.split("\n");
3144
+ const out = [];
3145
+ const max = Math.max(b.length, a.length);
3146
+ for (let i = 0; i < max; i++) {
3147
+ if (b[i] === a[i]) {
3148
+ if (a[i] !== void 0) out.push(` ${a[i]}`);
3149
+ } else {
3150
+ if (b[i] !== void 0) out.push(`- ${b[i]}`);
3151
+ if (a[i] !== void 0) out.push(`+ ${a[i]}`);
3152
+ }
3153
+ }
3154
+ return out.join("\n");
3155
+ }
3156
+
3157
+ // src/installer/registry.ts
3158
+ import { join as join3 } from "path";
3159
+ function jsonKeyedClient(args) {
3160
+ return {
3161
+ id: args.id,
3162
+ label: args.label,
3163
+ format: "json",
3164
+ note: args.note,
3165
+ path: (ctx) => ctx.scope === "project" ? args.projectPath?.(ctx) : args.globalPath(ctx),
3166
+ apply: (existing, name, entry) => deepMerge(existing, { [args.key]: { [name]: mcpServerObject(entry) } })
2864
3167
  };
2865
- process.stderr.write(JSON.stringify(entry) + "\n");
2866
3168
  }
2867
- function logInfo(message, context) {
2868
- log("INFO", message, context);
3169
+ var claudeCode = jsonKeyedClient({
3170
+ id: "claude-code",
3171
+ label: "Claude Code",
3172
+ key: "mcpServers",
3173
+ globalPath: (ctx) => join3(ctx.home, ".claude.json"),
3174
+ projectPath: (ctx) => join3(ctx.cwd, ".mcp.json")
3175
+ });
3176
+ var cursor = jsonKeyedClient({
3177
+ id: "cursor",
3178
+ label: "Cursor",
3179
+ key: "mcpServers",
3180
+ globalPath: (ctx) => join3(ctx.home, ".cursor", "mcp.json"),
3181
+ projectPath: (ctx) => join3(ctx.cwd, ".cursor", "mcp.json")
3182
+ });
3183
+ function vscodeServerObject(entry) {
3184
+ if (entry.url) return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
3185
+ return { type: "stdio", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
3186
+ }
3187
+ function vscodeUserDir(ctx) {
3188
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Code", "User");
3189
+ if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Code", "User");
3190
+ return join3(ctx.home, ".config", "Code", "User");
3191
+ }
3192
+ var vscode = {
3193
+ id: "vscode",
3194
+ label: "VS Code (Copilot)",
3195
+ format: "json",
3196
+ note: "Uses the `servers` key (not `mcpServers`) \u2014 the most common copy-paste mistake.",
3197
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".vscode", "mcp.json") : join3(vscodeUserDir(ctx), "mcp.json"),
3198
+ apply: (existing, name, entry) => deepMerge(existing, { servers: { [name]: vscodeServerObject(entry) } })
3199
+ };
3200
+ var codex = {
3201
+ id: "codex",
3202
+ label: "Codex CLI",
3203
+ format: "toml",
3204
+ note: 'Project scope only loads in "trusted" projects.',
3205
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".codex", "config.toml") : join3(ctx.home, ".codex", "config.toml"),
3206
+ apply: (existing, name, entry) => deepMerge(existing, { mcp_servers: { [name]: mcpServerObject(entry) } })
3207
+ };
3208
+ var windsurf = jsonKeyedClient({
3209
+ id: "windsurf",
3210
+ label: "Windsurf",
3211
+ key: "mcpServers",
3212
+ globalPath: (ctx) => join3(ctx.home, ".codeium", "windsurf", "mcp_config.json")
3213
+ });
3214
+ function codeGlobalStorage(ctx, extensionId) {
3215
+ return join3(vscodeUserDir(ctx), "globalStorage", extensionId, "settings", "cline_mcp_settings.json");
3216
+ }
3217
+ var cline = {
3218
+ id: "cline",
3219
+ label: "Cline",
3220
+ format: "json",
3221
+ path: (ctx) => ctx.scope === "project" ? void 0 : codeGlobalStorage(ctx, "saoudrizwan.claude-dev"),
3222
+ // Cline augments the standard object with its own auto-approve/disable flags.
3223
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: { ...mcpServerObject(entry), alwaysAllow: [], disabled: false } } })
3224
+ };
3225
+ var roo = {
3226
+ id: "roo",
3227
+ label: "Roo Code",
3228
+ format: "json",
3229
+ note: "Project .roo/mcp.json takes precedence over the global settings.",
3230
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".roo", "mcp.json") : codeGlobalStorage(ctx, "rooveterinaryinc.roo-cline"),
3231
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
3232
+ };
3233
+ var zed = {
3234
+ id: "zed",
3235
+ label: "Zed",
3236
+ format: "json",
3237
+ note: 'Manual servers need "source": "custom"; remote uses an mcp-remote bridge.',
3238
+ path: (ctx) => {
3239
+ if (ctx.scope === "project") return join3(ctx.cwd, ".zed", "settings.json");
3240
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Zed", "settings.json");
3241
+ return join3(ctx.home, ".config", "zed", "settings.json");
3242
+ },
3243
+ apply: (existing, name, entry) => {
3244
+ const inner = entry.url ? { source: "custom", url: entry.url } : { source: "custom", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
3245
+ return deepMerge(existing, { context_servers: { [name]: inner } });
3246
+ }
3247
+ };
3248
+ var junie = {
3249
+ id: "junie",
3250
+ label: "JetBrains / Junie",
3251
+ format: "json",
3252
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".junie", "mcp", "mcp.json") : join3(ctx.home, ".junie", "mcp", "mcp.json"),
3253
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
3254
+ };
3255
+ var gemini = {
3256
+ id: "gemini",
3257
+ label: "Gemini CLI",
3258
+ format: "json",
3259
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".gemini", "settings.json") : join3(ctx.home, ".gemini", "settings.json"),
3260
+ apply: (existing, name, entry) => {
3261
+ const inner = entry.url ? { httpUrl: entry.url, ...entry.env ? { env: entry.env } : {} } : mcpServerObject(entry);
3262
+ return deepMerge(existing, { mcpServers: { [name]: inner } });
3263
+ }
3264
+ };
3265
+ var goose = {
3266
+ id: "goose",
3267
+ label: "Goose",
3268
+ format: "yaml",
3269
+ note: "Verify the extension shape against current Goose docs; built-ins are left untouched.",
3270
+ path: (ctx) => {
3271
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Block", "goose", "config", "config.yaml");
3272
+ return join3(ctx.home, ".config", "goose", "config.yaml");
3273
+ },
3274
+ apply: (existing, name, entry) => {
3275
+ const inner = entry.url ? { name, type: "streamable_http", enabled: true, uri: entry.url, ...entry.env ? { env: entry.env } : {} } : { name, type: "stdio", enabled: true, command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
3276
+ return deepMerge(existing, { extensions: { [name]: inner } });
3277
+ }
3278
+ };
3279
+ function isObj(v) {
3280
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3281
+ }
3282
+ var openhands = {
3283
+ id: "openhands",
3284
+ label: "OpenHands",
3285
+ format: "toml",
3286
+ note: "SHTTP is preferred; SSE is legacy. Only api_key is supported (no arbitrary headers).",
3287
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, "config.toml") : join3(ctx.home, ".openhands", "config.toml"),
3288
+ apply: (existing, name, entry) => {
3289
+ const mcp = isObj(existing.mcp) ? { ...existing.mcp } : {};
3290
+ const key = entry.url ? "shttp_servers" : "stdio_servers";
3291
+ const arr = Array.isArray(mcp[key]) ? [...mcp[key]] : [];
3292
+ const item = entry.url ? { url: entry.url, ...entry.env ? { env: entry.env } : {} } : { name, command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
3293
+ const matches2 = (s) => entry.url ? s.url === entry.url : s.name === name;
3294
+ const idx = arr.findIndex(matches2);
3295
+ if (idx >= 0) arr[idx] = item;
3296
+ else arr.push(item);
3297
+ mcp[key] = arr;
3298
+ return { ...existing, mcp };
3299
+ }
3300
+ };
3301
+ var claudeDesktop = {
3302
+ id: "claude-desktop",
3303
+ label: "Claude Desktop",
3304
+ format: "json",
3305
+ note: "One-click install is also available via the .mcpb bundle (npm run build:mcpb).",
3306
+ path: (ctx) => {
3307
+ if (ctx.scope === "project") return void 0;
3308
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
3309
+ if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
3310
+ return join3(ctx.home, ".config", "Claude", "claude_desktop_config.json");
3311
+ },
3312
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
3313
+ };
3314
+ var CLIENTS = [
3315
+ claudeCode,
3316
+ cursor,
3317
+ vscode,
3318
+ codex,
3319
+ windsurf,
3320
+ cline,
3321
+ roo,
3322
+ zed,
3323
+ junie,
3324
+ gemini,
3325
+ goose,
3326
+ openhands,
3327
+ claudeDesktop
3328
+ ];
3329
+ function getClient(id) {
3330
+ return CLIENTS.find((c) => c.id === id);
3331
+ }
3332
+ function listClients() {
3333
+ return CLIENTS.map(({ id, label, format, note }) => ({ id, label, format, note }));
3334
+ }
3335
+
3336
+ // src/installer/deeplinks.ts
3337
+ function cursorDeeplink(name, entry) {
3338
+ const config = Buffer.from(JSON.stringify(mcpServerObject(entry))).toString("base64");
3339
+ const params = new URLSearchParams({ name, config });
3340
+ return `cursor://anysphere.cursor-deeplink/mcp/install?${params.toString()}`;
2869
3341
  }
2870
- function logWarn(message, context) {
2871
- log("WARN", message, context);
3342
+ function vscodeDeeplink(name, entry, opts = {}) {
3343
+ const scheme = opts.insiders ? "vscode-insiders" : "vscode";
3344
+ const payload = encodeURIComponent(JSON.stringify({ name, ...mcpServerObject(entry) }));
3345
+ return `${scheme}://mcp/install?${payload}`;
2872
3346
  }
2873
- function logError(message, context) {
2874
- log("ERROR", message, context);
3347
+ function codeAddMcpCommand(name, entry) {
3348
+ return `code --add-mcp '${JSON.stringify({ name, ...mcpServerObject(entry) })}'`;
2875
3349
  }
2876
3350
 
2877
3351
  // src/cli.ts
@@ -2882,6 +3356,46 @@ var green = (s) => `\x1B[32m${s}\x1B[0m`;
2882
3356
  var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
2883
3357
  var magenta = (s) => `\x1B[35m${s}\x1B[0m`;
2884
3358
  var red = (s) => `\x1B[31m${s}\x1B[0m`;
3359
+ function renderDiffText(d) {
3360
+ const out = [];
3361
+ out.push(`${bold("Topology diff")} ${dim(d.base.sessionId.slice(0, 8))} \u2192 ${dim(d.current.sessionId.slice(0, 8))}`);
3362
+ out.push(` base: ${d.base.nodeCount} nodes, ${d.base.edgeCount} edges ${dim(d.base.startedAt)}`);
3363
+ out.push(` current: ${d.current.nodeCount} nodes, ${d.current.edgeCount} edges ${dim(d.current.startedAt)}`);
3364
+ out.push("");
3365
+ out.push(` nodes: ${green("+" + d.summary.nodesAdded)} ${red("-" + d.summary.nodesRemoved)} ${yellow("~" + d.summary.nodesChanged)} edges: ${green("+" + d.summary.edgesAdded)} ${red("-" + d.summary.edgesRemoved)}`);
3366
+ if (d.summary.nodesAdded + d.summary.nodesRemoved + d.summary.nodesChanged + d.summary.edgesAdded + d.summary.edgesRemoved === 0) {
3367
+ out.push("");
3368
+ out.push(` ${green("\u2713")} No drift between the two sessions.`);
3369
+ return out.join("\n");
3370
+ }
3371
+ out.push("");
3372
+ for (const n of d.nodes.added) out.push(` ${green("+")} ${n.id} ${dim("(" + n.type + ")")}`);
3373
+ for (const n of d.nodes.removed) out.push(` ${red("-")} ${n.id} ${dim("(" + n.type + ")")}`);
3374
+ for (const c of d.nodes.changed) out.push(` ${yellow("~")} ${c.id} ${dim("[" + c.changedFields.join(", ") + "]")}`);
3375
+ for (const e of d.edges.added) out.push(` ${green("+")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
3376
+ for (const e of d.edges.removed) out.push(` ${red("-")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
3377
+ return out.join("\n");
3378
+ }
3379
+ function renderDriftSummaryText(r) {
3380
+ const s = r.delta.summary;
3381
+ const base = r.baseSessionId ? r.baseSessionId.slice(0, 8) : "\u2205";
3382
+ return `${green("+" + s.nodesAdded)}/${red("-" + s.nodesRemoved)}/${yellow("~" + s.nodesChanged)} nodes, ${green("+" + s.edgesAdded)}/${red("-" + s.edgesRemoved)} edges ${dim("(session " + r.sessionId.slice(0, 8) + ", base " + base + ")")}`;
3383
+ }
3384
+ function maybeQueueForSync(db, sessionId, config, w) {
3385
+ if (!config.centralDb?.url) return;
3386
+ try {
3387
+ const r = runSyncClassify(db, sessionId, config);
3388
+ if (r.enqueued > 0) {
3389
+ w(` ${cyan("\u21EA")} ${bold(String(r.enqueued))} item(s) queued for review \u2014 run ${bold("'datasynx-cartography sync review'")}
3390
+ `);
3391
+ } else if (r.autoShared > 0) {
3392
+ w(` ${cyan("\u21EA")} ${bold(String(r.autoShared))} item(s) auto-approved by policy \u2014 run ${bold("'datasynx-cartography sync push'")}
3393
+ `);
3394
+ }
3395
+ } catch (err) {
3396
+ logWarn(`central-DB sync classify skipped: ${err instanceof Error ? err.message : String(err)}`);
3397
+ }
3398
+ }
2885
3399
  main();
2886
3400
  function main() {
2887
3401
  let activeDb = null;
@@ -2894,17 +3408,35 @@ function main() {
2894
3408
  }
2895
3409
  activeDb = null;
2896
3410
  }
2897
- process.exit(signal === "SIGINT" ? 130 : 0);
3411
+ process.removeListener("SIGTERM", shutdown);
3412
+ process.removeListener("SIGINT", shutdown);
3413
+ process.kill(process.pid, signal);
2898
3414
  };
2899
- process.on("SIGTERM", () => shutdown("SIGTERM"));
2900
- process.on("SIGINT", () => shutdown("SIGINT"));
3415
+ process.on("SIGTERM", shutdown);
3416
+ process.on("SIGINT", shutdown);
2901
3417
  cleanupTempFiles();
2902
3418
  const program = new Command();
2903
3419
  const CMD = "datasynx-cartography";
2904
- const VERSION = "1.0.1";
3420
+ const __dirname = import.meta.dirname ?? dirname2(fileURLToPath(import.meta.url));
3421
+ let VERSION = "0.0.0";
3422
+ try {
3423
+ VERSION = JSON.parse(readFileSync5(resolve2(__dirname, "..", "package.json"), "utf-8")).version ?? VERSION;
3424
+ } catch {
3425
+ logWarn("Could not read package.json version; falling back to 0.0.0");
3426
+ }
2905
3427
  program.name(CMD).description("AI-powered Infrastructure Discovery & Agentic AI Cartography").version(VERSION);
2906
- program.command("discover").description("Scan and map your infrastructure").option("--entry <hosts...>", "Entry points", ["localhost"]).option("--depth <n>", "Max crawl depth", "8").option("--max-turns <n>", "Max agent turns", "50").option("--model <m>", "Agent model", "claude-sonnet-4-5-20250929").option("--org <name>", "Organization name (for Backstage)").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--db <path>", "DB path").option("-v, --verbose", "Show agent reasoning", false).action(async (opts) => {
2907
- checkPrerequisites();
3428
+ program.command("discover").description("Scan and map your infrastructure").option("--entry <hosts...>", "Entry points", ["localhost"]).option("--depth <n>", "Max crawl depth", "8").option("--max-turns <n>", "Max agent turns", "50").option("--provider <name>", "Agent provider: claude, openai, ollama (or CARTOGRAPHY_PROVIDER)").option("--model <m>", "Agent model", "claude-sonnet-4-5-20250929").option("--org <name>", "Organization name (for Backstage)").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--db <path>", "DB path").option("--name <name>", "Custom session name (default: auto-derived from the topology)").option("--update [sessionId]", "Re-scan an existing session in place (deterministic local scan; default: latest discover session)").option("--output-format <fmt>", "Progress/result format: text, json, stream-json", "text").option("-v, --verbose", "Show agent reasoning", false).action(async (opts) => {
3429
+ const providerName = opts.provider ?? process.env.CARTOGRAPHY_PROVIDER ?? "claude";
3430
+ if (!defaultProviderRegistry.has(providerName)) {
3431
+ process.stderr.write(
3432
+ `\u274C Unknown provider "${providerName}" (valid: ${defaultProviderRegistry.names().join(", ")})
3433
+ `
3434
+ );
3435
+ process.exitCode = 2;
3436
+ return;
3437
+ }
3438
+ const provider = providerName;
3439
+ checkPrerequisites(provider);
2908
3440
  const parsedDepth = parseInt(opts.depth, 10);
2909
3441
  const parsedMaxTurns = parseInt(opts.maxTurns, 10);
2910
3442
  if (Number.isNaN(parsedDepth) || parsedDepth < 1 || parsedDepth > 50) {
@@ -2919,11 +3451,20 @@ function main() {
2919
3451
  process.exitCode = 2;
2920
3452
  return;
2921
3453
  }
3454
+ const fmt = opts.outputFormat ?? "text";
3455
+ if (!["text", "json", "stream-json"].includes(fmt)) {
3456
+ process.stderr.write(`\u274C Invalid --output-format: "${opts.outputFormat}" (must be text, json, or stream-json)
3457
+ `);
3458
+ process.exitCode = 2;
3459
+ return;
3460
+ }
3461
+ const isText = fmt === "text";
2922
3462
  setVerbose(opts.verbose);
2923
3463
  const config = defaultConfig({
2924
3464
  entryPoints: opts.entry,
2925
3465
  maxDepth: parsedDepth,
2926
3466
  maxTurns: parsedMaxTurns,
3467
+ provider,
2927
3468
  agentModel: opts.model,
2928
3469
  organization: opts.org,
2929
3470
  outputDir: opts.output,
@@ -2932,14 +3473,63 @@ function main() {
2932
3473
  });
2933
3474
  logInfo("Discovery started", {
2934
3475
  entryPoints: config.entryPoints,
3476
+ provider: config.provider,
2935
3477
  model: config.agentModel,
2936
3478
  maxTurns: config.maxTurns,
2937
3479
  maxDepth: config.maxDepth
2938
3480
  });
2939
3481
  const db = new CartographyDB(config.dbPath);
2940
3482
  activeDb = db;
2941
- const sessionId = db.createSession("discover", config);
2942
3483
  const w = process.stderr.write.bind(process.stderr);
3484
+ if (opts.update) {
3485
+ const tenantId = normalizeTenant(opts.org);
3486
+ const targetId = typeof opts.update === "string" ? opts.update : db.getLatestSession("discover", tenantId)?.id;
3487
+ const targetSession = targetId ? db.getSession(targetId) : void 0;
3488
+ if (!targetId || !targetSession) {
3489
+ process.stderr.write(
3490
+ `\u274C No discover session to update${typeof opts.update === "string" ? ` (id "${opts.update}")` : ""}; run \`discover\` first.
3491
+ `
3492
+ );
3493
+ process.exitCode = 2;
3494
+ db.close();
3495
+ activeDb = null;
3496
+ return;
3497
+ }
3498
+ const baseNodeCount = db.getNodes(targetId).length;
3499
+ const baseEdgeCount = db.getEdges(targetId).length;
3500
+ if (isText) {
3501
+ w("\n");
3502
+ w(` ${bold("CARTOGRAPHY")} ${dim("incremental rescan \xB7 " + targetId.slice(0, 8))}
3503
+ `);
3504
+ w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n"));
3505
+ }
3506
+ try {
3507
+ const r = await runLocalDiscovery(db, targetId, { mode: "update" });
3508
+ const updated = db.getSession(targetId);
3509
+ const diff = {
3510
+ base: { sessionId: targetId, startedAt: targetSession.startedAt, nodeCount: baseNodeCount, edgeCount: baseEdgeCount },
3511
+ current: { sessionId: targetId, startedAt: updated?.lastScannedAt ?? (/* @__PURE__ */ new Date()).toISOString(), nodeCount: r.nodes, edgeCount: r.edges },
3512
+ nodes: r.delta?.nodes ?? { added: [], removed: [], changed: [], unchanged: 0 },
3513
+ edges: r.delta?.edges ?? { added: [], removed: [], unchanged: 0 },
3514
+ summary: r.delta?.summary ?? { nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0, edgesAdded: 0, edgesRemoved: 0 },
3515
+ anomalies: { base: [], current: [], added: [] }
3516
+ };
3517
+ if (fmt === "text") w(renderDiffText(diff) + "\n\n");
3518
+ else process.stdout.write(JSON.stringify(diff, null, 2) + "\n");
3519
+ logInfo("Incremental rescan complete", { sessionId: targetId, ...diff.summary });
3520
+ } catch (err) {
3521
+ const errMsg = err instanceof Error ? err.message : String(err);
3522
+ logError("Incremental rescan failed", { sessionId: targetId, error: errMsg });
3523
+ w(`
3524
+ ${bold(red("\u2717"))} Rescan failed: ${errMsg}
3525
+ `);
3526
+ process.exitCode = 1;
3527
+ }
3528
+ db.close();
3529
+ activeDb = null;
3530
+ return;
3531
+ }
3532
+ const sessionId = db.createSession("discover", config, opts.org);
2943
3533
  const SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2944
3534
  let spinIdx = 0;
2945
3535
  let spinnerTimer = null;
@@ -2964,13 +3554,15 @@ function main() {
2964
3554
  let turnNum = 0;
2965
3555
  let nodeCount = 0;
2966
3556
  let edgeCount = 0;
2967
- w("\n");
2968
- w(` ${bold("CARTOGRAPHY")} ${dim(config.entryPoints.join(", "))}
3557
+ if (isText) {
3558
+ w("\n");
3559
+ w(` ${bold("CARTOGRAPHY")} ${dim(config.entryPoints.join(", "))}
2969
3560
  `);
2970
- w(` ${dim("Model: " + config.agentModel + " | MaxTurns: " + config.maxTurns)}
3561
+ w(` ${dim("Model: " + config.agentModel + " | MaxTurns: " + config.maxTurns)}
2971
3562
  `);
2972
- w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
2973
- w("\n");
3563
+ w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
3564
+ w("\n");
3565
+ }
2974
3566
  const logLine = (icon, msg) => {
2975
3567
  stopSpinner();
2976
3568
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -2978,6 +3570,10 @@ function main() {
2978
3570
  `);
2979
3571
  };
2980
3572
  const handleEvent = (event) => {
3573
+ if (!isText) {
3574
+ if (fmt === "stream-json") process.stdout.write(JSON.stringify(event) + "\n");
3575
+ return;
3576
+ }
2981
3577
  switch (event.kind) {
2982
3578
  case "turn":
2983
3579
  turnNum = event.turn;
@@ -2994,7 +3590,7 @@ function main() {
2994
3590
  }
2995
3591
  break;
2996
3592
  case "tool_call": {
2997
- const toolName = event.tool.replace("mcp__cartograph__", "");
3593
+ const toolName = event.tool.replace("mcp__cartography__", "");
2998
3594
  if (toolName === "Bash") {
2999
3595
  const cmd = (event.input["command"] ?? "").substring(0, 70);
3000
3596
  startSpinner(`${yellow("$")} ${cmd}`);
@@ -3042,6 +3638,7 @@ function main() {
3042
3638
  }
3043
3639
  };
3044
3640
  const onAskUser = async (question, context) => {
3641
+ if (!isText) return "(Non-interactive mode \u2014 please continue without this information)";
3045
3642
  stopSpinner();
3046
3643
  w("\n");
3047
3644
  w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
@@ -3056,7 +3653,7 @@ function main() {
3056
3653
  return "(Non-interactive mode \u2014 please continue without this information)";
3057
3654
  }
3058
3655
  const rl = createInterface({ input: process.stdin, output: process.stderr });
3059
- const answer = await new Promise((resolve2) => rl.question(` ${cyan("\u2192")} `, resolve2));
3656
+ const answer = await new Promise((resolve3) => rl.question(` ${cyan("\u2192")} `, resolve3));
3060
3657
  rl.close();
3061
3658
  w("\n");
3062
3659
  return answer || "(No answer \u2014 please continue)";
@@ -3065,7 +3662,8 @@ function main() {
3065
3662
  await runDiscovery(config, db, sessionId, handleEvent, onAskUser, void 0);
3066
3663
  } catch (err) {
3067
3664
  stopSpinner();
3068
- const errMsg = err instanceof Error ? err.message : String(err);
3665
+ const rawMsg = err instanceof Error ? err.message : String(err);
3666
+ const errMsg = rawMsg.replace(/https?:\/\/[^\s]+/g, (u) => stripSensitive(u));
3069
3667
  logError("Discovery failed", { sessionId, error: errMsg });
3070
3668
  w(`
3071
3669
  ${bold("\x1B[31m\u2717\x1B[0m")} Discovery failed: ${errMsg}
@@ -3077,6 +3675,9 @@ function main() {
3077
3675
  }
3078
3676
  stopSpinner();
3079
3677
  db.endSession(sessionId);
3678
+ maybeQueueForSync(db, sessionId, config, w);
3679
+ const sessionName = opts.name?.trim() || deriveSessionName(db.getGraphSummary(sessionId), db.getSession(sessionId)?.startedAt ?? (/* @__PURE__ */ new Date()).toISOString());
3680
+ db.setSessionName(sessionId, sessionName);
3080
3681
  const stats = db.getStats(sessionId);
3081
3682
  const totalSec = ((Date.now() - startTime) / 1e3).toFixed(1);
3082
3683
  logInfo("Discovery completed", {
@@ -3085,6 +3686,22 @@ function main() {
3085
3686
  edges: stats.edges,
3086
3687
  durationSec: parseFloat(totalSec)
3087
3688
  });
3689
+ if (!isText) {
3690
+ const durationMs = Date.now() - startTime;
3691
+ if (fmt === "stream-json") {
3692
+ process.stdout.write(JSON.stringify({ kind: "result", sessionId, nodes: stats.nodes, edges: stats.edges, durationMs }) + "\n");
3693
+ } else {
3694
+ process.stdout.write(JSON.stringify(
3695
+ { sessionId, stats, nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId), durationMs },
3696
+ null,
3697
+ 2
3698
+ ) + "\n");
3699
+ }
3700
+ exportAll(db, sessionId, config.outputDir, ["discovery"]);
3701
+ db.close();
3702
+ activeDb = null;
3703
+ return;
3704
+ }
3088
3705
  w("\n");
3089
3706
  w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
3090
3707
  w(` ${green(bold("DONE"))} ${bold(String(stats.nodes))} nodes, ${bold(String(stats.edges))} edges ${dim("in " + totalSec + "s")}
@@ -3124,7 +3741,7 @@ function main() {
3124
3741
  w("\n");
3125
3742
  const rl = createInterface({ input: process.stdin, output: process.stderr });
3126
3743
  const answer = await new Promise(
3127
- (resolve2) => rl.question(` ${yellow("?")} Remove nodes (numbers, empty = keep all): `, resolve2)
3744
+ (resolve3) => rl.question(` ${yellow("?")} Remove nodes (numbers, empty = keep all): `, resolve3)
3128
3745
  );
3129
3746
  rl.close();
3130
3747
  const toRemove = answer.trim().split(/[\s,]+/).map(Number).filter((n) => n >= 1 && n <= allNodes.length);
@@ -3143,9 +3760,9 @@ function main() {
3143
3760
  }
3144
3761
  }
3145
3762
  exportAll(db, sessionId, config.outputDir, ["discovery"]);
3146
- const discoveryPath = resolve(config.outputDir, "discovery.html");
3763
+ const discoveryPath = resolve2(config.outputDir, "discovery.html");
3147
3764
  w("\n");
3148
- if (existsSync2(discoveryPath)) {
3765
+ if (existsSync3(discoveryPath)) {
3149
3766
  w(` ${green("\u2713")} ${bold("discovery.html")} ${dim("\u2190 Enterprise Map")}
3150
3767
  `);
3151
3768
  }
@@ -3162,7 +3779,7 @@ function main() {
3162
3779
  while (continueDiscovery) {
3163
3780
  const rlFollowup = createInterface({ input: process.stdin, output: process.stderr });
3164
3781
  const followupHint = await new Promise(
3165
- (resolve2) => rlFollowup.question(` ${yellow("\u2192")} Search for (Enter = finish): `, resolve2)
3782
+ (resolve3) => rlFollowup.question(` ${yellow("\u2192")} Search for (Enter = finish): `, resolve3)
3166
3783
  );
3167
3784
  rlFollowup.close();
3168
3785
  if (!followupHint.trim()) {
@@ -3190,7 +3807,7 @@ function main() {
3190
3807
  `);
3191
3808
  w("\n");
3192
3809
  exportAll(db, sessionId, config.outputDir, ["discovery"]);
3193
- if (existsSync2(discoveryPath)) {
3810
+ if (existsSync3(discoveryPath)) {
3194
3811
  w(` ${green("\u2713")} ${bold("discovery.html updated")}
3195
3812
  `);
3196
3813
  }
@@ -3199,7 +3816,7 @@ function main() {
3199
3816
  }
3200
3817
  db.close();
3201
3818
  });
3202
- program.command("export [session-id]").description("Generate all output files").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--format <fmt...>", "Formats: mermaid,json,yaml,html,map").action((sessionId, opts) => {
3819
+ program.command("export [session-id]").description("Generate all output files").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--format <fmt...>", "Formats: mermaid,json,yaml,html,map,cost").action((sessionId, opts) => {
3203
3820
  const config = defaultConfig({ outputDir: opts.output });
3204
3821
  const db = new CartographyDB(config.dbPath);
3205
3822
  const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
@@ -3215,6 +3832,216 @@ function main() {
3215
3832
  `);
3216
3833
  db.close();
3217
3834
  });
3835
+ program.command("diff [base] [current]").description("Compare two discovery sessions (drift detection). Defaults to the two most recent.").option("--format <fmt>", "Output format: text, json, mermaid", "text").option("-o, --output <file>", "Write to a file instead of stdout").option("--db <path>", "DB path").action((base, current, opts) => {
3836
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
3837
+ const db = new CartographyDB(config.dbPath);
3838
+ activeDb = db;
3839
+ try {
3840
+ const sessions = db.getSessions();
3841
+ const currentId = current ?? sessions[0]?.id;
3842
+ const baseId = base ?? sessions[1]?.id;
3843
+ if (!baseId || !currentId) {
3844
+ process.stderr.write("\u274C Need at least two discovery sessions to diff\n");
3845
+ process.exitCode = 1;
3846
+ return;
3847
+ }
3848
+ if (baseId === currentId) {
3849
+ process.stderr.write("\u274C Base and current session are the same\n");
3850
+ process.exitCode = 1;
3851
+ return;
3852
+ }
3853
+ const d = db.diffSessions(baseId, currentId);
3854
+ const out = opts.format === "json" ? JSON.stringify(d, null, 2) : opts.format === "mermaid" ? generateDiffMermaid(d) : renderDiffText(d);
3855
+ if (opts.output) {
3856
+ writeFileSync3(opts.output, out + "\n");
3857
+ process.stderr.write(`\u2713 Wrote diff to: ${opts.output}
3858
+ `);
3859
+ } else {
3860
+ process.stdout.write(out + "\n");
3861
+ }
3862
+ } catch (err) {
3863
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3864
+ `);
3865
+ process.exitCode = 1;
3866
+ } finally {
3867
+ db.close();
3868
+ activeDb = null;
3869
+ }
3870
+ });
3871
+ program.command("compliance [session-id]").description("Score a session against a compliance ruleset (CIS/SOC2/ISO 27001 starter sets)").option("--ruleset <name>", "Ruleset: baseline, cis, soc2, iso27001", "baseline").option("--format <fmt>", "Output format: text, json, markdown, mermaid", "text").option("-o, --output <file>", "Write to a file instead of stdout").option("--db <path>", "DB path").action((sessionId, opts) => {
3872
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
3873
+ const db = new CartographyDB(config.dbPath);
3874
+ activeDb = db;
3875
+ try {
3876
+ const ruleset = getRuleset(opts.ruleset);
3877
+ if (!ruleset) {
3878
+ process.stderr.write(`\u274C Unknown ruleset: "${opts.ruleset}" (available: ${listRulesets().map((r) => r.name).join(", ")})
3879
+ `);
3880
+ process.exitCode = 1;
3881
+ return;
3882
+ }
3883
+ const sid = sessionId ?? db.getLatestSession()?.id;
3884
+ if (!sid) {
3885
+ process.stderr.write("\u274C No session to score (run discovery first or pass a session id)\n");
3886
+ process.exitCode = 1;
3887
+ return;
3888
+ }
3889
+ const report = db.scoreSession(sid, ruleset);
3890
+ const out = opts.format === "json" || opts.format === "markdown" || opts.format === "mermaid" ? exportComplianceReport(report, opts.format) : formatComplianceText(report);
3891
+ if (opts.output) {
3892
+ writeFileSync3(opts.output, out + "\n");
3893
+ process.stderr.write(`\u2713 Wrote compliance report to: ${opts.output}
3894
+ `);
3895
+ } else {
3896
+ process.stdout.write(out + "\n");
3897
+ }
3898
+ } catch (err) {
3899
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3900
+ `);
3901
+ process.exitCode = 1;
3902
+ } finally {
3903
+ db.close();
3904
+ activeDb = null;
3905
+ }
3906
+ });
3907
+ program.command("drift [base] [current]").description("Classify drift between two sessions and emit to configured sinks (default: stdout). Defaults to the two most recent.").option("--min-severity <s>", "Minimum severity to emit: info|warning|critical", "info").option("--webhook <url>", "Outbound webhook URL (overrides config; token via CARTOGRAPHY_DRIFT_TOKEN)").option("--db <path>", "DB path").action(async (base, current, opts) => {
3908
+ let drift;
3909
+ try {
3910
+ drift = DriftConfigSchema.parse({
3911
+ minSeverity: opts.minSeverity,
3912
+ sinks: opts.webhook ? [{ type: "webhook", url: opts.webhook }] : [{ type: "stdout" }]
3913
+ });
3914
+ } catch (err) {
3915
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3916
+ `);
3917
+ process.exitCode = 1;
3918
+ return;
3919
+ }
3920
+ const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, drift });
3921
+ const db = new CartographyDB(config.dbPath);
3922
+ activeDb = db;
3923
+ try {
3924
+ const alert = await runDrift(db, config, { base, current, minSeverity: drift.minSeverity });
3925
+ if (!alert) {
3926
+ process.stderr.write("\u2139 Need at least two discovery sessions for drift; nothing to do.\n");
3927
+ return;
3928
+ }
3929
+ process.stderr.write(`\u2713 drift severity=${alert.severity} items=${alert.items.length}
3930
+ `);
3931
+ } catch (err) {
3932
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3933
+ `);
3934
+ process.exitCode = 1;
3935
+ } finally {
3936
+ db.close();
3937
+ activeDb = null;
3938
+ }
3939
+ });
3940
+ program.command("schedule").description("Run discovery recurringly and record per-run topology drift").requiredOption("--config <file>", "Path to a JSON config file with a schedule block").option("--once", "Run a single pass and exit (cron-driver friendly; default)", false).option("--watch", "Run continuously on the configured cron schedule", false).option("--output-format <fmt>", "Result format: text, json, stream-json (overrides config)").option("--db <path>", "DB path (overrides config)").action(async (opts) => {
3941
+ let cfg;
3942
+ try {
3943
+ cfg = loadConfig(opts.config);
3944
+ } catch (err) {
3945
+ process.stderr.write(`\u274C ${err instanceof ConfigError ? err.message : String(err)}
3946
+ `);
3947
+ process.exitCode = 2;
3948
+ return;
3949
+ }
3950
+ if (opts.db) cfg = defaultConfig({ ...cfg, dbPath: opts.db });
3951
+ const fmt = opts.outputFormat ?? cfg.schedule?.outputFormat ?? "json";
3952
+ if (!["text", "json", "stream-json"].includes(fmt)) {
3953
+ process.stderr.write(`\u274C Invalid --output-format: "${fmt}" (must be text, json, or stream-json)
3954
+ `);
3955
+ process.exitCode = 2;
3956
+ return;
3957
+ }
3958
+ if (opts.once && opts.watch) {
3959
+ process.stderr.write("\u274C --once and --watch are mutually exclusive\n");
3960
+ process.exitCode = 2;
3961
+ return;
3962
+ }
3963
+ const cron = cfg.schedule?.cron;
3964
+ if (opts.watch && !cron) {
3965
+ process.stderr.write("\u274C --watch requires a `schedule.cron` in the config file\n");
3966
+ process.exitCode = 2;
3967
+ return;
3968
+ }
3969
+ if (cron) {
3970
+ try {
3971
+ nextRun(cron, /* @__PURE__ */ new Date());
3972
+ } catch (err) {
3973
+ process.stderr.write(`\u274C Invalid cron "${cron}": ${err instanceof Error ? err.message : String(err)}
3974
+ `);
3975
+ process.exitCode = 2;
3976
+ return;
3977
+ }
3978
+ }
3979
+ const db = new CartographyDB(cfg.dbPath);
3980
+ activeDb = db;
3981
+ const emit = (r) => {
3982
+ if (fmt === "text") {
3983
+ process.stdout.write(renderDriftSummaryText(r) + "\n");
3984
+ } else {
3985
+ const payload = { sessionId: r.sessionId, baseSessionId: r.baseSessionId ?? null, summary: r.delta.summary };
3986
+ process.stdout.write(JSON.stringify(payload) + "\n");
3987
+ }
3988
+ };
3989
+ const doRun = async () => {
3990
+ const r = await runOnce(cfg, db);
3991
+ db.recordDriftRun(r.sessionId, r.baseSessionId, r.delta);
3992
+ maybeQueueForSync(db, r.sessionId, cfg, (s) => process.stderr.write(s));
3993
+ emit(r);
3994
+ };
3995
+ if (opts.watch) {
3996
+ let stopped = false;
3997
+ let timer = null;
3998
+ const MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
3999
+ let nextAnnounced = null;
4000
+ const schedule = () => {
4001
+ if (stopped) return;
4002
+ const next = nextRun(cron, /* @__PURE__ */ new Date());
4003
+ const targetMs = next.getTime();
4004
+ if (next.toISOString() !== nextAnnounced) {
4005
+ logInfo(`next scheduled run at ${next.toISOString()}`);
4006
+ nextAnnounced = next.toISOString();
4007
+ }
4008
+ const remaining = targetMs - Date.now();
4009
+ if (remaining > MAX_DELAY_MS) {
4010
+ timer = setTimeout(schedule, MAX_DELAY_MS);
4011
+ return;
4012
+ }
4013
+ timer = setTimeout(() => {
4014
+ void (async () => {
4015
+ try {
4016
+ await doRun();
4017
+ } catch (err) {
4018
+ logError(`scheduled run failed: ${err instanceof Error ? err.message : String(err)}`);
4019
+ }
4020
+ nextAnnounced = null;
4021
+ schedule();
4022
+ })();
4023
+ }, Math.max(0, remaining));
4024
+ };
4025
+ const stop = () => {
4026
+ stopped = true;
4027
+ if (timer) clearTimeout(timer);
4028
+ };
4029
+ process.once("SIGINT", stop);
4030
+ process.once("SIGTERM", stop);
4031
+ schedule();
4032
+ return;
4033
+ }
4034
+ try {
4035
+ await doRun();
4036
+ } catch (err) {
4037
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
4038
+ `);
4039
+ process.exitCode = 1;
4040
+ } finally {
4041
+ db.close();
4042
+ activeDb = null;
4043
+ }
4044
+ });
3218
4045
  program.command("show [session-id]").description("Show session details").action((sessionId) => {
3219
4046
  const config = defaultConfig();
3220
4047
  const db = new CartographyDB(config.dbPath);
@@ -3229,6 +4056,8 @@ function main() {
3229
4056
  const nodes = db.getNodes(session.id);
3230
4057
  process.stdout.write(`
3231
4058
  Session: ${session.id}
4059
+ `);
4060
+ if (session.name) process.stdout.write(` Name: ${session.name}
3232
4061
  `);
3233
4062
  process.stdout.write(` Mode: ${session.mode}
3234
4063
  `);
@@ -3244,6 +4073,15 @@ Session: ${session.id}
3244
4073
  `);
3245
4074
  process.stdout.write(` Tasks: ${stats.tasks}
3246
4075
  `);
4076
+ const events = db.getEvents(session.id);
4077
+ if (events.length > 0) {
4078
+ process.stdout.write("\n Recent activity:\n");
4079
+ for (const e of events.slice(-15)) {
4080
+ const kb = e.resultBytes != null ? ` (${(e.resultBytes / 1024).toFixed(1)} KB)` : "";
4081
+ process.stdout.write(` ${e.timestamp} ${e.process} ${(e.command ?? "").slice(0, 60)}${kb}
4082
+ `);
4083
+ }
4084
+ }
3247
4085
  if (nodes.length > 0) {
3248
4086
  process.stdout.write("\n Discovered nodes:\n");
3249
4087
  for (const node of nodes.slice(0, 20)) {
@@ -3271,13 +4109,13 @@ Session: ${session.id}
3271
4109
  const stats = db.getStats(session.id);
3272
4110
  const status = session.completedAt ? "\u2713" : "\u25CF";
3273
4111
  process.stdout.write(
3274
- `${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}
4112
+ `${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}${session.name ? ` ${session.name}` : ""}
3275
4113
  `
3276
4114
  );
3277
4115
  }
3278
4116
  db.close();
3279
4117
  });
3280
- program.command("overview").description("Overview of all cartography sessions").option("--db <path>", "DB-Pfad").action((opts) => {
4118
+ program.command("overview").description("Overview of all cartography sessions").option("--db <path>", "DB path").action((opts) => {
3281
4119
  const config = defaultConfig();
3282
4120
  const db = new CartographyDB(opts.db ?? config.dbPath);
3283
4121
  const sessions = db.getSessions();
@@ -3310,7 +4148,7 @@ Session: ${session.id}
3310
4148
  const status = session.completedAt ? green("\u2713") : yellow("\u25CF");
3311
4149
  const age = session.startedAt.substring(0, 16).replace("T", " ");
3312
4150
  const sid = cyan(session.id.substring(0, 8));
3313
- w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}
4151
+ w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}${session.name ? ` ${d(session.name)}` : ""}
3314
4152
  `);
3315
4153
  w(` ${d("Nodes: " + stats.nodes + " Edges: " + stats.edges)}
3316
4154
  `);
@@ -3328,8 +4166,9 @@ Session: ${session.id}
3328
4166
  }
3329
4167
  db.close();
3330
4168
  });
3331
- program.command("chat [session-id]").description("Interactive chat about your mapped infrastructure").option("--db <path>", "DB-Pfad").option("--model <m>", "Model", "claude-sonnet-4-5-20250929").action(async (sessionIdArg, opts) => {
4169
+ program.command("chat [session-id]").description("Interactive chat about your mapped infrastructure").option("--db <path>", "DB path").option("--model <m>", "Model (defaults to the fast helper model)").action(async (sessionIdArg, opts) => {
3332
4170
  const config = defaultConfig();
4171
+ const model = opts.model ?? config.models.fast;
3333
4172
  const db = new CartographyDB(opts.db ?? config.dbPath);
3334
4173
  const sessions = db.getSessions();
3335
4174
  const session = sessionIdArg ? sessions.find((s) => s.id.startsWith(sessionIdArg)) : sessions.filter((s) => s.completedAt).at(-1) ?? sessions.at(-1);
@@ -3373,7 +4212,7 @@ INFRASTRUCTURE SNAPSHOT (${nodes.length} nodes, ${edges.length} edges):
3373
4212
  ${infraSummary.substring(0, 12e3)}`;
3374
4213
  const history = [];
3375
4214
  const rl = createInterface({ input: process.stdin, output: process.stdout });
3376
- const ask = () => new Promise((resolve2) => rl.question(` ${cyan(">")} `, resolve2));
4215
+ const ask = () => new Promise((resolve3) => rl.question(` ${cyan(">")} `, resolve3));
3377
4216
  while (true) {
3378
4217
  let userInput;
3379
4218
  try {
@@ -3386,7 +4225,7 @@ ${infraSummary.substring(0, 12e3)}`;
3386
4225
  history.push({ role: "user", content: userInput });
3387
4226
  try {
3388
4227
  const resp = await client.messages.create({
3389
- model: opts.model,
4228
+ model,
3390
4229
  max_tokens: 1024,
3391
4230
  system: systemPrompt,
3392
4231
  messages: history
@@ -3455,9 +4294,9 @@ ${infraSummary.substring(0, 12e3)}`;
3455
4294
  out("\n");
3456
4295
  out(` ${green("datasynx-cartography discover")}
3457
4296
  `);
3458
- out(` Scans your local infrastructure (Claude Sonnet).
4297
+ out(` Scans your local infrastructure (provider-agnostic: claude, openai, ollama).
3459
4298
  `);
3460
- out(` Claude autonomously runs ${IS_WIN ? "Get-NetTCPConnection, Get-Process" : IS_MAC ? "lsof, ps" : "ss, ps"}, curl, docker inspect, kubectl get
4299
+ out(` The agent autonomously runs ${IS_WIN ? "Get-NetTCPConnection, Get-Process" : IS_MAC ? "lsof, ps" : "ss, ps"}, curl, docker inspect, kubectl get
3461
4300
  `);
3462
4301
  out(` and stores everything in SQLite.
3463
4302
  `);
@@ -3480,7 +4319,7 @@ ${infraSummary.substring(0, 12e3)}`;
3480
4319
  out("\n");
3481
4320
  out(` ${green("datasynx-cartography export [session-id]")}
3482
4321
  `);
3483
- out(dim(" --format <fmt...> mermaid, json, yaml, html, map (default: all)\n"));
4322
+ out(dim(" --format <fmt...> mermaid, json, yaml, html, map, cost (default: all but cost)\n"));
3484
4323
  out(dim(" -o, --output <dir> Output directory\n"));
3485
4324
  out("\n");
3486
4325
  out(` ${green("datasynx-cartography show [session-id]")} ${dim("Session details + node list")}
@@ -3504,7 +4343,7 @@ ${infraSummary.substring(0, 12e3)}`;
3504
4343
  out(dim(" \u2514\u2500\u2500 Platform Detection (platform.ts)\n"));
3505
4344
  out(dim(" \u2514\u2500\u2500 Shell: /bin/sh (Unix) | PowerShell (Windows)\n"));
3506
4345
  out(dim(" \u2514\u2500\u2500 Agent Orchestrator (agent.ts)\n"));
3507
- out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192 Claude Sonnet + Bash + MCP Tools\n"));
4346
+ out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192 AgentProvider (claude|openai|ollama) + Bash + MCP Tools\n"));
3508
4347
  out(dim(" \u2514\u2500\u2500 Custom MCP Tools (tools.ts)\n"));
3509
4348
  out(dim(" save_node, save_edge,\n"));
3510
4349
  out(dim(" scan_bookmarks, scan_browser_history,\n"));
@@ -3541,10 +4380,10 @@ ${infraSummary.substring(0, 12e3)}`;
3541
4380
  out("\n");
3542
4381
  });
3543
4382
  program.command("bookmarks").description("View all browser bookmarks (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)").action(async () => {
3544
- const { scanAllBookmarks: scanAllBookmarks2 } = await import("./bookmarks-BWNVQGPG.js");
4383
+ const { scanAllBookmarks } = await import("./bookmarks-WXHE7GN7.js");
3545
4384
  const out = (s) => process.stdout.write(s);
3546
4385
  process.stderr.write(" Scanning bookmarks...\n\n");
3547
- const hosts = await scanAllBookmarks2();
4386
+ const hosts = await scanAllBookmarks();
3548
4387
  if (hosts.length === 0) {
3549
4388
  out(" (No bookmarks found \u2014 Chrome, Edge, Brave, Vivaldi, Opera and Firefox are supported)\n\n");
3550
4389
  return;
@@ -3571,16 +4410,52 @@ ${infraSummary.substring(0, 12e3)}`;
3571
4410
  `));
3572
4411
  out(dim(" Tip: ") + "datasynx-cartography discover" + dim(" \u2014 scans + classifies all bookmarks automatically\n\n"));
3573
4412
  });
3574
- program.command("seed").description("Manually add known infrastructure (tools, DBs, APIs, etc.)").option("--file <path>", "JSON file with node definitions").option("--session <id>", "Add to existing session (default: new session)").option("--db <path>", "DB path").action(async (opts) => {
3575
- const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {} });
4413
+ program.command("cost").description("Import cost/owner attribution from a CSV and enrich a session (FinOps)").requiredOption("--file <path>", "CSV: nodeId,owner,amount,currency,period[,source]").option("--session <id>", "Session to enrich (default: latest)").option("--match <strategy>", "Row\u2192node match: nodeId | name | tag", "nodeId").option("--db <path>", "DB path").action(async (opts) => {
4414
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4415
+ const db = new CartographyDB(config.dbPath);
4416
+ activeDb = db;
4417
+ try {
4418
+ const sessionId = opts.session ?? db.getLatestSession("discover")?.id;
4419
+ if (!sessionId) {
4420
+ process.stderr.write("\u274C No session to enrich (run discovery first or pass --session)\n");
4421
+ process.exitCode = 1;
4422
+ return;
4423
+ }
4424
+ const match = opts.match;
4425
+ if (!["nodeId", "name", "tag"].includes(match)) {
4426
+ process.stderr.write(`\u274C Invalid --match: "${match}" (nodeId | name | tag)
4427
+ `);
4428
+ process.exitCode = 1;
4429
+ return;
4430
+ }
4431
+ const source = new CsvCostSource({ filePath: opts.file, match, db, sessionId });
4432
+ const r = await enrichCosts(db, sessionId, source);
4433
+ process.stderr.write(`\u2713 cost: ${r.matched} matched, ${r.unmatched} unmatched (of ${r.total}) from ${r.source}
4434
+ `);
4435
+ if (r.unmatchedIds.length > 0) {
4436
+ process.stderr.write(` unmatched ids: ${r.unmatchedIds.slice(0, 20).join(", ")}${r.unmatchedIds.length > 20 ? " \u2026" : ""}
4437
+ `);
4438
+ }
4439
+ if (r.matched === 0 && r.total > 0) process.exitCode = 1;
4440
+ } catch (err) {
4441
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
4442
+ `);
4443
+ process.exitCode = 1;
4444
+ } finally {
4445
+ db.close();
4446
+ activeDb = null;
4447
+ }
4448
+ });
4449
+ program.command("seed").description("Manually add known infrastructure (tools, DBs, APIs, etc.)").option("--file <path>", "JSON file with node definitions").option("--session <id>", "Add to existing session (default: new session)").option("--org <name>", "Tenant/organization to scope the session to (default: local)").option("--db <path>", "DB path").action(async (opts) => {
4450
+ const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, ...opts.org ? { organization: opts.org } : {} });
3576
4451
  const db = new CartographyDB(config.dbPath);
3577
- const sessionId = opts.session ?? db.createSession("discover", config);
4452
+ const sessionId = opts.session ?? db.createSession("discover", config, opts.org);
3578
4453
  const out = (s) => process.stdout.write(s);
3579
4454
  const w = (s) => process.stderr.write(s);
3580
4455
  if (opts.file) {
3581
4456
  let raw;
3582
4457
  try {
3583
- raw = JSON.parse(readFileSync2(resolve(opts.file), "utf8"));
4458
+ raw = JSON.parse(readFileSync5(resolve2(opts.file), "utf8"));
3584
4459
  } catch (e) {
3585
4460
  w(red(`
3586
4461
  \u2717 Could not read file: ${e}
@@ -3598,7 +4473,7 @@ ${infraSummary.substring(0, 12e3)}`;
3598
4473
  for (const entry of raw) {
3599
4474
  const type = entry["type"];
3600
4475
  const name = entry["name"];
3601
- const host = entry["host"];
4476
+ const host2 = entry["host"];
3602
4477
  const port = entry["port"];
3603
4478
  const tags = entry["tags"] ?? [];
3604
4479
  const metadata = entry["metadata"] ?? {};
@@ -3607,14 +4482,14 @@ ${infraSummary.substring(0, 12e3)}`;
3607
4482
  `));
3608
4483
  continue;
3609
4484
  }
3610
- const id = host ? `${type}:${host}${port ? ":" + port : ""}` : `${type}:${name.toLowerCase().replace(/\s+/g, "-")}`;
4485
+ const id = host2 ? `${type}:${host2}${port ? ":" + port : ""}` : `${type}:${name.toLowerCase().replace(/\s+/g, "-")}`;
3611
4486
  db.upsertNode(sessionId, {
3612
4487
  id,
3613
4488
  type,
3614
4489
  name,
3615
4490
  discoveredVia: "manual",
3616
4491
  confidence: 1,
3617
- metadata: { ...metadata, ...host ? { host } : {}, ...port ? { port } : {} },
4492
+ metadata: { ...metadata, ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
3618
4493
  tags
3619
4494
  });
3620
4495
  out(` ${green("+")} ${cyan(id)} ${dim("(" + type + ")")}
@@ -3628,7 +4503,7 @@ ${infraSummary.substring(0, 12e3)}`;
3628
4503
  `);
3629
4504
  return;
3630
4505
  }
3631
- const { NODE_TYPES: NODE_TYPES2 } = await import("./types-54623ALF.js");
4506
+ const { NODE_TYPES } = await import("./types-TJWXAQ2L.js");
3632
4507
  if (!process.stdin.isTTY) {
3633
4508
  w(red("\n \u2717 Interactive mode requires a terminal (use --file for non-interactive)\n\n"));
3634
4509
  process.exitCode = 1;
@@ -3643,7 +4518,7 @@ ${infraSummary.substring(0, 12e3)}`;
3643
4518
  const rl = createInterface({ input: process.stdin, output: process.stderr });
3644
4519
  const ask = (q) => new Promise((res) => rl.question(q, res));
3645
4520
  let saved = 0;
3646
- const typeList = NODE_TYPES2.map((t, i) => `${dim((i + 1).toString().padStart(2))} ${t}`).join("\n ");
4521
+ const typeList = NODE_TYPES.map((t, i) => `${dim((i + 1).toString().padStart(2))} ${t}`).join("\n ");
3647
4522
  while (true) {
3648
4523
  w("\n");
3649
4524
  w(dim(" Node types:\n"));
@@ -3654,9 +4529,9 @@ ${infraSummary.substring(0, 12e3)}`;
3654
4529
  if (!typeInput) break;
3655
4530
  let nodeType;
3656
4531
  const asNum = parseInt(typeInput, 10);
3657
- if (!isNaN(asNum) && asNum >= 1 && asNum <= NODE_TYPES2.length) {
3658
- nodeType = NODE_TYPES2[asNum - 1];
3659
- } else if (NODE_TYPES2.includes(typeInput)) {
4532
+ if (!isNaN(asNum) && asNum >= 1 && asNum <= NODE_TYPES.length) {
4533
+ nodeType = NODE_TYPES[asNum - 1];
4534
+ } else if (NODE_TYPES.includes(typeInput)) {
3660
4535
  nodeType = typeInput;
3661
4536
  } else {
3662
4537
  w(yellow(` \u26A0 Unknown type: "${typeInput}"
@@ -3671,17 +4546,17 @@ ${infraSummary.substring(0, 12e3)}`;
3671
4546
  const hostRaw = (await ask(` ${cyan("Host / IP")} ${dim("[optional, Enter=skip]")}: `)).trim();
3672
4547
  const portRaw = (await ask(` ${cyan("Port")} ${dim("[optional]")}: `)).trim();
3673
4548
  const tagsRaw = (await ask(` ${cyan("Tags")} ${dim("[comma-separated, optional]")}: `)).trim();
3674
- const host = hostRaw || void 0;
4549
+ const host2 = hostRaw || void 0;
3675
4550
  const port = portRaw ? parseInt(portRaw, 10) : void 0;
3676
4551
  const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
3677
- const id = host ? `${nodeType}:${host}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
4552
+ const id = host2 ? `${nodeType}:${host2}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
3678
4553
  db.upsertNode(sessionId, {
3679
4554
  id,
3680
4555
  type: nodeType,
3681
4556
  name,
3682
4557
  discoveredVia: "manual",
3683
4558
  confidence: 1,
3684
- metadata: { ...host ? { host } : {}, ...port ? { port } : {} },
4559
+ metadata: { ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
3685
4560
  tags
3686
4561
  });
3687
4562
  out(` ${green("+")} ${cyan(id)}
@@ -3704,8 +4579,8 @@ ${infraSummary.substring(0, 12e3)}`;
3704
4579
  });
3705
4580
  program.command("doctor").description("Check all requirements and cloud CLIs").action(async () => {
3706
4581
  const { execSync: execSync2 } = await import("child_process");
3707
- const { existsSync: existsSync3, readFileSync: readFileSync3 } = await import("fs");
3708
- const { join: join3 } = await import("path");
4582
+ const { existsSync: existsSync4, readFileSync: readFileSync6 } = await import("fs");
4583
+ const { join: join4 } = await import("path");
3709
4584
  const out = (s) => process.stdout.write(s);
3710
4585
  const ok = (msg) => out(` \x1B[32m\u2713\x1B[0m ${msg}
3711
4586
  `);
@@ -3719,10 +4594,10 @@ ${infraSummary.substring(0, 12e3)}`;
3719
4594
  out(dim2(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
3720
4595
  const nodeVer = process.versions.node;
3721
4596
  const [major] = nodeVer.split(".").map(Number);
3722
- if ((major ?? 0) >= 18) {
4597
+ if ((major ?? 0) >= 20) {
3723
4598
  ok(`Node.js ${nodeVer}`);
3724
4599
  } else {
3725
- err(`Node.js ${nodeVer} \u2014 ben\xF6tigt >=18`);
4600
+ err(`Node.js ${nodeVer} \u2014 requires >=20`);
3726
4601
  allGood = false;
3727
4602
  }
3728
4603
  try {
@@ -3736,7 +4611,7 @@ ${infraSummary.substring(0, 12e3)}`;
3736
4611
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
3737
4612
  let hasOAuth = false;
3738
4613
  try {
3739
- const creds = JSON.parse(readFileSync3(join3(home, ".claude", ".credentials.json"), "utf8"));
4614
+ const creds = JSON.parse(readFileSync6(join4(home, ".claude", ".credentials.json"), "utf8"));
3740
4615
  const oauth = creds["claudeAiOauth"];
3741
4616
  hasOAuth = typeof oauth?.["accessToken"] === "string" && oauth["accessToken"].length > 0;
3742
4617
  } catch {
@@ -3786,8 +4661,8 @@ ${infraSummary.substring(0, 12e3)}`;
3786
4661
  warn(`${name} not found ${dim2("\u2014 discovery without " + name + " will be limited")}`);
3787
4662
  }
3788
4663
  }
3789
- const dbDir = join3(home, ".cartography");
3790
- if (existsSync3(dbDir)) {
4664
+ const dbDir = join4(home, ".cartography");
4665
+ if (existsSync4(dbDir)) {
3791
4666
  ok(`~/.cartography ${dim2("(data directory exists)")}`);
3792
4667
  } else {
3793
4668
  warn("~/.cartography does not exist yet " + dim2("\u2014 will be created on first run"));
@@ -3834,64 +4709,337 @@ ${infraSummary.substring(0, 12e3)}`;
3834
4709
  }
3835
4710
  db.close();
3836
4711
  });
4712
+ program.command("list-clients").description("List the AI hosts the installer can configure").action(() => {
4713
+ o("\n" + bold(" Supported MCP hosts:") + "\n\n");
4714
+ for (const c of listClients()) {
4715
+ o(` ${green(c.id.padEnd(16))} ${bold(c.label.padEnd(20))} ${dim(c.format)}
4716
+ `);
4717
+ if (c.note) o(` ${" ".repeat(16)} ${dim("\u21B3 " + c.note)}
4718
+ `);
4719
+ }
4720
+ o("\n" + dim(` Install: ${CMD} install --client <id> [--project] [--dry-run]`) + "\n\n");
4721
+ });
4722
+ program.command("install").description("Register the Cartography MCP server into an AI host's config (parse-merge, never clobber)").requiredOption("--client <id>", "Target host id (see `list-clients`)").option("--global", "Write the global/user config (default)", false).option("--project", "Write the project-local config instead", false).option("--dry-run", "Show the merge diff without writing", false).option("--deeplink", "Print a one-click install deeplink instead of writing (Cursor / VS Code)", false).option("--name <name>", "Server name to register", DEFAULT_SERVER_NAME).option("--http", "Register the Streamable HTTP endpoint instead of stdio", false).option("--url <url>", "HTTP endpoint (with --http)").option("--db <path>", "Pass --db <path> to the server").option("--session <id>", "Pass --session <id> to the server").action((opts) => {
4723
+ const spec = getClient(opts.client);
4724
+ if (!spec) {
4725
+ logError(`Unknown client "${opts.client}". Run \`${CMD} list-clients\` to see options.`);
4726
+ process.exitCode = 1;
4727
+ return;
4728
+ }
4729
+ const scope = opts.project ? "project" : "global";
4730
+ const packageArgs = [];
4731
+ if (opts.db) packageArgs.push("--db", opts.db);
4732
+ if (opts.session) packageArgs.push("--session", opts.session);
4733
+ const entry = defaultServerEntry({
4734
+ transport: opts.http ? "http" : "stdio",
4735
+ ...opts.url ? { url: opts.url } : {},
4736
+ ...packageArgs.length ? { packageArgs } : {}
4737
+ });
4738
+ if (opts.deeplink) {
4739
+ if (opts.client === "cursor") {
4740
+ o("\n" + bold(" Cursor one-click:") + "\n " + cyan(cursorDeeplink(opts.name, entry)) + "\n\n");
4741
+ } else if (opts.client === "vscode") {
4742
+ o("\n" + bold(" VS Code one-click:") + "\n " + cyan(vscodeDeeplink(opts.name, entry)) + "\n");
4743
+ o(" " + dim("or: ") + codeAddMcpCommand(opts.name, entry) + "\n\n");
4744
+ } else {
4745
+ logWarn(`No deeplink available for "${opts.client}". Deeplinks exist for: cursor, vscode.`);
4746
+ }
4747
+ return;
4748
+ }
4749
+ try {
4750
+ const plan = planInstall(spec, defaultContext(scope), { serverName: opts.name, entry });
4751
+ o("\n" + bold(` ${plan.label}`) + dim(` (${plan.format}, ${scope})`) + "\n");
4752
+ o(dim(` ${plan.path}`) + "\n");
4753
+ if (plan.note) o(yellow(` \u26A0 ${plan.note}`) + "\n");
4754
+ o("\n" + renderDiff(plan.before, plan.after) + "\n\n");
4755
+ if (!plan.changed) {
4756
+ o(green(" \u2713 Already up to date \u2014 nothing to write.") + "\n\n");
4757
+ return;
4758
+ }
4759
+ if (opts.dryRun) {
4760
+ o(yellow(" Dry run \u2014 no file written.") + "\n\n");
4761
+ return;
4762
+ }
4763
+ applyInstall(plan);
4764
+ o(green(` \u2713 Wrote ${plan.fileExists ? "updated" : "new"} config.`) + " " + dim("Restart the host to pick it up.") + "\n\n");
4765
+ } catch (err) {
4766
+ logError(err instanceof Error ? err.message : String(err));
4767
+ process.exitCode = 1;
4768
+ }
4769
+ });
4770
+ program;
4771
+ const consent = program.command("consent").description("Manage the per-employee data-sharing policy (none|anonymized|full) + admin anonymization");
4772
+ consent.command("default <level>").description("Set the global default sharing level (none|anonymized|full)").option("--db <path>", "DB path").action((level, opts) => {
4773
+ const parsed = SharingLevelSchema.safeParse(level);
4774
+ if (!parsed.success) {
4775
+ logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
4776
+ process.exitCode = 1;
4777
+ return;
4778
+ }
4779
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4780
+ const db = new CartographyDB(config.dbPath);
4781
+ try {
4782
+ db.setSharingLevel("*", parsed.data);
4783
+ logInfo(`default sharing level set to "${parsed.data}"`);
4784
+ } finally {
4785
+ db.close();
4786
+ }
4787
+ });
4788
+ consent.command("set <pattern> <level>").description("Set a pattern override (glob over the node id; * = within-segment, ** = any)").option("--db <path>", "DB path").action((pattern, level, opts) => {
4789
+ const parsed = SharingLevelSchema.safeParse(level);
4790
+ if (!parsed.success) {
4791
+ logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
4792
+ process.exitCode = 1;
4793
+ return;
4794
+ }
4795
+ if (pattern === "*" || pattern === "**") {
4796
+ logError("Use `consent default <level>` to set the global default; `set` is for narrower overrides");
4797
+ process.exitCode = 1;
4798
+ return;
4799
+ }
4800
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4801
+ const db = new CartographyDB(config.dbPath);
4802
+ try {
4803
+ db.setSharingLevel(pattern, parsed.data);
4804
+ logInfo(`override "${pattern}" \u2192 "${parsed.data}"`);
4805
+ } finally {
4806
+ db.close();
4807
+ }
4808
+ });
4809
+ consent.command("clear <pattern>").description("Remove a pattern override (the global default cannot be cleared)").option("--db <path>", "DB path").action((pattern, opts) => {
4810
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4811
+ const db = new CartographyDB(config.dbPath);
4812
+ try {
4813
+ db.clearSharingOverride(pattern);
4814
+ logInfo(`override "${pattern}" cleared`);
4815
+ } finally {
4816
+ db.close();
4817
+ }
4818
+ });
4819
+ consent.command("list").description("Show the global default + every pattern override").option("--db <path>", "DB path").action((opts) => {
4820
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4821
+ const db = new CartographyDB(config.dbPath);
4822
+ try {
4823
+ const policy = db.getSharingPolicy();
4824
+ process.stdout.write(JSON.stringify(policy, null, 2) + "\n");
4825
+ } finally {
4826
+ db.close();
4827
+ }
4828
+ });
4829
+ consent.command("preview [session]").description("Show exactly what would leave the machine for a session (default: latest)").option("--db <path>", "DB path").option("--org <name>", "Organization namespace for the org key").action((session, opts) => {
4830
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4831
+ const db = new CartographyDB(config.dbPath);
4832
+ try {
4833
+ const sid = session && session !== "latest" ? session : db.getLatestSession("discover")?.id;
4834
+ if (!sid) {
4835
+ logError("No session found to preview");
4836
+ process.exitCode = 1;
4837
+ return;
4838
+ }
4839
+ const orgKey = loadOrgKey({ organization: opts.org });
4840
+ const policy = db.getSharingPolicy();
4841
+ const preview = previewShare(db, sid, orgKey, policy);
4842
+ process.stdout.write(JSON.stringify(preview, null, 2) + "\n");
4843
+ } finally {
4844
+ db.close();
4845
+ }
4846
+ });
4847
+ const consentKey = consent.command("key").description("Org-key administration");
4848
+ consentKey.command("rotate").description("Rotate the org key (prior reversal entries become unrecoverable)").option("--org <name>", "Organization namespace for the org key").action((opts) => {
4849
+ rotateOrgKey({ organization: opts.org });
4850
+ logInfo("org key rotated");
4851
+ });
4852
+ consent.command("reverse <token>").description("Admin: recover the original plaintext behind a pseudonym token").option("--db <path>", "DB path").option("--org <name>", "Organization namespace for the org key").action((token, opts) => {
4853
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4854
+ const db = new CartographyDB(config.dbPath);
4855
+ try {
4856
+ const orgKey = loadOrgKey({ organization: opts.org });
4857
+ const plaintext = reversePseudonym(token, orgKey, db);
4858
+ if (plaintext === void 0) {
4859
+ logError(`Could not reverse "${token}" (unknown token or wrong/rotated org key)`);
4860
+ process.exitCode = 1;
4861
+ return;
4862
+ }
4863
+ process.stdout.write(plaintext + "\n");
4864
+ } finally {
4865
+ db.close();
4866
+ }
4867
+ });
4868
+ const sync = program.command("sync").description("Central-DB outbound sync: review queued items and push approved deltas (opt-in)");
4869
+ sync.command("status").description("Show the pending-review queue (counts by status + pending items)").option("--db <path>", "DB path").action((opts) => {
4870
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4871
+ const db = new CartographyDB(config.dbPath);
4872
+ try {
4873
+ if (!config.centralDb?.url) {
4874
+ logWarn("centralDb is not configured \u2014 sync is inert (set centralDb in ~/.cartography/config.json or CARTOGRAPHY_CENTRAL_URL/TOKEN)");
4875
+ }
4876
+ const counts = db.countPendingByStatus();
4877
+ process.stdout.write(JSON.stringify(counts, null, 2) + "\n");
4878
+ const pending = db.getPendingShares({ status: "pending" });
4879
+ for (const p of pending.slice(0, 50)) {
4880
+ process.stdout.write(` ${p.kind === "node" ? "\u25CF" : "\u2192"} ${p.nodeId ?? p.contentHash.slice(0, 12)} ${dim("(" + p.kind + ")")}
4881
+ `);
4882
+ }
4883
+ if (pending.length > 50) process.stdout.write(` ${dim("\u2026 and " + (pending.length - 50) + " more")}
4884
+ `);
4885
+ } finally {
4886
+ db.close();
4887
+ }
4888
+ });
4889
+ sync.command("review").description("Interactively approve/withhold each pending item (decisions are remembered)").option("--db <path>", "DB path").action(async (opts) => {
4890
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4891
+ const db = new CartographyDB(config.dbPath);
4892
+ try {
4893
+ const pending = db.getPendingShares({ status: "pending" });
4894
+ if (pending.length === 0) {
4895
+ logInfo("no pending items to review");
4896
+ return;
4897
+ }
4898
+ if (!process.stdin.isTTY) {
4899
+ logWarn(`${pending.length} pending item(s); run \`sync review\` in an interactive terminal to decide them`);
4900
+ return;
4901
+ }
4902
+ const w = process.stderr.write.bind(process.stderr);
4903
+ const patternFor = (p) => p.nodeId;
4904
+ for (const p of pending) {
4905
+ w("\n");
4906
+ w(` ${yellow(bold("?"))} Share ${p.kind} ${bold(p.nodeId ?? p.contentHash.slice(0, 12))}?
4907
+ `);
4908
+ w(` ${dim(JSON.stringify(p.payload))}
4909
+ `);
4910
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
4911
+ const ans = (await new Promise((res) => rl.question(` ${cyan("\u2192")} [s]hare / [w]ithhold / [a]lways / [n]ever / [q]uit: `, res))).trim().toLowerCase();
4912
+ rl.close();
4913
+ const pat = patternFor(p);
4914
+ if (ans === "q") break;
4915
+ if (ans === "s") {
4916
+ db.setPendingStatus(p.contentHash, "approved", "user");
4917
+ } else if (ans === "w") {
4918
+ db.setPendingStatus(p.contentHash, "withheld", "user");
4919
+ } else if (ans === "a") {
4920
+ if (pat) db.setSharingLevel(pat, "full");
4921
+ db.setPendingStatus(p.contentHash, "approved", "user");
4922
+ } else if (ans === "n") {
4923
+ if (pat) db.setSharingLevel(pat, "none");
4924
+ db.setPendingStatus(p.contentHash, "withheld", "user");
4925
+ } else {
4926
+ w(` ${dim("skipped (left pending)")}
4927
+ `);
4928
+ }
4929
+ }
4930
+ const counts = db.countPendingByStatus();
4931
+ logInfo(`review done \u2014 approved ${counts.approved}, withheld ${counts.withheld}, pending ${counts.pending}`);
4932
+ } finally {
4933
+ db.close();
4934
+ }
4935
+ });
4936
+ sync.command("push").description("Push approved deltas to the central ingest endpoint (bearer-auth HTTPS)").option("--db <path>", "DB path").option("--dry-run", "Preview the batches without sending", false).action(async (opts) => {
4937
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4938
+ if (!config.centralDb?.url) {
4939
+ logError("centralDb is not configured \u2014 nothing to push (set centralDb.url + token)");
4940
+ process.exitCode = 1;
4941
+ return;
4942
+ }
4943
+ const db = new CartographyDB(config.dbPath);
4944
+ try {
4945
+ const approved = db.getApprovedShares();
4946
+ const items = approved.map((p) => ({ contentHash: p.contentHash, kind: p.kind, payload: p.payload }));
4947
+ const result = await pushDeltas(config, items, { dryRun: opts.dryRun });
4948
+ if (!opts.dryRun) {
4949
+ for (const hash of result.sentHashes) db.setPendingStatus(hash, "shared");
4950
+ }
4951
+ logInfo(`sync push: sent ${result.sent}, batches ${result.batches}, failed ${result.failed}${opts.dryRun ? " (dry-run)" : ""}`);
4952
+ } catch (err) {
4953
+ logError(`sync push failed: ${err instanceof Error ? err.message : String(err)}`);
4954
+ process.exitCode = 1;
4955
+ } finally {
4956
+ db.close();
4957
+ }
4958
+ });
4959
+ program.command("mcp").description("Run the Model Context Protocol server (stdio by default) \u2014 the primary interface for AI agents").option("--http", "Use Streamable HTTP transport instead of stdio", false).option("--port <n>", "HTTP port", "3737").option("--host <h>", "HTTP host", "127.0.0.1").option("--allowed-hosts <list>", "Comma-separated Host allowlist (required for non-loopback --host)").option("--token <secret>", "Bearer token required on HTTP requests (or CARTOGRAPHY_HTTP_TOKEN); mandatory for non-loopback --host").option("--db <path>", "DB path").option("--session <id>", 'Session to serve (id or "latest")', "latest").option("--tenant <id>", "Tenant/organization whose topology to serve (alias: --org; default: local)").option("--org <id>", "Alias for --tenant").option("--no-semantic", "Disable semantic (vector) search").option("--plugins <list>", "Comma-separated scanner plugin package names to load (opt-in; or CARTOGRAPHY_PLUGINS)").option("--server-mode", "Run as a central collector: enable the authenticated POST /ingest write route + org-wide summary (implies --http; opt-in)", false).option("--anon-mode <mode>", "On ingest, reject|strip un-anonymized identifying fragments (server-mode)", "reject").action(async (opts) => {
4960
+ try {
4961
+ const anonMode = opts.anonMode;
4962
+ if (anonMode !== "reject" && anonMode !== "strip") {
4963
+ process.stderr.write(`
4964
+ error: --anon-mode must be 'reject' or 'strip' (got '${anonMode}')
4965
+ `);
4966
+ process.exitCode = 1;
4967
+ return;
4968
+ }
4969
+ await startMcp({
4970
+ transport: opts.http ? "http" : "stdio",
4971
+ port: parseInt(opts.port, 10),
4972
+ host: opts.host,
4973
+ allowedHosts: opts.allowedHosts ? String(opts.allowedHosts).split(",").map((h) => h.trim()).filter(Boolean) : void 0,
4974
+ token: opts.token,
4975
+ dbPath: opts.db,
4976
+ session: opts.session,
4977
+ tenant: opts.tenant ?? opts.org,
4978
+ semantic: opts.semantic,
4979
+ plugins: opts.plugins ? String(opts.plugins).split(",").map((p) => p.trim()).filter(Boolean) : void 0,
4980
+ serverMode: opts.serverMode === true,
4981
+ anonMode
4982
+ });
4983
+ } catch (err) {
4984
+ process.stderr.write(`
4985
+ error: ${err instanceof Error ? err.message : String(err)}
4986
+ `);
4987
+ process.exitCode = 1;
4988
+ }
4989
+ });
3837
4990
  const o = (s) => process.stderr.write(s);
3838
- const _b = (s) => `\x1B[1m${s}\x1B[0m`;
3839
- const _d = (s) => `\x1B[2m${s}\x1B[0m`;
3840
- const _c = (s) => `\x1B[36m${s}\x1B[0m`;
3841
- const _g = (s) => `\x1B[32m${s}\x1B[0m`;
3842
- const _m = (s) => `\x1B[35m${s}\x1B[0m`;
3843
4991
  o("\n");
3844
- o(_c(" ____ _ ____ ") + "\n");
3845
- o(_c(" | _ \\ __ _| |_ __ _/ ___| _ _ _ __ __ __") + "\n");
3846
- o(_c(" | | | |/ _` | __/ _` \\___ \\| | | | '_ \\\\ \\/ /") + "\n");
3847
- o(_c(" | |_| | (_| | || (_| |___) | |_| | | | |> < ") + "\n");
3848
- o(_c(" |____/ \\__,_|\\__\\__,_|____/ \\__, |_| |_/_/\\_\\") + "\n");
3849
- o(_c(" |___/ ") + "\n");
4992
+ o(cyan(" ____ _ ____ ") + "\n");
4993
+ o(cyan(" | _ \\ __ _| |_ __ _/ ___| _ _ _ __ __ __") + "\n");
4994
+ o(cyan(" | | | |/ _` | __/ _` \\___ \\| | | | '_ \\\\ \\/ /") + "\n");
4995
+ o(cyan(" | |_| | (_| | || (_| |___) | |_| | | | |> < ") + "\n");
4996
+ o(cyan(" |____/ \\__,_|\\__\\__,_|____/ \\__, |_| |_/_/\\_\\") + "\n");
4997
+ o(cyan(" |___/ ") + "\n");
3850
4998
  o("\n");
3851
- o(_b(" Cartography") + " " + _d("v" + VERSION) + "\n");
3852
- o(_d(" AI-powered Infrastructure Discovery & Agentic AI Cartography\n"));
3853
- o(_d(" Built on Claude Agent SDK\n"));
4999
+ o(bold(" Cartography") + " " + dim("v" + VERSION) + "\n");
5000
+ o(dim(" AI-powered Infrastructure Discovery & Agentic AI Cartography\n"));
5001
+ o(dim(" Autonomous infrastructure discovery \u2014 zero-config, provider-agnostic\n"));
3854
5002
  o("\n");
3855
5003
  if (process.argv.length <= 2) {
3856
- o(_d(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
5004
+ o(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
3857
5005
  o("\n");
3858
- o(_b(" Commands:\n"));
5006
+ o(bold(" Commands:\n"));
3859
5007
  o("\n");
3860
- o(` ${_g("discover")} ${_d("Scan infrastructure (Claude Sonnet)")}
5008
+ o(` ${green("discover")} ${dim("Scan infrastructure (provider: claude|openai|ollama)")}
3861
5009
  `);
3862
- o(` ${_g("seed")} ${_d("Manually add known tools/DBs/APIs")}
5010
+ o(` ${green("seed")} ${dim("Manually add known tools/DBs/APIs")}
3863
5011
  `);
3864
- o(` ${_g("bookmarks")} ${_d("View browser bookmarks")}
5012
+ o(` ${green("bookmarks")} ${dim("View browser bookmarks")}
3865
5013
  `);
3866
- o(` ${_g("export")} ${_d("[session]")} ${_d("Export Mermaid, JSON, YAML, HTML")}
5014
+ o(` ${green("export")} ${dim("[session]")} ${dim("Export Mermaid, JSON, YAML, HTML")}
3867
5015
  `);
3868
- o(` ${_g("show")} ${_d("[session]")} ${_d("Show session details")}
5016
+ o(` ${green("show")} ${dim("[session]")} ${dim("Show session details")}
3869
5017
  `);
3870
- o(` ${_g("sessions")} ${_d("List all sessions")}
5018
+ o(` ${green("sessions")} ${dim("List all sessions")}
3871
5019
  `);
3872
- o(` ${_g("doctor")} ${_d("Check requirements (kubectl, aws, gcloud, az)")}
5020
+ o(` ${green("doctor")} ${dim("Check requirements (kubectl, aws, gcloud, az)")}
3873
5021
  `);
3874
- o(` ${_g("docs")} ${_d("Full feature reference")}
5022
+ o(` ${green("docs")} ${dim("Full feature reference")}
3875
5023
  `);
3876
5024
  o("\n");
3877
- o(_d(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
5025
+ o(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
3878
5026
  o("\n");
3879
- o(_b(" Quick Start:\n"));
5027
+ o(bold(" Quick Start:\n"));
3880
5028
  o("\n");
3881
- o(` ${_m("$")} ${_b("datasynx-cartography doctor")} ${_d("Check requirements")}
5029
+ o(` ${magenta("$")} ${bold("datasynx-cartography doctor")} ${dim("Check requirements")}
3882
5030
  `);
3883
- o(` ${_m("$")} ${_b("datasynx-cartography seed")} ${_d("Add known infrastructure")}
5031
+ o(` ${magenta("$")} ${bold("datasynx-cartography seed")} ${dim("Add known infrastructure")}
3884
5032
  `);
3885
- o(` ${_m("$")} ${_b("datasynx-cartography discover")} ${_d("One-time scan")}
5033
+ o(` ${magenta("$")} ${bold("datasynx-cartography discover")} ${dim("One-time scan")}
3886
5034
  `);
3887
5035
  o("\n");
3888
- o(_d(" Docs: datasynx-cartography docs\n"));
3889
- o(_d(" Help: datasynx-cartography --help\n"));
3890
- o(_d(" npm: @datasynx/agentic-ai-cartography\n"));
5036
+ o(dim(" Docs: datasynx-cartography docs\n"));
5037
+ o(dim(" Help: datasynx-cartography --help\n"));
5038
+ o(dim(" npm: @datasynx/agentic-ai-cartography\n"));
3891
5039
  o("\n");
3892
5040
  return;
3893
5041
  }
3894
- o(_d(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
5042
+ o(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
3895
5043
  o("\n");
3896
5044
  program.exitOverride((err) => {
3897
5045
  if (err.code === "commander.helpDisplayed") {