@datasynx/agentic-ai-cartography 2.0.0 → 2.3.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.
Files changed (68) hide show
  1. package/AGENTS.md +32 -0
  2. package/README.md +115 -6
  3. package/dist/api-bin.js +24 -0
  4. package/dist/api-bin.js.map +1 -0
  5. package/dist/{bookmarks-VS56KVCO.js → bookmarks-WXHE7GN7.js} +6 -3
  6. package/dist/{chunk-CJ2PITFA.js → chunk-2SZ5QHGH.js} +71 -9
  7. package/dist/chunk-2SZ5QHGH.js.map +1 -0
  8. package/dist/chunk-7QEBFMN4.js +3278 -0
  9. package/dist/chunk-7QEBFMN4.js.map +1 -0
  10. package/dist/chunk-7VZH5PFV.js +1134 -0
  11. package/dist/chunk-7VZH5PFV.js.map +1 -0
  12. package/dist/chunk-B2AKONVW.js +2465 -0
  13. package/dist/chunk-B2AKONVW.js.map +1 -0
  14. package/dist/chunk-WCR47QA2.js +277 -0
  15. package/dist/chunk-WCR47QA2.js.map +1 -0
  16. package/dist/cli.js +2367 -663
  17. package/dist/cli.js.map +1 -1
  18. package/dist/index.cjs +9405 -57913
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +3048 -69
  21. package/dist/index.d.ts +3048 -69
  22. package/dist/index.js +9150 -2607
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp-bin.js +17 -26
  25. package/dist/mcp-bin.js.map +1 -1
  26. package/dist/types-TJWXAQ2L.js +66 -0
  27. package/llms-full.txt +758 -0
  28. package/llms.txt +24 -0
  29. package/package.json +27 -9
  30. package/scripts/build-llms.mjs +89 -0
  31. package/scripts/build-mcpb.mjs +31 -0
  32. package/scripts/gen-api-schemas.ts +29 -0
  33. package/scripts/gen-docs.ts +123 -0
  34. package/scripts/sync-version.mjs +51 -0
  35. package/scripts/validate-server-json.mjs +54 -0
  36. package/server.json +4 -4
  37. package/dist/chunk-CJ2PITFA.js.map +0 -1
  38. package/dist/chunk-D6SRSLBF.js +0 -48
  39. package/dist/chunk-J6FDZ6HZ.js +0 -142
  40. package/dist/chunk-J6FDZ6HZ.js.map +0 -1
  41. package/dist/chunk-UGSNG3QJ.js +0 -49
  42. package/dist/chunk-UGSNG3QJ.js.map +0 -1
  43. package/dist/chunk-W7YE6AAH.js +0 -1516
  44. package/dist/chunk-W7YE6AAH.js.map +0 -1
  45. package/dist/onnxruntime_binding-6Q6HXASN.node +0 -0
  46. package/dist/onnxruntime_binding-EKZT2NRK.node +0 -0
  47. package/dist/onnxruntime_binding-P6S7V3CI.node +0 -0
  48. package/dist/onnxruntime_binding-PJNNIIUO.node +0 -0
  49. package/dist/onnxruntime_binding-UN6SPTQK.node +0 -0
  50. package/dist/sdk-A6NLO3DJ.js +0 -12294
  51. package/dist/sdk-A6NLO3DJ.js.map +0 -1
  52. package/dist/sdk-G5D4WQZ4.js +0 -12293
  53. package/dist/sdk-G5D4WQZ4.js.map +0 -1
  54. package/dist/sdk-QSTAREST.js +0 -4869
  55. package/dist/sdk-QSTAREST.js.map +0 -1
  56. package/dist/sqlite-vec-EZN67B2V.js +0 -40
  57. package/dist/sqlite-vec-EZN67B2V.js.map +0 -1
  58. package/dist/sqlite-vec-UK5YYE5T.js +0 -39
  59. package/dist/sqlite-vec-UK5YYE5T.js.map +0 -1
  60. package/dist/transformers.node-BTYUTJK5.js +0 -42884
  61. package/dist/transformers.node-BTYUTJK5.js.map +0 -1
  62. package/dist/transformers.node-J6PRTTOX.js +0 -42883
  63. package/dist/transformers.node-J6PRTTOX.js.map +0 -1
  64. package/dist/types-JG27FR3E.js +0 -29
  65. package/dist/types-JG27FR3E.js.map +0 -1
  66. package/scripts/postinstall.mjs +0 -7
  67. /package/dist/{bookmarks-VS56KVCO.js.map → bookmarks-WXHE7GN7.js.map} +0 -0
  68. /package/dist/{chunk-D6SRSLBF.js.map → types-TJWXAQ2L.js.map} +0 -0
package/dist/cli.js CHANGED
@@ -1,39 +1,55 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- CartographyDB,
3
+ getRuleset,
4
+ isPersonalHost,
5
+ listRulesets,
6
+ loadOrgKey,
7
+ pseudonymize,
8
+ pseudonymizeString,
9
+ reversePseudonym,
10
+ rotateOrgKey,
11
+ runDrift,
12
+ runLocalDiscovery,
4
13
  startMcp
5
- } from "./chunk-W7YE6AAH.js";
14
+ } from "./chunk-B2AKONVW.js";
15
+ import {
16
+ startApi
17
+ } from "./chunk-7VZH5PFV.js";
18
+ import {
19
+ CartographyDB,
20
+ buildCartographyToolHandlers,
21
+ createCartographyTools,
22
+ deriveSessionName,
23
+ diffTopology,
24
+ normalizeTenant,
25
+ redactValue,
26
+ stableStringify,
27
+ stripSensitive
28
+ } from "./chunk-7QEBFMN4.js";
6
29
  import {
30
+ ConfigFileSchema,
31
+ CostEntrySchema,
7
32
  DOMAIN_COLORS,
8
33
  DOMAIN_PALETTE,
9
- EDGE_RELATIONSHIPS,
10
- NODE_TYPES,
34
+ DriftConfigSchema,
11
35
  NODE_TYPE_GROUPS,
36
+ SharingLevelSchema,
37
+ centralDbFromEnv,
12
38
  defaultConfig
13
- } from "./chunk-J6FDZ6HZ.js";
39
+ } from "./chunk-WCR47QA2.js";
14
40
  import {
15
- HOME,
16
- IS_LINUX,
17
41
  IS_MAC,
18
42
  IS_WIN,
19
43
  PLATFORM,
20
44
  checkReadOnly,
21
45
  cleanupTempFiles,
22
- commandExists,
23
- dbScanDirs,
24
- findFiles,
25
46
  logDebug,
26
47
  logError,
27
48
  logInfo,
28
49
  logWarn,
29
50
  run,
30
- scanAllBookmarks,
31
- scanAllHistory,
32
- scanWindowsDbServices,
33
- scanWindowsPrograms,
34
51
  setVerbose
35
- } from "./chunk-CJ2PITFA.js";
36
- import "./chunk-UGSNG3QJ.js";
52
+ } from "./chunk-2SZ5QHGH.js";
37
53
 
38
54
  // src/cli.ts
39
55
  import { Command } from "commander";
@@ -54,7 +70,30 @@ function isOAuthLoggedIn() {
54
70
  return false;
55
71
  }
56
72
  }
