@datasynx/agentic-ai-cartography 2.0.0 → 2.2.0

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