57
- function checkPrerequisites() {
73
+ function checkPrerequisites(provider = "claude") {
74
+ if (provider === "openai") {
75
+ checkOpenAIPrerequisites();
76
+ return;
77
+ }
78
+ if (provider === "ollama") {
79
+ process.stderr.write(
80
+ `\u2713 Ollama provider selected (host: ${process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434"})
81
+ `
82
+ );
83
+ return;
84
+ }
85
+ checkClaudePrerequisites();
86
+ }
87
+ function checkOpenAIPrerequisites() {
88
+ if (!process.env.OPENAI_API_KEY) {
89
+ process.stderr.write(
90
+ "\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"
91
+ );
92
+ process.exitCode = 1;
93
+ throw new Error("OPENAI_API_KEY not set");
94
+ }
95
+ }
96
+ function checkClaudePrerequisites() {
58
97
  try {
59
98
  execSync("claude --version", { stdio: "pipe" });
60
99
  } catch {
@@ -75,515 +114,24 @@ function checkPrerequisites() {
75
114
  }
76
115
  }
77
116
 
78
- // src/tools.ts
79
- import { z } from "zod";
80
- function createScanRunner(runFn, opts = {}) {
81
- const threshold = opts.threshold ?? 3;
82
- let consecutiveFailures = 0;
83
- let tripped = false;
84
- return (cmd) => {
85
- if (tripped) {
86
- logDebug(`Circuit breaker: skipping "${cmd}" (${consecutiveFailures} consecutive failures)`);
87
- return "(skipped \u2014 circuit breaker: too many consecutive failures)";
88
- }
89
- const result = runFn(cmd, { timeout: opts.timeout ?? 2e4, env: opts.env });
90
- if (!result) {
91
- consecutiveFailures++;
92
- if (consecutiveFailures >= threshold) {
93
- tripped = true;
94
- logDebug(`Circuit breaker tripped after ${threshold} failures, last command: "${cmd}"`);
95
- }
96
- return "(error or not available)";
97
- }
98
- consecutiveFailures = 0;
99
- return result;
100
- };
101
- }
102
- function stripSensitive(target) {
103
- const raw = target.trim();
104
- if (!raw) return raw;
105
- try {
106
- const url = new URL(raw.startsWith("http") ? raw : `tcp://${raw}`);
107
- const stripped = `${url.hostname}${url.port ? ":" + url.port : ""}`;
108
- return stripped || raw;
109
- } catch {
110
- const stripped = raw.replace(/\/.*$/, "").replace(/\?.*$/, "").replace(/@.*:/, ":");
111
- return stripped || raw;
112
- }
113
- }
114
- async function createCartographyTools(db, sessionId, opts = {}) {
115
- const { tool, createSdkMcpServer } = await import("./sdk-A6NLO3DJ.js");
116
- const tools = [
117
- tool("save_node", "Save an infrastructure node to the catalog", {
118
- id: z.string(),
119
- type: z.enum(NODE_TYPES),
120
- name: z.string(),
121
- discoveredVia: z.string(),
122
- confidence: z.number().min(0).max(1),
123
- metadata: z.record(z.string(), z.unknown()).optional(),
124
- tags: z.array(z.string()).optional(),
125
- domain: z.string().optional().describe('Business domain, e.g. "Marketing", "Finance"'),
126
- subDomain: z.string().optional().describe('Sub-domain, e.g. "Forecast client orders"'),
127
- qualityScore: z.number().min(0).max(100).optional().describe("Data quality score 0\u2013100")
128
- }, async (args) => {
129
- const node = {
130
- id: stripSensitive(args["id"]),
131
- type: args["type"],
132
- name: args["name"],
133
- discoveredVia: args["discoveredVia"],
134
- confidence: args["confidence"],
135
- metadata: args["metadata"] ?? {},
136
- tags: args["tags"] ?? [],
137
- domain: args["domain"],
138
- subDomain: args["subDomain"],
139
- qualityScore: args["qualityScore"]
140
- };
141
- db.upsertNode(sessionId, node);
142
- return { content: [{ type: "text", text: `\u2713 Node: ${node.id}` }] };
143
- }),
144
- tool("save_edge", "Save a relationship (edge) between two nodes \u2014 ALWAYS save edges when connections are clear", {
145
- sourceId: z.string(),
146
- targetId: z.string(),
147
- relationship: z.enum(EDGE_RELATIONSHIPS),
148
- evidence: z.string(),
149
- confidence: z.number().min(0).max(1)
150
- }, async (args) => {
151
- db.insertEdge(sessionId, {
152
- sourceId: args["sourceId"],
153
- targetId: args["targetId"],
154
- relationship: args["relationship"],
155
- evidence: args["evidence"],
156
- confidence: args["confidence"]
157
- });
158
- return { content: [{ type: "text", text: `\u2713 ${args["sourceId"]}\u2192${args["targetId"]}` }] };
159
- }),
160
- tool("get_catalog", "Get the current catalog \u2014 use before save_node to avoid duplicates", {
161
- includeEdges: z.boolean().default(true)
162
- }, async (args) => {
163
- const nodes = db.getNodes(sessionId);
164
- const edges = args["includeEdges"] ? db.getEdges(sessionId) : [];
165
- return {
166
- content: [{
167
- type: "text",
168
- text: JSON.stringify({
169
- count: { nodes: nodes.length, edges: edges.length },
170
- nodeIds: nodes.map((n) => n.id)
171
- })
172
- }]
173
- };
174
- }),
175
- tool("ask_user", "Ask the user a question \u2014 for clarifications, missing context, or consent (e.g. before scanning browser history)", {
176
- question: z.string().describe("The question for the user (clear and specific)"),
177
- context: z.string().optional().describe("Optional context explaining why this is relevant")
178
- }, async (args) => {
179
- const question = args["question"];
180
- const context = args["context"];
181
- if (opts.onAskUser) {
182
- const answer = await opts.onAskUser(question, context);
183
- return { content: [{ type: "text", text: answer }] };
184
- }
185
- return {
186
- content: [{ type: "text", text: "(Non-interactive mode \u2014 please continue without this information)" }]
187
- };
188
- }),
189
- tool("scan_bookmarks", "Scan all browser bookmarks \u2014 hostnames only, no personal data (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)", {
190
- minConfidence: z.number().min(0).max(1).default(0.5).optional()
191
- }, async () => {
192
- const hosts = await scanAllBookmarks();
193
- return {
194
- content: [{
195
- type: "text",
196
- text: JSON.stringify({
197
- count: hosts.length,
198
- hosts: hosts.map((h) => ({
199
- hostname: h.hostname,
200
- port: h.port,
201
- protocol: h.protocol,
202
- source: h.source
203
- })),
204
- note: "Hostnames only \u2014 no paths, no personal data. Classify each as a business tool (save_node) or ignore (social media, news, shopping)."
205
- })
206
- }]
207
- };
208
- }),
209
- tool("scan_browser_history", "Scan browser history \u2014 anonymized hostnames + visit frequency. ALWAYS call ask_user for consent before using this tool.", {
210
- minVisits: z.number().min(1).default(3).optional().describe("Minimum visit count to include a host (filters rarely-visited sites)")
211
- }, async (args) => {
212
- const minVisits = args["minVisits"] ?? 3;
213
- const hosts = await scanAllHistory();
214
- const filtered = hosts.filter((h) => h.visitCount >= minVisits);
215
- return {
216
- content: [{
217
- type: "text",
218
- text: JSON.stringify({
219
- count: filtered.length,
220
- note: "Anonymized \u2014 hostnames only, no URLs, no paths, no personal data. Classify business tools as saas_tool nodes.",
221
- hosts: filtered.map((h) => ({
222
- hostname: h.hostname,
223
- visitCount: h.visitCount,
224
- protocol: h.protocol,
225
- source: h.source
226
- }))
227
- })
228
- }]
229
- };
230
- }),
231
- tool("scan_local_databases", "Scan for local database files and running DB servers \u2014 PostgreSQL databases, MySQL, SQLite files from installed apps", {
232
- deep: z.boolean().default(false).optional().describe("Also search home directory recursively for SQLite/DB files (slower)")
233
- }, async (args) => {
234
- const deep = args["deep"] ?? false;
235
- const results = {};
236
- results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
237
- if (IS_WIN) {
238
- results["DB_SERVICES"] = scanWindowsDbServices() || "(no database services found)";
239
- }
240
- if (commandExists("psql")) {
241
- if (IS_WIN) {
242
- results["POSTGRES_DATABASES"] = run("psql -lqt", { timeout: 1e4 }) || "(psql found but not running or requires auth)";
243
- } else {
244
- results["POSTGRES_DATABASES"] = run(`psql -lqt 2>/dev/null | grep -v "template0\\|template1" | awk '{print $1}' | grep -v "^$\\|^|"`) || "(psql not running or not available)";
245
- results["POSTGRES_CLUSTERS"] = run("pg_lsclusters 2>/dev/null") || "(pg_lsclusters not available)";
246
- }
247
- } else {
248
- results["POSTGRES_DATABASES"] = "(psql not installed)";
249
- }
250
- if (commandExists("mysql")) {
251
- if (IS_WIN) {
252
- results["MYSQL_DATABASES"] = run('mysql --connect-timeout=3 -e "SHOW DATABASES;"', { timeout: 1e4 }) || "(mysql not running or requires auth)";
253
- } else {
254
- results["MYSQL_DATABASES"] = run('mysql --connect-timeout=3 -e "SHOW DATABASES;" 2>/dev/null') || "(mysql not running or requires auth)";
255
- }
256
- } else {
257
- results["MYSQL_DATABASES"] = "(mysql not installed)";
258
- }
259
- if (commandExists("mongosh")) {
260
- if (IS_WIN) {
261
- results["MONGODB_DATABASES"] = run(`mongosh --quiet --eval "db.adminCommand({listDatabases:1}).databases.map(d=>d.name).join('\\n')"`, { timeout: 1e4 }) || "(mongosh not available)";
262
- } else {
263
- results["MONGODB_DATABASES"] = run(`mongosh --quiet --eval "db.adminCommand({listDatabases:1}).databases.map(d=>d.name).join('\\n')" 2>/dev/null`) || "(mongosh not available)";
264
- }
265
- } else {
266
- results["MONGODB_DATABASES"] = "(mongosh not installed)";
267
- }
268
- if (commandExists("redis-cli")) {
269
- if (IS_WIN) {
270
- results["REDIS_INFO"] = run("redis-cli info server", { timeout: 1e4 }).split("\n").slice(0, 5).join("\n") || "(redis-cli not available)";
271
- } else {
272
- results["REDIS_INFO"] = run("redis-cli info server 2>/dev/null | head -5") || "(redis-cli not available)";
273
- }
274
- } else {
275
- results["REDIS_INFO"] = "(redis-cli not installed)";
276
- }
277
- const appDirs = dbScanDirs();
278
- if (appDirs.length > 0) {
279
- results["SQLITE_APP_FILES"] = findFiles(appDirs, ["*.sqlite", "*.sqlite3", "*.db"], 4, 80) || "(none found)";
280
- }
281
- if (deep) {
282
- if (IS_WIN) {
283
- results["SQLITE_DEEP_SCAN"] = run(
284
- `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`,
285
- { timeout: 3e4 }
286
- ) || "(none found)";
287
- } else {
288
- 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)";
289
- }
290
- }
291
- if (IS_WIN) {
292
- results["DB_CONFIG_FILES"] = run(
293
- `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`,
294
- { timeout: 15e3 }
295
- ) || "(none found)";
296
- } else {
297
- 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)";
298
- }
299
- const out = Object.entries(results).map(([k, v]) => `=== ${k} ===
300
- ${v}`).join("\n\n");
301
- return { content: [{ type: "text", text: out }] };
302
- }),
303
- tool("scan_k8s_resources", "Scan Kubernetes cluster via kubectl \u2014 100% readonly (get, describe)", {
304
- namespace: z.string().optional().describe("Filter by namespace \u2014 empty = all namespaces")
305
- }, async (args) => {
306
- const ns = args["namespace"];
307
- const nsFlag = ns ? `-n ${ns}` : "--all-namespaces";
308
- const runK = createScanRunner(run, { timeout: 15e3, threshold: 3 });
309
- const sections = IS_WIN ? [
310
- ["CONTEXT", "kubectl config current-context"],
311
- ["NODES", "kubectl get nodes -o wide"],
312
- ["NAMESPACES", "kubectl get namespaces"],
313
- ["SERVICES", `kubectl get services ${nsFlag}`],
314
- ["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
315
- ["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
316
- ["INGRESSES", `kubectl get ingress ${nsFlag}`],
317
- ["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running`],
318
- ["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system"]
319
- ] : [
320
- ["CONTEXT", 'kubectl config current-context 2>/dev/null || echo "(no context set)"'],
321
- ["NODES", "kubectl get nodes -o wide"],
322
- ["NAMESPACES", "kubectl get namespaces"],
323
- ["SERVICES", `kubectl get services ${nsFlag}`],
324
- ["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
325
- ["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
326
- ["INGRESSES", `kubectl get ingress ${nsFlag} 2>/dev/null || echo "(none)"`],
327
- ["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running 2>/dev/null | head -60`],
328
- ["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system 2>/dev/null | head -30"]
329
- ];
330
- const out = sections.map(([l, c]) => `=== ${l} ===
331
- ${runK(c)}`).join("\n\n");
332
- return { content: [{ type: "text", text: out }] };
333
- }),
334
- tool("scan_aws_resources", "Scan AWS infrastructure via AWS CLI \u2014 100% readonly (describe, list)", {
335
- region: z.string().optional().describe("AWS Region \u2014 default: AWS_DEFAULT_REGION or profile"),
336
- profile: z.string().optional().describe("AWS CLI profile")
337
- }, async (args) => {
338
- const region = args["region"];
339
- const profile = args["profile"];
340
- const env = { ...process.env };
341
- if (region) env["AWS_DEFAULT_REGION"] = region;
342
- const pf = profile ? `--profile ${profile}` : "";
343
- const runAws = createScanRunner(run, { timeout: 2e4, env, threshold: 3 });
344
- const sections = [
345
- ["IDENTITY", `aws sts get-caller-identity ${pf} --output json`],
346
- ["EC2", `aws ec2 describe-instances ${pf} --query "Reservations[*].Instances[*].[InstanceId,InstanceType,State.Name,PublicIpAddress,PrivateIpAddress]" --output table`],
347
- ["RDS", `aws rds describe-db-instances ${pf} --query "DBInstances[*].[DBInstanceIdentifier,Engine,DBInstanceStatus,Endpoint.Address,Endpoint.Port]" --output table`],
348
- ["ELB_V2", `aws elbv2 describe-load-balancers ${pf} --query "LoadBalancers[*].[LoadBalancerName,DNSName,Type,State.Code]" --output table`],
349
- ["EKS", `aws eks list-clusters ${pf} --output json`],
350
- ["ELASTICACHE", `aws elasticache describe-cache-clusters ${pf} --query "CacheClusters[*].[CacheClusterId,Engine,CacheClusterStatus]" --output table`],
351
- ["S3", `aws s3 ls ${pf}`],
352
- ["VPC", `aws ec2 describe-vpcs ${pf} --query "Vpcs[*].[VpcId,CidrBlock,IsDefault]" --output table`]
353
- ];
354
- const out = sections.map(([l, c]) => `=== ${l} ===
355
- ${runAws(c)}`).join("\n\n");
356
- return { content: [{ type: "text", text: out }] };
357
- }),
358
- tool("scan_gcp_resources", "Scan Google Cloud Platform via gcloud CLI \u2014 100% readonly (list, describe)", {
359
- project: z.string().optional().describe("GCP Project ID \u2014 default: current gcloud project")
360
- }, async (args) => {
361
- const project = args["project"];
362
- const pf = project ? `--project ${project}` : "";
363
- const runGcp = createScanRunner(run, { timeout: 2e4, threshold: 3 });
364
- const sections = [
365
- ["IDENTITY", `gcloud config list account --format="value(core.account)"`],
366
- ["COMPUTE_INSTANCES", `gcloud compute instances list ${pf}`],
367
- ["SQL_INSTANCES", `gcloud sql instances list ${pf}`],
368
- ["GKE_CLUSTERS", `gcloud container clusters list ${pf}`],
369
- ["CLOUD_RUN", `gcloud run services list ${pf} --platform managed`],
370
- ["CLOUD_FUNCTIONS", `gcloud functions list ${pf}`],
371
- ["REDIS", `gcloud redis instances list ${pf} --regions=-`],
372
- ["PUBSUB", `gcloud pubsub topics list ${pf}`],
373
- ["SPANNER", `gcloud spanner instances list ${pf}`]
374
- ];
375
- const out = sections.map(([l, c]) => `=== ${l} ===
376
- ${runGcp(c)}`).join("\n\n");
377
- return { content: [{ type: "text", text: out }] };
378
- }),
379
- tool("scan_azure_resources", "Scan Azure infrastructure via az CLI \u2014 100% readonly (list, show)", {
380
- subscription: z.string().optional().describe("Azure Subscription ID"),
381
- resourceGroup: z.string().optional().describe("Filter by resource group")
382
- }, async (args) => {
383
- const sub = args["subscription"];
384
- const rg = args["resourceGroup"];
385
- const sf = sub ? `--subscription ${sub}` : "";
386
- const rf = rg ? `--resource-group ${rg}` : "";
387
- const runAz = createScanRunner(run, { timeout: 2e4, threshold: 3 });
388
- const sections = [
389
- ["IDENTITY", `az account show --output json ${sf}`],
390
- ["VMS", `az vm list ${sf} ${rf} --output table`],
391
- ["AKS", `az aks list ${sf} ${rf} --output table`],
392
- ["SQL_SERVERS", `az sql server list ${sf} ${rf} --output table`],
393
- ["POSTGRES", `az postgres server list ${sf} ${rf} --output table`],
394
- ["REDIS", `az redis list ${sf} ${rf} --output table`],
395
- ["WEBAPPS", `az webapp list ${sf} ${rf} --output table`],
396
- ["CONTAINER_APPS", `az containerapp list ${sf} ${rf} --output table`],
397
- ["FUNCTIONS", `az functionapp list ${sf} ${rf} --output table`]
398
- ];
399
- const out = sections.map(([l, c]) => `=== ${l} ===
400
- ${runAz(c)}`).join("\n\n");
401
- return { content: [{ type: "text", text: out }] };
402
- }),
403
- tool("scan_installed_apps", "Scan all installed apps and tools \u2014 IDEs, office, dev tools, business apps, databases", {
404
- searchHint: z.string().optional().describe('Optional search term to find specific tools (e.g. "hubspot windsurf cursor")')
405
- }, async (args) => {
406
- const hint = args["searchHint"];
407
- const results = {};
408
- results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
409
- if (IS_MAC) {
410
- results["APPLICATIONS"] = run("ls /Applications/ 2>/dev/null | head -200") || "(empty)";
411
- results["USER_APPLICATIONS"] = run("ls ~/Applications/ 2>/dev/null | head -100") || "(empty)";
412
- results["BREW_CASKS"] = run("brew list --cask 2>/dev/null | head -100") || "(brew not installed)";
413
- results["BREW_FORMULAE"] = run("brew list --formula 2>/dev/null | head -150") || "(brew not installed)";
414
- results["SPOTLIGHT_APPS"] = run(`mdfind "kMDItemKind == 'Application'" 2>/dev/null | grep -v "^/System" | grep -v "^/Library/Apple" | head -100`) || "(Spotlight not available)";
415
- } else if (IS_LINUX) {
416
- results["DPKG"] = run("dpkg --list 2>/dev/null | awk '{print $2}' | head -200") || "(dpkg not available)";
417
- results["SNAP"] = run("snap list 2>/dev/null | head -50") || "(snap not available)";
418
- results["FLATPAK"] = run("flatpak list 2>/dev/null | head -50") || "(flatpak not available)";
419
- 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)";
420
- results["RPM"] = run("rpm -qa 2>/dev/null | head -200") || "(rpm not available)";
421
- } else if (IS_WIN) {
422
- results["WINGET"] = run("winget list --accept-source-agreements", { timeout: 2e4 }) || "(winget not available)";
423
- results["INSTALLED_PROGRAMS"] = scanWindowsPrograms() || "(registry scan failed)";
424
- results["CHOCO"] = run("choco list --local-only", { timeout: 15e3 }) || "(chocolatey not installed)";
425
- results["SCOOP"] = run("scoop list", { timeout: 15e3 }) || "(scoop not installed)";
426
- }
427
- const knownTools = [
428
- // IDEs & Editors
429
- "code",
430
- "code-insiders",
431
- "cursor",
432
- "windsurf",
433
- "zed",
434
- "vim",
435
- "nvim",
436
- "emacs",
437
- "nano",
438
- "sublime_text",
439
- "atom",
440
- "idea",
441
- "webstorm",
442
- "pycharm",
443
- "goland",
444
- "datagrip",
445
- "clion",
446
- "rider",
447
- "phpstorm",
448
- "rubymine",
449
- "appcode",
450
- // Dev Tools
451
- "git",
452
- "gh",
453
- "docker",
454
- "docker-compose",
455
- "podman",
456
- "kubectl",
457
- "helm",
458
- "terraform",
459
- "ansible",
460
- "node",
461
- "npm",
462
- "npx",
463
- "yarn",
464
- "pnpm",
465
- "bun",
466
- "deno",
467
- "python",
468
- "python3",
469
- "pip",
470
- "pip3",
471
- "pipenv",
472
- "poetry",
473
- "conda",
474
- "ruby",
475
- "gem",
476
- "bundler",
477
- "rails",
478
- "java",
479
- "mvn",
480
- "gradle",
481
- "kotlin",
482
- "go",
483
- "cargo",
484
- "rustc",
485
- "php",
486
- "composer",
487
- "dotnet",
488
- // Databases
489
- "psql",
490
- "mysql",
491
- "mysqladmin",
492
- "mongo",
493
- "mongosh",
494
- "redis-cli",
495
- "sqlite3",
496
- "clickhouse-client",
497
- // Cloud CLIs
498
- "aws",
499
- "gcloud",
500
- "az",
501
- "heroku",
502
- "fly",
503
- "vercel",
504
- "netlify",
505
- "wrangler",
506
- // Infra
507
- "vagrant",
508
- "packer",
509
- "consul",
510
- "vault",
511
- "nomad",
512
- // Communication / SaaS
513
- "slack",
514
- "discord",
515
- "zoom",
516
- "teams",
517
- "skype",
518
- "telegram",
519
- "signal",
520
- // Browsers
521
- "google-chrome",
522
- "chromium",
523
- "firefox",
524
- "safari",
525
- "brave",
526
- "opera",
527
- "edge",
528
- // Windows-specific
529
- ...IS_WIN ? ["pwsh", "powershell", "wsl", "winget", "choco", "scoop", "notepad++"] : [],
530
- // Monitoring / Analytics
531
- "datadog-agent",
532
- "newrelic-agent",
533
- "prometheus",
534
- "grafana-cli",
535
- // Other tools
536
- "ngrok",
537
- "stripe",
538
- "supabase",
539
- "neon"
540
- ];
541
- const found = [];
542
- const notFound = [];
543
- for (const t of knownTools) {
544
- const r = commandExists(t);
545
- if (r) found.push(`${t}: ${r}`);
546
- else notFound.push(t);
547
- }
548
- results["TOOLS_FOUND"] = found.join("\n") || "(none found)";
549
- results["TOOLS_NOT_FOUND"] = notFound.join(", ");
550
- if (hint) {
551
- const terms = hint.split(/[\s,]+/).filter(Boolean);
552
- const hintResults = [];
553
- for (const term of terms) {
554
- const safe = term.replace(/[^a-zA-Z0-9._-]/g, "");
555
- if (!safe) continue;
556
- const cmdPath = commandExists(safe);
557
- if (cmdPath) {
558
- hintResults.push(`${term}: ${cmdPath}`);
559
- continue;
560
- }
561
- let fallback = "";
562
- if (IS_WIN) {
563
- fallback = run(
564
- `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`,
565
- { timeout: 1e4 }
566
- );
567
- } else if (IS_MAC) {
568
- fallback = run(`mdfind -name "${safe}" 2>/dev/null | head -5`);
569
- } else {
570
- 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`);
571
- }
572
- hintResults.push(fallback ? `${term}: ${fallback}` : `${term}: (not found)`);
573
- }
574
- results["HINT_SEARCH"] = hintResults.join("\n");
575
- }
576
- const out = Object.entries(results).map(([k, v]) => `=== ${k} ===
577
- ${v}`).join("\n\n");
578
- return { content: [{ type: "text", text: out }] };
579
- })
580
- ];
581
- return createSdkMcpServer({
582
- name: "cartography",
583
- version: "0.1.0",
584
- tools
585
- });
586
- }
117
+ // src/providers/types.ts
118
+ var ProviderRegistry = class {
119
+ factories = /* @__PURE__ */ new Map();
120
+ register(name, factory) {
121
+ this.factories.set(name, factory);
122
+ }
123
+ has(name) {
124
+ return this.factories.has(name);
125
+ }
126
+ resolve(name) {
127
+ const f = this.factories.get(name);
128
+ if (!f) throw new Error(`Unknown provider "${name}". Available: ${this.names().join(", ")}`);
129
+ return f();
130
+ }
131
+ names() {
132
+ return [...this.factories.keys()];
133
+ }
134
+ };
587
135
 
588
136
  // src/safety.ts
589
137
  var safetyHook = async (input, _toolUseID, _options) => {
@@ -611,10 +159,451 @@ var safetyHook = async (input, _toolUseID, _options) => {
611
159
  };
612
160
  };
613
161
 
162
+ // src/audit.ts
163
+ function createAuditHook(db, sessionId) {
164
+ return async (input) => {
165
+ try {
166
+ if (!("tool_name" in input)) return {};
167
+ const i = input;
168
+ const command = i.tool_input?.command ?? JSON.stringify(i.tool_input ?? {}).slice(0, 2e3);
169
+ const response = typeof i.tool_response === "string" ? i.tool_response : JSON.stringify(i.tool_response ?? "");
170
+ db.insertEvent(sessionId, {
171
+ eventType: "tool_executed",
172
+ process: i.tool_name,
173
+ pid: process.pid,
174
+ command,
175
+ resultBytes: Buffer.byteLength(response)
176
+ });
177
+ } catch (err) {
178
+ logDebug(`audit hook failed to record event: ${String(err)}`);
179
+ }
180
+ return {};
181
+ };
182
+ }
183
+
184
+ // src/providers/claude.ts
185
+ function createClaudeProvider() {
186
+ return {
187
+ name: "claude",
188
+ async ensureAvailable(_config) {
189
+ try {
190
+ await import("@anthropic-ai/claude-agent-sdk");
191
+ } catch {
192
+ throw new Error(
193
+ "Claude provider unavailable: the @anthropic-ai/claude-agent-sdk package is not installed.\n Install: npm install @anthropic-ai/claude-agent-sdk"
194
+ );
195
+ }
196
+ },
197
+ async *run(ctx) {
198
+ const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
199
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
200
+ const tools = await createCartographyTools(db, sessionId, {
201
+ onAskUser,
202
+ maxResponseBytes: config.maxToolResponseBytes
203
+ });
204
+ let turnCount = 0;
205
+ for await (const msg of query({
206
+ prompt: initialPrompt,
207
+ options: {
208
+ model: config.models.lead,
209
+ maxTurns: config.maxTurns,
210
+ systemPrompt,
211
+ mcpServers: { cartography: tools },
212
+ allowedTools: [
213
+ "Bash",
214
+ "mcp__cartography__save_node",
215
+ "mcp__cartography__save_edge",
216
+ "mcp__cartography__get_catalog",
217
+ "mcp__cartography__scan_bookmarks",
218
+ "mcp__cartography__scan_browser_history",
219
+ "mcp__cartography__scan_installed_apps",
220
+ "mcp__cartography__scan_local_databases",
221
+ "mcp__cartography__scan_k8s_resources",
222
+ "mcp__cartography__scan_aws_resources",
223
+ "mcp__cartography__scan_gcp_resources",
224
+ "mcp__cartography__scan_azure_resources",
225
+ "mcp__cartography__ask_user"
226
+ ],
227
+ hooks: {
228
+ PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }],
229
+ PostToolUse: [{ hooks: [createAuditHook(db, sessionId)] }]
230
+ },
231
+ permissionMode: "bypassPermissions"
232
+ }
233
+ })) {
234
+ if (Date.now() > deadlineMs) {
235
+ yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
236
+ yield { kind: "done" };
237
+ return;
238
+ }
239
+ if (msg.type === "assistant") {
240
+ turnCount++;
241
+ yield { kind: "turn", turn: turnCount };
242
+ for (const block of msg.message.content) {
243
+ if (block.type === "text") {
244
+ yield { kind: "thinking", text: block.text };
245
+ }
246
+ if (block.type === "tool_use") {
247
+ yield {
248
+ kind: "tool_call",
249
+ tool: block.name,
250
+ input: block.input
251
+ };
252
+ }
253
+ }
254
+ }
255
+ if (msg.type === "user") {
256
+ const content = msg.message?.content;
257
+ if (Array.isArray(content)) {
258
+ for (const block of content) {
259
+ if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
260
+ const tb = block;
261
+ const text = typeof tb.content === "string" ? tb.content : "";
262
+ yield { kind: "tool_result", tool: tb.tool_use_id ?? "", output: text };
263
+ }
264
+ }
265
+ }
266
+ }
267
+ if (msg.type === "result") {
268
+ yield { kind: "done" };
269
+ return;
270
+ }
271
+ }
272
+ }
273
+ };
274
+ }
275
+
276
+ // src/providers/shell.ts
277
+ import { z } from "zod";
278
+ function createBashTool() {
279
+ const shell = IS_WIN ? "powershell" : "posix";
280
+ return {
281
+ name: "Bash",
282
+ description: "Run a read-only shell command (inspect ports, processes, config). Mutating or destructive commands are blocked by the read-only allowlist.",
283
+ inputShape: { command: z.string().describe("The read-only shell command to run") },
284
+ annotations: { readOnlyHint: true, openWorldHint: true },
285
+ handler: async (args) => {
286
+ const command = String(args["command"] ?? "").trim();
287
+ if (!command) return { content: [{ type: "text", text: "" }] };
288
+ const decision = checkReadOnly(command, { shell });
289
+ if (!decision.allowed) {
290
+ return {
291
+ content: [
292
+ { type: "text", text: `BLOCKED: ${decision.reason ?? "not read-only"} \u2014 read-only allowlist policy` }
293
+ ]
294
+ };
295
+ }
296
+ const output = run(command) || "(no output)";
297
+ return { content: [{ type: "text", text: output }] };
298
+ }
299
+ };
300
+ }
301
+
302
+ // src/providers/zod-schema.ts
303
+ function unwrap(schema) {
304
+ let current = schema;
305
+ let required = true;
306
+ let description = current.description;
307
+ for (; ; ) {
308
+ const def = current.def;
309
+ const typeName = def?.type;
310
+ if (typeName === "optional" || typeName === "default") {
311
+ required = false;
312
+ const inner = def?.innerType;
313
+ if (!inner) break;
314
+ current = inner;
315
+ description = description ?? current.description;
316
+ continue;
317
+ }
318
+ if (typeName === "nullable") {
319
+ const inner = def?.innerType;
320
+ if (!inner) break;
321
+ current = inner;
322
+ description = description ?? current.description;
323
+ continue;
324
+ }
325
+ break;
326
+ }
327
+ return { schema: current, required, description };
328
+ }
329
+ function convert(schema, field) {
330
+ const def = schema.def;
331
+ const typeName = def?.["type"];
332
+ switch (typeName) {
333
+ case "string":
334
+ return { type: "string" };
335
+ case "number": {
336
+ const out = { type: "number" };
337
+ const checks = def?.["checks"] ?? [];
338
+ for (const c of checks) {
339
+ const cd = c?._zod?.def;
340
+ if (cd?.check === "greater_than") out["minimum"] = cd.value;
341
+ if (cd?.check === "less_than") out["maximum"] = cd.value;
342
+ }
343
+ return out;
344
+ }
345
+ case "boolean":
346
+ return { type: "boolean" };
347
+ case "enum": {
348
+ const entries = def?.["entries"];
349
+ const values = entries ? Object.values(entries) : [];
350
+ return { type: "string", enum: values };
351
+ }
352
+ case "array": {
353
+ const element = def?.["element"];
354
+ return { type: "array", items: element ? convert(unwrap(element).schema, field) : {} };
355
+ }
356
+ case "record":
357
+ return { type: "object", additionalProperties: true };
358
+ default:
359
+ throw new Error(
360
+ `zod-schema: unsupported zod construct "${typeName ?? "unknown"}" on field "${field}". Extend src/providers/zod-schema.ts to support it.`
361
+ );
362
+ }
363
+ }
364
+ function shapeToJsonSchema(shape) {
365
+ const properties = {};
366
+ const required = [];
367
+ for (const [key, raw] of Object.entries(shape)) {
368
+ const { schema, required: isRequired, description } = unwrap(raw);
369
+ const prop = convert(schema, key);
370
+ if (description) prop["description"] = description;
371
+ properties[key] = prop;
372
+ if (isRequired) required.push(key);
373
+ }
374
+ return { type: "object", properties, required, additionalProperties: false };
375
+ }
376
+
377
+ // src/providers/audit.ts
378
+ function recordToolEvent(db, sessionId, evt) {
379
+ try {
380
+ db.insertEvent(sessionId, {
381
+ eventType: "tool_executed",
382
+ process: evt.tool,
383
+ pid: process.pid,
384
+ command: evt.command,
385
+ resultBytes: Buffer.byteLength(evt.response)
386
+ });
387
+ } catch (err) {
388
+ logDebug(`audit writer failed to record event: ${String(err)}`);
389
+ }
390
+ }
391
+
392
+ // src/providers/loop.ts
393
+ async function dispatchTool(call, tools, db, sessionId) {
394
+ const tool = tools.find((t) => t.name === call.name);
395
+ if (!tool) {
396
+ const text = `ERROR: unknown tool "${call.name}"`;
397
+ recordToolEvent(db, sessionId, { tool: call.name, command: JSON.stringify(call.args).slice(0, 2e3), response: text });
398
+ return text;
399
+ }
400
+ let output;
401
+ try {
402
+ const result = await tool.handler(call.args);
403
+ output = result.content.map((c) => c.text).join("\n");
404
+ } catch (err) {
405
+ output = `ERROR: ${err instanceof Error ? err.message : String(err)}`;
406
+ }
407
+ const command = call.name === "Bash" ? String(call.args["command"] ?? "") : JSON.stringify(call.args).slice(0, 2e3);
408
+ recordToolEvent(db, sessionId, { tool: call.name, command, response: output });
409
+ return output;
410
+ }
411
+ async function* runToolLoop(opts, chat) {
412
+ const { db, sessionId, tools, maxTurns, deadlineMs } = opts;
413
+ let outcomes = [];
414
+ let turn = 0;
415
+ try {
416
+ while (turn < maxTurns) {
417
+ if (Date.now() > deadlineMs) {
418
+ yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
419
+ yield { kind: "done" };
420
+ return;
421
+ }
422
+ const result = await chat(outcomes);
423
+ turn++;
424
+ yield { kind: "turn", turn };
425
+ if (result.text) yield { kind: "thinking", text: result.text };
426
+ if (result.toolCalls.length === 0) {
427
+ yield { kind: "done" };
428
+ return;
429
+ }
430
+ const nextOutcomes = [];
431
+ for (const call of result.toolCalls) {
432
+ yield { kind: "tool_call", tool: call.name, input: call.args };
433
+ const output = await dispatchTool(call, tools, db, sessionId);
434
+ yield { kind: "tool_result", tool: call.name, output };
435
+ nextOutcomes.push({ id: call.id, name: call.name, output });
436
+ }
437
+ outcomes = nextOutcomes;
438
+ }
439
+ yield { kind: "done" };
440
+ } catch (err) {
441
+ yield { kind: "error", text: `Discovery error: ${err instanceof Error ? err.message : String(err)}` };
442
+ yield { kind: "done" };
443
+ }
444
+ }
445
+
446
+ // src/providers/openai.ts
447
+ function toOpenAITools(tools) {
448
+ return tools.map((t) => ({
449
+ type: "function",
450
+ function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
451
+ }));
452
+ }
453
+ function createOpenAIProvider() {
454
+ return {
455
+ name: "openai",
456
+ async ensureAvailable(_config) {
457
+ try {
458
+ await import("openai");
459
+ } catch {
460
+ throw new Error(
461
+ "OpenAI provider unavailable: the `openai` package is not installed.\n Install: npm install openai"
462
+ );
463
+ }
464
+ if (!process.env["OPENAI_API_KEY"]) {
465
+ throw new Error(
466
+ "OpenAI provider unavailable: OPENAI_API_KEY is not set.\n Set it: export OPENAI_API_KEY=sk-..."
467
+ );
468
+ }
469
+ },
470
+ async *run(ctx) {
471
+ const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
472
+ const mod = await import("openai");
473
+ const apiKey = process.env["OPENAI_API_KEY"] ?? "";
474
+ const baseURL = process.env["OPENAI_BASE_URL"];
475
+ const client = new mod.default({ apiKey, ...baseURL ? { baseURL } : {} });
476
+ const handlers = await buildCartographyToolHandlers(db, sessionId, {
477
+ onAskUser,
478
+ maxResponseBytes: config.maxToolResponseBytes
479
+ });
480
+ const tools = [...handlers, createBashTool()];
481
+ const openaiTools = toOpenAITools(tools);
482
+ const messages = [
483
+ { role: "system", content: systemPrompt },
484
+ { role: "user", content: initialPrompt }
485
+ ];
486
+ const chat = async (outcomes) => {
487
+ for (const oc of outcomes) {
488
+ messages.push({ role: "tool", tool_call_id: oc.id, content: oc.output });
489
+ }
490
+ const completion = await client.chat.completions.create({
491
+ model: config.models.lead,
492
+ messages,
493
+ tools: openaiTools,
494
+ tool_choice: "auto"
495
+ });
496
+ const choice = completion.choices[0]?.message;
497
+ const text = choice?.content ?? "";
498
+ const toolCalls = choice?.tool_calls ?? [];
499
+ messages.push({ role: "assistant", content: text || null, ...toolCalls.length ? { tool_calls: toolCalls } : {} });
500
+ return {
501
+ text,
502
+ toolCalls: toolCalls.map((tc) => ({
503
+ id: tc.id,
504
+ name: tc.function.name,
505
+ args: parseArgs(tc.function.arguments)
506
+ }))
507
+ };
508
+ };
509
+ yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
510
+ }
511
+ };
512
+ }
513
+ function parseArgs(raw) {
514
+ try {
515
+ const parsed = JSON.parse(raw || "{}");
516
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
517
+ } catch {
518
+ return {};
519
+ }
520
+ }
521
+
522
+ // src/providers/ollama.ts
523
+ var DEFAULT_HOST = "http://127.0.0.1:11434";
524
+ function host() {
525
+ return (process.env["OLLAMA_HOST"] || DEFAULT_HOST).replace(/\/+$/, "");
526
+ }
527
+ function toOllamaTools(tools) {
528
+ return tools.map((t) => ({
529
+ type: "function",
530
+ function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
531
+ }));
532
+ }
533
+ function createOllamaProvider() {
534
+ return {
535
+ name: "ollama",
536
+ async ensureAvailable(_config) {
537
+ const base = host();
538
+ try {
539
+ const res = await fetch(`${base}/api/tags`, { method: "GET" });
540
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
541
+ } catch {
542
+ throw new Error(
543
+ `Ollama provider unavailable: not reachable at ${base}.
544
+ Start it: ollama serve (or set OLLAMA_HOST=<url>)`
545
+ );
546
+ }
547
+ },
548
+ async *run(ctx) {
549
+ const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
550
+ const base = host();
551
+ const handlers = await buildCartographyToolHandlers(db, sessionId, {
552
+ onAskUser,
553
+ maxResponseBytes: config.maxToolResponseBytes
554
+ });
555
+ const tools = [...handlers, createBashTool()];
556
+ const ollamaTools = toOllamaTools(tools);
557
+ const messages = [
558
+ { role: "system", content: systemPrompt },
559
+ { role: "user", content: initialPrompt }
560
+ ];
561
+ const chat = async (outcomes) => {
562
+ for (const oc of outcomes) {
563
+ messages.push({ role: "tool", content: oc.output });
564
+ }
565
+ const res = await fetch(`${base}/api/chat`, {
566
+ method: "POST",
567
+ headers: { "content-type": "application/json" },
568
+ body: JSON.stringify({ model: config.models.lead, messages, tools: ollamaTools, stream: false })
569
+ });
570
+ if (!res.ok) {
571
+ throw new Error(`Ollama /api/chat returned HTTP ${res.status}`);
572
+ }
573
+ const data = await res.json();
574
+ const text = data.message?.content ?? "";
575
+ const toolCalls = data.message?.tool_calls ?? [];
576
+ messages.push({
577
+ role: "assistant",
578
+ content: text,
579
+ ...toolCalls.length ? { tool_calls: toolCalls } : {}
580
+ });
581
+ return {
582
+ text,
583
+ toolCalls: toolCalls.map((tc, i) => ({
584
+ id: `${tc.function.name}:${i}`,
585
+ name: tc.function.name,
586
+ args: tc.function.arguments ?? {}
587
+ }))
588
+ };
589
+ };
590
+ yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
591
+ }
592
+ };
593
+ }
594
+
595
+ // src/providers/registry.ts
596
+ function createDefaultRegistry() {
597
+ const r = new ProviderRegistry();
598
+ r.register("claude", createClaudeProvider);
599
+ r.register("openai", createOpenAIProvider);
600
+ r.register("ollama", createOllamaProvider);
601
+ return r;
602
+ }
603
+ var defaultProviderRegistry = createDefaultRegistry();
604
+
614
605
  // src/agent.ts
615
606
  async function runDiscovery(config, db, sessionId, onEvent, onAskUser, hint) {
616
- const { query } = await import("./sdk-A6NLO3DJ.js");
617
- const tools = await createCartographyTools(db, sessionId, { onAskUser });
618
607
  const hintSection = hint ? `
619
608
  \u26A1 USER HINT (HIGH PRIORITY): The user wants to find these specific tools: "${hint}"
620
609
  \u2192 Run scan_installed_apps(searchHint: "${hint}") IMMEDIATELY and save found tools as saas_tool nodes!
@@ -714,6 +703,7 @@ RULES:
714
703
  \u2022 metadata allowed: { description, category, port, version, path } \u2014 no passwords
715
704
  \u2022 Call get_catalog before save_node \u2192 avoid duplicates
716
705
  \u2022 Save edges whenever connections are clearly identifiable
706
+ \u2022 Max crawl depth: ${config.maxDepth} hops from an entry point \u2014 do not chase leads deeper than this
717
707
 
718
708
  Entry points: ${config.entryPoints.join(", ")}`;
719
709
  const initialPrompt = hint ? `Start discovery with USER HINT: "${hint}".
@@ -728,75 +718,28 @@ Then systematically scan local services, then config files.
728
718
  Finally, map all edges (Step 8 \u2014 critical!) before finishing.
729
719
  Use ask_user when you need context from the user.`;
730
720
  const MAX_DISCOVERY_MS = 30 * 60 * 1e3;
731
- let turnCount = 0;
721
+ const startTime = Date.now();
722
+ const deadlineMs = startTime + MAX_DISCOVERY_MS;
723
+ const provider = defaultProviderRegistry.resolve(config.provider ?? "claude");
724
+ await provider.ensureAvailable(config);
725
+ const ctx = {
726
+ config,
727
+ db,
728
+ sessionId,
729
+ systemPrompt,
730
+ initialPrompt,
731
+ onAskUser,
732
+ deadlineMs
733
+ };
732
734
  try {
733
- const startTime = Date.now();
734
- for await (const msg of query({
735
- prompt: initialPrompt,
736
- options: {
737
- model: config.agentModel,
738
- maxTurns: config.maxTurns,
739
- systemPrompt,
740
- mcpServers: { cartography: tools },
741
- allowedTools: [
742
- "Bash",
743
- "mcp__cartograph__save_node",
744
- "mcp__cartograph__save_edge",
745
- "mcp__cartograph__get_catalog",
746
- "mcp__cartograph__scan_bookmarks",
747
- "mcp__cartograph__scan_browser_history",
748
- "mcp__cartograph__scan_installed_apps",
749
- "mcp__cartograph__scan_local_databases",
750
- "mcp__cartograph__scan_k8s_resources",
751
- "mcp__cartograph__scan_aws_resources",
752
- "mcp__cartograph__scan_gcp_resources",
753
- "mcp__cartograph__scan_azure_resources",
754
- "mcp__cartograph__ask_user"
755
- ],
756
- hooks: {
757
- PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }]
758
- },
759
- permissionMode: "bypassPermissions"
760
- }
761
- })) {
762
- if (Date.now() - startTime > MAX_DISCOVERY_MS) {
735
+ for await (const event of provider.run(ctx)) {
736
+ onEvent?.(event);
737
+ if (event.kind === "done") return;
738
+ if (Date.now() > deadlineMs) {
763
739
  onEvent?.({ kind: "error", text: `Discovery timeout after ${MAX_DISCOVERY_MS / 6e4} minutes` });
764
740
  onEvent?.({ kind: "done" });
765
741
  return;
766
742
  }
767
- if (!onEvent) continue;
768
- if (msg.type === "assistant") {
769
- turnCount++;
770
- onEvent({ kind: "turn", turn: turnCount });
771
- for (const block of msg.message.content) {
772
- if (block.type === "text") {
773
- onEvent({ kind: "thinking", text: block.text });
774
- }
775
- if (block.type === "tool_use") {
776
- onEvent({
777
- kind: "tool_call",
778
- tool: block.name,
779
- input: block.input
780
- });
781
- }
782
- }
783
- }
784
- if (msg.type === "user") {
785
- const content = msg.message?.content;
786
- if (Array.isArray(content)) {
787
- for (const block of content) {
788
- if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
789
- const tb = block;
790
- const text = typeof tb.content === "string" ? tb.content : "";
791
- onEvent({ kind: "tool_result", tool: tb.tool_use_id ?? "", output: text });
792
- }
793
- }
794
- }
795
- }
796
- if (msg.type === "result") {
797
- onEvent({ kind: "done" });
798
- return;
799
- }
800
743
  }
801
744
  } catch (err) {
802
745
  const message = err instanceof Error ? err.message : String(err);
@@ -805,6 +748,182 @@ Use ask_user when you need context from the user.`;
805
748
  }
806
749
  }
807
750
 
751
+ // src/config.ts
752
+ import { readFileSync as readFileSync2 } from "fs";
753
+ var ConfigError = class extends Error {
754
+ constructor(message) {
755
+ super(message);
756
+ this.name = "ConfigError";
757
+ }
758
+ };
759
+ function loadConfig(path) {
760
+ const file = readConfigFile(path);
761
+ const overrides = {};
762
+ if (file.organization) overrides.organization = file.organization;
763
+ const entryPoints = file.schedule?.entryPoints ?? file.entryPoints;
764
+ if (entryPoints) overrides.entryPoints = [...entryPoints];
765
+ const dbPath = file.schedule?.dbPath ?? file.dbPath;
766
+ if (dbPath) overrides.dbPath = dbPath;
767
+ if (file.schedule) overrides.schedule = file.schedule;
768
+ if (file.centralDb) {
769
+ const merged = { ...file.centralDb, ...centralDbFromEnv() };
770
+ overrides.centralDb = merged;
771
+ }
772
+ return defaultConfig(overrides);
773
+ }
774
+ function readConfigFile(path) {
775
+ let raw;
776
+ try {
777
+ raw = readFileSync2(path, "utf-8");
778
+ } catch (err) {
779
+ throw new ConfigError(
780
+ `Cannot read config file ${path}: ${err instanceof Error ? err.message : String(err)}`
781
+ );
782
+ }
783
+ let json;
784
+ try {
785
+ json = JSON.parse(raw);
786
+ } catch (err) {
787
+ throw new ConfigError(
788
+ `Invalid JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`
789
+ );
790
+ }
791
+ const parsed = ConfigFileSchema.safeParse(json);
792
+ if (!parsed.success) {
793
+ const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
794
+ throw new ConfigError(`Invalid config in ${path}: ${detail}`);
795
+ }
796
+ return parsed.data;
797
+ }
798
+
799
+ // src/schedule.ts
800
+ var FIELD_SPECS = [
801
+ { name: "minute", min: 0, max: 59 },
802
+ { name: "hour", min: 0, max: 23 },
803
+ { name: "dom", min: 1, max: 31 },
804
+ { name: "month", min: 1, max: 12 },
805
+ { name: "dow", min: 0, max: 7 }
806
+ // 7 and 0 both mean Sunday; normalized to 0 below
807
+ ];
808
+ function parseField(raw, spec) {
809
+ const out = /* @__PURE__ */ new Set();
810
+ const add = (n) => {
811
+ if (!Number.isInteger(n) || n < spec.min || n > spec.max) {
812
+ throw new RangeError(`Invalid value "${n}" in cron field "${spec.name}" (allowed ${spec.min}-${spec.max})`);
813
+ }
814
+ out.add(spec.name === "dow" && n === 7 ? 0 : n);
815
+ };
816
+ for (const part of raw.split(",")) {
817
+ if (part === "") {
818
+ throw new RangeError(`Empty term in cron field "${spec.name}"`);
819
+ }
820
+ const [rangePart, stepPart, ...rest] = part.split("/");
821
+ if (rest.length > 0) {
822
+ throw new RangeError(`Malformed step in cron field "${spec.name}": "${part}"`);
823
+ }
824
+ let step = 1;
825
+ if (stepPart !== void 0) {
826
+ step = Number(stepPart);
827
+ if (!Number.isInteger(step) || step < 1) {
828
+ throw new RangeError(`Invalid step "${stepPart}" in cron field "${spec.name}"`);
829
+ }
830
+ }
831
+ let lo;
832
+ let hi;
833
+ if (rangePart === "*") {
834
+ lo = spec.min;
835
+ hi = spec.max;
836
+ } else if (rangePart.includes("-")) {
837
+ const [a, b, ...extra] = rangePart.split("-");
838
+ if (extra.length > 0) {
839
+ throw new RangeError(`Malformed range in cron field "${spec.name}": "${rangePart}"`);
840
+ }
841
+ lo = Number(a);
842
+ hi = Number(b);
843
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
844
+ throw new RangeError(`Non-numeric range in cron field "${spec.name}": "${rangePart}"`);
845
+ }
846
+ if (lo > hi) {
847
+ throw new RangeError(`Descending range in cron field "${spec.name}": "${rangePart}"`);
848
+ }
849
+ } else {
850
+ const n = Number(rangePart);
851
+ if (!Number.isInteger(n)) {
852
+ throw new RangeError(`Non-numeric value in cron field "${spec.name}": "${rangePart}"`);
853
+ }
854
+ lo = n;
855
+ hi = stepPart !== void 0 ? spec.max : n;
856
+ }
857
+ for (let v = lo; v <= hi; v += step) add(v);
858
+ }
859
+ if (out.size === 0) {
860
+ throw new RangeError(`Cron field "${spec.name}" matched no values`);
861
+ }
862
+ return out;
863
+ }
864
+ function parseCron(expr) {
865
+ const fields = expr.trim().split(/\s+/);
866
+ if (fields.length !== 5) {
867
+ throw new RangeError(`Cron expression must have 5 fields (got ${fields.length}): "${expr}"`);
868
+ }
869
+ const [minute, hour, dom, month, dow] = FIELD_SPECS.map((spec, i) => parseField(fields[i], spec));
870
+ return { minute, hour, dom, month, dow };
871
+ }
872
+ function matches(fields, date) {
873
+ if (!fields.minute.has(date.getUTCMinutes())) return false;
874
+ if (!fields.hour.has(date.getUTCHours())) return false;
875
+ if (!fields.month.has(date.getUTCMonth() + 1)) return false;
876
+ const domRestricted = fields.dom.size !== 31;
877
+ const dowRestricted = fields.dow.size !== 7;
878
+ const domOk = fields.dom.has(date.getUTCDate());
879
+ const dowOk = fields.dow.has(date.getUTCDay());
880
+ if (domRestricted && dowRestricted) return domOk || dowOk;
881
+ if (domRestricted) return domOk;
882
+ if (dowRestricted) return dowOk;
883
+ return true;
884
+ }
885
+ var MAX_SEARCH_MINUTES = 4 * 366 * 24 * 60;
886
+ function nextRun(expr, after) {
887
+ const fields = parseCron(expr);
888
+ const cursor2 = new Date(after.getTime());
889
+ cursor2.setUTCSeconds(0, 0);
890
+ cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
891
+ for (let i = 0; i < MAX_SEARCH_MINUTES; i++) {
892
+ if (matches(fields, cursor2)) return new Date(cursor2.getTime());
893
+ cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
894
+ }
895
+ throw new RangeError(`No cron match for "${expr}" within ~4 years after ${after.toISOString()}`);
896
+ }
897
+ async function runOnce(cfg, db) {
898
+ const prior = db.getLatestSession("discover");
899
+ if (prior) {
900
+ const r = await runLocalDiscovery(db, prior.id, {
901
+ hint: cfg.entryPoints.join(","),
902
+ plugins: cfg.plugins,
903
+ mode: "update",
904
+ onProgress: (line) => logInfo(`scan: ${line}`)
905
+ });
906
+ const delta = r.delta ?? diffTopology({ nodes: [], edges: [] }, { nodes: [], edges: [] });
907
+ logInfo("scheduled run complete", { sessionId: prior.id, base: prior.id, ...delta.summary });
908
+ return { sessionId: prior.id, baseSessionId: prior.id, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
909
+ }
910
+ const sessionId = db.createSession("discover", cfg);
911
+ try {
912
+ const r = await runLocalDiscovery(db, sessionId, {
913
+ hint: cfg.entryPoints.join(","),
914
+ plugins: cfg.plugins,
915
+ mode: "replace",
916
+ onProgress: (line) => logInfo(`scan: ${line}`)
917
+ });
918
+ const current = { nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId) };
919
+ const delta = diffTopology({ nodes: [], edges: [] }, current);
920
+ logInfo("scheduled run complete", { sessionId, base: null, ...delta.summary });
921
+ return { sessionId, baseSessionId: void 0, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
922
+ } finally {
923
+ db.endSession(sessionId);
924
+ }
925
+ }
926
+
808
927
  // src/exporter.ts
809
928
  import { mkdirSync, writeFileSync } from "fs";
810
929
  import { join as join2 } from "path";
@@ -1171,6 +1290,66 @@ function generateDependencyMermaid(nodes, edges) {
1171
1290
  }
1172
1291
  return lines.join("\n");
1173
1292
  }
1293
+ var DIFF_CLASSES = {
1294
+ added: "fill:#0d3d0d,stroke:#22c55e,color:#86efac",
1295
+ removed: "fill:#3d0d0d,stroke:#ef4444,color:#fca5a5",
1296
+ changed: "fill:#3d2f0d,stroke:#f59e0b,color:#fcd34d",
1297
+ context: "fill:#1e1e1e,stroke:#555555,color:#999999"
1298
+ };
1299
+ function diffNodeLabel(node, suffix) {
1300
+ const icon = MERMAID_ICONS[node.type] ?? "?";
1301
+ const extra = suffix ? `<br/><small>\u0394 ${suffix}</small>` : "";
1302
+ return `["${icon} <b>${node.name}</b><br/><small>${node.type}</small>${extra}"]`;
1303
+ }
1304
+ function generateDiffMermaid(diff) {
1305
+ const total = diff.summary.nodesAdded + diff.summary.nodesRemoved + diff.summary.nodesChanged + diff.summary.edgesAdded + diff.summary.edgesRemoved;
1306
+ if (total === 0) return 'graph TB\n nodrift["\u2713 No drift between the two sessions"]';
1307
+ const lines = ["graph TB"];
1308
+ for (const [k, style] of Object.entries(DIFF_CLASSES)) lines.push(` classDef ${k} ${style}`);
1309
+ lines.push("");
1310
+ const rank = { added: 3, removed: 3, changed: 3, context: 0 };
1311
+ const entries = /* @__PURE__ */ new Map();
1312
+ const place = (node, cls, suffix) => {
1313
+ const prev = entries.get(node.id);
1314
+ if (prev && rank[prev.cls] >= rank[cls]) return;
1315
+ entries.set(node.id, { node, cls, suffix });
1316
+ };
1317
+ for (const n of diff.nodes.added) place(n, "added");
1318
+ for (const n of diff.nodes.removed) place(n, "removed");
1319
+ for (const c of diff.nodes.changed) place(c.after, "changed", c.changedFields.join(", "));
1320
+ const contextNode = (id) => ({
1321
+ id,
1322
+ type: "unknown",
1323
+ name: id,
1324
+ discoveredVia: "diff",
1325
+ confidence: 1,
1326
+ metadata: {},
1327
+ tags: [],
1328
+ sessionId: "",
1329
+ discoveredAt: "",
1330
+ depth: 0
1331
+ });
1332
+ const ensureEndpoint = (id) => {
1333
+ if (!entries.has(id)) place(contextNode(id), "context");
1334
+ };
1335
+ for (const e of [...diff.edges.added, ...diff.edges.removed]) {
1336
+ ensureEndpoint(e.sourceId);
1337
+ ensureEndpoint(e.targetId);
1338
+ }
1339
+ for (const { node, cls, suffix } of entries.values()) {
1340
+ lines.push(` ${sanitize(node.id)}${diffNodeLabel(node, suffix)}:::${cls}`);
1341
+ }
1342
+ lines.push("");
1343
+ for (const e of diff.edges.added) {
1344
+ const label = EDGE_LABELS[e.relationship] ?? e.relationship;
1345
+ lines.push(` ${sanitize(e.sourceId)} ==>|"+ ${label}"| ${sanitize(e.targetId)}`);
1346
+ }
1347
+ for (const e of diff.edges.removed) {
1348
+ const label = EDGE_LABELS[e.relationship] ?? e.relationship;
1349
+ lines.push(` ${sanitize(e.sourceId)} -.->|"- ${label}"| ${sanitize(e.targetId)}`);
1350
+ }
1351
+ return lines.join("\n");
1352
+ }
1174
1353
  function exportBackstageYAML(nodes, edges, org) {
1175
1354
  const owner = org ?? "unknown";
1176
1355
  const docs = [];
@@ -1190,7 +1369,7 @@ function exportBackstageYAML(nodes, edges, org) {
1190
1369
  `spec:`,
1191
1370
  ` type: ${node.type}`,
1192
1371
  ` lifecycle: production`,
1193
- ` owner: ${owner}`,
1372
+ ` owner: ${node.owner ?? owner}`,
1194
1373
  ...deps.length > 0 ? [" dependsOn:", ...deps] : []
1195
1374
  ].join("\n");
1196
1375
  docs.push(doc);
@@ -2311,11 +2490,85 @@ function exportJGF(nodes, edges) {
2311
2490
  };
2312
2491
  return JSON.stringify(jgf, null, 2);
2313
2492
  }
2314
- function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery"]) {
2315
- mkdirSync(outputDir, { recursive: true });
2316
- const nodes = db.getNodes(sessionId);
2317
- const edges = db.getEdges(sessionId);
2318
- const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
2493
+ function csvField(v) {
2494
+ let s = String(v);
2495
+ if (/^[=+\-@]/.test(s)) s = `'${s}`;
2496
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
2497
+ }
2498
+ function exportCostCSV(summary) {
2499
+ const rows = ["scope,key,currency,period,total,nodes"];
2500
+ for (const c of summary.costByDomain) {
2501
+ rows.push(["domain", c.domain, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
2502
+ }
2503
+ for (const c of summary.costByOwner) {
2504
+ rows.push(["owner", c.owner, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
2505
+ }
2506
+ return rows.join("\n") + "\n";
2507
+ }
2508
+ function exportCostSummary(summary) {
2509
+ return JSON.stringify({
2510
+ costByDomain: summary.costByDomain,
2511
+ costByOwner: summary.costByOwner,
2512
+ costCoverage: summary.costCoverage
2513
+ }, null, 2);
2514
+ }
2515
+ var SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
2516
+ function exportComplianceReport(report, format) {
2517
+ if (format === "json") return JSON.stringify(report, null, 2);
2518
+ if (format === "markdown") {
2519
+ const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
2520
+ const out = [
2521
+ `# Compliance \u2014 ${report.rulesetName} v${report.rulesetVersion}`,
2522
+ ``,
2523
+ `**Status:** ${report.status.toUpperCase()} \xB7 **Score:** ${scoreStr}`,
2524
+ ``,
2525
+ `| Controls | Count |`,
2526
+ `|----------|-------|`,
2527
+ `| Passed | ${report.totals.passed} |`,
2528
+ `| Failed | ${report.totals.failed} |`,
2529
+ `| Not applicable | ${report.totals.notApplicable} |`,
2530
+ `| Total | ${report.totals.rules} |`,
2531
+ ``,
2532
+ `| Severity | Failed | Passed |`,
2533
+ `|----------|--------|--------|`,
2534
+ ...["critical", "high", "medium", "low"].map(
2535
+ (s) => `| ${s} | ${report.bySeverity[s].failed} | ${report.bySeverity[s].passed} |`
2536
+ )
2537
+ ];
2538
+ if (report.gaps.length === 0) {
2539
+ out.push(``, `\u2713 No compliance gaps.`);
2540
+ } else {
2541
+ out.push(``, `## Gaps`);
2542
+ for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
2543
+ out.push(``, `### [${g.severity}] ${g.control} \u2014 ${g.title}`, ...g.nodeIds.map((id) => `- \`${id}\``));
2544
+ }
2545
+ }
2546
+ return out.join("\n");
2547
+ }
2548
+ const lines = [
2549
+ "graph TB",
2550
+ " classDef critical fill:#7f1d1d,stroke:#ef4444,color:#fff;",
2551
+ " classDef high fill:#7c2d12,stroke:#f97316,color:#fff;",
2552
+ " classDef medium fill:#713f12,stroke:#eab308,color:#fff;",
2553
+ " classDef low fill:#1e3a5f,stroke:#3b82f6,color:#fff;"
2554
+ ];
2555
+ if (report.gaps.length === 0) {
2556
+ lines.push(' ok["\u2713 No compliance gaps"]');
2557
+ return lines.join("\n");
2558
+ }
2559
+ const mmSafe = (s) => s.replace(/["\]\r\n]/g, "'");
2560
+ let i = 0;
2561
+ for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
2562
+ const gid = `g${i++}`;
2563
+ lines.push(` ${gid}["${mmSafe(g.control)}: ${mmSafe(g.title)} (${g.nodeIds.length})"]:::${g.severity}`);
2564
+ }
2565
+ return lines.join("\n");
2566
+ }
2567
+ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery"]) {
2568
+ mkdirSync(outputDir, { recursive: true });
2569
+ const nodes = db.getNodes(sessionId);
2570
+ const edges = db.getEdges(sessionId);
2571
+ const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
2319
2572
  writeFileSync(jgfPath, exportJGF(nodes, edges));
2320
2573
  if (formats.includes("mermaid")) {
2321
2574
  writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
@@ -2330,13 +2583,777 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
2330
2583
  if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
2331
2584
  writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
2332
2585
  }
2586
+ if (formats.includes("cost")) {
2587
+ const summary = db.getGraphSummary(sessionId);
2588
+ writeFileSync(join2(outputDir, "cost-by-domain.csv"), exportCostCSV(summary));
2589
+ writeFileSync(join2(outputDir, "cost-summary.json"), exportCostSummary(summary));
2590
+ }
2591
+ }
2592
+
2593
+ // src/compliance/report.ts
2594
+ var NODE_CAP = 50;
2595
+ function formatComplianceText(report) {
2596
+ const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
2597
+ const lines = [
2598
+ `Compliance: ${report.rulesetName} v${report.rulesetVersion} \u2014 ${report.status.toUpperCase()} (score ${scoreStr})`,
2599
+ `Controls: ${report.totals.passed} passed, ${report.totals.failed} failed, ${report.totals.notApplicable} n/a (of ${report.totals.rules})`,
2600
+ "",
2601
+ "By severity (failed/passed):",
2602
+ ...["critical", "high", "medium", "low"].map(
2603
+ (s) => ` - ${s}: ${report.bySeverity[s].failed} failed / ${report.bySeverity[s].passed} passed`
2604
+ )
2605
+ ];
2606
+ if (report.gaps.length === 0) {
2607
+ lines.push("", "\u2713 No compliance gaps.");
2608
+ return lines.join("\n");
2609
+ }
2610
+ lines.push("", `Gaps (${report.gaps.length}):`);
2611
+ for (const g of report.gaps) {
2612
+ lines.push(` \u2717 [${g.severity}] ${g.control} \u2014 ${g.title}`);
2613
+ const shown = g.nodeIds.slice(0, NODE_CAP);
2614
+ for (const id of shown) lines.push(` ${id}`);
2615
+ if (g.nodeIds.length > NODE_CAP) lines.push(` \u2026 +${g.nodeIds.length - NODE_CAP} more`);
2616
+ }
2617
+ return lines.join("\n");
2618
+ }
2619
+
2620
+ // src/cost.ts
2621
+ import { readFileSync as readFileSync3 } from "fs";
2622
+ import { resolve } from "path";
2623
+ function splitCsvLine(line) {
2624
+ const out = [];
2625
+ let cur = "";
2626
+ let inQuotes = false;
2627
+ for (let i = 0; i < line.length; i++) {
2628
+ const ch = line[i];
2629
+ if (inQuotes) {
2630
+ if (ch === '"') {
2631
+ if (line[i + 1] === '"') {
2632
+ cur += '"';
2633
+ i++;
2634
+ } else {
2635
+ inQuotes = false;
2636
+ }
2637
+ } else cur += ch;
2638
+ } else if (ch === '"') {
2639
+ inQuotes = true;
2640
+ } else if (ch === ",") {
2641
+ out.push(cur);
2642
+ cur = "";
2643
+ } else cur += ch;
2644
+ }
2645
+ out.push(cur);
2646
+ return out.map((s) => s.trim());
2647
+ }
2648
+ function parseCostCsv(text) {
2649
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
2650
+ if (lines.length === 0) return [];
2651
+ const header = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
2652
+ const col = (name) => header.indexOf(name);
2653
+ const iNode = col("nodeid");
2654
+ const iOwner = col("owner");
2655
+ const iAmount = col("amount");
2656
+ const iCurrency = col("currency");
2657
+ const iPeriod = col("period");
2658
+ const iSource = col("source");
2659
+ if (iNode < 0) {
2660
+ logWarn('cost csv: missing required "nodeId" header column');
2661
+ return [];
2662
+ }
2663
+ const records = [];
2664
+ for (let r = 1; r < lines.length; r++) {
2665
+ const f = splitCsvLine(lines[r]);
2666
+ const nodeId = f[iNode];
2667
+ if (!nodeId) {
2668
+ logWarn(`cost csv: row ${r + 1} skipped (empty nodeId)`);
2669
+ continue;
2670
+ }
2671
+ const rec = { nodeId };
2672
+ if (iOwner >= 0 && f[iOwner]) rec.owner = f[iOwner];
2673
+ const amountRaw = iAmount >= 0 ? f[iAmount] : "";
2674
+ if (amountRaw) {
2675
+ const parsed = CostEntrySchema.safeParse({
2676
+ amount: Number(amountRaw),
2677
+ currency: iCurrency >= 0 ? f[iCurrency] : void 0,
2678
+ period: iPeriod >= 0 ? f[iPeriod] : void 0,
2679
+ ...iSource >= 0 && f[iSource] ? { source: f[iSource] } : {}
2680
+ });
2681
+ if (!parsed.success) {
2682
+ logWarn(`cost csv: row ${r + 1} skipped (invalid cost fields)`);
2683
+ if (!rec.owner) continue;
2684
+ } else {
2685
+ rec.cost = parsed.data;
2686
+ }
2687
+ }
2688
+ if (rec.owner || rec.cost) records.push(rec);
2689
+ }
2690
+ return records;
2691
+ }
2692
+ var CsvCostSource = class {
2693
+ constructor(opts) {
2694
+ this.opts = opts;
2695
+ const base = opts.filePath.split(/[\\/]/).pop() ?? opts.filePath;
2696
+ this.id = `csv:${base}`;
2697
+ }
2698
+ id;
2699
+ async fetch() {
2700
+ const text = readFileSync3(resolve(this.opts.filePath), "utf-8");
2701
+ const records = parseCostCsv(text);
2702
+ const match = this.opts.match ?? "nodeId";
2703
+ const out = /* @__PURE__ */ new Map();
2704
+ if (match === "nodeId") {
2705
+ for (const rec of records) out.set(rec.nodeId, rec);
2706
+ return out;
2707
+ }
2708
+ if (!this.opts.db || !this.opts.sessionId) {
2709
+ logWarn(`cost csv: match '${match}' requires db + sessionId; falling back to nodeId`);
2710
+ for (const rec of records) out.set(rec.nodeId, rec);
2711
+ return out;
2712
+ }
2713
+ const nodes = this.opts.db.getNodes(this.opts.sessionId);
2714
+ const index = /* @__PURE__ */ new Map();
2715
+ for (const n of nodes) {
2716
+ if (match === "name") index.set(n.name, n.id);
2717
+ else for (const t of n.tags) index.set(t, n.id);
2718
+ }
2719
+ for (const rec of records) {
2720
+ const resolved = index.get(rec.nodeId);
2721
+ out.set(resolved ?? rec.nodeId, { ...rec, nodeId: resolved ?? rec.nodeId });
2722
+ }
2723
+ return out;
2724
+ }
2725
+ };
2726
+ async function enrichCosts(db, sessionId, source) {
2727
+ const records = await source.fetch();
2728
+ let matched = 0;
2729
+ const unmatchedIds = [];
2730
+ for (const [nodeId, rec] of records) {
2731
+ const ok = db.enrichNodeAttribution(sessionId, nodeId, {
2732
+ owner: rec.owner ?? void 0,
2733
+ cost: rec.cost ?? void 0
2734
+ });
2735
+ if (ok) matched++;
2736
+ else unmatchedIds.push(nodeId);
2737
+ }
2738
+ return { source: source.id, total: records.size, matched, unmatched: unmatchedIds.length, unmatchedIds };
2333
2739
  }
2334
2740
 
2335
2741
  // src/cli.ts
2336
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2337
- import { resolve, dirname } from "path";
2742
+ import { readFileSync as readFileSync5, existsSync as existsSync3, writeFileSync as writeFileSync3 } from "fs";
2743
+ import { resolve as resolve2, dirname as dirname2 } from "path";
2338
2744
  import { fileURLToPath } from "url";
2339
2745
  import { createInterface } from "readline";
2746
+
2747
+ // src/sharing.ts
2748
+ function wildcardCount(pattern) {
2749
+ return (pattern.match(/\*/g) ?? []).length;
2750
+ }
2751
+ function globMatch(pattern, id) {
2752
+ let re = "^";
2753
+ for (let i = 0; i < pattern.length; i++) {
2754
+ const c = pattern[i];
2755
+ if (c === "*") {
2756
+ if (pattern[i + 1] === "*") {
2757
+ re += ".*";
2758
+ i++;
2759
+ } else re += "[^:]*";
2760
+ } else {
2761
+ re += c.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2762
+ }
2763
+ }
2764
+ re += "$";
2765
+ return new RegExp(re).test(id);
2766
+ }
2767
+ function resolveSharingLevel(nodeId, policy) {
2768
+ const matches2 = policy.overrides.filter((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId)).sort(
2769
+ (a, b) => wildcardCount(a.pattern) - wildcardCount(b.pattern) || b.pattern.length - a.pattern.length
2770
+ );
2771
+ return matches2.length ? matches2[0].level : policy.defaultLevel;
2772
+ }
2773
+ function nodeHosts(node) {
2774
+ const out = [node.id, node.name];
2775
+ const meta = node.metadata ?? {};
2776
+ for (const k of ["host", "url", "domain"]) {
2777
+ const v = meta[k];
2778
+ if (typeof v === "string") out.push(v);
2779
+ }
2780
+ if (node.domain) out.push(node.domain);
2781
+ return out;
2782
+ }
2783
+ function resolveEffectiveLevel(node, policy) {
2784
+ if (nodeHosts(node).some((h) => isPersonalHost(h))) return "none";
2785
+ return resolveSharingLevel(node.id, policy);
2786
+ }
2787
+ function applySharingLevel(node, level, orgKey, db) {
2788
+ if (level === "none") return null;
2789
+ if (level === "full") return { ...node, metadata: { ...node.metadata ?? {} }, tags: [...node.tags ?? []] };
2790
+ return {
2791
+ ...node,
2792
+ id: pseudonymizeString(node.id, orgKey, db),
2793
+ name: pseudonymizeString(node.name, orgKey, db),
2794
+ metadata: pseudonymize(node.metadata ?? {}, orgKey, db),
2795
+ tags: (node.tags ?? []).map((t) => pseudonymizeString(t, orgKey, db))
2796
+ };
2797
+ }
2798
+ function previewShare(db, sessionId, orgKey, policy, opts = {}) {
2799
+ const persist = opts.persistReversal ? db : void 0;
2800
+ const nodes = db.getNodes(sessionId);
2801
+ const edges = db.getEdges(sessionId);
2802
+ const entries = [];
2803
+ const idMap = /* @__PURE__ */ new Map();
2804
+ const droppedNodeIds = [];
2805
+ for (const node of nodes) {
2806
+ const level = resolveEffectiveLevel(node, policy);
2807
+ const payload = applySharingLevel(node, level, orgKey, persist);
2808
+ entries.push({ node, level, payload });
2809
+ if (payload === null) {
2810
+ idMap.set(node.id, null);
2811
+ droppedNodeIds.push(node.id);
2812
+ } else {
2813
+ idMap.set(node.id, payload.id);
2814
+ }
2815
+ }
2816
+ const outEdges = [];
2817
+ for (const e of edges) {
2818
+ const src = idMap.get(e.sourceId);
2819
+ const tgt = idMap.get(e.targetId);
2820
+ if (src == null || tgt == null) continue;
2821
+ outEdges.push({ sourceId: src, targetId: tgt, relationship: e.relationship });
2822
+ }
2823
+ return { nodes: entries, edges: outEdges, droppedNodeIds };
2824
+ }
2825
+ function isRemembered(policy, nodeId) {
2826
+ const matched = policy.overrides.some((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId));
2827
+ return matched || policy.defaultLevel !== "none";
2828
+ }
2829
+
2830
+ // src/sync/hash.ts
2831
+ import { createHash } from "crypto";
2832
+ function shareHash(kind, payload) {
2833
+ return createHash("sha256").update(stableStringify({ kind, payload })).digest("hex");
2834
+ }
2835
+
2836
+ // src/sync/classify.ts
2837
+ function classify(input) {
2838
+ const { preview, policy, sharedHashes } = input;
2839
+ const result = { share: [], withhold: [], pending: [] };
2840
+ const sharedNodeIds = /* @__PURE__ */ new Set();
2841
+ for (const entry of preview.nodes) {
2842
+ if (entry.payload === null) {
2843
+ result.withhold.push({ contentHash: "", kind: "node", nodeId: entry.node.id, payload: null });
2844
+ continue;
2845
+ }
2846
+ const contentHash = shareHash("node", entry.payload);
2847
+ if (sharedHashes.has(contentHash)) continue;
2848
+ const item = { contentHash, kind: "node", nodeId: entry.node.id, payload: entry.payload };
2849
+ if (isRemembered(policy, entry.node.id)) {
2850
+ result.share.push(item);
2851
+ sharedNodeIds.add(entry.node.id);
2852
+ } else {
2853
+ result.pending.push(item);
2854
+ }
2855
+ }
2856
+ const sharedRemappedIds = /* @__PURE__ */ new Set();
2857
+ for (const entry of preview.nodes) {
2858
+ if (entry.payload !== null && sharedNodeIds.has(entry.node.id)) {
2859
+ sharedRemappedIds.add(entry.payload.id);
2860
+ }
2861
+ }
2862
+ for (const e of preview.edges) {
2863
+ const payload = { sourceId: e.sourceId, targetId: e.targetId, relationship: e.relationship };
2864
+ const contentHash = shareHash("edge", payload);
2865
+ const bothShared = sharedRemappedIds.has(e.sourceId) && sharedRemappedIds.has(e.targetId);
2866
+ if (!bothShared) {
2867
+ result.withhold.push({ contentHash: "", kind: "edge", payload });
2868
+ continue;
2869
+ }
2870
+ if (sharedHashes.has(contentHash)) continue;
2871
+ result.share.push({ contentHash, kind: "edge", payload });
2872
+ }
2873
+ return result;
2874
+ }
2875
+
2876
+ // src/sync/push.ts
2877
+ import { createHash as createHash2 } from "crypto";
2878
+ var PUSH_SCHEMA_VERSION = 1;
2879
+ var DEFAULT_BATCH = 100;
2880
+ var DEFAULT_RETRIES = 4;
2881
+ var DEFAULT_TIMEOUT_MS = 15e3;
2882
+ function defaultLog(line) {
2883
+ process.stderr.write(`[cartography-sync] ${line}
2884
+ `);
2885
+ }
2886
+ function defaultSleep(ms) {
2887
+ return new Promise((r) => setTimeout(r, ms));
2888
+ }
2889
+ function batchKey(items) {
2890
+ const hashes = items.map((i) => i.contentHash).sort();
2891
+ return createHash2("sha256").update(stableStringify(hashes)).digest("hex");
2892
+ }
2893
+ async function pushDeltas(config, items, opts = {}) {
2894
+ const central = config.centralDb;
2895
+ if (!central?.url || !central.token) {
2896
+ throw new Error("sync push: centralDb not configured (set centralDb.url + token)");
2897
+ }
2898
+ let parsed;
2899
+ try {
2900
+ parsed = new URL(central.url);
2901
+ } catch {
2902
+ throw new Error("sync push: centralDb.url is not a valid URL");
2903
+ }
2904
+ const insecureAllowed = process.env.CARTOGRAPHY_ALLOW_INSECURE_SYNC === "1";
2905
+ if (parsed.protocol !== "https:" && !insecureAllowed) {
2906
+ throw new Error(
2907
+ `sync push: refusing to send over insecure ${parsed.protocol}// \u2014 use https:// (or set CARTOGRAPHY_ALLOW_INSECURE_SYNC=1 for local testing only)`
2908
+ );
2909
+ }
2910
+ const log = opts.log ?? defaultLog;
2911
+ const sleep = opts.sleep ?? defaultSleep;
2912
+ const fetchImpl = opts.fetchImpl ?? fetch;
2913
+ const batchSize = Math.max(1, opts.batchSize ?? central.batchSize ?? DEFAULT_BATCH);
2914
+ const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_RETRIES);
2915
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2916
+ const safeUrl = stripSensitive(central.url);
2917
+ if (items.length === 0) {
2918
+ log("nothing to push (0 approved items)");
2919
+ return { sent: 0, batches: 0, failed: 0, sentHashes: [] };
2920
+ }
2921
+ const batches = [];
2922
+ for (let i = 0; i < items.length; i += batchSize) {
2923
+ batches.push(items.slice(i, i + batchSize).map((it) => ({ ...it, payload: redactValue(it.payload) })));
2924
+ }
2925
+ let sent = 0;
2926
+ let failed = 0;
2927
+ const sentHashes = [];
2928
+ for (const batch of batches) {
2929
+ const key = batchKey(batch);
2930
+ const body = JSON.stringify({
2931
+ schemaVersion: PUSH_SCHEMA_VERSION,
2932
+ ...central.org ? { org: central.org } : {},
2933
+ items: batch.map((b) => ({ contentHash: b.contentHash, kind: b.kind, payload: b.payload }))
2934
+ });
2935
+ if (opts.dryRun) {
2936
+ log(`dry-run: would POST ${batch.length} item(s) to ${safeUrl} (idempotency ${key.slice(0, 12)}\u2026)`);
2937
+ sent += batch.length;
2938
+ sentHashes.push(...batch.map((b) => b.contentHash));
2939
+ continue;
2940
+ }
2941
+ let ok = false;
2942
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2943
+ const controller = new AbortController();
2944
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2945
+ const startedAt = Date.now();
2946
+ try {
2947
+ const res = await fetchImpl(central.url, {
2948
+ method: "POST",
2949
+ headers: {
2950
+ "authorization": `Bearer ${central.token}`,
2951
+ "content-type": "application/json",
2952
+ "x-idempotency-key": key
2953
+ },
2954
+ body,
2955
+ signal: controller.signal
2956
+ });
2957
+ const elapsed = Date.now() - startedAt;
2958
+ if (res.ok) {
2959
+ log(`pushed ${batch.length} item(s) \u2192 ${safeUrl} [${res.status}] ${elapsed}ms (attempt ${attempt + 1})`);
2960
+ ok = true;
2961
+ break;
2962
+ }
2963
+ if (res.status >= 400 && res.status < 500) {
2964
+ log(`batch rejected \u2192 ${safeUrl} [${res.status}] (no retry)`);
2965
+ break;
2966
+ }
2967
+ log(`batch failed \u2192 ${safeUrl} [${res.status}] (attempt ${attempt + 1}/${maxRetries + 1})`);
2968
+ } catch (err) {
2969
+ const msg = err instanceof Error ? err.message : String(err);
2970
+ log(`batch error \u2192 ${safeUrl}: ${msg.replace(/https?:\/\/[^\s]+/g, (u) => stripSensitive(u))} (attempt ${attempt + 1}/${maxRetries + 1})`);
2971
+ } finally {
2972
+ clearTimeout(timer);
2973
+ }
2974
+ if (attempt < maxRetries) {
2975
+ const base = Math.min(2 ** attempt * 250, 4e3);
2976
+ await sleep(base + Math.floor(Math.random() * 100));
2977
+ }
2978
+ }
2979
+ if (ok) {
2980
+ sent += batch.length;
2981
+ sentHashes.push(...batch.map((b) => b.contentHash));
2982
+ } else {
2983
+ failed += batch.length;
2984
+ }
2985
+ }
2986
+ return { sent, batches: batches.length, failed, sentHashes };
2987
+ }
2988
+
2989
+ // src/sync/index.ts
2990
+ function runSyncClassify(db, sessionId, config, opts = {}) {
2991
+ if (!config.centralDb?.url) return { enqueued: 0, autoShared: 0, withheld: 0 };
2992
+ const orgKey = opts.orgKey ?? loadOrgKey({ organization: config.organization });
2993
+ const policy = db.getSharingPolicy();
2994
+ const preview = previewShare(db, sessionId, orgKey, policy, { persistReversal: true });
2995
+ const sharedHashes = db.getSharedHashes();
2996
+ const { share, pending, withhold } = classify({ preview, policy, sharedHashes });
2997
+ const writeAll = db.rawConnection().transaction(() => {
2998
+ for (const item of share) {
2999
+ db.enqueuePending({
3000
+ contentHash: item.contentHash,
3001
+ sessionId,
3002
+ nodeId: item.nodeId,
3003
+ kind: item.kind,
3004
+ payload: item.payload,
3005
+ status: "approved",
3006
+ decidedBy: "rule"
3007
+ });
3008
+ }
3009
+ for (const item of pending) {
3010
+ db.enqueuePending({
3011
+ contentHash: item.contentHash,
3012
+ sessionId,
3013
+ nodeId: item.nodeId,
3014
+ kind: item.kind,
3015
+ payload: item.payload,
3016
+ status: "pending"
3017
+ });
3018
+ }
3019
+ for (const item of withhold) {
3020
+ if (!item.contentHash) continue;
3021
+ db.enqueuePending({
3022
+ contentHash: item.contentHash,
3023
+ sessionId,
3024
+ nodeId: item.nodeId,
3025
+ kind: item.kind,
3026
+ payload: item.payload,
3027
+ status: "withheld",
3028
+ decidedBy: "rule"
3029
+ });
3030
+ }
3031
+ });
3032
+ writeAll();
3033
+ return { enqueued: pending.length, autoShared: share.length, withheld: withhold.length };
3034
+ }
3035
+
3036
+ // src/installer/format.ts
3037
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
3038
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3039
+ function parseConfig(text, format) {
3040
+ if (!text.trim()) return {};
3041
+ try {
3042
+ switch (format) {
3043
+ case "json":
3044
+ return JSON.parse(text);
3045
+ case "toml":
3046
+ return parseToml(text);
3047
+ case "yaml":
3048
+ return parseYaml(text) ?? {};
3049
+ }
3050
+ } catch (err) {
3051
+ const detail = err instanceof Error ? err.message : String(err);
3052
+ throw new Error(`Failed to parse existing ${format.toUpperCase()} config: ${detail}`);
3053
+ }
3054
+ }
3055
+ function serializeConfig(obj, format) {
3056
+ switch (format) {
3057
+ case "json":
3058
+ return JSON.stringify(obj, null, 2) + "\n";
3059
+ case "toml":
3060
+ return stringifyToml(obj) + "\n";
3061
+ case "yaml":
3062
+ return stringifyYaml(obj);
3063
+ }
3064
+ }
3065
+
3066
+ // src/installer/merge.ts
3067
+ function isPlainObject(v) {
3068
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3069
+ }
3070
+ function deepMerge(target, source) {
3071
+ const out = { ...target };
3072
+ for (const [key, value] of Object.entries(source)) {
3073
+ const existing = out[key];
3074
+ if (isPlainObject(existing) && isPlainObject(value)) {
3075
+ out[key] = deepMerge(existing, value);
3076
+ } else {
3077
+ out[key] = value;
3078
+ }
3079
+ }
3080
+ return out;
3081
+ }
3082
+
3083
+ // src/installer/shapes.ts
3084
+ function mcpServerObject(entry) {
3085
+ if (entry.url) {
3086
+ return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
3087
+ }
3088
+ return {
3089
+ command: entry.command,
3090
+ args: entry.args ?? [],
3091
+ ...entry.env ? { env: entry.env } : {}
3092
+ };
3093
+ }
3094
+
3095
+ // src/installer/entry.ts
3096
+ var PACKAGE_NAME = "@datasynx/agentic-ai-cartography";
3097
+ var MCP_BIN = "cartography-mcp";
3098
+ var DEFAULT_SERVER_NAME = "cartography";
3099
+ function defaultServerEntry(opts = {}) {
3100
+ if (opts.transport === "http") {
3101
+ return { url: opts.url ?? "http://127.0.0.1:3737/mcp", ...opts.env ? { env: opts.env } : {} };
3102
+ }
3103
+ const args = ["-y", "--package", PACKAGE_NAME, MCP_BIN, ...opts.packageArgs ?? []];
3104
+ return { command: "npx", args, ...opts.env ? { env: opts.env } : {} };
3105
+ }
3106
+
3107
+ // src/installer/install.ts
3108
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
3109
+ import { dirname } from "path";
3110
+ import { homedir } from "os";
3111
+ function currentOs() {
3112
+ if (process.platform === "win32") return "win";
3113
+ if (process.platform === "darwin") return "mac";
3114
+ return "linux";
3115
+ }
3116
+ function defaultContext(scope) {
3117
+ return { scope, os: currentOs(), home: homedir(), cwd: process.cwd(), env: process.env };
3118
+ }
3119
+ function planInstall(spec, ctx, opts) {
3120
+ const path = spec.path(ctx);
3121
+ if (!path) {
3122
+ throw new Error(`${spec.label} does not support the "${ctx.scope}" scope.`);
3123
+ }
3124
+ const fileExists = existsSync2(path);
3125
+ const before = fileExists ? readFileSync4(path, "utf8") : "";
3126
+ const existing = parseConfig(before, spec.format);
3127
+ const merged = spec.apply(existing, opts.serverName ?? DEFAULT_SERVER_NAME, opts.entry);
3128
+ const after = serializeConfig(merged, spec.format);
3129
+ return {
3130
+ client: spec.id,
3131
+ label: spec.label,
3132
+ path,
3133
+ format: spec.format,
3134
+ before,
3135
+ after,
3136
+ fileExists,
3137
+ changed: after !== before,
3138
+ ...spec.note ? { note: spec.note } : {}
3139
+ };
3140
+ }
3141
+ function applyInstall(plan) {
3142
+ mkdirSync2(dirname(plan.path), { recursive: true });
3143
+ writeFileSync2(plan.path, plan.after, "utf8");
3144
+ }
3145
+ function renderDiff(before, after) {
3146
+ if (before === after) return " (no changes)";
3147
+ const b = before.length ? before.split("\n") : [];
3148
+ const a = after.split("\n");
3149
+ const out = [];
3150
+ const max = Math.max(b.length, a.length);
3151
+ for (let i = 0; i < max; i++) {
3152
+ if (b[i] === a[i]) {
3153
+ if (a[i] !== void 0) out.push(` ${a[i]}`);
3154
+ } else {
3155
+ if (b[i] !== void 0) out.push(`- ${b[i]}`);
3156
+ if (a[i] !== void 0) out.push(`+ ${a[i]}`);
3157
+ }
3158
+ }
3159
+ return out.join("\n");
3160
+ }
3161
+
3162
+ // src/installer/registry.ts
3163
+ import { join as join3 } from "path";
3164
+ function jsonKeyedClient(args) {
3165
+ return {
3166
+ id: args.id,
3167
+ label: args.label,
3168
+ format: "json",
3169
+ note: args.note,
3170
+ path: (ctx) => ctx.scope === "project" ? args.projectPath?.(ctx) : args.globalPath(ctx),
3171
+ apply: (existing, name, entry) => deepMerge(existing, { [args.key]: { [name]: mcpServerObject(entry) } })
3172
+ };
3173
+ }
3174
+ var claudeCode = jsonKeyedClient({
3175
+ id: "claude-code",
3176
+ label: "Claude Code",
3177
+ key: "mcpServers",
3178
+ globalPath: (ctx) => join3(ctx.home, ".claude.json"),
3179
+ projectPath: (ctx) => join3(ctx.cwd, ".mcp.json")
3180
+ });
3181
+ var cursor = jsonKeyedClient({
3182
+ id: "cursor",
3183
+ label: "Cursor",
3184
+ key: "mcpServers",
3185
+ globalPath: (ctx) => join3(ctx.home, ".cursor", "mcp.json"),
3186
+ projectPath: (ctx) => join3(ctx.cwd, ".cursor", "mcp.json")
3187
+ });
3188
+ function vscodeServerObject(entry) {
3189
+ if (entry.url) return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
3190
+ return { type: "stdio", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
3191
+ }
3192
+ function vscodeUserDir(ctx) {
3193
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Code", "User");
3194
+ if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Code", "User");
3195
+ return join3(ctx.home, ".config", "Code", "User");
3196
+ }
3197
+ var vscode = {
3198
+ id: "vscode",
3199
+ label: "VS Code (Copilot)",
3200
+ format: "json",
3201
+ note: "Uses the `servers` key (not `mcpServers`) \u2014 the most common copy-paste mistake.",
3202
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".vscode", "mcp.json") : join3(vscodeUserDir(ctx), "mcp.json"),
3203
+ apply: (existing, name, entry) => deepMerge(existing, { servers: { [name]: vscodeServerObject(entry) } })
3204
+ };
3205
+ var codex = {
3206
+ id: "codex",
3207
+ label: "Codex CLI",
3208
+ format: "toml",
3209
+ note: 'Project scope only loads in "trusted" projects.',
3210
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".codex", "config.toml") : join3(ctx.home, ".codex", "config.toml"),
3211
+ apply: (existing, name, entry) => deepMerge(existing, { mcp_servers: { [name]: mcpServerObject(entry) } })
3212
+ };
3213
+ var windsurf = jsonKeyedClient({
3214
+ id: "windsurf",
3215
+ label: "Windsurf",
3216
+ key: "mcpServers",
3217
+ globalPath: (ctx) => join3(ctx.home, ".codeium", "windsurf", "mcp_config.json")
3218
+ });
3219
+ function codeGlobalStorage(ctx, extensionId) {
3220
+ return join3(vscodeUserDir(ctx), "globalStorage", extensionId, "settings", "cline_mcp_settings.json");
3221
+ }
3222
+ var cline = {
3223
+ id: "cline",
3224
+ label: "Cline",
3225
+ format: "json",
3226
+ path: (ctx) => ctx.scope === "project" ? void 0 : codeGlobalStorage(ctx, "saoudrizwan.claude-dev"),
3227
+ // Cline augments the standard object with its own auto-approve/disable flags.
3228
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: { ...mcpServerObject(entry), alwaysAllow: [], disabled: false } } })
3229
+ };
3230
+ var roo = {
3231
+ id: "roo",
3232
+ label: "Roo Code",
3233
+ format: "json",
3234
+ note: "Project .roo/mcp.json takes precedence over the global settings.",
3235
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".roo", "mcp.json") : codeGlobalStorage(ctx, "rooveterinaryinc.roo-cline"),
3236
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
3237
+ };
3238
+ var zed = {
3239
+ id: "zed",
3240
+ label: "Zed",
3241
+ format: "json",
3242
+ note: 'Manual servers need "source": "custom"; remote uses an mcp-remote bridge.',
3243
+ path: (ctx) => {
3244
+ if (ctx.scope === "project") return join3(ctx.cwd, ".zed", "settings.json");
3245
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Zed", "settings.json");
3246
+ return join3(ctx.home, ".config", "zed", "settings.json");
3247
+ },
3248
+ apply: (existing, name, entry) => {
3249
+ const inner = entry.url ? { source: "custom", url: entry.url } : { source: "custom", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
3250
+ return deepMerge(existing, { context_servers: { [name]: inner } });
3251
+ }
3252
+ };
3253
+ var junie = {
3254
+ id: "junie",
3255
+ label: "JetBrains / Junie",
3256
+ format: "json",
3257
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".junie", "mcp", "mcp.json") : join3(ctx.home, ".junie", "mcp", "mcp.json"),
3258
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
3259
+ };
3260
+ var gemini = {
3261
+ id: "gemini",
3262
+ label: "Gemini CLI",
3263
+ format: "json",
3264
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".gemini", "settings.json") : join3(ctx.home, ".gemini", "settings.json"),
3265
+ apply: (existing, name, entry) => {
3266
+ const inner = entry.url ? { httpUrl: entry.url, ...entry.env ? { env: entry.env } : {} } : mcpServerObject(entry);
3267
+ return deepMerge(existing, { mcpServers: { [name]: inner } });
3268
+ }
3269
+ };
3270
+ var goose = {
3271
+ id: "goose",
3272
+ label: "Goose",
3273
+ format: "yaml",
3274
+ note: "Verify the extension shape against current Goose docs; built-ins are left untouched.",
3275
+ path: (ctx) => {
3276
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Block", "goose", "config", "config.yaml");
3277
+ return join3(ctx.home, ".config", "goose", "config.yaml");
3278
+ },
3279
+ apply: (existing, name, entry) => {
3280
+ 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 } : {} };
3281
+ return deepMerge(existing, { extensions: { [name]: inner } });
3282
+ }
3283
+ };
3284
+ function isObj(v) {
3285
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3286
+ }
3287
+ var openhands = {
3288
+ id: "openhands",
3289
+ label: "OpenHands",
3290
+ format: "toml",
3291
+ note: "SHTTP is preferred; SSE is legacy. Only api_key is supported (no arbitrary headers).",
3292
+ path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, "config.toml") : join3(ctx.home, ".openhands", "config.toml"),
3293
+ apply: (existing, name, entry) => {
3294
+ const mcp = isObj(existing.mcp) ? { ...existing.mcp } : {};
3295
+ const key = entry.url ? "shttp_servers" : "stdio_servers";
3296
+ const arr = Array.isArray(mcp[key]) ? [...mcp[key]] : [];
3297
+ const item = entry.url ? { url: entry.url, ...entry.env ? { env: entry.env } : {} } : { name, command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
3298
+ const matches2 = (s) => entry.url ? s.url === entry.url : s.name === name;
3299
+ const idx = arr.findIndex(matches2);
3300
+ if (idx >= 0) arr[idx] = item;
3301
+ else arr.push(item);
3302
+ mcp[key] = arr;
3303
+ return { ...existing, mcp };
3304
+ }
3305
+ };
3306
+ var claudeDesktop = {
3307
+ id: "claude-desktop",
3308
+ label: "Claude Desktop",
3309
+ format: "json",
3310
+ note: "One-click install is also available via the .mcpb bundle (npm run build:mcpb).",
3311
+ path: (ctx) => {
3312
+ if (ctx.scope === "project") return void 0;
3313
+ if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
3314
+ if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
3315
+ return join3(ctx.home, ".config", "Claude", "claude_desktop_config.json");
3316
+ },
3317
+ apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
3318
+ };
3319
+ var CLIENTS = [
3320
+ claudeCode,
3321
+ cursor,
3322
+ vscode,
3323
+ codex,
3324
+ windsurf,
3325
+ cline,
3326
+ roo,
3327
+ zed,
3328
+ junie,
3329
+ gemini,
3330
+ goose,
3331
+ openhands,
3332
+ claudeDesktop
3333
+ ];
3334
+ function getClient(id) {
3335
+ return CLIENTS.find((c) => c.id === id);
3336
+ }
3337
+ function listClients() {
3338
+ return CLIENTS.map(({ id, label, format, note }) => ({ id, label, format, note }));
3339
+ }
3340
+
3341
+ // src/installer/deeplinks.ts
3342
+ function cursorDeeplink(name, entry) {
3343
+ const config = Buffer.from(JSON.stringify(mcpServerObject(entry))).toString("base64");
3344
+ const params = new URLSearchParams({ name, config });
3345
+ return `cursor://anysphere.cursor-deeplink/mcp/install?${params.toString()}`;
3346
+ }
3347
+ function vscodeDeeplink(name, entry, opts = {}) {
3348
+ const scheme = opts.insiders ? "vscode-insiders" : "vscode";
3349
+ const payload = encodeURIComponent(JSON.stringify({ name, ...mcpServerObject(entry) }));
3350
+ return `${scheme}://mcp/install?${payload}`;
3351
+ }
3352
+ function codeAddMcpCommand(name, entry) {
3353
+ return `code --add-mcp '${JSON.stringify({ name, ...mcpServerObject(entry) })}'`;
3354
+ }
3355
+
3356
+ // src/cli.ts
2340
3357
  var bold = (s) => `\x1B[1m${s}\x1B[0m`;
2341
3358
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
2342
3359
  var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
@@ -2344,6 +3361,46 @@ var green = (s) => `\x1B[32m${s}\x1B[0m`;
2344
3361
  var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
2345
3362
  var magenta = (s) => `\x1B[35m${s}\x1B[0m`;
2346
3363
  var red = (s) => `\x1B[31m${s}\x1B[0m`;
3364
+ function renderDiffText(d) {
3365
+ const out = [];
3366
+ out.push(`${bold("Topology diff")} ${dim(d.base.sessionId.slice(0, 8))} \u2192 ${dim(d.current.sessionId.slice(0, 8))}`);
3367
+ out.push(` base: ${d.base.nodeCount} nodes, ${d.base.edgeCount} edges ${dim(d.base.startedAt)}`);
3368
+ out.push(` current: ${d.current.nodeCount} nodes, ${d.current.edgeCount} edges ${dim(d.current.startedAt)}`);
3369
+ out.push("");
3370
+ out.push(` nodes: ${green("+" + d.summary.nodesAdded)} ${red("-" + d.summary.nodesRemoved)} ${yellow("~" + d.summary.nodesChanged)} edges: ${green("+" + d.summary.edgesAdded)} ${red("-" + d.summary.edgesRemoved)}`);
3371
+ if (d.summary.nodesAdded + d.summary.nodesRemoved + d.summary.nodesChanged + d.summary.edgesAdded + d.summary.edgesRemoved === 0) {
3372
+ out.push("");
3373
+ out.push(` ${green("\u2713")} No drift between the two sessions.`);
3374
+ return out.join("\n");
3375
+ }
3376
+ out.push("");
3377
+ for (const n of d.nodes.added) out.push(` ${green("+")} ${n.id} ${dim("(" + n.type + ")")}`);
3378
+ for (const n of d.nodes.removed) out.push(` ${red("-")} ${n.id} ${dim("(" + n.type + ")")}`);
3379
+ for (const c of d.nodes.changed) out.push(` ${yellow("~")} ${c.id} ${dim("[" + c.changedFields.join(", ") + "]")}`);
3380
+ for (const e of d.edges.added) out.push(` ${green("+")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
3381
+ for (const e of d.edges.removed) out.push(` ${red("-")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
3382
+ return out.join("\n");
3383
+ }
3384
+ function renderDriftSummaryText(r) {
3385
+ const s = r.delta.summary;
3386
+ const base = r.baseSessionId ? r.baseSessionId.slice(0, 8) : "\u2205";
3387
+ 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 + ")")}`;
3388
+ }
3389
+ function maybeQueueForSync(db, sessionId, config, w) {
3390
+ if (!config.centralDb?.url) return;
3391
+ try {
3392
+ const r = runSyncClassify(db, sessionId, config);
3393
+ if (r.enqueued > 0) {
3394
+ w(` ${cyan("\u21EA")} ${bold(String(r.enqueued))} item(s) queued for review \u2014 run ${bold("'datasynx-cartography sync review'")}
3395
+ `);
3396
+ } else if (r.autoShared > 0) {
3397
+ w(` ${cyan("\u21EA")} ${bold(String(r.autoShared))} item(s) auto-approved by policy \u2014 run ${bold("'datasynx-cartography sync push'")}
3398
+ `);
3399
+ }
3400
+ } catch (err) {
3401
+ logWarn(`central-DB sync classify skipped: ${err instanceof Error ? err.message : String(err)}`);
3402
+ }
3403
+ }
2347
3404
  main();
2348
3405
  function main() {
2349
3406
  let activeDb = null;
@@ -2356,18 +3413,35 @@ function main() {
2356
3413
  }
2357
3414
  activeDb = null;
2358
3415
  }
2359
- process.exit(signal === "SIGINT" ? 130 : 0);
3416
+ process.removeListener("SIGTERM", shutdown);
3417
+ process.removeListener("SIGINT", shutdown);
3418
+ process.kill(process.pid, signal);
2360
3419
  };
2361
- process.on("SIGTERM", () => shutdown("SIGTERM"));
2362
- process.on("SIGINT", () => shutdown("SIGINT"));
3420
+ process.on("SIGTERM", shutdown);
3421
+ process.on("SIGINT", shutdown);
2363
3422
  cleanupTempFiles();
2364
3423
  const program = new Command();
2365
3424
  const CMD = "datasynx-cartography";
2366
- const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url));
2367
- const { version: VERSION } = JSON.parse(readFileSync2(resolve(__dirname, "..", "package.json"), "utf-8"));
3425
+ const __dirname = import.meta.dirname ?? dirname2(fileURLToPath(import.meta.url));
3426
+ let VERSION = "0.0.0";
3427
+ try {
3428
+ VERSION = JSON.parse(readFileSync5(resolve2(__dirname, "..", "package.json"), "utf-8")).version ?? VERSION;
3429
+ } catch {
3430
+ logWarn("Could not read package.json version; falling back to 0.0.0");
3431
+ }
2368
3432
  program.name(CMD).description("AI-powered Infrastructure Discovery & Agentic AI Cartography").version(VERSION);
2369
- 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) => {
2370
- checkPrerequisites();
3433
+ 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) => {
3434
+ const providerName = opts.provider ?? process.env.CARTOGRAPHY_PROVIDER ?? "claude";
3435
+ if (!defaultProviderRegistry.has(providerName)) {
3436
+ process.stderr.write(
3437
+ `\u274C Unknown provider "${providerName}" (valid: ${defaultProviderRegistry.names().join(", ")})
3438
+ `
3439
+ );
3440
+ process.exitCode = 2;
3441
+ return;
3442
+ }
3443
+ const provider = providerName;
3444
+ checkPrerequisites(provider);
2371
3445
  const parsedDepth = parseInt(opts.depth, 10);
2372
3446
  const parsedMaxTurns = parseInt(opts.maxTurns, 10);
2373
3447
  if (Number.isNaN(parsedDepth) || parsedDepth < 1 || parsedDepth > 50) {
@@ -2382,11 +3456,20 @@ function main() {
2382
3456
  process.exitCode = 2;
2383
3457
  return;
2384
3458
  }
3459
+ const fmt = opts.outputFormat ?? "text";
3460
+ if (!["text", "json", "stream-json"].includes(fmt)) {
3461
+ process.stderr.write(`\u274C Invalid --output-format: "${opts.outputFormat}" (must be text, json, or stream-json)
3462
+ `);
3463
+ process.exitCode = 2;
3464
+ return;
3465
+ }
3466
+ const isText = fmt === "text";
2385
3467
  setVerbose(opts.verbose);
2386
3468
  const config = defaultConfig({
2387
3469
  entryPoints: opts.entry,
2388
3470
  maxDepth: parsedDepth,
2389
3471
  maxTurns: parsedMaxTurns,
3472
+ provider,
2390
3473
  agentModel: opts.model,
2391
3474
  organization: opts.org,
2392
3475
  outputDir: opts.output,
@@ -2395,14 +3478,63 @@ function main() {
2395
3478
  });
2396
3479
  logInfo("Discovery started", {
2397
3480
  entryPoints: config.entryPoints,
3481
+ provider: config.provider,
2398
3482
  model: config.agentModel,
2399
3483
  maxTurns: config.maxTurns,
2400
3484
  maxDepth: config.maxDepth
2401
3485
  });
2402
3486
  const db = new CartographyDB(config.dbPath);
2403
3487
  activeDb = db;
2404
- const sessionId = db.createSession("discover", config);
2405
3488
  const w = process.stderr.write.bind(process.stderr);
3489
+ if (opts.update) {
3490
+ const tenantId = normalizeTenant(opts.org);
3491
+ const targetId = typeof opts.update === "string" ? opts.update : db.getLatestSession("discover", tenantId)?.id;
3492
+ const targetSession = targetId ? db.getSession(targetId) : void 0;
3493
+ if (!targetId || !targetSession) {
3494
+ process.stderr.write(
3495
+ `\u274C No discover session to update${typeof opts.update === "string" ? ` (id "${opts.update}")` : ""}; run \`discover\` first.
3496
+ `
3497
+ );
3498
+ process.exitCode = 2;
3499
+ db.close();
3500
+ activeDb = null;
3501
+ return;
3502
+ }
3503
+ const baseNodeCount = db.getNodes(targetId).length;
3504
+ const baseEdgeCount = db.getEdges(targetId).length;
3505
+ if (isText) {
3506
+ w("\n");
3507
+ w(` ${bold("CARTOGRAPHY")} ${dim("incremental rescan \xB7 " + targetId.slice(0, 8))}
3508
+ `);
3509
+ 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"));
3510
+ }
3511
+ try {
3512
+ const r = await runLocalDiscovery(db, targetId, { mode: "update" });
3513
+ const updated = db.getSession(targetId);
3514
+ const diff = {
3515
+ base: { sessionId: targetId, startedAt: targetSession.startedAt, nodeCount: baseNodeCount, edgeCount: baseEdgeCount },
3516
+ current: { sessionId: targetId, startedAt: updated?.lastScannedAt ?? (/* @__PURE__ */ new Date()).toISOString(), nodeCount: r.nodes, edgeCount: r.edges },
3517
+ nodes: r.delta?.nodes ?? { added: [], removed: [], changed: [], unchanged: 0 },
3518
+ edges: r.delta?.edges ?? { added: [], removed: [], unchanged: 0 },
3519
+ summary: r.delta?.summary ?? { nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0, edgesAdded: 0, edgesRemoved: 0 },
3520
+ anomalies: { base: [], current: [], added: [] }
3521
+ };
3522
+ if (fmt === "text") w(renderDiffText(diff) + "\n\n");
3523
+ else process.stdout.write(JSON.stringify(diff, null, 2) + "\n");
3524
+ logInfo("Incremental rescan complete", { sessionId: targetId, ...diff.summary });
3525
+ } catch (err) {
3526
+ const errMsg = err instanceof Error ? err.message : String(err);
3527
+ logError("Incremental rescan failed", { sessionId: targetId, error: errMsg });
3528
+ w(`
3529
+ ${bold(red("\u2717"))} Rescan failed: ${errMsg}
3530
+ `);
3531
+ process.exitCode = 1;
3532
+ }
3533
+ db.close();
3534
+ activeDb = null;
3535
+ return;
3536
+ }
3537
+ const sessionId = db.createSession("discover", config, opts.org);
2406
3538
  const SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2407
3539
  let spinIdx = 0;
2408
3540
  let spinnerTimer = null;
@@ -2427,13 +3559,15 @@ function main() {
2427
3559
  let turnNum = 0;
2428
3560
  let nodeCount = 0;
2429
3561
  let edgeCount = 0;
2430
- w("\n");
2431
- w(` ${bold("CARTOGRAPHY")} ${dim(config.entryPoints.join(", "))}
3562
+ if (isText) {
3563
+ w("\n");
3564
+ w(` ${bold("CARTOGRAPHY")} ${dim(config.entryPoints.join(", "))}
2432
3565
  `);
2433
- w(` ${dim("Model: " + config.agentModel + " | MaxTurns: " + config.maxTurns)}
3566
+ w(` ${dim("Model: " + config.agentModel + " | MaxTurns: " + config.maxTurns)}
2434
3567
  `);
2435
- 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"));
2436
- w("\n");
3568
+ 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"));
3569
+ w("\n");
3570
+ }
2437
3571
  const logLine = (icon, msg) => {
2438
3572
  stopSpinner();
2439
3573
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -2441,6 +3575,10 @@ function main() {
2441
3575
  `);
2442
3576
  };
2443
3577
  const handleEvent = (event) => {
3578
+ if (!isText) {
3579
+ if (fmt === "stream-json") process.stdout.write(JSON.stringify(event) + "\n");
3580
+ return;
3581
+ }
2444
3582
  switch (event.kind) {
2445
3583
  case "turn":
2446
3584
  turnNum = event.turn;
@@ -2457,7 +3595,7 @@ function main() {
2457
3595
  }
2458
3596
  break;
2459
3597
  case "tool_call": {
2460
- const toolName = event.tool.replace("mcp__cartograph__", "");
3598
+ const toolName = event.tool.replace("mcp__cartography__", "");
2461
3599
  if (toolName === "Bash") {
2462
3600
  const cmd = (event.input["command"] ?? "").substring(0, 70);
2463
3601
  startSpinner(`${yellow("$")} ${cmd}`);
@@ -2505,6 +3643,7 @@ function main() {
2505
3643
  }
2506
3644
  };
2507
3645
  const onAskUser = async (question, context) => {
3646
+ if (!isText) return "(Non-interactive mode \u2014 please continue without this information)";
2508
3647
  stopSpinner();
2509
3648
  w("\n");
2510
3649
  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"));
@@ -2519,7 +3658,7 @@ function main() {
2519
3658
  return "(Non-interactive mode \u2014 please continue without this information)";
2520
3659
  }
2521
3660
  const rl = createInterface({ input: process.stdin, output: process.stderr });
2522
- const answer = await new Promise((resolve2) => rl.question(` ${cyan("\u2192")} `, resolve2));
3661
+ const answer = await new Promise((resolve3) => rl.question(` ${cyan("\u2192")} `, resolve3));
2523
3662
  rl.close();
2524
3663
  w("\n");
2525
3664
  return answer || "(No answer \u2014 please continue)";
@@ -2541,6 +3680,9 @@ function main() {
2541
3680
  }
2542
3681
  stopSpinner();
2543
3682
  db.endSession(sessionId);
3683
+ maybeQueueForSync(db, sessionId, config, w);
3684
+ const sessionName = opts.name?.trim() || deriveSessionName(db.getGraphSummary(sessionId), db.getSession(sessionId)?.startedAt ?? (/* @__PURE__ */ new Date()).toISOString());
3685
+ db.setSessionName(sessionId, sessionName);
2544
3686
  const stats = db.getStats(sessionId);
2545
3687
  const totalSec = ((Date.now() - startTime) / 1e3).toFixed(1);
2546
3688
  logInfo("Discovery completed", {
@@ -2549,6 +3691,22 @@ function main() {
2549
3691
  edges: stats.edges,
2550
3692
  durationSec: parseFloat(totalSec)
2551
3693
  });
3694
+ if (!isText) {
3695
+ const durationMs = Date.now() - startTime;
3696
+ if (fmt === "stream-json") {
3697
+ process.stdout.write(JSON.stringify({ kind: "result", sessionId, nodes: stats.nodes, edges: stats.edges, durationMs }) + "\n");
3698
+ } else {
3699
+ process.stdout.write(JSON.stringify(
3700
+ { sessionId, stats, nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId), durationMs },
3701
+ null,
3702
+ 2
3703
+ ) + "\n");
3704
+ }
3705
+ exportAll(db, sessionId, config.outputDir, ["discovery"]);
3706
+ db.close();
3707
+ activeDb = null;
3708
+ return;
3709
+ }
2552
3710
  w("\n");
2553
3711
  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"));
2554
3712
  w(` ${green(bold("DONE"))} ${bold(String(stats.nodes))} nodes, ${bold(String(stats.edges))} edges ${dim("in " + totalSec + "s")}
@@ -2588,7 +3746,7 @@ function main() {
2588
3746
  w("\n");
2589
3747
  const rl = createInterface({ input: process.stdin, output: process.stderr });
2590
3748
  const answer = await new Promise(
2591
- (resolve2) => rl.question(` ${yellow("?")} Remove nodes (numbers, empty = keep all): `, resolve2)
3749
+ (resolve3) => rl.question(` ${yellow("?")} Remove nodes (numbers, empty = keep all): `, resolve3)
2592
3750
  );
2593
3751
  rl.close();
2594
3752
  const toRemove = answer.trim().split(/[\s,]+/).map(Number).filter((n) => n >= 1 && n <= allNodes.length);
@@ -2607,9 +3765,9 @@ function main() {
2607
3765
  }
2608
3766
  }
2609
3767
  exportAll(db, sessionId, config.outputDir, ["discovery"]);
2610
- const discoveryPath = resolve(config.outputDir, "discovery.html");
3768
+ const discoveryPath = resolve2(config.outputDir, "discovery.html");
2611
3769
  w("\n");
2612
- if (existsSync2(discoveryPath)) {
3770
+ if (existsSync3(discoveryPath)) {
2613
3771
  w(` ${green("\u2713")} ${bold("discovery.html")} ${dim("\u2190 Enterprise Map")}
2614
3772
  `);
2615
3773
  }
@@ -2626,7 +3784,7 @@ function main() {
2626
3784
  while (continueDiscovery) {
2627
3785
  const rlFollowup = createInterface({ input: process.stdin, output: process.stderr });
2628
3786
  const followupHint = await new Promise(
2629
- (resolve2) => rlFollowup.question(` ${yellow("\u2192")} Search for (Enter = finish): `, resolve2)
3787
+ (resolve3) => rlFollowup.question(` ${yellow("\u2192")} Search for (Enter = finish): `, resolve3)
2630
3788
  );
2631
3789
  rlFollowup.close();
2632
3790
  if (!followupHint.trim()) {
@@ -2654,7 +3812,7 @@ function main() {
2654
3812
  `);
2655
3813
  w("\n");
2656
3814
  exportAll(db, sessionId, config.outputDir, ["discovery"]);
2657
- if (existsSync2(discoveryPath)) {
3815
+ if (existsSync3(discoveryPath)) {
2658
3816
  w(` ${green("\u2713")} ${bold("discovery.html updated")}
2659
3817
  `);
2660
3818
  }
@@ -2663,7 +3821,7 @@ function main() {
2663
3821
  }
2664
3822
  db.close();
2665
3823
  });
2666
- 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) => {
3824
+ 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) => {
2667
3825
  const config = defaultConfig({ outputDir: opts.output });
2668
3826
  const db = new CartographyDB(config.dbPath);
2669
3827
  const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
@@ -2679,6 +3837,216 @@ function main() {
2679
3837
  `);
2680
3838
  db.close();
2681
3839
  });
3840
+ 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) => {
3841
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
3842
+ const db = new CartographyDB(config.dbPath);
3843
+ activeDb = db;
3844
+ try {
3845
+ const sessions = db.getSessions();
3846
+ const currentId = current ?? sessions[0]?.id;
3847
+ const baseId = base ?? sessions[1]?.id;
3848
+ if (!baseId || !currentId) {
3849
+ process.stderr.write("\u274C Need at least two discovery sessions to diff\n");
3850
+ process.exitCode = 1;
3851
+ return;
3852
+ }
3853
+ if (baseId === currentId) {
3854
+ process.stderr.write("\u274C Base and current session are the same\n");
3855
+ process.exitCode = 1;
3856
+ return;
3857
+ }
3858
+ const d = db.diffSessions(baseId, currentId);
3859
+ const out = opts.format === "json" ? JSON.stringify(d, null, 2) : opts.format === "mermaid" ? generateDiffMermaid(d) : renderDiffText(d);
3860
+ if (opts.output) {
3861
+ writeFileSync3(opts.output, out + "\n");
3862
+ process.stderr.write(`\u2713 Wrote diff to: ${opts.output}
3863
+ `);
3864
+ } else {
3865
+ process.stdout.write(out + "\n");
3866
+ }
3867
+ } catch (err) {
3868
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3869
+ `);
3870
+ process.exitCode = 1;
3871
+ } finally {
3872
+ db.close();
3873
+ activeDb = null;
3874
+ }
3875
+ });
3876
+ 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) => {
3877
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
3878
+ const db = new CartographyDB(config.dbPath);
3879
+ activeDb = db;
3880
+ try {
3881
+ const ruleset = getRuleset(opts.ruleset);
3882
+ if (!ruleset) {
3883
+ process.stderr.write(`\u274C Unknown ruleset: "${opts.ruleset}" (available: ${listRulesets().map((r) => r.name).join(", ")})
3884
+ `);
3885
+ process.exitCode = 1;
3886
+ return;
3887
+ }
3888
+ const sid = sessionId ?? db.getLatestSession()?.id;
3889
+ if (!sid) {
3890
+ process.stderr.write("\u274C No session to score (run discovery first or pass a session id)\n");
3891
+ process.exitCode = 1;
3892
+ return;
3893
+ }
3894
+ const report = db.scoreSession(sid, ruleset);
3895
+ const out = opts.format === "json" || opts.format === "markdown" || opts.format === "mermaid" ? exportComplianceReport(report, opts.format) : formatComplianceText(report);
3896
+ if (opts.output) {
3897
+ writeFileSync3(opts.output, out + "\n");
3898
+ process.stderr.write(`\u2713 Wrote compliance report to: ${opts.output}
3899
+ `);
3900
+ } else {
3901
+ process.stdout.write(out + "\n");
3902
+ }
3903
+ } catch (err) {
3904
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3905
+ `);
3906
+ process.exitCode = 1;
3907
+ } finally {
3908
+ db.close();
3909
+ activeDb = null;
3910
+ }
3911
+ });
3912
+ 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) => {
3913
+ let drift;
3914
+ try {
3915
+ drift = DriftConfigSchema.parse({
3916
+ minSeverity: opts.minSeverity,
3917
+ sinks: opts.webhook ? [{ type: "webhook", url: opts.webhook }] : [{ type: "stdout" }]
3918
+ });
3919
+ } catch (err) {
3920
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3921
+ `);
3922
+ process.exitCode = 1;
3923
+ return;
3924
+ }
3925
+ const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, drift });
3926
+ const db = new CartographyDB(config.dbPath);
3927
+ activeDb = db;
3928
+ try {
3929
+ const alert = await runDrift(db, config, { base, current, minSeverity: drift.minSeverity });
3930
+ if (!alert) {
3931
+ process.stderr.write("\u2139 Need at least two discovery sessions for drift; nothing to do.\n");
3932
+ return;
3933
+ }
3934
+ process.stderr.write(`\u2713 drift severity=${alert.severity} items=${alert.items.length}
3935
+ `);
3936
+ } catch (err) {
3937
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
3938
+ `);
3939
+ process.exitCode = 1;
3940
+ } finally {
3941
+ db.close();
3942
+ activeDb = null;
3943
+ }
3944
+ });
3945
+ 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) => {
3946
+ let cfg;
3947
+ try {
3948
+ cfg = loadConfig(opts.config);
3949
+ } catch (err) {
3950
+ process.stderr.write(`\u274C ${err instanceof ConfigError ? err.message : String(err)}
3951
+ `);
3952
+ process.exitCode = 2;
3953
+ return;
3954
+ }
3955
+ if (opts.db) cfg = defaultConfig({ ...cfg, dbPath: opts.db });
3956
+ const fmt = opts.outputFormat ?? cfg.schedule?.outputFormat ?? "json";
3957
+ if (!["text", "json", "stream-json"].includes(fmt)) {
3958
+ process.stderr.write(`\u274C Invalid --output-format: "${fmt}" (must be text, json, or stream-json)
3959
+ `);
3960
+ process.exitCode = 2;
3961
+ return;
3962
+ }
3963
+ if (opts.once && opts.watch) {
3964
+ process.stderr.write("\u274C --once and --watch are mutually exclusive\n");
3965
+ process.exitCode = 2;
3966
+ return;
3967
+ }
3968
+ const cron = cfg.schedule?.cron;
3969
+ if (opts.watch && !cron) {
3970
+ process.stderr.write("\u274C --watch requires a `schedule.cron` in the config file\n");
3971
+ process.exitCode = 2;
3972
+ return;
3973
+ }
3974
+ if (cron) {
3975
+ try {
3976
+ nextRun(cron, /* @__PURE__ */ new Date());
3977
+ } catch (err) {
3978
+ process.stderr.write(`\u274C Invalid cron "${cron}": ${err instanceof Error ? err.message : String(err)}
3979
+ `);
3980
+ process.exitCode = 2;
3981
+ return;
3982
+ }
3983
+ }
3984
+ const db = new CartographyDB(cfg.dbPath);
3985
+ activeDb = db;
3986
+ const emit = (r) => {
3987
+ if (fmt === "text") {
3988
+ process.stdout.write(renderDriftSummaryText(r) + "\n");
3989
+ } else {
3990
+ const payload = { sessionId: r.sessionId, baseSessionId: r.baseSessionId ?? null, summary: r.delta.summary };
3991
+ process.stdout.write(JSON.stringify(payload) + "\n");
3992
+ }
3993
+ };
3994
+ const doRun = async () => {
3995
+ const r = await runOnce(cfg, db);
3996
+ db.recordDriftRun(r.sessionId, r.baseSessionId, r.delta);
3997
+ maybeQueueForSync(db, r.sessionId, cfg, (s) => process.stderr.write(s));
3998
+ emit(r);
3999
+ };
4000
+ if (opts.watch) {
4001
+ let stopped = false;
4002
+ let timer = null;
4003
+ const MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
4004
+ let nextAnnounced = null;
4005
+ const schedule = () => {
4006
+ if (stopped) return;
4007
+ const next = nextRun(cron, /* @__PURE__ */ new Date());
4008
+ const targetMs = next.getTime();
4009
+ if (next.toISOString() !== nextAnnounced) {
4010
+ logInfo(`next scheduled run at ${next.toISOString()}`);
4011
+ nextAnnounced = next.toISOString();
4012
+ }
4013
+ const remaining = targetMs - Date.now();
4014
+ if (remaining > MAX_DELAY_MS) {
4015
+ timer = setTimeout(schedule, MAX_DELAY_MS);
4016
+ return;
4017
+ }
4018
+ timer = setTimeout(() => {
4019
+ void (async () => {
4020
+ try {
4021
+ await doRun();
4022
+ } catch (err) {
4023
+ logError(`scheduled run failed: ${err instanceof Error ? err.message : String(err)}`);
4024
+ }
4025
+ nextAnnounced = null;
4026
+ schedule();
4027
+ })();
4028
+ }, Math.max(0, remaining));
4029
+ };
4030
+ const stop = () => {
4031
+ stopped = true;
4032
+ if (timer) clearTimeout(timer);
4033
+ };
4034
+ process.once("SIGINT", stop);
4035
+ process.once("SIGTERM", stop);
4036
+ schedule();
4037
+ return;
4038
+ }
4039
+ try {
4040
+ await doRun();
4041
+ } catch (err) {
4042
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
4043
+ `);
4044
+ process.exitCode = 1;
4045
+ } finally {
4046
+ db.close();
4047
+ activeDb = null;
4048
+ }
4049
+ });
2682
4050
  program.command("show [session-id]").description("Show session details").action((sessionId) => {
2683
4051
  const config = defaultConfig();
2684
4052
  const db = new CartographyDB(config.dbPath);
@@ -2693,6 +4061,8 @@ function main() {
2693
4061
  const nodes = db.getNodes(session.id);
2694
4062
  process.stdout.write(`
2695
4063
  Session: ${session.id}
4064
+ `);
4065
+ if (session.name) process.stdout.write(` Name: ${session.name}
2696
4066
  `);
2697
4067
  process.stdout.write(` Mode: ${session.mode}
2698
4068
  `);
@@ -2708,6 +4078,15 @@ Session: ${session.id}
2708
4078
  `);
2709
4079
  process.stdout.write(` Tasks: ${stats.tasks}
2710
4080
  `);
4081
+ const events = db.getEvents(session.id);
4082
+ if (events.length > 0) {
4083
+ process.stdout.write("\n Recent activity:\n");
4084
+ for (const e of events.slice(-15)) {
4085
+ const kb = e.resultBytes != null ? ` (${(e.resultBytes / 1024).toFixed(1)} KB)` : "";
4086
+ process.stdout.write(` ${e.timestamp} ${e.process} ${(e.command ?? "").slice(0, 60)}${kb}
4087
+ `);
4088
+ }
4089
+ }
2711
4090
  if (nodes.length > 0) {
2712
4091
  process.stdout.write("\n Discovered nodes:\n");
2713
4092
  for (const node of nodes.slice(0, 20)) {
@@ -2735,7 +4114,7 @@ Session: ${session.id}
2735
4114
  const stats = db.getStats(session.id);
2736
4115
  const status = session.completedAt ? "\u2713" : "\u25CF";
2737
4116
  process.stdout.write(
2738
- `${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}
4117
+ `${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}${session.name ? ` ${session.name}` : ""}
2739
4118
  `
2740
4119
  );
2741
4120
  }
@@ -2774,7 +4153,7 @@ Session: ${session.id}
2774
4153
  const status = session.completedAt ? green("\u2713") : yellow("\u25CF");
2775
4154
  const age = session.startedAt.substring(0, 16).replace("T", " ");
2776
4155
  const sid = cyan(session.id.substring(0, 8));
2777
- w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}
4156
+ w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}${session.name ? ` ${d(session.name)}` : ""}
2778
4157
  `);
2779
4158
  w(` ${d("Nodes: " + stats.nodes + " Edges: " + stats.edges)}
2780
4159
  `);
@@ -2792,8 +4171,9 @@ Session: ${session.id}
2792
4171
  }
2793
4172
  db.close();
2794
4173
  });
2795
- program.command("chat [session-id]").description("Interactive chat about your mapped infrastructure").option("--db <path>", "DB path").option("--model <m>", "Model", "claude-sonnet-4-5-20250929").action(async (sessionIdArg, opts) => {
4174
+ 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) => {
2796
4175
  const config = defaultConfig();
4176
+ const model = opts.model ?? config.models.fast;
2797
4177
  const db = new CartographyDB(opts.db ?? config.dbPath);
2798
4178
  const sessions = db.getSessions();
2799
4179
  const session = sessionIdArg ? sessions.find((s) => s.id.startsWith(sessionIdArg)) : sessions.filter((s) => s.completedAt).at(-1) ?? sessions.at(-1);
@@ -2815,7 +4195,7 @@ Session: ${session.id}
2815
4195
  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\u2500\u2500\u2500\u2500\u2500
2816
4196
  `));
2817
4197
  w(` ${dim("Ask anything about your infrastructure. exit = quit.\n\n")}`);
2818
- const Anthropic = (await import("./sdk-QSTAREST.js")).default;
4198
+ const Anthropic = (await import("@anthropic-ai/sdk")).default;
2819
4199
  const client = new Anthropic();
2820
4200
  const infraSummary = JSON.stringify({
2821
4201
  nodes: nodes.map((n) => ({
@@ -2837,7 +4217,7 @@ INFRASTRUCTURE SNAPSHOT (${nodes.length} nodes, ${edges.length} edges):
2837
4217
  ${infraSummary.substring(0, 12e3)}`;
2838
4218
  const history = [];
2839
4219
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2840
- const ask = () => new Promise((resolve2) => rl.question(` ${cyan(">")} `, resolve2));
4220
+ const ask = () => new Promise((resolve3) => rl.question(` ${cyan(">")} `, resolve3));
2841
4221
  while (true) {
2842
4222
  let userInput;
2843
4223
  try {
@@ -2850,7 +4230,7 @@ ${infraSummary.substring(0, 12e3)}`;
2850
4230
  history.push({ role: "user", content: userInput });
2851
4231
  try {
2852
4232
  const resp = await client.messages.create({
2853
- model: opts.model,
4233
+ model,
2854
4234
  max_tokens: 1024,
2855
4235
  system: systemPrompt,
2856
4236
  messages: history
@@ -2919,9 +4299,9 @@ ${infraSummary.substring(0, 12e3)}`;
2919
4299
  out("\n");
2920
4300
  out(` ${green("datasynx-cartography discover")}
2921
4301
  `);
2922
- out(` Scans your local infrastructure (Claude Sonnet).
4302
+ out(` Scans your local infrastructure (provider-agnostic: claude, openai, ollama).
2923
4303
  `);
2924
- out(` Claude autonomously runs ${IS_WIN ? "Get-NetTCPConnection, Get-Process" : IS_MAC ? "lsof, ps" : "ss, ps"}, curl, docker inspect, kubectl get
4304
+ out(` The agent autonomously runs ${IS_WIN ? "Get-NetTCPConnection, Get-Process" : IS_MAC ? "lsof, ps" : "ss, ps"}, curl, docker inspect, kubectl get
2925
4305
  `);
2926
4306
  out(` and stores everything in SQLite.
2927
4307
  `);
@@ -2944,7 +4324,7 @@ ${infraSummary.substring(0, 12e3)}`;
2944
4324
  out("\n");
2945
4325
  out(` ${green("datasynx-cartography export [session-id]")}
2946
4326
  `);
2947
- out(dim(" --format <fmt...> mermaid, json, yaml, html, map (default: all)\n"));
4327
+ out(dim(" --format <fmt...> mermaid, json, yaml, html, map, cost (default: all but cost)\n"));
2948
4328
  out(dim(" -o, --output <dir> Output directory\n"));
2949
4329
  out("\n");
2950
4330
  out(` ${green("datasynx-cartography show [session-id]")} ${dim("Session details + node list")}
@@ -2968,7 +4348,7 @@ ${infraSummary.substring(0, 12e3)}`;
2968
4348
  out(dim(" \u2514\u2500\u2500 Platform Detection (platform.ts)\n"));
2969
4349
  out(dim(" \u2514\u2500\u2500 Shell: /bin/sh (Unix) | PowerShell (Windows)\n"));
2970
4350
  out(dim(" \u2514\u2500\u2500 Agent Orchestrator (agent.ts)\n"));
2971
- out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192 Claude Sonnet + Bash + MCP Tools\n"));
4351
+ out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192 AgentProvider (claude|openai|ollama) + Bash + MCP Tools\n"));
2972
4352
  out(dim(" \u2514\u2500\u2500 Custom MCP Tools (tools.ts)\n"));
2973
4353
  out(dim(" save_node, save_edge,\n"));
2974
4354
  out(dim(" scan_bookmarks, scan_browser_history,\n"));
@@ -3005,10 +4385,10 @@ ${infraSummary.substring(0, 12e3)}`;
3005
4385
  out("\n");
3006
4386
  });
3007
4387
  program.command("bookmarks").description("View all browser bookmarks (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)").action(async () => {
3008
- const { scanAllBookmarks: scanAllBookmarks2 } = await import("./bookmarks-VS56KVCO.js");
4388
+ const { scanAllBookmarks } = await import("./bookmarks-WXHE7GN7.js");
3009
4389
  const out = (s) => process.stdout.write(s);
3010
4390
  process.stderr.write(" Scanning bookmarks...\n\n");
3011
- const hosts = await scanAllBookmarks2();
4391
+ const hosts = await scanAllBookmarks();
3012
4392
  if (hosts.length === 0) {
3013
4393
  out(" (No bookmarks found \u2014 Chrome, Edge, Brave, Vivaldi, Opera and Firefox are supported)\n\n");
3014
4394
  return;
@@ -3035,16 +4415,52 @@ ${infraSummary.substring(0, 12e3)}`;
3035
4415
  `));
3036
4416
  out(dim(" Tip: ") + "datasynx-cartography discover" + dim(" \u2014 scans + classifies all bookmarks automatically\n\n"));
3037
4417
  });
3038
- 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) => {
3039
- const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {} });
4418
+ 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) => {
4419
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4420
+ const db = new CartographyDB(config.dbPath);
4421
+ activeDb = db;
4422
+ try {
4423
+ const sessionId = opts.session ?? db.getLatestSession("discover")?.id;
4424
+ if (!sessionId) {
4425
+ process.stderr.write("\u274C No session to enrich (run discovery first or pass --session)\n");
4426
+ process.exitCode = 1;
4427
+ return;
4428
+ }
4429
+ const match = opts.match;
4430
+ if (!["nodeId", "name", "tag"].includes(match)) {
4431
+ process.stderr.write(`\u274C Invalid --match: "${match}" (nodeId | name | tag)
4432
+ `);
4433
+ process.exitCode = 1;
4434
+ return;
4435
+ }
4436
+ const source = new CsvCostSource({ filePath: opts.file, match, db, sessionId });
4437
+ const r = await enrichCosts(db, sessionId, source);
4438
+ process.stderr.write(`\u2713 cost: ${r.matched} matched, ${r.unmatched} unmatched (of ${r.total}) from ${r.source}
4439
+ `);
4440
+ if (r.unmatchedIds.length > 0) {
4441
+ process.stderr.write(` unmatched ids: ${r.unmatchedIds.slice(0, 20).join(", ")}${r.unmatchedIds.length > 20 ? " \u2026" : ""}
4442
+ `);
4443
+ }
4444
+ if (r.matched === 0 && r.total > 0) process.exitCode = 1;
4445
+ } catch (err) {
4446
+ process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
4447
+ `);
4448
+ process.exitCode = 1;
4449
+ } finally {
4450
+ db.close();
4451
+ activeDb = null;
4452
+ }
4453
+ });
4454
+ 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) => {
4455
+ const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, ...opts.org ? { organization: opts.org } : {} });
3040
4456
  const db = new CartographyDB(config.dbPath);
3041
- const sessionId = opts.session ?? db.createSession("discover", config);
4457
+ const sessionId = opts.session ?? db.createSession("discover", config, opts.org);
3042
4458
  const out = (s) => process.stdout.write(s);
3043
4459
  const w = (s) => process.stderr.write(s);
3044
4460
  if (opts.file) {
3045
4461
  let raw;
3046
4462
  try {
3047
- raw = JSON.parse(readFileSync2(resolve(opts.file), "utf8"));
4463
+ raw = JSON.parse(readFileSync5(resolve2(opts.file), "utf8"));
3048
4464
  } catch (e) {
3049
4465
  w(red(`
3050
4466
  \u2717 Could not read file: ${e}
@@ -3062,7 +4478,7 @@ ${infraSummary.substring(0, 12e3)}`;
3062
4478
  for (const entry of raw) {
3063
4479
  const type = entry["type"];
3064
4480
  const name = entry["name"];
3065
- const host = entry["host"];
4481
+ const host2 = entry["host"];
3066
4482
  const port = entry["port"];
3067
4483
  const tags = entry["tags"] ?? [];
3068
4484
  const metadata = entry["metadata"] ?? {};
@@ -3071,14 +4487,14 @@ ${infraSummary.substring(0, 12e3)}`;
3071
4487
  `));
3072
4488
  continue;
3073
4489
  }
3074
- const id = host ? `${type}:${host}${port ? ":" + port : ""}` : `${type}:${name.toLowerCase().replace(/\s+/g, "-")}`;
4490
+ const id = host2 ? `${type}:${host2}${port ? ":" + port : ""}` : `${type}:${name.toLowerCase().replace(/\s+/g, "-")}`;
3075
4491
  db.upsertNode(sessionId, {
3076
4492
  id,
3077
4493
  type,
3078
4494
  name,
3079
4495
  discoveredVia: "manual",
3080
4496
  confidence: 1,
3081
- metadata: { ...metadata, ...host ? { host } : {}, ...port ? { port } : {} },
4497
+ metadata: { ...metadata, ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
3082
4498
  tags
3083
4499
  });
3084
4500
  out(` ${green("+")} ${cyan(id)} ${dim("(" + type + ")")}
@@ -3092,7 +4508,7 @@ ${infraSummary.substring(0, 12e3)}`;
3092
4508
  `);
3093
4509
  return;
3094
4510
  }
3095
- const { NODE_TYPES: NODE_TYPES2 } = await import("./types-JG27FR3E.js");
4511
+ const { NODE_TYPES } = await import("./types-TJWXAQ2L.js");
3096
4512
  if (!process.stdin.isTTY) {
3097
4513
  w(red("\n \u2717 Interactive mode requires a terminal (use --file for non-interactive)\n\n"));
3098
4514
  process.exitCode = 1;
@@ -3107,7 +4523,7 @@ ${infraSummary.substring(0, 12e3)}`;
3107
4523
  const rl = createInterface({ input: process.stdin, output: process.stderr });
3108
4524
  const ask = (q) => new Promise((res) => rl.question(q, res));
3109
4525
  let saved = 0;
3110
- const typeList = NODE_TYPES2.map((t, i) => `${dim((i + 1).toString().padStart(2))} ${t}`).join("\n ");
4526
+ const typeList = NODE_TYPES.map((t, i) => `${dim((i + 1).toString().padStart(2))} ${t}`).join("\n ");
3111
4527
  while (true) {
3112
4528
  w("\n");
3113
4529
  w(dim(" Node types:\n"));
@@ -3118,9 +4534,9 @@ ${infraSummary.substring(0, 12e3)}`;
3118
4534
  if (!typeInput) break;
3119
4535
  let nodeType;
3120
4536
  const asNum = parseInt(typeInput, 10);
3121
- if (!isNaN(asNum) && asNum >= 1 && asNum <= NODE_TYPES2.length) {
3122
- nodeType = NODE_TYPES2[asNum - 1];
3123
- } else if (NODE_TYPES2.includes(typeInput)) {
4537
+ if (!isNaN(asNum) && asNum >= 1 && asNum <= NODE_TYPES.length) {
4538
+ nodeType = NODE_TYPES[asNum - 1];
4539
+ } else if (NODE_TYPES.includes(typeInput)) {
3124
4540
  nodeType = typeInput;
3125
4541
  } else {
3126
4542
  w(yellow(` \u26A0 Unknown type: "${typeInput}"
@@ -3135,17 +4551,17 @@ ${infraSummary.substring(0, 12e3)}`;
3135
4551
  const hostRaw = (await ask(` ${cyan("Host / IP")} ${dim("[optional, Enter=skip]")}: `)).trim();
3136
4552
  const portRaw = (await ask(` ${cyan("Port")} ${dim("[optional]")}: `)).trim();
3137
4553
  const tagsRaw = (await ask(` ${cyan("Tags")} ${dim("[comma-separated, optional]")}: `)).trim();
3138
- const host = hostRaw || void 0;
4554
+ const host2 = hostRaw || void 0;
3139
4555
  const port = portRaw ? parseInt(portRaw, 10) : void 0;
3140
4556
  const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
3141
- const id = host ? `${nodeType}:${host}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
4557
+ const id = host2 ? `${nodeType}:${host2}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
3142
4558
  db.upsertNode(sessionId, {
3143
4559
  id,
3144
4560
  type: nodeType,
3145
4561
  name,
3146
4562
  discoveredVia: "manual",
3147
4563
  confidence: 1,
3148
- metadata: { ...host ? { host } : {}, ...port ? { port } : {} },
4564
+ metadata: { ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
3149
4565
  tags
3150
4566
  });
3151
4567
  out(` ${green("+")} ${cyan(id)}
@@ -3168,8 +4584,8 @@ ${infraSummary.substring(0, 12e3)}`;
3168
4584
  });
3169
4585
  program.command("doctor").description("Check all requirements and cloud CLIs").action(async () => {
3170
4586
  const { execSync: execSync2 } = await import("child_process");
3171
- const { existsSync: existsSync3, readFileSync: readFileSync3 } = await import("fs");
3172
- const { join: join3 } = await import("path");
4587
+ const { existsSync: existsSync4, readFileSync: readFileSync6 } = await import("fs");
4588
+ const { join: join4 } = await import("path");
3173
4589
  const out = (s) => process.stdout.write(s);
3174
4590
  const ok = (msg) => out(` \x1B[32m\u2713\x1B[0m ${msg}
3175
4591
  `);
@@ -3183,10 +4599,10 @@ ${infraSummary.substring(0, 12e3)}`;
3183
4599
  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"));
3184
4600
  const nodeVer = process.versions.node;
3185
4601
  const [major] = nodeVer.split(".").map(Number);
3186
- if ((major ?? 0) >= 18) {
4602
+ if ((major ?? 0) >= 20) {
3187
4603
  ok(`Node.js ${nodeVer}`);
3188
4604
  } else {
3189
- err(`Node.js ${nodeVer} \u2014 requires >=18`);
4605
+ err(`Node.js ${nodeVer} \u2014 requires >=20`);
3190
4606
  allGood = false;
3191
4607
  }
3192
4608
  try {
@@ -3200,7 +4616,7 @@ ${infraSummary.substring(0, 12e3)}`;
3200
4616
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
3201
4617
  let hasOAuth = false;
3202
4618
  try {
3203
- const creds = JSON.parse(readFileSync3(join3(home, ".claude", ".credentials.json"), "utf8"));
4619
+ const creds = JSON.parse(readFileSync6(join4(home, ".claude", ".credentials.json"), "utf8"));
3204
4620
  const oauth = creds["claudeAiOauth"];
3205
4621
  hasOAuth = typeof oauth?.["accessToken"] === "string" && oauth["accessToken"].length > 0;
3206
4622
  } catch {
@@ -3250,8 +4666,8 @@ ${infraSummary.substring(0, 12e3)}`;
3250
4666
  warn(`${name} not found ${dim2("\u2014 discovery without " + name + " will be limited")}`);
3251
4667
  }
3252
4668
  }
3253
- const dbDir = join3(home, ".cartography");
3254
- if (existsSync3(dbDir)) {
4669
+ const dbDir = join4(home, ".cartography");
4670
+ if (existsSync4(dbDir)) {
3255
4671
  ok(`~/.cartography ${dim2("(data directory exists)")}`);
3256
4672
  } else {
3257
4673
  warn("~/.cartography does not exist yet " + dim2("\u2014 will be created on first run"));
@@ -3298,15 +4714,303 @@ ${infraSummary.substring(0, 12e3)}`;
3298
4714
  }
3299
4715
  db.close();
3300
4716
  });
3301
- 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("--db <path>", "DB path").option("--session <id>", 'Session to serve (id or "latest")', "latest").option("--no-semantic", "Disable semantic (vector) search").action(async (opts) => {
3302
- await startMcp({
4717
+ program.command("list-clients").description("List the AI hosts the installer can configure").action(() => {
4718
+ o("\n" + bold(" Supported MCP hosts:") + "\n\n");
4719
+ for (const c of listClients()) {
4720
+ o(` ${green(c.id.padEnd(16))} ${bold(c.label.padEnd(20))} ${dim(c.format)}
4721
+ `);
4722
+ if (c.note) o(` ${" ".repeat(16)} ${dim("\u21B3 " + c.note)}
4723
+ `);
4724
+ }
4725
+ o("\n" + dim(` Install: ${CMD} install --client <id> [--project] [--dry-run]`) + "\n\n");
4726
+ });
4727
+ 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) => {
4728
+ const spec = getClient(opts.client);
4729
+ if (!spec) {
4730
+ logError(`Unknown client "${opts.client}". Run \`${CMD} list-clients\` to see options.`);
4731
+ process.exitCode = 1;
4732
+ return;
4733
+ }
4734
+ const scope = opts.project ? "project" : "global";
4735
+ const packageArgs = [];
4736
+ if (opts.db) packageArgs.push("--db", opts.db);
4737
+ if (opts.session) packageArgs.push("--session", opts.session);
4738
+ const entry = defaultServerEntry({
3303
4739
  transport: opts.http ? "http" : "stdio",
3304
- port: parseInt(opts.port, 10),
3305
- host: opts.host,
3306
- dbPath: opts.db,
3307
- session: opts.session,
3308
- semantic: opts.semantic
4740
+ ...opts.url ? { url: opts.url } : {},
4741
+ ...packageArgs.length ? { packageArgs } : {}
3309
4742
  });
4743
+ if (opts.deeplink) {
4744
+ if (opts.client === "cursor") {
4745
+ o("\n" + bold(" Cursor one-click:") + "\n " + cyan(cursorDeeplink(opts.name, entry)) + "\n\n");
4746
+ } else if (opts.client === "vscode") {
4747
+ o("\n" + bold(" VS Code one-click:") + "\n " + cyan(vscodeDeeplink(opts.name, entry)) + "\n");
4748
+ o(" " + dim("or: ") + codeAddMcpCommand(opts.name, entry) + "\n\n");
4749
+ } else {
4750
+ logWarn(`No deeplink available for "${opts.client}". Deeplinks exist for: cursor, vscode.`);
4751
+ }
4752
+ return;
4753
+ }
4754
+ try {
4755
+ const plan = planInstall(spec, defaultContext(scope), { serverName: opts.name, entry });
4756
+ o("\n" + bold(` ${plan.label}`) + dim(` (${plan.format}, ${scope})`) + "\n");
4757
+ o(dim(` ${plan.path}`) + "\n");
4758
+ if (plan.note) o(yellow(` \u26A0 ${plan.note}`) + "\n");
4759
+ o("\n" + renderDiff(plan.before, plan.after) + "\n\n");
4760
+ if (!plan.changed) {
4761
+ o(green(" \u2713 Already up to date \u2014 nothing to write.") + "\n\n");
4762
+ return;
4763
+ }
4764
+ if (opts.dryRun) {
4765
+ o(yellow(" Dry run \u2014 no file written.") + "\n\n");
4766
+ return;
4767
+ }
4768
+ applyInstall(plan);
4769
+ o(green(` \u2713 Wrote ${plan.fileExists ? "updated" : "new"} config.`) + " " + dim("Restart the host to pick it up.") + "\n\n");
4770
+ } catch (err) {
4771
+ logError(err instanceof Error ? err.message : String(err));
4772
+ process.exitCode = 1;
4773
+ }
4774
+ });
4775
+ program;
4776
+ const consent = program.command("consent").description("Manage the per-employee data-sharing policy (none|anonymized|full) + admin anonymization");
4777
+ consent.command("default <level>").description("Set the global default sharing level (none|anonymized|full)").option("--db <path>", "DB path").action((level, opts) => {
4778
+ const parsed = SharingLevelSchema.safeParse(level);
4779
+ if (!parsed.success) {
4780
+ logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
4781
+ process.exitCode = 1;
4782
+ return;
4783
+ }
4784
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4785
+ const db = new CartographyDB(config.dbPath);
4786
+ try {
4787
+ db.setSharingLevel("*", parsed.data);
4788
+ logInfo(`default sharing level set to "${parsed.data}"`);
4789
+ } finally {
4790
+ db.close();
4791
+ }
4792
+ });
4793
+ 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) => {
4794
+ const parsed = SharingLevelSchema.safeParse(level);
4795
+ if (!parsed.success) {
4796
+ logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
4797
+ process.exitCode = 1;
4798
+ return;
4799
+ }
4800
+ if (pattern === "*" || pattern === "**") {
4801
+ logError("Use `consent default <level>` to set the global default; `set` is for narrower overrides");
4802
+ process.exitCode = 1;
4803
+ return;
4804
+ }
4805
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4806
+ const db = new CartographyDB(config.dbPath);
4807
+ try {
4808
+ db.setSharingLevel(pattern, parsed.data);
4809
+ logInfo(`override "${pattern}" \u2192 "${parsed.data}"`);
4810
+ } finally {
4811
+ db.close();
4812
+ }
4813
+ });
4814
+ consent.command("clear <pattern>").description("Remove a pattern override (the global default cannot be cleared)").option("--db <path>", "DB path").action((pattern, opts) => {
4815
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4816
+ const db = new CartographyDB(config.dbPath);
4817
+ try {
4818
+ db.clearSharingOverride(pattern);
4819
+ logInfo(`override "${pattern}" cleared`);
4820
+ } finally {
4821
+ db.close();
4822
+ }
4823
+ });
4824
+ consent.command("list").description("Show the global default + every pattern override").option("--db <path>", "DB path").action((opts) => {
4825
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4826
+ const db = new CartographyDB(config.dbPath);
4827
+ try {
4828
+ const policy = db.getSharingPolicy();
4829
+ process.stdout.write(JSON.stringify(policy, null, 2) + "\n");
4830
+ } finally {
4831
+ db.close();
4832
+ }
4833
+ });
4834
+ 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) => {
4835
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4836
+ const db = new CartographyDB(config.dbPath);
4837
+ try {
4838
+ const sid = session && session !== "latest" ? session : db.getLatestSession("discover")?.id;
4839
+ if (!sid) {
4840
+ logError("No session found to preview");
4841
+ process.exitCode = 1;
4842
+ return;
4843
+ }
4844
+ const orgKey = loadOrgKey({ organization: opts.org });
4845
+ const policy = db.getSharingPolicy();
4846
+ const preview = previewShare(db, sid, orgKey, policy);
4847
+ process.stdout.write(JSON.stringify(preview, null, 2) + "\n");
4848
+ } finally {
4849
+ db.close();
4850
+ }
4851
+ });
4852
+ const consentKey = consent.command("key").description("Org-key administration");
4853
+ consentKey.command("rotate").description("Rotate the org key (prior reversal entries become unrecoverable)").option("--org <name>", "Organization namespace for the org key").action((opts) => {
4854
+ rotateOrgKey({ organization: opts.org });
4855
+ logInfo("org key rotated");
4856
+ });
4857
+ 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) => {
4858
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4859
+ const db = new CartographyDB(config.dbPath);
4860
+ try {
4861
+ const orgKey = loadOrgKey({ organization: opts.org });
4862
+ const plaintext = reversePseudonym(token, orgKey, db);
4863
+ if (plaintext === void 0) {
4864
+ logError(`Could not reverse "${token}" (unknown token or wrong/rotated org key)`);
4865
+ process.exitCode = 1;
4866
+ return;
4867
+ }
4868
+ process.stdout.write(plaintext + "\n");
4869
+ } finally {
4870
+ db.close();
4871
+ }
4872
+ });
4873
+ const sync = program.command("sync").description("Central-DB outbound sync: review queued items and push approved deltas (opt-in)");
4874
+ sync.command("status").description("Show the pending-review queue (counts by status + pending items)").option("--db <path>", "DB path").action((opts) => {
4875
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4876
+ const db = new CartographyDB(config.dbPath);
4877
+ try {
4878
+ if (!config.centralDb?.url) {
4879
+ logWarn("centralDb is not configured \u2014 sync is inert (set centralDb in ~/.cartography/config.json or CARTOGRAPHY_CENTRAL_URL/TOKEN)");
4880
+ }
4881
+ const counts = db.countPendingByStatus();
4882
+ process.stdout.write(JSON.stringify(counts, null, 2) + "\n");
4883
+ const pending = db.getPendingShares({ status: "pending" });
4884
+ for (const p of pending.slice(0, 50)) {
4885
+ process.stdout.write(` ${p.kind === "node" ? "\u25CF" : "\u2192"} ${p.nodeId ?? p.contentHash.slice(0, 12)} ${dim("(" + p.kind + ")")}
4886
+ `);
4887
+ }
4888
+ if (pending.length > 50) process.stdout.write(` ${dim("\u2026 and " + (pending.length - 50) + " more")}
4889
+ `);
4890
+ } finally {
4891
+ db.close();
4892
+ }
4893
+ });
4894
+ sync.command("review").description("Interactively approve/withhold each pending item (decisions are remembered)").option("--db <path>", "DB path").action(async (opts) => {
4895
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4896
+ const db = new CartographyDB(config.dbPath);
4897
+ try {
4898
+ const pending = db.getPendingShares({ status: "pending" });
4899
+ if (pending.length === 0) {
4900
+ logInfo("no pending items to review");
4901
+ return;
4902
+ }
4903
+ if (!process.stdin.isTTY) {
4904
+ logWarn(`${pending.length} pending item(s); run \`sync review\` in an interactive terminal to decide them`);
4905
+ return;
4906
+ }
4907
+ const w = process.stderr.write.bind(process.stderr);
4908
+ const patternFor = (p) => p.nodeId;
4909
+ for (const p of pending) {
4910
+ w("\n");
4911
+ w(` ${yellow(bold("?"))} Share ${p.kind} ${bold(p.nodeId ?? p.contentHash.slice(0, 12))}?
4912
+ `);
4913
+ w(` ${dim(JSON.stringify(p.payload))}
4914
+ `);
4915
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
4916
+ const ans = (await new Promise((res) => rl.question(` ${cyan("\u2192")} [s]hare / [w]ithhold / [a]lways / [n]ever / [q]uit: `, res))).trim().toLowerCase();
4917
+ rl.close();
4918
+ const pat = patternFor(p);
4919
+ if (ans === "q") break;
4920
+ if (ans === "s") {
4921
+ db.setPendingStatus(p.contentHash, "approved", "user");
4922
+ } else if (ans === "w") {
4923
+ db.setPendingStatus(p.contentHash, "withheld", "user");
4924
+ } else if (ans === "a") {
4925
+ if (pat) db.setSharingLevel(pat, "full");
4926
+ db.setPendingStatus(p.contentHash, "approved", "user");
4927
+ } else if (ans === "n") {
4928
+ if (pat) db.setSharingLevel(pat, "none");
4929
+ db.setPendingStatus(p.contentHash, "withheld", "user");
4930
+ } else {
4931
+ w(` ${dim("skipped (left pending)")}
4932
+ `);
4933
+ }
4934
+ }
4935
+ const counts = db.countPendingByStatus();
4936
+ logInfo(`review done \u2014 approved ${counts.approved}, withheld ${counts.withheld}, pending ${counts.pending}`);
4937
+ } finally {
4938
+ db.close();
4939
+ }
4940
+ });
4941
+ 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) => {
4942
+ const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
4943
+ if (!config.centralDb?.url) {
4944
+ logError("centralDb is not configured \u2014 nothing to push (set centralDb.url + token)");
4945
+ process.exitCode = 1;
4946
+ return;
4947
+ }
4948
+ const db = new CartographyDB(config.dbPath);
4949
+ try {
4950
+ const approved = db.getApprovedShares();
4951
+ const items = approved.map((p) => ({ contentHash: p.contentHash, kind: p.kind, payload: p.payload }));
4952
+ const result = await pushDeltas(config, items, { dryRun: opts.dryRun });
4953
+ if (!opts.dryRun) {
4954
+ for (const hash of result.sentHashes) db.setPendingStatus(hash, "shared");
4955
+ }
4956
+ logInfo(`sync push: sent ${result.sent}, batches ${result.batches}, failed ${result.failed}${opts.dryRun ? " (dry-run)" : ""}`);
4957
+ } catch (err) {
4958
+ logError(`sync push failed: ${err instanceof Error ? err.message : String(err)}`);
4959
+ process.exitCode = 1;
4960
+ } finally {
4961
+ db.close();
4962
+ }
4963
+ });
4964
+ 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) => {
4965
+ try {
4966
+ const anonMode = opts.anonMode;
4967
+ if (anonMode !== "reject" && anonMode !== "strip") {
4968
+ process.stderr.write(`
4969
+ error: --anon-mode must be 'reject' or 'strip' (got '${anonMode}')
4970
+ `);
4971
+ process.exitCode = 1;
4972
+ return;
4973
+ }
4974
+ await startMcp({
4975
+ transport: opts.http ? "http" : "stdio",
4976
+ port: parseInt(opts.port, 10),
4977
+ host: opts.host,
4978
+ allowedHosts: opts.allowedHosts ? String(opts.allowedHosts).split(",").map((h) => h.trim()).filter(Boolean) : void 0,
4979
+ token: opts.token,
4980
+ dbPath: opts.db,
4981
+ session: opts.session,
4982
+ tenant: opts.tenant ?? opts.org,
4983
+ semantic: opts.semantic,
4984
+ plugins: opts.plugins ? String(opts.plugins).split(",").map((p) => p.trim()).filter(Boolean) : void 0,
4985
+ serverMode: opts.serverMode === true,
4986
+ anonMode
4987
+ });
4988
+ } catch (err) {
4989
+ process.stderr.write(`
4990
+ error: ${err instanceof Error ? err.message : String(err)}
4991
+ `);
4992
+ process.exitCode = 1;
4993
+ }
4994
+ });
4995
+ program.command("api").description("Run the read-only REST/GraphQL API server over the topology store (4.2)").option("--http", "Use HTTP transport (default; kept for symmetry with mcp)", true).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("--allowed-origins <list>", "Comma-separated CORS Origin allowlist (default: same-origin only)").option("--token <secret>", "Bearer token required on 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>", "Default tenant whose topology to serve (alias: --org; default: local)").option("--org <id>", "Alias for --tenant").option("--no-graphql", "Disable the /graphql endpoint (REST only)").action(async (opts) => {
4996
+ try {
4997
+ await startApi({
4998
+ port: parseInt(opts.port, 10),
4999
+ host: opts.host,
5000
+ allowedHosts: opts.allowedHosts ? String(opts.allowedHosts).split(",").map((h) => h.trim()).filter(Boolean) : void 0,
5001
+ allowedOrigins: opts.allowedOrigins ? String(opts.allowedOrigins).split(",").map((o2) => o2.trim()).filter(Boolean) : void 0,
5002
+ token: opts.token,
5003
+ dbPath: opts.db,
5004
+ session: opts.session,
5005
+ tenant: opts.tenant ?? opts.org,
5006
+ graphql: opts.graphql
5007
+ });
5008
+ } catch (err) {
5009
+ process.stderr.write(`
5010
+ error: ${err instanceof Error ? err.message : String(err)}
5011
+ `);
5012
+ process.exitCode = 1;
5013
+ }
3310
5014
  });
3311
5015
  const o = (s) => process.stderr.write(s);
3312
5016
  o("\n");
@@ -3326,7 +5030,7 @@ ${infraSummary.substring(0, 12e3)}`;
3326
5030
  o("\n");
3327
5031
  o(bold(" Commands:\n"));
3328
5032
  o("\n");
3329
- o(` ${green("discover")} ${dim("Scan infrastructure (Claude Sonnet)")}
5033
+ o(` ${green("discover")} ${dim("Scan infrastructure (provider: claude|openai|ollama)")}
3330
5034
  `);
3331
5035
  o(` ${green("seed")} ${dim("Manually add known tools/DBs/APIs")}
3332
5036
  `);