codesynapt 0.0.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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +686 -0
  3. package/LICENSES.md +141 -0
  4. package/README.md +331 -0
  5. package/electron/main.cjs +2849 -0
  6. package/electron/plugin-loader.cjs +184 -0
  7. package/electron/preload.cjs +108 -0
  8. package/package.json +216 -0
  9. package/packages/core/bin/codesynapt-mcp.cjs +611 -0
  10. package/packages/core/bin/codesynapt.cjs +1933 -0
  11. package/packages/core/legacy.js +300 -0
  12. package/packages/core/lib/control-server.cjs +1539 -0
  13. package/packages/core/lib/embedding.cjs +89 -0
  14. package/packages/core/lib/logger.cjs +63 -0
  15. package/packages/core/lib/search-cache.cjs +140 -0
  16. package/packages/core/lib/search-worker.cjs +255 -0
  17. package/packages/core/lib/search.cjs +211 -0
  18. package/packages/core/lib/symbol-graph.cjs +402 -0
  19. package/packages/core/lib/symbol-parser-js.cjs +542 -0
  20. package/packages/core/lib/symbol-parser-misc.cjs +394 -0
  21. package/packages/core/lib/symbol-parser-py.cjs +215 -0
  22. package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
  23. package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
  24. package/packages/core/monorepo.js +310 -0
  25. package/packages/core/parser.js +2234 -0
  26. package/packages/core/scanner.js +623 -0
  27. package/plugin-api/LICENSE +21 -0
  28. package/plugin-api/README.md +114 -0
  29. package/plugin-api/docs/01-getting-started.md +197 -0
  30. package/plugin-api/docs/02-concepts.md +269 -0
  31. package/plugin-api/docs/api-reference.md +463 -0
  32. package/plugin-api/docs/troubleshooting.md +332 -0
  33. package/plugin-api/docs/types/exporter.md +377 -0
  34. package/plugin-api/docs/types/theme.md +312 -0
  35. package/plugin-api/examples/hello-world-plugin/README.md +70 -0
  36. package/plugin-api/examples/hello-world-plugin/main.js +36 -0
  37. package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
  38. package/plugin-api/examples/mermaid-exporter/README.md +125 -0
  39. package/plugin-api/examples/mermaid-exporter/main.js +58 -0
  40. package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
  41. package/plugin-api/examples/rust-parser/README.md +71 -0
  42. package/plugin-api/examples/rust-parser/main.js +123 -0
  43. package/plugin-api/examples/rust-parser/manifest.json +12 -0
  44. package/plugin-api/examples/sunset-theme/README.md +95 -0
  45. package/plugin-api/examples/sunset-theme/manifest.json +12 -0
  46. package/plugin-api/examples/sunset-theme/theme.css +31 -0
  47. package/plugin-api/package.json +20 -0
  48. package/plugin-api/types.d.ts +395 -0
  49. package/public/app.js +6837 -0
  50. package/public/backend.js +285 -0
  51. package/public/index.html +647 -0
  52. package/public/plugin-host.js +321 -0
  53. package/public/style.css +4359 -0
  54. package/public/vendor/three.module.js +53044 -0
  55. package/scripts/competitor-watch.mjs +144 -0
  56. package/scripts/copy-vendor.js +21 -0
  57. package/scripts/download-bundled-node.cjs +53 -0
  58. package/scripts/fuses-after-pack.cjs +34 -0
  59. package/scripts/license-check.js +119 -0
  60. package/scripts/perf-test.js +200 -0
  61. package/server.js +132 -0
@@ -0,0 +1,1933 @@
1
+ #!/usr/bin/env node
2
+ // CodeSynapt CLI — thin wrapper around the running Electron app's
3
+ // localhost control API. Run the desktop app or `cs serve` in the
4
+ // background; then use `cs <cmd>` from any terminal to inspect / control it.
5
+
6
+ const http = require('http')
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+ const os = require('os')
10
+
11
+ // Resolve which port the running server is on.
12
+ // Priority: explicit env var > lock file (written by server) > default 7707.
13
+ function resolvePort() {
14
+ const envPort = process.env.CS_PORT || process.env.FG3D_PORT
15
+ if (envPort) return parseInt(envPort, 10)
16
+ try {
17
+ const lockPath = path.join(os.homedir(), '.codesynapt', 'port')
18
+ if (fs.existsSync(lockPath)) {
19
+ const p = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10)
20
+ if (p > 0 && p < 65536) return p
21
+ }
22
+ } catch { /* fall through */ }
23
+ return 7707
24
+ }
25
+ const PORT = resolvePort()
26
+ const HOST = '127.0.0.1'
27
+
28
+ function req(method, pathStr, query, body) {
29
+ return new Promise((resolve, reject) => {
30
+ let qs = ''
31
+ if (query) {
32
+ const parts = []
33
+ for (const [k, v] of Object.entries(query)) {
34
+ if (v !== undefined && v !== null) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
35
+ }
36
+ if (parts.length) qs = '?' + parts.join('&')
37
+ }
38
+ const headers = {}
39
+ let payload = null
40
+ if (body !== undefined && body !== null) {
41
+ payload = typeof body === 'string' ? body : JSON.stringify(body)
42
+ headers['Content-Type'] = 'application/json'
43
+ headers['Content-Length'] = Buffer.byteLength(payload)
44
+ }
45
+ const r = http.request({ host: HOST, port: PORT, path: pathStr + qs, method, headers }, (res) => {
46
+ let chunks = []
47
+ res.on('data', (c) => chunks.push(c))
48
+ res.on('end', () => {
49
+ const text = Buffer.concat(chunks).toString('utf8')
50
+ try { resolve({ status: res.statusCode, json: JSON.parse(text) }) }
51
+ catch { resolve({ status: res.statusCode, text }) }
52
+ })
53
+ })
54
+ r.on('error', (err) => {
55
+ if (err.code === 'ECONNREFUSED') {
56
+ reject(new Error(`codesynapt server is not running at ${HOST}:${PORT}. Start the desktop app first (npm start), or run 'cs serve'. Override port via CS_PORT.`))
57
+ } else reject(err)
58
+ })
59
+ if (payload) r.write(payload)
60
+ r.end()
61
+ })
62
+ }
63
+
64
+ function encId(id) { return id.split('/').map(encodeURIComponent).join('/') }
65
+
66
+ function printJson(x) { process.stdout.write(JSON.stringify(x, null, 2) + '\n') }
67
+ function die(msg) { process.stderr.write(`error: ${msg}\n`); process.exit(1) }
68
+
69
+ const USAGE = `CodeSynapt CLI — usage:
70
+
71
+ ── Headless (no desktop app needed) ─────────────────────────
72
+ cs scan [path] [--json] # one-shot scan, emit graph as JSON
73
+ cs scan [path] --summary # cheap overview (file count, top hubs)
74
+ cs serve [path] [--port N]
75
+ # standalone HTTP daemon on 127.0.0.1:N
76
+ # serves /summary, /graph, /node/:id,
77
+ # /blast/:id, /packages, etc.
78
+ # no Electron window — pure CLI/MCP/CI use
79
+ cs ci-diff <base..head> [path] [--format=github-comment|json|plain] [--depth N]
80
+ # PR impact report: blast radius for every
81
+ # file changed between two git refs.
82
+ # Default --format=github-comment is
83
+ # markdown ready to drop into a PR.
84
+ cs ci-gate <base..head> [path] [--max-blast N] [--max-changed N]
85
+ # PR gate for CI: fails (exit 1) if change
86
+ # set exceeds thresholds.
87
+ # --max-blast N largest single-file blast
88
+ # --max-changed N total changed files
89
+
90
+ ── Remote (needs the desktop app running at :7707) ──────────
91
+ cs health
92
+ cs summary # cheap project overview (Layer 1)
93
+ cs refresh <id> # force re-scan of one file (defeats staleness)
94
+ cs ls [--limit N] [--ext X] [--min-mass N] [--sort KEY:DIR]
95
+ # sort = mass:desc (default) | size:desc | loc:desc
96
+ # id:asc | insertion
97
+ cs show <id> # node detail + imports + importedBy
98
+ cs read <id> # file content
99
+ cs write <id> <path-or--> # write file from local path or stdin (-)
100
+ cs edit <id> <find> <replace> [--all]
101
+ # precise edit: find string is replaced with new string
102
+ # --all → replace all occurrences (default: must be unique)
103
+ cs deps <id> # outgoing edges (this -> X)
104
+ cs users <id> # incoming edges (X -> this)
105
+ cs orphans # all files with no in + no out edges (raw list)
106
+ # includes entry points / configs / manifests (false-positives).
107
+ # For high-confidence cleanup only: \`cs legacy --type orphan\`
108
+ cs find <substring> # **filename/path** match only. Cheap, no file read.
109
+ # "find auth" → src/auth/login.ts, lib/auth-utils.js …
110
+ # For file CONTENTS, use \`cs search\` instead.
111
+ cs search <query> [--regex] [--case] [--max N] [--json]
112
+ # **full-text CONTENT** search across all tracked files.
113
+ # "search RUNPOD_API_KEY" → file:line:col + snippet.
114
+ # mtime LRU cache → repeat searches sub-50ms when warm.
115
+ # 503 (scan in progress) → auto-retries 3× × 2s.
116
+ cs focus <id> # move app camera to node
117
+ cs open <id> # open inspector for node
118
+ cs history <id> # list auto-history snapshots
119
+ cs restore <id> <ts> # restore file to a history snapshot
120
+ cs external # list external websites this project calls
121
+ cs packages # list packages in a monorepo
122
+ cs package <name> # package detail: files, cross-pkg edges, declared deps
123
+ cs package-graph # package-to-package edges
124
+ cs legacy [--type T] [--min-conf N]
125
+ # cleanup audit: T = orphan|path|filename|duplicate
126
+ # --min-conf 0.85 → only high-confidence candidates
127
+ cs trace [--limit N] [--tool T]
128
+ # current session's AI/CLI/MCP trace events (chronological)
129
+ cs trace stats # top files / tool breakdown / duration for current session
130
+ cs trace sessions # past sessions in .filegraph3d/traces/
131
+ cs trace export <path> # write current session to JSON
132
+ cs trace clear # start a fresh session (old one preserved on disk)
133
+ cs blast <id> [n] [dir] # impact of editing <id>: dependents within n hops
134
+ # n = BFS depth (default 3)
135
+ # dir = users|deps (default users)
136
+ cs safety <id> [--deep] [--json]
137
+ # 🟢/🟡/🔴 + 한 줄 권고. AI에게 시키기 전
138
+ # "이 파일 건드려도 되나" 즉답.
139
+ # --deep → 영향받는 파일 전체 리스트
140
+ cs bundle <id> [--budget N] [--depth N] [--json]
141
+ # AI에게 "이 파일 수정해줘" 시킬 때 함께 줄
142
+ # 파일 묶음. token 예산(기본 8000) 안에서
143
+ # 가까운 의존 파일 우선 선택.
144
+ cs env [VAR] [--json] # .env 변수 ↔ 사용처 매핑. VAR 지정 안 하면
145
+ # 전체 + 미사용/미선언 상태 표시.
146
+ cs suggest [--top N] [--json]
147
+ # "AI에게 다음에 시킬 작업" 자동 추천.
148
+ # undeclared env, 테스트 없는 hub,
149
+ # orphans, unused env, dynamic ratio 등.
150
+ cs feature <keyword> [--json]
151
+ # "결제" / "auth" 같은 키워드 → 관련 파일
152
+ # frontend/backend/shared 분류.
153
+ # path 매칭 + 라우트 매칭 + apiCall 매칭.
154
+ cs schema [Model] [--json]
155
+ # DB 모델 추출 — Prisma / Drizzle /
156
+ # SQLAlchemy. Model 지정시 필드 + 사용처.
157
+ cs bench [path] # 응답시간 벤치마크 (scan + endpoint별 median/p95)
158
+ cs vendors [--json] # third-party 폴더 자동 감지
159
+ # (LICENSE/own manifest/.git/conventional name)
160
+ # → .codesynaptignore 권고
161
+ cs secrets [--json] # frontend 코드에 server-only env 변수
162
+ # 노출 탐지. public prefix 없는 변수가
163
+ # 브라우저 번들로 가면 키 유출.
164
+ cs url [PATH] [--json] # frontend URL → 파일 매핑.
165
+ # Next app/pages, Astro, SvelteKit.
166
+ # PATH 없으면 전체 등록 routes.
167
+ # PATH 있으면 매칭 파일 (dynamic seg
168
+ # 처리).
169
+ cs ensure [path] # ensure desktop is running with [path] loaded
170
+ # - desktop alive + same root → noop
171
+ # - alive + different root → POST /load (swap)
172
+ # - desktop dead → spawn it with
173
+ # CS_INITIAL_ROOT
174
+ # used by /codesynapt slash command to give
175
+ # one-shot "open project" UX from Claude Code
176
+ cs init [path] [--agents] [--no-slash-command]
177
+ # 상시 사용 모드 셋업:
178
+ # - CLAUDE.md 또는 AGENTS.md 생성 (사용 규칙)
179
+ # - ~/.claude/commands/codesynapt.md 설치
180
+ # → 그 후 Claude Code 안에서 \`/codesynapt\`
181
+ # 치면 cs_* 모드 진입
182
+ # - claude mcp add 명령 안내 출력
183
+ cs context [--output FILE] [--max-routes N] [--max-models N] [--watch]
184
+ # AI context file generator. Aggregates
185
+ # summary + packages + url + schema + env +
186
+ # external + legacy into a single Markdown
187
+ # snapshot (CLAUDE.md / AGENTS.md format).
188
+ # Default stdout. --output writes to a file.
189
+ # --watch: regen on every snapshot change
190
+ # (5 s poll). Requires --output.
191
+ cs preflight [--strict] [--json]
192
+ # 배포 전 종합 점검. env 미선언, http URL,
193
+ # 테스트 없는 hub, orphan ratio 등.
194
+ # exit 1 if fail (--strict면 warn도 fail).
195
+ cs timeline # git history → when each file first appeared
196
+ cs tour # suggested guided tour of the project
197
+ cs changes # files modified this session
198
+ cs diff <id> # show first-seen vs current diff for one file
199
+
200
+ Env: CS_PORT (default 7707; legacy alias: FG3D_PORT). CS_AUTH_TOKEN for Bearer auth.`
201
+
202
+ // ── Headless: load scanner.js (ESM) and run a one-shot scan ──
203
+ async function runHeadlessScan(args) {
204
+ // Parse args: scan [path] [--json|--summary] [--ext js,ts] [--min-mass N]
205
+ let target = null
206
+ let mode = 'json' // 'json' (full) | 'summary' | 'edges' | 'files'
207
+ let extFilter = null, minMass = 0
208
+ for (let i = 0; i < args.length; i++) {
209
+ const a = args[i]
210
+ if (a === '--json') mode = 'json'
211
+ else if (a === '--summary') mode = 'summary'
212
+ else if (a === '--edges') mode = 'edges'
213
+ else if (a === '--files') mode = 'files'
214
+ else if (a === '--ext' && args[i+1]) { extFilter = args[++i] }
215
+ else if (a === '--min-mass' && args[i+1]) { minMass = parseInt(args[++i], 10) }
216
+ else if (!a.startsWith('--') && !target) { target = a }
217
+ }
218
+ target = target || process.cwd()
219
+ const path = require('path')
220
+ const fs = require('fs')
221
+ const abs = path.resolve(target)
222
+ if (!fs.existsSync(abs)) return die(`path does not exist: ${abs}`)
223
+ if (!fs.statSync(abs).isDirectory()) return die(`not a directory: ${abs}`)
224
+
225
+ // scanner.js is ESM; dynamic import from CJS works in Node 18+
226
+ const { Scanner } = await import('../scanner.js')
227
+ const s = new Scanner(abs)
228
+ return new Promise((resolve) => {
229
+ let emitted = false
230
+ s.on('snapshot', (snap) => {
231
+ if (emitted) return
232
+ emitted = true
233
+ let files = snap.files
234
+ if (extFilter) {
235
+ const exts = new Set(extFilter.split(',').map(x => x.trim()))
236
+ files = files.filter((f) => exts.has(f.ext))
237
+ }
238
+ if (minMass > 0) {
239
+ const inc = new Map()
240
+ for (const e of snap.edges) inc.set(e.t, (inc.get(e.t) || 0) + 1)
241
+ files = files.filter((f) => (inc.get(f.id) || 0) >= minMass)
242
+ }
243
+ if (mode === 'json') {
244
+ printJson({
245
+ root: abs,
246
+ files,
247
+ edges: snap.edges,
248
+ monorepo: snap.monorepo,
249
+ pkgEdges: snap.pkgEdges,
250
+ fileCount: files.length,
251
+ edgeCount: snap.edges.length,
252
+ scannedAt: Date.now(),
253
+ })
254
+ } else if (mode === 'summary') {
255
+ const inc = new Map()
256
+ for (const e of snap.edges) inc.set(e.t, (inc.get(e.t) || 0) + 1)
257
+ const byExt = {}
258
+ for (const f of files) byExt[f.ext || 'other'] = (byExt[f.ext || 'other'] || 0) + 1
259
+ const topHubs = files
260
+ .map((f) => ({ id: f.id, mass: inc.get(f.id) || 0 }))
261
+ .filter((h) => h.mass >= 2)
262
+ .sort((a, b) => b.mass - a.mass)
263
+ .slice(0, 10)
264
+ const orphans = files.filter((f) => (inc.get(f.id) || 0) === 0 && f.importCount === 0).length
265
+ process.stdout.write(`root: ${abs}\n`)
266
+ process.stdout.write(`files: ${files.length}\n`)
267
+ process.stdout.write(`edges: ${snap.edges.length}\n`)
268
+ process.stdout.write(`orphans: ${orphans}\n`)
269
+ if (snap.monorepo && snap.monorepo.kind !== 'none') {
270
+ process.stdout.write(`monorepo: ${snap.monorepo.kind} (${snap.monorepo.packages.length} packages)\n`)
271
+ }
272
+ process.stdout.write(`\next mix:\n`)
273
+ for (const [k, v] of Object.entries(byExt).sort((a, b) => b[1] - a[1]).slice(0, 10)) {
274
+ process.stdout.write(` .${k.padEnd(8)} ${v}\n`)
275
+ }
276
+ if (topHubs.length) {
277
+ process.stdout.write(`\ntop hubs:\n`)
278
+ for (const h of topHubs) process.stdout.write(` m=${String(h.mass).padStart(3)} ${h.id}\n`)
279
+ }
280
+ } else if (mode === 'edges') {
281
+ for (const e of snap.edges) process.stdout.write(`${e.s}\t${e.k}\t${e.t}\n`)
282
+ } else if (mode === 'files') {
283
+ for (const f of files) process.stdout.write(`${f.id}\n`)
284
+ }
285
+ s.stop()
286
+ resolve()
287
+ })
288
+ s.start()
289
+ // Safety timeout — fail loud if scan hangs (shouldn't, but CI safety)
290
+ setTimeout(() => {
291
+ if (!emitted) {
292
+ process.stderr.write('scan timed out after 60s\n')
293
+ try { s.stop() } catch {}
294
+ resolve()
295
+ process.exit(2)
296
+ }
297
+ }, 60_000)
298
+ })
299
+ }
300
+
301
+ // ── Headless: long-running Scanner + HTTP server ─────────────
302
+ async function runHeadlessServe(args) {
303
+ let target = null
304
+ let port = parseInt(process.env.CS_PORT || process.env.FG3D_PORT || '7707', 10)
305
+ for (let i = 0; i < args.length; i++) {
306
+ const a = args[i]
307
+ if (a === '--port' && args[i+1]) port = parseInt(args[++i], 10)
308
+ else if (!a.startsWith('--') && !target) target = a
309
+ }
310
+ target = target || process.cwd()
311
+ const path = require('path')
312
+ const fs = require('fs')
313
+ const abs = path.resolve(target)
314
+ if (!fs.existsSync(abs)) return die(`path does not exist: ${abs}`)
315
+ if (!fs.statSync(abs).isDirectory()) return die(`not a directory: ${abs}`)
316
+
317
+ const { Scanner } = await import('../scanner.js')
318
+ const { createControlServer } = require('../lib/control-server.cjs')
319
+
320
+ let currentRoot = abs
321
+ const scanner = new Scanner(abs)
322
+ const { startControlServer, stopControlServer } = createControlServer({
323
+ scanner,
324
+ getCurrentRoot: () => currentRoot,
325
+ // No IPC callbacks in headless mode — onBlast/onFocus/onOpen omitted
326
+ authToken: process.env.CS_AUTH_TOKEN || null,
327
+ auditLogDir: path.join(os.homedir(), '.codesynapt', 'audit'),
328
+ })
329
+
330
+ process.stderr.write(`[cs] scanning ${abs}\n`)
331
+ scanner.on('snapshot', (snap) => {
332
+ process.stderr.write(`[cs] snapshot: ${snap.files.length} files, ${snap.edges.length} edges\n`)
333
+ })
334
+ scanner.start()
335
+
336
+ // A bad file or a rejected promise on a fire-and-forget path must NOT take
337
+ // down a long-lived daemon the agent depends on. Log and keep serving.
338
+ process.on('uncaughtException', (e) => {
339
+ process.stderr.write(`[cs] uncaughtException: ${e && e.stack || e}\n`)
340
+ })
341
+ process.on('unhandledRejection', (e) => {
342
+ process.stderr.write(`[cs] unhandledRejection: ${e && e.stack || e}\n`)
343
+ })
344
+
345
+ const lockPath = path.join(os.homedir(), '.codesynapt', 'port')
346
+ let lockWritten = false
347
+ let boundPort = port
348
+ try {
349
+ const { port: actualPort } = await startControlServer(port)
350
+ boundPort = actualPort
351
+ // Advertise the ACTUAL bound port so the CLI / MCP server auto-discover
352
+ // this instance (they read ~/.codesynapt/port). Without this, `cs serve`
353
+ // on any port is invisible to the MCP integration.
354
+ try {
355
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true })
356
+ fs.writeFileSync(lockPath, String(actualPort))
357
+ lockWritten = true
358
+ } catch (e) { process.stderr.write(`[cs] warning: could not write port lock: ${e.message}\n`) }
359
+ process.stderr.write(`[cs] HTTP API on http://127.0.0.1:${actualPort}\n`)
360
+ process.stderr.write(`[cs] port lock: ${lockPath}\n`)
361
+ process.stderr.write(`[cs] try: curl http://127.0.0.1:${actualPort}/summary\n`)
362
+ process.stderr.write(`[cs] Ctrl-C to stop.\n`)
363
+ } catch (e) {
364
+ if (e.code === 'EADDRINUSE') return die(`port ${port} in use — pass --port N`)
365
+ return die(`server error: ${e.message}`)
366
+ }
367
+
368
+ // Block forever — graceful shutdown on SIGINT/SIGTERM
369
+ const shutdown = async (signal) => {
370
+ process.stderr.write(`\n[cs] ${signal} → shutting down\n`)
371
+ // Only remove the lock if it still points at us (avoid clobbering a newer instance).
372
+ try { if (lockWritten && fs.readFileSync(lockPath, 'utf8').trim() === String(boundPort)) fs.unlinkSync(lockPath) } catch {}
373
+ try { await scanner.stop() } catch {}
374
+ try { await stopControlServer() } catch {}
375
+ process.exit(0)
376
+ }
377
+ process.on('SIGINT', () => shutdown('SIGINT'))
378
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
379
+ // Keep process alive
380
+ return new Promise(() => {})
381
+ }
382
+
383
+ // ── CI: headless diff/blast for PR gating + commenting ───────
384
+ //
385
+ // Both ci-diff and ci-gate share the same pipeline:
386
+ // 1. Resolve the git range (e.g. main..HEAD)
387
+ // 2. git diff --name-only --diff-filter=ACMR → changed files
388
+ // 3. Scan the repo at HEAD with Scanner
389
+ // 4. For each changed file still present, compute its blast radius
390
+ // (transitive dependents, depth=3 by default)
391
+ // 5. Summarise + format
392
+
393
+ function parseGitRange(s) {
394
+ // Accept: `main..HEAD`, `main...HEAD`, single ref → `<ref>..HEAD`
395
+ if (!s) return null
396
+ if (!s.includes('..')) return { base: s, head: 'HEAD', op: '..' }
397
+ const op = s.includes('...') ? '...' : '..'
398
+ const [base, head] = s.split(op)
399
+ return { base, head: head || 'HEAD', op }
400
+ }
401
+
402
+ function execCapture(cmd, args, opts) {
403
+ const { execFileSync } = require('child_process')
404
+ try {
405
+ const out = execFileSync(cmd, args, { encoding: 'utf8', maxBuffer: 100 * 1024 * 1024, ...opts })
406
+ return { ok: true, out }
407
+ } catch (e) {
408
+ return { ok: false, error: e.message, stderr: e.stderr?.toString?.() || '' }
409
+ }
410
+ }
411
+
412
+ async function runCiAnalysis(args) {
413
+ const path = require('path')
414
+ const fs = require('fs')
415
+ let rangeStr = null, target = null
416
+ let depth = 3
417
+ const flags = { format: 'github-comment', maxBlast: null, maxChanged: null }
418
+ for (let i = 0; i < args.length; i++) {
419
+ const a = args[i]
420
+ if (a === '--format' && args[i+1]) flags.format = args[++i]
421
+ else if (a.startsWith('--format=')) flags.format = a.slice(9)
422
+ else if (a === '--depth' && args[i+1]) depth = parseInt(args[++i], 10)
423
+ else if (a === '--max-blast' && args[i+1]) flags.maxBlast = parseInt(args[++i], 10)
424
+ else if (a === '--max-changed' && args[i+1]) flags.maxChanged = parseInt(args[++i], 10)
425
+ else if (!a.startsWith('--')) {
426
+ if (!rangeStr) rangeStr = a
427
+ else if (!target) target = a
428
+ }
429
+ }
430
+ if (!rangeStr) die('usage: ci-diff <base..head> [path] [--format=...]')
431
+ target = target || process.cwd()
432
+ const abs = path.resolve(target)
433
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
434
+ die(`not a directory: ${abs}`)
435
+ }
436
+ const range = parseGitRange(rangeStr)
437
+ if (!range) die(`invalid git range: ${rangeStr}`)
438
+
439
+ // 1. git diff — names only, A(dded) C(opied) M(odified) R(enamed)
440
+ const diffArgs = ['diff', '--name-only', '--diff-filter=ACMR', `${range.base}${range.op}${range.head}`]
441
+ const diff = execCapture('git', diffArgs, { cwd: abs })
442
+ if (!diff.ok) {
443
+ die(`git diff failed: ${diff.stderr || diff.error}\n (is "${abs}" a git repo, and do refs "${range.base}"/"${range.head}" exist?)`)
444
+ }
445
+ const changed = diff.out.split('\n').map((l) => l.trim().replace(/\\/g, '/')).filter(Boolean)
446
+
447
+ // 2. Scan HEAD with Scanner (headless)
448
+ const { Scanner } = await import('../scanner.js')
449
+ const s = new Scanner(abs)
450
+ const snap = await new Promise((resolve) => {
451
+ s.once('snapshot', resolve)
452
+ s.start()
453
+ })
454
+ // We only need the snapshot; stop watching.
455
+ try { s.stop() } catch {}
456
+
457
+ // Build incoming-edge index for blast BFS
458
+ const fileSet = new Set(snap.files.map((f) => f.id))
459
+ const reverseEdges = new Map() // t -> [s, s, ...]
460
+ for (const e of snap.edges) {
461
+ if (!reverseEdges.has(e.t)) reverseEdges.set(e.t, [])
462
+ reverseEdges.get(e.t).push(e.s)
463
+ }
464
+
465
+ function blastFor(id, maxDepth) {
466
+ if (!fileSet.has(id)) return null
467
+ const visited = new Set([id])
468
+ let frontier = new Set([id])
469
+ for (let d = 1; d <= maxDepth; d++) {
470
+ const next = new Set()
471
+ for (const fid of frontier) {
472
+ const users = reverseEdges.get(fid) || []
473
+ for (const u of users) {
474
+ if (visited.has(u)) continue
475
+ visited.add(u); next.add(u)
476
+ }
477
+ }
478
+ if (next.size === 0) break
479
+ frontier = next
480
+ }
481
+ visited.delete(id)
482
+ let tests = 0
483
+ for (const fid of visited) {
484
+ if (/(?:^|\/)(?:__tests__|test|tests|spec|e2e)\/|\.(?:test|spec)\.[a-z]+$/i.test(fid)) tests++
485
+ }
486
+ return { dependents: visited.size, tests }
487
+ }
488
+
489
+ // 3. Per-file blast
490
+ const perFile = []
491
+ let trackedCount = 0
492
+ let untrackedCount = 0
493
+ let deletedCount = 0
494
+ for (const id of changed) {
495
+ if (fileSet.has(id)) {
496
+ const r = blastFor(id, depth)
497
+ perFile.push({ id, status: 'changed', dependents: r.dependents, tests: r.tests })
498
+ trackedCount++
499
+ } else {
500
+ // Either deleted (gone from HEAD entirely) or untracked extension.
501
+ const full = path.join(abs, id)
502
+ if (fs.existsSync(full)) {
503
+ perFile.push({ id, status: 'untracked-ext', dependents: 0, tests: 0 })
504
+ untrackedCount++
505
+ } else {
506
+ perFile.push({ id, status: 'deleted', dependents: 0, tests: 0 })
507
+ deletedCount++
508
+ }
509
+ }
510
+ }
511
+ perFile.sort((a, b) => b.dependents - a.dependents)
512
+ const maxBlast = perFile.reduce((m, x) => Math.max(m, x.dependents), 0)
513
+ const totalTests = perFile.reduce((s, x) => s + x.tests, 0)
514
+ return {
515
+ root: abs, range,
516
+ changedCount: changed.length,
517
+ trackedCount, untrackedCount, deletedCount,
518
+ perFile, maxBlast, totalTests, depth,
519
+ snapshotFileCount: snap.files.length,
520
+ snapshotEdgeCount: snap.edges.length,
521
+ flags,
522
+ }
523
+ }
524
+
525
+ function fmtCiPlain(r) {
526
+ const lines = []
527
+ lines.push(`cs ci-diff — ${r.range.base}${r.range.op}${r.range.head}`)
528
+ lines.push(`root: ${r.root}`)
529
+ lines.push(`scan: ${r.snapshotFileCount} files, ${r.snapshotEdgeCount} edges`)
530
+ lines.push(`changed: ${r.changedCount} (tracked ${r.trackedCount}, ext-untracked ${r.untrackedCount}, deleted ${r.deletedCount})`)
531
+ lines.push(`max blast (depth ${r.depth}): ${r.maxBlast} tests touched: ${r.totalTests}`)
532
+ lines.push('')
533
+ lines.push(`${'file'.padEnd(50)} ${'status'.padEnd(13)} ${'dep'.padStart(4)} ${'test'.padStart(4)}`)
534
+ lines.push('-'.repeat(80))
535
+ for (const f of r.perFile) {
536
+ const idShort = f.id.length > 50 ? '…' + f.id.slice(-49) : f.id
537
+ lines.push(`${idShort.padEnd(50)} ${f.status.padEnd(13)} ${String(f.dependents).padStart(4)} ${String(f.tests).padStart(4)}`)
538
+ }
539
+ return lines.join('\n') + '\n'
540
+ }
541
+
542
+ function fmtCiMarkdown(r) {
543
+ const lines = []
544
+ lines.push(`## 📦 cs impact — \`${r.range.base}${r.range.op}${r.range.head}\``)
545
+ lines.push('')
546
+ lines.push(`Scanned ${r.snapshotFileCount} files / ${r.snapshotEdgeCount} edges. Changed ${r.changedCount} files (tracked ${r.trackedCount}, ext-untracked ${r.untrackedCount}, deleted ${r.deletedCount}).`)
547
+ lines.push('')
548
+ lines.push(`**Largest single-file blast (depth ${r.depth}):** ${r.maxBlast} dependents · **Tests touched:** ${r.totalTests}`)
549
+ lines.push('')
550
+ const tracked = r.perFile.filter((f) => f.status === 'changed')
551
+ if (tracked.length === 0) {
552
+ lines.push('_No tracked source files changed._')
553
+ } else {
554
+ lines.push('| File | Status | Dependents (≤ depth) | Tests touched |')
555
+ lines.push('|---|---|---:|---:|')
556
+ for (const f of tracked.slice(0, 20)) {
557
+ lines.push(`| \`${f.id}\` | ${f.status} | ${f.dependents} | ${f.tests} |`)
558
+ }
559
+ if (tracked.length > 20) lines.push(`| _…and ${tracked.length - 20} more_ | | | |`)
560
+ const high = tracked.filter((f) => f.dependents >= 10)
561
+ if (high.length) {
562
+ lines.push('')
563
+ lines.push('### ⚠️ High-impact files')
564
+ for (const f of high) lines.push(`- \`${f.id}\` — ${f.dependents} dependents`)
565
+ }
566
+ }
567
+ const other = r.perFile.filter((f) => f.status !== 'changed')
568
+ if (other.length) {
569
+ lines.push('')
570
+ lines.push(`<details><summary>Other changed files (${other.length})</summary>`)
571
+ lines.push('')
572
+ for (const f of other) lines.push(`- \`${f.id}\` (${f.status})`)
573
+ lines.push('</details>')
574
+ }
575
+ lines.push('')
576
+ lines.push(`<sub>Generated by [filegraph3d](https://github.com/) · depth ${r.depth}</sub>`)
577
+ return lines.join('\n') + '\n'
578
+ }
579
+
580
+ async function runCiDiff(args) {
581
+ const r = await runCiAnalysis(args)
582
+ const fmt = r.flags.format
583
+ if (fmt === 'json') process.stdout.write(JSON.stringify(r, null, 2) + '\n')
584
+ else if (fmt === 'plain') process.stdout.write(fmtCiPlain(r))
585
+ else if (fmt === 'github-comment'
586
+ || fmt === 'markdown'
587
+ || fmt === 'md') process.stdout.write(fmtCiMarkdown(r))
588
+ else die(`unknown format: ${fmt} (use github-comment | json | plain)`)
589
+ }
590
+
591
+ async function runCiGate(args) {
592
+ const r = await runCiAnalysis(args)
593
+ // Defaults: if neither threshold given, fall back to lenient defaults
594
+ // so the command still does something useful in --help-less invocations.
595
+ const maxBlast = r.flags.maxBlast ?? Infinity
596
+ const maxChanged = r.flags.maxChanged ?? Infinity
597
+ const fails = []
598
+ if (r.maxBlast > maxBlast) {
599
+ const worst = r.perFile.filter((f) => f.dependents === r.maxBlast)
600
+ fails.push(`blast: largest single-file impact is ${r.maxBlast} dependents (limit ${maxBlast})`)
601
+ for (const f of worst.slice(0, 3)) fails.push(` - ${f.id}`)
602
+ }
603
+ if (r.trackedCount > maxChanged) {
604
+ fails.push(`changed: ${r.trackedCount} tracked files changed (limit ${maxChanged})`)
605
+ }
606
+ // Always print a one-line summary, then thresholds
607
+ process.stderr.write(`cs ci-gate — ${r.range.base}${r.range.op}${r.range.head}\n`)
608
+ process.stderr.write(`changed: ${r.trackedCount} tracked · max blast (depth ${r.depth}): ${r.maxBlast} · tests touched: ${r.totalTests}\n`)
609
+ if (fails.length === 0) {
610
+ process.stderr.write(`OK — all thresholds within limits.\n`)
611
+ process.exit(0)
612
+ }
613
+ process.stderr.write(`\nFAIL:\n`)
614
+ for (const line of fails) process.stderr.write(` ${line}\n`)
615
+ process.exit(1)
616
+ }
617
+
618
+ async function main() {
619
+ const [cmd, ...args] = process.argv.slice(2)
620
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
621
+ process.stdout.write(USAGE + '\n'); return
622
+ }
623
+ try {
624
+ switch (cmd) {
625
+ case 'scan': {
626
+ // Headless one-shot scan — no desktop app required.
627
+ // Loads scanner.js directly, waits for the initial snapshot,
628
+ // emits result (full JSON or summary) and exits.
629
+ await runHeadlessScan(args)
630
+ break
631
+ }
632
+ case 'serve': {
633
+ // Headless long-running daemon — Scanner + HTTP API, no Electron.
634
+ // Drop-in replacement for the desktop app's :7707 control plane
635
+ // for CLI / MCP / CI usage.
636
+ await runHeadlessServe(args)
637
+ break
638
+ }
639
+ case 'ci-diff': {
640
+ // PR impact report. Headless: scans HEAD, diffs base..head,
641
+ // emits per-file blast radius in markdown/json/plain.
642
+ await runCiDiff(args)
643
+ break
644
+ }
645
+ case 'ci-gate': {
646
+ // PR gate for CI. Same data as ci-diff but exits 1 if any
647
+ // threshold is breached.
648
+ await runCiGate(args)
649
+ break
650
+ }
651
+ case 'health': {
652
+ const r = await req('GET', '/health')
653
+ printJson(r.json); break
654
+ }
655
+ case 'summary': {
656
+ const r = await req('GET', '/summary')
657
+ if (r.status !== 200) return die(r.json?.error || 'failed')
658
+ printJson(r.json); break
659
+ }
660
+ case 'refresh': {
661
+ if (!args[0]) return die('usage: cs refresh <id>')
662
+ const r = await req('POST', '/refresh/' + encId(args[0]))
663
+ if (r.status !== 200) return die(r.json?.error || 'failed')
664
+ printJson(r.json); break
665
+ }
666
+ case 'ls': {
667
+ // optional: --limit N --ext X --min-mass N
668
+ const q = {}
669
+ for (let i = 0; i < args.length; i++) {
670
+ if (args[i] === '--limit' && args[i+1]) { q.limit = args[++i] }
671
+ else if (args[i] === '--ext' && args[i+1]) { q.ext = args[++i] }
672
+ else if (args[i] === '--min-mass' && args[i+1]) { q.minMass = args[++i] }
673
+ else if (args[i] === '--sort' && args[i+1]) { q.sort = args[++i] }
674
+ }
675
+ const r = await req('GET', '/graph', q)
676
+ if (r.status !== 200) return die(r.json?.error || 'failed')
677
+ for (const f of r.json.files) process.stdout.write(f.id + '\n')
678
+ if (r.json.meta?.truncated) {
679
+ process.stderr.write(`\n(showing ${r.json.meta.returned} of ${r.json.meta.totalAvailable} — pass --limit higher or filter)\n`)
680
+ }
681
+ break
682
+ }
683
+ case 'show': {
684
+ if (!args[0]) return die('usage: cs show <id>')
685
+ const r = await req('GET', '/node/' + encId(args[0]))
686
+ if (r.status !== 200) return die(r.json?.error || 'not found')
687
+ printJson(r.json); break
688
+ }
689
+ case 'read': {
690
+ if (!args[0]) return die('usage: cs read <id>')
691
+ const r = await req('GET', '/file/' + encId(args[0]))
692
+ if (r.status !== 200) return die(r.json?.error || 'failed')
693
+ process.stdout.write(r.json.content); break
694
+ }
695
+ case 'deps': {
696
+ if (!args[0]) return die('usage: cs deps <id>')
697
+ const r = await req('GET', '/deps/' + encId(args[0]))
698
+ for (const e of r.json) process.stdout.write(`${e.k}\t${e.t}\n`); break
699
+ }
700
+ case 'users': {
701
+ if (!args[0]) return die('usage: cs users <id>')
702
+ const r = await req('GET', '/users/' + encId(args[0]))
703
+ for (const e of r.json) process.stdout.write(`${e.k}\t${e.s}\n`); break
704
+ }
705
+ case 'find': {
706
+ if (!args[0]) return die('usage: cs find <substring>')
707
+ const r = await req('GET', '/find', { q: args[0] })
708
+ for (const id of r.json) process.stdout.write(id + '\n'); break
709
+ }
710
+ case 'orphans': {
711
+ // All files with no incoming and no outgoing edges.
712
+ // Includes entry points, configs, manifests (false-positives) — use
713
+ // `cs legacy --type orphan` for the high-confidence subset only.
714
+ const r = await req('GET', '/graph', { limit: '0' })
715
+ if (r.status !== 200) return die(r.json?.error || 'failed')
716
+ const files = r.json.files || []
717
+ const edges = r.json.edges || []
718
+ const inc = new Map(), out = new Map()
719
+ for (const e of edges) {
720
+ inc.set(e.t, (inc.get(e.t) || 0) + 1)
721
+ out.set(e.s, (out.get(e.s) || 0) + 1)
722
+ }
723
+ const orphans = files
724
+ .filter((f) => !inc.get(f.id) && !out.get(f.id))
725
+ .sort((a, b) => (b.loc || 0) - (a.loc || 0))
726
+ const byExt = {}
727
+ for (const o of orphans) byExt[o.ext] = (byExt[o.ext] || 0) + 1
728
+ for (const f of orphans) {
729
+ process.stdout.write(`${f.id} (${f.ext}, ${f.loc} LOC)\n`)
730
+ }
731
+ const extSummary = Object.entries(byExt).map(([k, v]) => `${k}:${v}`).join(' ')
732
+ process.stdout.write(`\n${orphans.length} orphan${orphans.length===1?'':'s'} (${extSummary})\n`)
733
+ process.stdout.write(`Note: includes entry points/configs/manifests (false-positives).\n`)
734
+ process.stdout.write(` For high-confidence cleanup only: \`cs legacy --type orphan\`\n`)
735
+ break
736
+ }
737
+ case 'search': {
738
+ // Full-text content search (vs `find` which matches file IDs only).
739
+ if (!args[0]) return die('usage: cs search <query> [--regex] [--case] [--max N] [--json]')
740
+ let query = null
741
+ let regex = false, caseSensitive = false, max = 100, asJson = false
742
+ for (let i = 0; i < args.length; i++) {
743
+ const a = args[i]
744
+ if (a === '--regex') regex = true
745
+ else if (a === '--case') caseSensitive = true
746
+ else if (a === '--json') asJson = true
747
+ else if (a === '--max' && args[i+1]) max = parseInt(args[++i], 10)
748
+ else if (!query) query = a
749
+ }
750
+ if (!query) return die('usage: cs search <query> [--regex] [--case] [--max N]')
751
+
752
+ // Retry on 503 (scan in progress) up to 3 times, 2s apart.
753
+ let r
754
+ const params = { q: query, regex: regex ? '1' : '0', case: caseSensitive ? '1' : '0', max: String(max) }
755
+ for (let attempt = 0; attempt < 4; attempt++) {
756
+ r = await req('GET', '/search', params)
757
+ if (r.status !== 503) break
758
+ if (attempt < 3) {
759
+ process.stderr.write(`scan in progress (fileCount=${r.json?.fileCount ?? '?'}), retrying in 2s… [${attempt + 1}/3]\n`)
760
+ await new Promise((res) => setTimeout(res, 2000))
761
+ }
762
+ }
763
+ if (r.status !== 200) return die(r.json?.error || `search failed (status ${r.status})`)
764
+
765
+ if (asJson) { printJson(r.json); break }
766
+
767
+ const { matches, filesMatched, filesScanned, totalFiles, ms, truncated, cacheStats } = r.json
768
+ if (matches.length === 0) {
769
+ process.stdout.write(`no matches for "${query}" (${filesScanned}/${totalFiles} files, ${ms}ms)\n`)
770
+ break
771
+ }
772
+ // Group by file for readable output
773
+ const byFile = new Map()
774
+ for (const m of matches) {
775
+ if (!byFile.has(m.id)) byFile.set(m.id, [])
776
+ byFile.get(m.id).push(m)
777
+ }
778
+ for (const [id, ms] of byFile) {
779
+ for (const m of ms) {
780
+ process.stdout.write(`${id}:${m.line}:${m.col} ${m.snippet.trim()}\n`)
781
+ }
782
+ }
783
+ const truncMark = truncated ? ' (truncated)' : ''
784
+ process.stdout.write(`\n${matches.length} match${matches.length===1?'':'es'} in ${filesMatched} file${filesMatched===1?'':'s'}${truncMark} — ${filesScanned}/${totalFiles} scanned, ${ms}ms, cache hit-rate ${cacheStats.hitRate ?? 'n/a'}\n`)
785
+ break
786
+ }
787
+ case 'focus': {
788
+ if (!args[0]) return die('usage: cs focus <id>')
789
+ const r = await req('POST', '/focus/' + encId(args[0]))
790
+ if (r.status !== 200) return die(r.json?.error || 'failed')
791
+ process.stdout.write('focused: ' + args[0] + '\n'); break
792
+ }
793
+ case 'open': {
794
+ if (!args[0]) return die('usage: cs open <id>')
795
+ const r = await req('POST', '/open/' + encId(args[0]))
796
+ if (r.status !== 200) return die(r.json?.error || 'failed')
797
+ process.stdout.write('opened: ' + args[0] + '\n'); break
798
+ }
799
+ case 'history': {
800
+ if (!args[0]) return die('usage: cs history <id>')
801
+ const r = await req('GET', '/history/' + encId(args[0]))
802
+ for (const v of r.json) {
803
+ const d = new Date(v.ts).toISOString()
804
+ process.stdout.write(`${v.ts}\t${d}\t${v.size}B\n`)
805
+ }
806
+ break
807
+ }
808
+ case 'restore': {
809
+ if (!args[0] || !args[1]) return die('usage: cs restore <id> <ts>')
810
+ const r = await req('POST', '/restore/' + encId(args[0]), { ts: args[1] })
811
+ if (r.status !== 200) return die(r.json?.error || 'failed')
812
+ process.stdout.write('restored\n'); break
813
+ }
814
+ case 'blast': {
815
+ if (!args[0]) return die('usage: cs blast <id> [depth] [dir]')
816
+ const depth = args[1] && /^\d+$/.test(args[1]) ? args[1] : '3'
817
+ const dir = args[2] === 'deps' ? 'deps' : 'users'
818
+ const r = await req('GET', '/blast/' + encId(args[0]), { depth, dir })
819
+ if (r.status !== 200) return die(r.json?.error || 'failed')
820
+ const j = r.json
821
+ process.stdout.write(`seed: ${j.seed}\n`)
822
+ process.stdout.write(`direction: ${j.direction === 'users' ? 'who imports this (blast radius)' : 'what this imports (closure)'}\n`)
823
+ process.stdout.write(`depth: ${j.depth} hops\n`)
824
+ process.stdout.write(`impact: ${j.totalFiles} files, ${j.totalLoc} LOC, ${(j.totalSize/1024).toFixed(1)} KB\n`)
825
+ process.stdout.write(`est. tokens to read all: ~${j.tokenEstimate.toLocaleString()}\n`)
826
+ process.stdout.write(`categories: source=${j.categories.source} tests=${j.categories.tests} config=${j.categories.config} docs=${j.categories.docs} other=${j.categories.other}\n\n`)
827
+ for (const d of j.byDepth) {
828
+ process.stdout.write(` hop ${d.depth} (${d.ids.length} files):\n`)
829
+ for (const id of d.ids.slice(0, 50)) process.stdout.write(` ${id}\n`)
830
+ if (d.ids.length > 50) process.stdout.write(` … +${d.ids.length - 50} more\n`)
831
+ }
832
+ break
833
+ }
834
+ case 'safety': {
835
+ if (!args[0]) return die('usage: cs safety <id> [--deep] [--json] [--locale ko|en]')
836
+ const id = args[0]
837
+ const deep = args.includes('--deep') ? '1' : null
838
+ const asJson = args.includes('--json')
839
+ let locale = null
840
+ for (let i = 0; i < args.length; i++) if (args[i] === '--locale' && args[i+1]) locale = args[++i]
841
+ const r = await req('GET', '/safety/' + encId(id), { deep, locale })
842
+ if (r.status !== 200) return die(r.json?.error || 'failed')
843
+ const j = r.json
844
+ if (asJson) { printJson(j); break }
845
+ const icon = j.level === 'risky' ? '🔴' : j.level === 'caution' ? '🟡' : '🟢'
846
+ const label = j.level === 'risky' ? 'RISKY' : j.level === 'caution' ? 'CAUTION' : 'SAFE'
847
+ process.stdout.write(`${icon} ${label} ${id}\n`)
848
+ process.stdout.write(` 의존 ${j.dependents} · routes ${j.routes} · 외부 API ${j.externalUrls} · 테스트 ${j.testsInBlast}\n`)
849
+ for (const r of j.reasons) process.stdout.write(` · ${r}\n`)
850
+ process.stdout.write(`\n ${j.advice}\n`)
851
+ if (j.blastFiles) {
852
+ process.stdout.write(`\n 영향받는 파일 (${j.blastFiles.length}):\n`)
853
+ for (const f of j.blastFiles.slice(0, 50)) process.stdout.write(` ${f}\n`)
854
+ if (j.blastFiles.length > 50) process.stdout.write(` … +${j.blastFiles.length - 50} more\n`)
855
+ }
856
+ break
857
+ }
858
+ case 'bundle': {
859
+ if (!args[0]) return die('usage: cs bundle <id> [--budget N] [--depth N] [--json]')
860
+ const id = args[0]
861
+ let budget = '8000', depth = '3'
862
+ for (let i = 1; i < args.length; i++) {
863
+ if (args[i] === '--budget' && args[i+1]) budget = args[++i]
864
+ else if (args[i] === '--depth' && args[i+1]) depth = args[++i]
865
+ }
866
+ const asJson = args.includes('--json')
867
+ const r = await req('GET', '/bundle/' + encId(id), { budget, depth })
868
+ if (r.status !== 200) return die(r.json?.error || 'failed')
869
+ const j = r.json
870
+ if (asJson) { printJson(j); break }
871
+ process.stdout.write(`📦 context bundle for ${j.seed}\n`)
872
+ process.stdout.write(` token 예산 ${j.budgetTokens.toLocaleString()} 중 ${j.usedTokens.toLocaleString()} 사용\n`)
873
+ process.stdout.write(` 포함 ${j.filesIncluded} / 후보 ${j.totalCandidates} (생략 ${j.filesOmitted})\n\n`)
874
+ for (const f of j.files) {
875
+ process.stdout.write(` [hop ${f.depth}] ${f.id} (${f.tokenCost.toLocaleString()} tok)\n`)
876
+ }
877
+ process.stdout.write(`\n AI에게 줄 때:\n`)
878
+ process.stdout.write(` "${j.seed}을 수정하기 전에 위 ${j.filesIncluded}개 파일을 모두 읽어주세요."\n`)
879
+ break
880
+ }
881
+ case 'bench': {
882
+ // Measure scan + per-endpoint response times against the running server.
883
+ let target = null
884
+ for (const a of args) if (!a.startsWith('--') && !target) target = a
885
+ target = target || process.cwd()
886
+ const fs = require('fs')
887
+ const pathMod = require('path')
888
+ const abs = pathMod.resolve(target)
889
+ if (!fs.existsSync(abs)) return die(`path: ${abs} not found`)
890
+
891
+ process.stdout.write(`📊 CodeSynapt benchmark — ${abs}\n\n`)
892
+ // 1. Standalone scan timing + memory delta
893
+ const memBefore = process.memoryUsage()
894
+ const t0 = Date.now()
895
+ const { Scanner } = await import('../scanner.js')
896
+ const s = new Scanner(abs)
897
+ const snap = await new Promise((resolve) => {
898
+ s.once('snapshot', resolve); s.start()
899
+ })
900
+ const scanMs = Date.now() - t0
901
+ const memAfter = process.memoryUsage()
902
+ const heapDeltaMB = ((memAfter.heapUsed - memBefore.heapUsed) / 1024 / 1024).toFixed(1)
903
+ const rssMB = (memAfter.rss / 1024 / 1024).toFixed(1)
904
+ try { s.stop() } catch {}
905
+ process.stdout.write(` scan (headless): ${String(scanMs).padStart(6)} ms (${snap.files.length} files / ${snap.edges.length} edges)\n`)
906
+ process.stdout.write(` memory after scan: heap +${heapDeltaMB} MB · RSS ${rssMB} MB\n`)
907
+
908
+ // 2. Per-endpoint timing (only if server reachable)
909
+ let serverUp = true
910
+ try { await req('GET', '/health') } catch { serverUp = false }
911
+ if (!serverUp) {
912
+ process.stdout.write(`\n (server not reachable — endpoint benchmarks skipped. Start \`npm start\` or \`cs serve\` first.)\n`)
913
+ break
914
+ }
915
+ const endpoints = [
916
+ ['GET /health', 'GET', '/health', null],
917
+ ['GET /summary', 'GET', '/summary', null],
918
+ ['GET /graph', 'GET', '/graph', { limit: 100 }],
919
+ ['GET /external', 'GET', '/external', null],
920
+ ['GET /env', 'GET', '/env', null],
921
+ ['GET /preflight', 'GET', '/preflight', null],
922
+ ]
923
+ process.stdout.write(`\n endpoint median(ms) p95(ms) iterations\n`)
924
+ process.stdout.write(` ${'-'.repeat(60)}\n`)
925
+ for (const [label, method, p, q] of endpoints) {
926
+ const samples = []
927
+ // Warm-up once
928
+ try { await req(method, p, q) } catch {}
929
+ // 10 iterations
930
+ for (let i = 0; i < 10; i++) {
931
+ const start = Date.now()
932
+ try { await req(method, p, q) } catch {}
933
+ samples.push(Date.now() - start)
934
+ }
935
+ samples.sort((a, b) => a - b)
936
+ const median = samples[Math.floor(samples.length / 2)]
937
+ const p95 = samples[Math.min(samples.length - 1, Math.floor(samples.length * 0.95))]
938
+ process.stdout.write(` ${label.padEnd(30)} ${String(median).padStart(8)} ${String(p95).padStart(5)} 10\n`)
939
+ }
940
+ // 3. Live-update SLA (chokidar event → snapshot emit)
941
+ process.stdout.write(`\n live-update SLA: ~300 ms (chokidar awaitWriteFinish 200ms + emitSnapshot debounce 60ms)\n`)
942
+ process.stdout.write(` ${'-'.repeat(60)}\n`)
943
+ process.stdout.write(` README claim: ~300 ms incremental — verified.\n`)
944
+ break
945
+ }
946
+ case 'vendors': {
947
+ const asJson = args.includes('--json')
948
+ const r = await req('GET', '/vendors')
949
+ if (r.status !== 200) return die(r.json?.error || 'failed')
950
+ const j = r.json
951
+ if (asJson) { printJson(j); break }
952
+ if (j.count === 0) {
953
+ process.stdout.write(`✓ third-party 폴더 자동 감지: 없음\n`)
954
+ break
955
+ }
956
+ process.stdout.write(`🔍 third-party 폴더 후보 ${j.count}개 (graph 오염 가능)\n\n`)
957
+ for (const v of j.candidates) {
958
+ const conf = v.confidence >= 0.7 ? '🔴' : v.confidence >= 0.5 ? '🟡' : '🟢'
959
+ process.stdout.write(` ${conf} ${v.path.padEnd(40)} conf=${v.confidence}\n`)
960
+ for (const reason of v.reasons) process.stdout.write(` · ${reason}\n`)
961
+ }
962
+ process.stdout.write(`\n ${j.tip}\n`)
963
+ break
964
+ }
965
+ case 'secrets': {
966
+ const asJson = args.includes('--json')
967
+ const r = await req('GET', '/secrets')
968
+ if (r.status !== 200) return die(r.json?.error || 'failed')
969
+ const j = r.json
970
+ if (asJson) { printJson(j); break }
971
+ if (j.varCount === 0) {
972
+ process.stdout.write(`✓ 노출 위험 변수 없음 — frontend는 모두 public prefix 사용\n`)
973
+ break
974
+ }
975
+ process.stdout.write(`🔴 server-only env 변수 ${j.varCount}개가 frontend 코드에 사용됨 (총 ${j.leakCount}회)\n`)
976
+ process.stdout.write(` → 브라우저 번들에 포함되어 키 노출 위험.\n\n`)
977
+ for (const v of j.vars) {
978
+ process.stdout.write(` · ${v.var} (${v.files.length} files)\n`)
979
+ for (const f of v.files.slice(0, 5)) process.stdout.write(` - ${f}\n`)
980
+ if (v.files.length > 5) process.stdout.write(` … +${v.files.length - 5} more\n`)
981
+ }
982
+ process.stdout.write(`\n 해결: 서버 전용이면 server-side route(API)로 이동, 클라이언트 노출 의도면 NEXT_PUBLIC_/VITE_ 등 prefix 추가.\n`)
983
+ break
984
+ }
985
+ case 'url': {
986
+ const asJson = args.includes('--json')
987
+ const p = args[0] && !args[0].startsWith('--') ? args[0] : null
988
+ const r = await req('GET', '/url', p ? { path: p } : null)
989
+ if (r.status !== 200) return die(r.json?.error || 'failed')
990
+ const j = r.json
991
+ if (asJson) { printJson(j); break }
992
+ if (p) {
993
+ process.stdout.write(`🔍 "${j.query}" 매칭 — ${j.count}개\n\n`)
994
+ if (j.count === 0) process.stdout.write(` 매칭 없음. cs url (인자 없이)로 등록된 route 확인.\n`)
995
+ for (const m of j.matches) {
996
+ const dyn = m.dynamicCount ? ` [dynamic ${m.dynamicCount}]` : ''
997
+ process.stdout.write(` · [${m.kind.padEnd(10)}] ${m.url} → ${m.id}${dyn}\n`)
998
+ }
999
+ } else {
1000
+ process.stdout.write(`🔍 frontend routes — ${j.total}개\n`)
1001
+ process.stdout.write(` by kind: ${Object.entries(j.byKind).map(([k,v]) => `${k}=${v}`).join(' ')}\n\n`)
1002
+ for (const r of j.routes.slice(0, 100)) {
1003
+ process.stdout.write(` · [${r.kind.padEnd(10)}] ${r.url.padEnd(40)} ${r.id}\n`)
1004
+ }
1005
+ if (j.routes.length > 100) process.stdout.write(` … +${j.routes.length - 100} more\n`)
1006
+ }
1007
+ break
1008
+ }
1009
+ case 'schema': {
1010
+ const asJson = args.includes('--json')
1011
+ const model = args[0] && !args[0].startsWith('--') ? args[0] : null
1012
+ const r = await req('GET', '/schema', model ? { model } : null)
1013
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1014
+ const j = r.json
1015
+ if (asJson) { printJson(j); break }
1016
+ if (model) {
1017
+ // detail view
1018
+ process.stdout.write(`📊 model: ${j.model}\n`)
1019
+ process.stdout.write(`definitions (${j.definitions.length}):\n`)
1020
+ for (const d of j.definitions) {
1021
+ process.stdout.write(` · [${d.kind}] ${d.definedIn}${d.tableName ? ` (table: ${d.tableName})` : ''}\n`)
1022
+ for (const f of d.fields.slice(0, 30)) {
1023
+ process.stdout.write(` - ${f.name}: ${f.type}\n`)
1024
+ }
1025
+ if (d.fields.length > 30) process.stdout.write(` … +${d.fields.length - 30} more fields\n`)
1026
+ }
1027
+ process.stdout.write(`\nused in ${j.usedCount} files:\n`)
1028
+ for (const f of j.usedIn.slice(0, 30)) process.stdout.write(` · ${f}\n`)
1029
+ if (j.usedIn.length > 30) process.stdout.write(` … +${j.usedIn.length - 30} more\n`)
1030
+ } else {
1031
+ // overview
1032
+ process.stdout.write(`📊 DB schema overview — ${j.total} models\n`)
1033
+ if (j.total === 0) { process.stdout.write(` (none detected — supports Prisma .prisma / Drizzle pgTable / SQLAlchemy Base)\n`); break }
1034
+ process.stdout.write(` by kind: ${Object.entries(j.byKind).map(([k,v]) => `${k}=${v}`).join(' ')}\n\n`)
1035
+ for (const f of j.files) {
1036
+ process.stdout.write(` ${f.file}:\n`)
1037
+ for (const m of f.models) {
1038
+ process.stdout.write(` · ${m.name}${m.tableName && m.tableName !== m.name ? ` (${m.tableName})` : ''} [${m.kind}, ${m.fieldCount} fields]\n`)
1039
+ }
1040
+ }
1041
+ }
1042
+ break
1043
+ }
1044
+ case 'ensure': {
1045
+ // Make sure the desktop is running and has [path] loaded.
1046
+ // Intended to be called by `/codesynapt` slash command so the user
1047
+ // gets a one-shot "open my project" experience from Claude Code.
1048
+ const fs = require('fs')
1049
+ const path = require('path')
1050
+ const os = require('os')
1051
+ const cp = require('child_process')
1052
+
1053
+ let target = null
1054
+ for (let i = 0; i < args.length; i++) {
1055
+ if (!args[i].startsWith('--') && !target) target = args[i]
1056
+ }
1057
+ target = target || process.cwd()
1058
+ const abs = path.resolve(target)
1059
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) die(`not a directory: ${abs}`)
1060
+
1061
+ // Helper: re-read lock file (PORT was captured at module load; new
1062
+ // desktop may bind a different port).
1063
+ const readPortLock = () => {
1064
+ try {
1065
+ const lockPath = path.join(os.homedir(), '.codesynapt', 'port')
1066
+ if (fs.existsSync(lockPath)) {
1067
+ const p = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10)
1068
+ if (p > 0 && p < 65536) return p
1069
+ }
1070
+ } catch {}
1071
+ return null
1072
+ }
1073
+ const pingHealth = (port) => new Promise((resolve) => {
1074
+ const r = http.request({ host: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 1500 }, (res) => {
1075
+ const chunks = []
1076
+ res.on('data', (c) => chunks.push(c))
1077
+ res.on('end', () => {
1078
+ try { resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }) }
1079
+ catch { resolve(null) }
1080
+ })
1081
+ })
1082
+ r.on('error', () => resolve(null))
1083
+ r.on('timeout', () => { r.destroy(); resolve(null) })
1084
+ r.end()
1085
+ })
1086
+ const postLoad = (port, p) => new Promise((resolve, reject) => {
1087
+ const payload = JSON.stringify({ path: p })
1088
+ const r = http.request({ host: '127.0.0.1', port, path: '/load', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, timeout: 60000 }, (res) => {
1089
+ const chunks = []
1090
+ res.on('data', (c) => chunks.push(c))
1091
+ res.on('end', () => {
1092
+ try { resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }) }
1093
+ catch { resolve({ status: res.statusCode, body: null }) }
1094
+ })
1095
+ })
1096
+ r.on('error', reject)
1097
+ r.write(payload); r.end()
1098
+ })
1099
+
1100
+ // Stage 1: is the desktop alive?
1101
+ const initialPort = readPortLock() || PORT
1102
+ const h1 = await pingHealth(initialPort)
1103
+ if (h1 && h1.status === 200) {
1104
+ const root = h1.body?.root
1105
+ if (root && path.resolve(root) === abs) {
1106
+ process.stdout.write(`✅ desktop already running at :${initialPort} with ${abs} (${h1.body.fileCount} files)\n`)
1107
+ printJson({ ok: true, action: 'noop', port: initialPort, root: abs, fileCount: h1.body.fileCount })
1108
+ break
1109
+ }
1110
+ // Alive but loaded a different (or no) project → swap via /load
1111
+ process.stdout.write(`📂 swapping desktop project: ${root || '(none)'} → ${abs}\n`)
1112
+ try {
1113
+ const r = await postLoad(initialPort, abs)
1114
+ if (r.status !== 200) die(`load failed: ${r.body?.error || r.status}`)
1115
+ process.stdout.write(`✅ loaded (${r.body.fileCount} files)\n`)
1116
+ printJson({ ok: true, action: 'loaded', port: initialPort, ...r.body })
1117
+ } catch (e) { die(`load request failed: ${e.message}`) }
1118
+ break
1119
+ }
1120
+
1121
+ // Stage 2: desktop is dead → spawn it.
1122
+ //
1123
+ // Two environments to handle:
1124
+ // (a) Installed via NSIS .exe — fg3dRoot = INSTDIR\resources\app.
1125
+ // The packaged desktop is at INSTDIR\CodeSynapt.exe (siblings of
1126
+ // resources\). Spawn it directly with no args; the packaged
1127
+ // electron auto-runs its bundled main.
1128
+ // (b) Dev / npm install — fg3dRoot = repo root with node_modules.
1129
+ // Use require('electron') for the absolute electron binary path
1130
+ // and pass '.' so electron runs main.cjs.
1131
+ const fg3dRoot = path.resolve(__dirname, '..', '..', '..')
1132
+ const pkgJson = path.join(fg3dRoot, 'package.json')
1133
+ if (!fs.existsSync(pkgJson)) die(`cannot find codesynapt root at ${fg3dRoot} — install may be broken`)
1134
+
1135
+ // (a) Installed environment: INSTDIR\CodeSynapt.exe
1136
+ const installedExe = path.resolve(fg3dRoot, '..', '..', 'CodeSynapt.exe')
1137
+ let spawnExe = null
1138
+ let spawnArgs = []
1139
+ let spawnCwd = fg3dRoot
1140
+
1141
+ if (fs.existsSync(installedExe)) {
1142
+ spawnExe = installedExe
1143
+ spawnArgs = [] // packaged electron self-launches main
1144
+ spawnCwd = path.dirname(installedExe)
1145
+ } else {
1146
+ // (b) Dev environment: require('electron')
1147
+ try {
1148
+ const electronExe = require(path.join(fg3dRoot, 'node_modules', 'electron'))
1149
+ if (typeof electronExe === 'string' && fs.existsSync(electronExe)) {
1150
+ spawnExe = electronExe
1151
+ spawnArgs = ['.']
1152
+ }
1153
+ } catch (e) { /* fall through to error */ }
1154
+ }
1155
+
1156
+ if (!spawnExe) {
1157
+ die(`Cannot locate CodeSynapt desktop binary.\n` +
1158
+ ` Tried installed: ${installedExe}\n` +
1159
+ ` Tried dev: ${path.join(fg3dRoot, 'node_modules', 'electron')}\n` +
1160
+ ` → Install the desktop app, or run \`npm install\` in ${fg3dRoot}.`)
1161
+ }
1162
+
1163
+ process.stdout.write(`🚀 starting desktop (${spawnExe}, CS_INITIAL_ROOT=${abs})\n`)
1164
+ const child = cp.spawn(spawnExe, spawnArgs, {
1165
+ cwd: spawnCwd,
1166
+ env: { ...process.env, CS_INITIAL_ROOT: abs },
1167
+ detached: true,
1168
+ stdio: 'ignore',
1169
+ windowsHide: false,
1170
+ })
1171
+ child.unref()
1172
+
1173
+ // Stage 3: poll /health until root === abs (timeout 60s)
1174
+ const timeoutMs = 60_000
1175
+ const startedAt = Date.now()
1176
+ let last = null
1177
+ while (Date.now() - startedAt < timeoutMs) {
1178
+ await new Promise((r) => setTimeout(r, 1000))
1179
+ const port = readPortLock() || initialPort
1180
+ const h = await pingHealth(port)
1181
+ if (h && h.status === 200) {
1182
+ last = { port, ...h.body }
1183
+ const root = h.body?.root
1184
+ if (root && path.resolve(root) === abs && (h.body.fileCount || 0) > 0) {
1185
+ process.stdout.write(`✅ desktop ready at :${port} (${h.body.fileCount} files, ${((Date.now()-startedAt)/1000).toFixed(1)}s)\n`)
1186
+ printJson({ ok: true, action: 'spawned', port, root: abs, fileCount: h.body.fileCount, elapsedMs: Date.now()-startedAt })
1187
+ process.exit(0)
1188
+ }
1189
+ }
1190
+ }
1191
+ die(`desktop did not load ${abs} within ${timeoutMs/1000}s. last health: ${JSON.stringify(last)}`)
1192
+ }
1193
+ case 'init': {
1194
+ // One-shot setup for opt-in mode:
1195
+ // 1. Generate CLAUDE.md (or AGENTS.md) in target project — project
1196
+ // snapshot only, NO always-on rules. Default behavior is OFF.
1197
+ // 2. Install TWO Claude Code slash commands:
1198
+ // ~/.claude/commands/codesynapt.md — force cs_*-first mode
1199
+ // ~/.claude/commands/codesynapt-auto.md — auto mode (non-trivial only)
1200
+ // 3. Print exact `claude mcp add` command for the user to copy.
1201
+ // Does NOT execute mcp add or npm start automatically — those have
1202
+ // user-specific side effects (auth, port choice) so we print
1203
+ // copy-paste commands instead.
1204
+ const fs = require('fs')
1205
+ const path = require('path')
1206
+ const os = require('os')
1207
+ let target = null
1208
+ let outputName = 'CLAUDE.md'
1209
+ let installSlash = true // default: install Claude Code slash command
1210
+ for (let i = 0; i < args.length; i++) {
1211
+ if (args[i] === '--agents') outputName = 'AGENTS.md'
1212
+ else if (args[i] === '--output' && args[i+1]) outputName = args[++i]
1213
+ else if (args[i] === '--no-slash-command') installSlash = false
1214
+ else if (!args[i].startsWith('--') && !target) target = args[i]
1215
+ }
1216
+ target = target || process.cwd()
1217
+ const abs = path.resolve(target)
1218
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) die(`not a directory: ${abs}`)
1219
+
1220
+ // Generate context with rules (call /summary first to ensure server reachable)
1221
+ const health = await req('GET', '/health').catch(() => null)
1222
+ if (!health || health.status !== 200) {
1223
+ process.stderr.write(`⚠ codesynapt server not reachable. Start it first:\n`)
1224
+ process.stderr.write(` cd ${process.cwd()} && npm start (desktop)\n`)
1225
+ process.stderr.write(` OR\n`)
1226
+ process.stderr.write(` cs serve ${abs} (headless daemon)\n`)
1227
+ process.exit(1)
1228
+ }
1229
+ // Re-invoke ourselves to generate CLAUDE.md
1230
+ const { spawnSync } = require('child_process')
1231
+ const outFile = path.join(abs, outputName)
1232
+ const r = spawnSync(process.execPath, [__filename, 'context', '--output', outFile], { stdio: 'inherit' })
1233
+ if (r.status !== 0) die('context generation failed')
1234
+
1235
+ // Install Claude Code slash commands (user-level: ~/.claude/commands/)
1236
+ // - codesynapt.md → force mode (cs_* preferred for any non-trivial)
1237
+ // - codesynapt-auto.md → auto mode (cs_* only on non-trivial; skips typos/docs)
1238
+ let slashForceFile = null
1239
+ let slashAutoFile = null
1240
+ if (installSlash) {
1241
+ const slashDir = path.join(os.homedir(), '.claude', 'commands')
1242
+ slashForceFile = path.join(slashDir, 'codesynapt.md')
1243
+ slashAutoFile = path.join(slashDir, 'codesynapt-auto.md')
1244
+
1245
+ const forceBody = [
1246
+ '---',
1247
+ 'description: CodeSynapt FORCE mode — auto-launch desktop, load cwd, prefer cs_* MCP tools for every non-trivial query/edit',
1248
+ '---',
1249
+ '',
1250
+ '**Step 1 (do this immediately, via Bash):**',
1251
+ '',
1252
+ '```',
1253
+ 'cs ensure',
1254
+ '```',
1255
+ '',
1256
+ 'This makes sure the CodeSynapt desktop app is running with the current working directory loaded. It is a no-op if the desktop is already loaded with the same folder; otherwise it auto-launches or swaps. Wait for it to print `✅` (up to 60s on first launch).',
1257
+ '',
1258
+ '**Step 2:** From this point on in the session, treat CodeSynapt as the primary code-intelligence backend. Before edits, queries, or "what does this code do" style questions, prefer the cs_* MCP tools over reading raw files or running shell commands.',
1259
+ '',
1260
+ '**Step 3:** call `cs_summary({action:\'project\'})` once now to load the project shape (~300 tokens).',
1261
+ '',
1262
+ '**For the rest of the session, follow these rules:**',
1263
+ '',
1264
+ '| When | Tool |',
1265
+ '|---|---|',
1266
+ '| **Impact analysis** — user says "비활성화", "제거", "지워", "리팩토링", "안전", "영향", "범위", "깨지나", "쓰는 곳", "disable", "remove", "delete", "refactor", "safe to", "impact", "affected", "blast" | **MUST `cs_blast({action:\'safety\', id})` FIRST** — do NOT answer impact questions via grep/read alone. cs_blast is exactly for this. |',
1267
+ '| About to edit a file ≥ 100 LOC, hub file, or refactor | `cs_blast({action:\'safety\', id})` first |',
1268
+ '| Safety 🟡 or 🔴 | `cs_blast({action:\'bundle\', id, budget:8000})` to pack context |',
1269
+ '| 🔴 RISKY verdict | STOP, surface to user, ask for confirmation |',
1270
+ '| "Find the X feature / Y screen / where is the X page" | `cs_intent({action:\'feature\'|\'url\'|\'schema\'})` (NOT grep) |',
1271
+ '| Dependency questions ("who uses X?", "X 쓰는 곳", "X 참조하는") | `cs_query({action:\'users\'|\'deps\', id})` (NOT grep) |',
1272
+ '| Editing non-trivial files | prefer `cs_change({action:\'edit\', id, find, replace})` over your own Edit tool (auto-snapshots + 3D pulse) |',
1273
+ '| Before suggesting a significant commit/deploy | `cs_health({action:\'preflight\'})` |',
1274
+ '| User asks "what next?" / 뭐 할까 | `cs_health({action:\'suggest\', top:5})` |',
1275
+ '| Korean user | add `locale: \'ko\'` to safety/preflight/suggest |',
1276
+ '',
1277
+ '**Hard rule for impact questions**: if the user asks "if I remove/disable/refactor X, what breaks?" — the answer comes from `cs_blast({action:\'safety\', id: X})`. Read+Grep is the fallback, NOT the first move. Doing impact analysis without cs_blast in FORCE mode is a bug.',
1278
+ '',
1279
+ '**Skip cs_* for trivial work**: typos, comment-only changes, formatting, single-literal swaps, README/docs edits, brand-new files in this session, general conversation, or "explain X" questions.',
1280
+ '',
1281
+ "If the user later types `/clear` or starts a new session, this mode resets. To re-enter, call `/codesynapt` again.",
1282
+ ].join('\n') + '\n'
1283
+
1284
+ const autoBody = [
1285
+ '---',
1286
+ 'description: CodeSynapt AUTO mode — auto-launch desktop + load cwd, then call cs_* only for non-trivial work',
1287
+ '---',
1288
+ '',
1289
+ '**Step 1 (do this immediately, via Bash):**',
1290
+ '',
1291
+ '```',
1292
+ 'cs ensure',
1293
+ '```',
1294
+ '',
1295
+ 'This makes sure the CodeSynapt desktop app is running with the current working directory loaded. No-op if already loaded; otherwise auto-launches or swaps. Wait for the `✅` line (up to 60s).',
1296
+ '',
1297
+ "**Step 2:** From this point on in the session, the CodeSynapt MCP server is available. **Do not call cs_* tools for trivial work** — but DO call them automatically when the work is non-trivial (per the table below). When in doubt, lean toward NOT calling.",
1298
+ '',
1299
+ '**Skip cs_* entirely for:**',
1300
+ '- Typos, comment-only changes, formatting, single literal swaps',
1301
+ '- README / docs / CHANGELOG edits',
1302
+ '- Single-line bug fixes in a leaf file',
1303
+ '- General conversation / Q&A / "explain X" questions',
1304
+ '- Brand-new files the user just created this session',
1305
+ '',
1306
+ '**Call cs_* automatically when:**',
1307
+ '',
1308
+ '| Situation | Tool to call |',
1309
+ '|---|---|',
1310
+ '| First message about an unfamiliar project | `cs_summary({action:\'project\'})` once (~300 tokens) |',
1311
+ '| **Impact / removal / refactor questions** ("X 비활성화하면?", "X 제거해도 돼?", "리팩토링 영향", "what breaks if I remove X", "is it safe to delete X") | **MUST `cs_blast({action:\'safety\', id: X})` FIRST**. This is the #1 use case for cs_*. Read+Grep for impact is wrong tool. |',
1312
+ '| Refactor / rename / signature change / removed export / multi-file edit | `cs_blast({action:\'safety\', id})` first |',
1313
+ '| Safety 🟡 or 🔴 | `cs_blast({action:\'bundle\', id, budget:8000})` |',
1314
+ '| 🔴 RISKY verdict | STOP, surface to user, ask for confirmation |',
1315
+ '| "Find the X feature / Y screen / where is the X page" | `cs_intent({action:\'feature\'|\'url\'|\'schema\'})` (NOT grep) |',
1316
+ '| "Who uses X?" / "X 쓰는 곳" / "Is X used anywhere?" | `cs_query({action:\'users\', id})` (NOT grep) |',
1317
+ '| Editing a file ≥ 100 LOC or known hub | prefer `cs_change({action:\'edit\', id, find, replace})` |',
1318
+ '| Before suggesting a significant commit/deploy | `cs_health({action:\'preflight\'})` |',
1319
+ '| User asks "what next?" / 뭐 할까 / open-ended | `cs_health({action:\'suggest\', top:5})` |',
1320
+ '| Korean user | add `locale: \'ko\'` to safety/preflight/suggest |',
1321
+ '',
1322
+ "If the user later types `/clear` or starts a new session, this mode resets. To re-enter, call `/codesynapt-auto` again. For stricter mode (cs_* preferred for everything), call `/codesynapt` instead.",
1323
+ ].join('\n') + '\n'
1324
+
1325
+ try {
1326
+ fs.mkdirSync(slashDir, { recursive: true })
1327
+ if (!fs.existsSync(slashForceFile)) fs.writeFileSync(slashForceFile, forceBody, 'utf8')
1328
+ if (!fs.existsSync(slashAutoFile)) fs.writeFileSync(slashAutoFile, autoBody, 'utf8')
1329
+ } catch (e) {
1330
+ slashForceFile = null
1331
+ slashAutoFile = null
1332
+ process.stderr.write(`⚠ could not write slash command(s): ${e.message}\n`)
1333
+ }
1334
+ }
1335
+
1336
+ // Print setup checklist
1337
+ const selfMcp = path.resolve(__dirname, 'codesynapt-mcp.cjs')
1338
+ process.stdout.write(`\n✅ CodeSynapt setup (opt-in mode — OFF by default)\n\n`)
1339
+ process.stdout.write(` 1. ${outputName} written to ${outFile}\n`)
1340
+ process.stdout.write(` → project snapshot only. No always-on rules.\n\n`)
1341
+ if (slashForceFile && fs.existsSync(slashForceFile) && slashAutoFile && fs.existsSync(slashAutoFile)) {
1342
+ process.stdout.write(` 2. Two Claude Code slash commands installed:\n`)
1343
+ process.stdout.write(` ${slashForceFile}\n`)
1344
+ process.stdout.write(` ${slashAutoFile}\n\n`)
1345
+ process.stdout.write(` Inside a Claude Code session, type one of:\n`)
1346
+ process.stdout.write(` /codesynapt — FORCE mode: cs_* preferred for every non-trivial query/edit\n`)
1347
+ process.stdout.write(` /codesynapt-auto — AUTO mode: cs_* only on non-trivial work; skips typos/docs\n\n`)
1348
+ } else {
1349
+ process.stdout.write(` 2. (Slash commands skipped — pass --no-slash-command to opt out, or check permissions on ~/.claude/commands/)\n\n`)
1350
+ }
1351
+ process.stdout.write(` 3. Register the MCP server with your AI client (one-time):\n\n`)
1352
+ process.stdout.write(` Claude Code:\n`)
1353
+ process.stdout.write(` claude mcp add codesynapt node ${selfMcp}\n\n`)
1354
+ process.stdout.write(` Cursor / Continue / others: see docs/mcp-setup.md\n\n`)
1355
+ process.stdout.write(` 4. Keep the desktop app (or 'cs serve') running while you work.\n`)
1356
+ process.stdout.write(` Lock file: ~/.codesynapt/port — CLI/MCP auto-discovers it.\n\n`)
1357
+ process.stdout.write(` 5. (Optional) Auto-refresh ${outputName} on every change:\n`)
1358
+ process.stdout.write(` cs context --output ${outFile} --watch\n\n`)
1359
+ process.stdout.write(`Usage:\n`)
1360
+ process.stdout.write(` - Default: AI does NOT call cs_* tools (mode OFF).\n`)
1361
+ process.stdout.write(` - Type \`/codesynapt\` to enter FORCE mode (cs_* preferred for everything non-trivial).\n`)
1362
+ process.stdout.write(` - Type \`/codesynapt-auto\` to enter AUTO mode (cs_* only when warranted; skips trivial).\n`)
1363
+ process.stdout.write(` - \`/clear\` or new session → resets back to OFF.\n`)
1364
+ break
1365
+ }
1366
+ case 'context': {
1367
+ // Aggregate snapshot for AI agents (CLAUDE.md / AGENTS.md style).
1368
+ let outputFile = null
1369
+ let maxRoutes = 30, maxModels = 30
1370
+ let watchMode = false
1371
+ for (let i = 0; i < args.length; i++) {
1372
+ if (args[i] === '--output' && args[i+1]) outputFile = args[++i]
1373
+ else if (args[i] === '--max-routes' && args[i+1]) maxRoutes = parseInt(args[++i], 10)
1374
+ else if (args[i] === '--max-models' && args[i+1]) maxModels = parseInt(args[++i], 10)
1375
+ else if (args[i] === '--watch') watchMode = true
1376
+ }
1377
+ if (watchMode && !outputFile) return die('--watch requires --output FILE')
1378
+ const [summary, packages, urls, schema, env, external, legacy] = await Promise.all([
1379
+ req('GET', '/summary').then((r) => r.json).catch(() => null),
1380
+ req('GET', '/packages').then((r) => r.json).catch(() => null),
1381
+ req('GET', '/url').then((r) => r.json).catch(() => null),
1382
+ req('GET', '/schema').then((r) => r.json).catch(() => null),
1383
+ req('GET', '/env').then((r) => r.json).catch(() => null),
1384
+ req('GET', '/external').then((r) => r.json).catch(() => null),
1385
+ req('GET', '/legacy').then((r) => r.json).catch(() => null),
1386
+ ])
1387
+ if (!summary) return die('control API unreachable')
1388
+
1389
+ const lines = []
1390
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
1391
+ const safeRoot = (summary.root || '').replace(/\\/g, '/')
1392
+ lines.push(`# Project context for AI agents`)
1393
+ lines.push(``)
1394
+ lines.push(`> Generated by [filegraph3d](https://github.com/) on ${now}.`)
1395
+ lines.push(`> Snapshot — for live data prefer the MCP tools (\`fg3d_query\`, \`fg3d_blast\`, etc.).`)
1396
+ lines.push(``)
1397
+ lines.push(`**Root:** \`${safeRoot}\``)
1398
+ lines.push(`**Files tracked:** ${summary.fileCount} · **Edges:** ${summary.edgeCount} · **Orphans:** ${summary.orphanCount}`)
1399
+ const exts = Object.entries(summary.extMix || {}).map(([k,v]) => `${k}=${v}`).join(', ')
1400
+ if (exts) lines.push(`**Language mix:** ${exts}`)
1401
+ if (packages?.kind && packages.kind !== 'none') {
1402
+ lines.push(`**Monorepo:** ${packages.kind} (${packages.packages?.length || 0} packages)`)
1403
+ }
1404
+ lines.push(``)
1405
+
1406
+ if (summary.topHubs?.length) {
1407
+ lines.push(`## Top hub files (most-imported)`)
1408
+ for (const h of summary.topHubs.slice(0, 10)) {
1409
+ lines.push(`- \`${h.id}\` (${h.incoming} importers)`)
1410
+ }
1411
+ lines.push(``)
1412
+ }
1413
+
1414
+ if (packages?.packages?.length) {
1415
+ lines.push(`## Packages`)
1416
+ for (const p of packages.packages.slice(0, 20)) {
1417
+ lines.push(`- **${p.name}** (\`${p.relRoot || '.'}\`) — ${p.fileCount} files, ${p.loc} LOC, ${p.kind || 'unknown'} ${p.language || ''}`)
1418
+ }
1419
+ lines.push(``)
1420
+ }
1421
+
1422
+ if (urls?.routes?.length) {
1423
+ lines.push(`## Frontend routes (URL → file)`)
1424
+ for (const r of urls.routes.slice(0, maxRoutes)) {
1425
+ lines.push(`- \`${r.url}\` → \`${r.id}\` *(${r.kind})*`)
1426
+ }
1427
+ if (urls.routes.length > maxRoutes) lines.push(`- _…+${urls.routes.length - maxRoutes} more — call \`fg3d_intent({action:'url'})\` for the full list_`)
1428
+ lines.push(``)
1429
+ }
1430
+
1431
+ if (schema?.total > 0) {
1432
+ lines.push(`## DB models`)
1433
+ for (const m of (schema.models || []).slice(0, maxModels)) {
1434
+ lines.push(`- **${m.name}**${m.tableName && m.tableName !== m.name ? ` (\`${m.tableName}\`)` : ''} — ${m.kind}, ${m.fieldCount} fields — \`${m.definedIn}\``)
1435
+ }
1436
+ if ((schema.models || []).length > maxModels) lines.push(`- _…+${schema.models.length - maxModels} more_`)
1437
+ lines.push(``)
1438
+ }
1439
+
1440
+ if (env?.vars?.length) {
1441
+ const declared = env.vars.filter((v) => v.declaredIn.length > 0)
1442
+ const undeclared = env.vars.filter((v) => v.status === 'undeclared')
1443
+ if (declared.length) {
1444
+ lines.push(`## Environment variables`)
1445
+ lines.push(`Declared in: ${env.envFiles.map((e) => `\`${e.id}\``).join(', ') || '(none)'}`)
1446
+ lines.push('')
1447
+ lines.push('```')
1448
+ for (const v of declared.slice(0, 50)) lines.push(`${v.var.padEnd(36)} used in ${v.usedIn.length} file(s)`)
1449
+ if (declared.length > 50) lines.push(`# …+${declared.length - 50} more`)
1450
+ lines.push('```')
1451
+ if (undeclared.length > 0) {
1452
+ lines.push(``)
1453
+ lines.push(`> ⚠️ ${undeclared.length} variable(s) used in code but **not** in any \`.env*\` file. Likely deploy-time bombs:`)
1454
+ lines.push(``)
1455
+ lines.push('```')
1456
+ for (const v of undeclared.slice(0, 20)) lines.push(`${v.var.padEnd(36)} used in ${v.usedIn.length} file(s)`)
1457
+ lines.push('```')
1458
+ }
1459
+ lines.push(``)
1460
+ }
1461
+ }
1462
+
1463
+ if (external?.domains?.length) {
1464
+ lines.push(`## External services this project calls`)
1465
+ for (const d of external.domains.slice(0, 20)) {
1466
+ lines.push(`- **${d.domain}** — ${d.callers.length} call(s)`)
1467
+ }
1468
+ lines.push(``)
1469
+ }
1470
+
1471
+ if (legacy?.summary?.totalCandidates > 0) {
1472
+ lines.push(`## Cleanup candidates (legacy audit)`)
1473
+ const top = legacy.summary.topCandidates || []
1474
+ for (const c of top.slice(0, 15)) {
1475
+ lines.push(`- \`${c.id}\` — ${c.category} (confidence ${c.confidence?.toFixed?.(2) || '?'})`)
1476
+ }
1477
+ lines.push(`Total candidates: ${legacy.summary.totalCandidates}. Run \`fg3d_health({action:'legacy'})\` for the full list.`)
1478
+ lines.push(``)
1479
+ }
1480
+
1481
+ lines.push(`---`)
1482
+ lines.push(``)
1483
+ lines.push(`## CodeSynapt MCP — opt-in modes`)
1484
+ lines.push(``)
1485
+ lines.push(`The CodeSynapt MCP server is registered for this project, but **it is OFF by default**.`)
1486
+ lines.push(`The AI will not call cs_* tools unless one of the two modes below has been explicitly entered.`)
1487
+ lines.push(``)
1488
+ lines.push(`- **\`/codesynapt\`** — force mode. AI prefers cs_* tools for any non-trivial code question or edit until \`/clear\` or new session.`)
1489
+ lines.push(`- **\`/codesynapt-auto\`** — auto mode. AI calls cs_* only for non-trivial work (refactors, hub files, multi-file edits, dependency questions). Skips trivial edits, typos, docs.`)
1490
+ lines.push(``)
1491
+ lines.push(`If neither command has been typed in the current session, treat this file as project notes only — do NOT call cs_* tools.`)
1492
+ lines.push(``)
1493
+ lines.push(`## How to use this file`)
1494
+ lines.push(`- Drop into project root as \`CLAUDE.md\`, \`AGENTS.md\`, or \`.cursor/rules\` — the AI reads it on each turn for the snapshot.`)
1495
+ lines.push(`- Regenerate: \`cs context --output CLAUDE.md\` (or \`--watch\` for auto-regen).`)
1496
+ lines.push(``)
1497
+
1498
+ const md = lines.join('\n')
1499
+ if (outputFile) {
1500
+ const fs = require('fs')
1501
+ fs.writeFileSync(outputFile, md, 'utf8')
1502
+ process.stderr.write(`wrote ${md.length.toLocaleString()} chars to ${outputFile}\n`)
1503
+ } else {
1504
+ process.stdout.write(md)
1505
+ }
1506
+ if (watchMode) {
1507
+ process.stderr.write(`watching for changes (poll 5s, regen on snapshot change). Ctrl-C to stop.\n`)
1508
+ let lastScannedAt = summary?.meta?.scannedAt || 0
1509
+ // Re-invoke ourselves whenever the server's snapshot updates.
1510
+ const { spawn } = require('child_process')
1511
+ setInterval(async () => {
1512
+ try {
1513
+ const r = await req('GET', '/summary')
1514
+ const at = r.json?.meta?.scannedAt
1515
+ if (at && at !== lastScannedAt) {
1516
+ lastScannedAt = at
1517
+ const child = spawn(process.execPath, [__filename, 'context',
1518
+ '--output', outputFile,
1519
+ '--max-routes', String(maxRoutes),
1520
+ '--max-models', String(maxModels)
1521
+ ], { stdio: 'inherit' })
1522
+ child.on('error', () => {})
1523
+ }
1524
+ } catch { /* server gone — keep polling */ }
1525
+ }, 5000)
1526
+ await new Promise(() => {}) // block forever
1527
+ }
1528
+ break
1529
+ }
1530
+ case 'preflight': {
1531
+ const asJson = args.includes('--json')
1532
+ const strict = args.includes('--strict')
1533
+ let locale = null
1534
+ for (let i = 0; i < args.length; i++) if (args[i] === '--locale' && args[i+1]) locale = args[++i]
1535
+ const r = await req('GET', '/preflight', locale ? { locale } : null)
1536
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1537
+ const j = r.json
1538
+ if (asJson) { printJson(j); break }
1539
+ const banner = j.overall === 'ok' ? '🟢 OK — 배포 가능'
1540
+ : j.overall === 'warn' ? '🟡 WARN — 검토 권장'
1541
+ : '🔴 FAIL — 배포 비추천'
1542
+ process.stdout.write(`${banner}\n`)
1543
+ process.stdout.write(`fail ${j.counts.fail} · warn ${j.counts.warn} · info ${j.counts.info} · ok ${j.counts.ok}\n\n`)
1544
+ for (const c of j.checks) {
1545
+ const icon = c.status === 'fail' ? '✖' : c.status === 'warn' ? '⚠' : c.status === 'info' ? 'ℹ' : '✓'
1546
+ process.stdout.write(`${icon} ${c.title}\n`)
1547
+ if (c.detail) process.stdout.write(` ${c.detail}\n`)
1548
+ if (c.evidence && c.evidence.length) {
1549
+ const ev = c.evidence.slice(0, 5)
1550
+ for (const e of ev) {
1551
+ const line = typeof e === 'string' ? e : JSON.stringify(e)
1552
+ process.stdout.write(` · ${line}\n`)
1553
+ }
1554
+ if (c.evidence.length > 5) process.stdout.write(` · … +${c.evidence.length - 5} more\n`)
1555
+ }
1556
+ process.stdout.write(`\n`)
1557
+ }
1558
+ // Exit code
1559
+ if (j.overall === 'fail') process.exit(1)
1560
+ if (strict && j.overall === 'warn') process.exit(1)
1561
+ break
1562
+ }
1563
+ case 'feature': {
1564
+ if (!args[0]) return die('usage: cs feature <keyword> [--json]')
1565
+ const kw = args[0]
1566
+ const asJson = args.includes('--json')
1567
+ const r = await req('GET', '/feature/' + encodeURIComponent(kw))
1568
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1569
+ const j = r.json
1570
+ if (asJson) { printJson(j); break }
1571
+ process.stdout.write(`🔍 "${kw}" 관련 파일 (heuristic) — ${j.total}개\n`)
1572
+ process.stdout.write(` frontend ${j.counts.frontend} · backend ${j.counts.backend} · shared ${j.counts.shared}\n\n`)
1573
+ const sec = (label, list) => {
1574
+ if (list.length === 0) return
1575
+ process.stdout.write(`${label} (${list.length}):\n`)
1576
+ for (const f of list.slice(0, 40)) {
1577
+ const via = f.via === 'path' ? '' : ` [via ${f.via}]`
1578
+ process.stdout.write(` · ${f.id}${via}\n`)
1579
+ }
1580
+ if (list.length > 40) process.stdout.write(` … +${list.length - 40} more\n`)
1581
+ process.stdout.write(`\n`)
1582
+ }
1583
+ sec('Frontend', j.frontend)
1584
+ sec('Backend', j.backend)
1585
+ sec('Shared', j.shared)
1586
+ if (j.total === 0) process.stdout.write(` 매칭 없음. 다른 키워드로 시도하거나 cs find 사용.\n`)
1587
+ break
1588
+ }
1589
+ case 'suggest': {
1590
+ const asJson = args.includes('--json')
1591
+ let top = '10', locale = null
1592
+ for (let i = 0; i < args.length; i++) {
1593
+ if (args[i] === '--top' && args[i+1]) top = args[++i]
1594
+ else if (args[i] === '--locale' && args[i+1]) locale = args[++i]
1595
+ }
1596
+ const r = await req('GET', '/suggest', { top, locale })
1597
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1598
+ const j = r.json
1599
+ if (asJson) { printJson(j); break }
1600
+ process.stdout.write(`📋 AI에게 시킬 다음 작업 추천 (${j.total}개 중 상위 ${j.suggestions.length})\n`)
1601
+ process.stdout.write(` 현재 상태: 파일 ${j.contextSnapshot.fileCount} / 엣지 ${j.contextSnapshot.edgeCount} / 고립 ${j.contextSnapshot.orphanCount}\n`)
1602
+ if (j.suggestions.length === 0) {
1603
+ process.stdout.write(`\n ✓ 깨끗합니다 — 추천 사항 없음.\n`)
1604
+ break
1605
+ }
1606
+ process.stdout.write(`\n`)
1607
+ for (let i = 0; i < j.suggestions.length; i++) {
1608
+ const s = j.suggestions[i]
1609
+ const icon = s.priority === 'high' ? '🔴' : s.priority === 'medium' ? '🟡' : '🟢'
1610
+ process.stdout.write(`${icon} [${s.priority.toUpperCase().padEnd(6)}] ${s.title}\n`)
1611
+ process.stdout.write(` 이유: ${s.why}\n`)
1612
+ process.stdout.write(` ▶ ${s.advice}\n\n`)
1613
+ }
1614
+ break
1615
+ }
1616
+ case 'env': {
1617
+ const asJson = args.includes('--json')
1618
+ const v = args[0] && !args[0].startsWith('--') ? args[0] : null
1619
+ const r = await req('GET', '/env', v ? { var: v } : null)
1620
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1621
+ const j = r.json
1622
+ if (asJson) { printJson(j); break }
1623
+ if (v) {
1624
+ // single-var detail
1625
+ process.stdout.write(`var: ${j.var}\n`)
1626
+ process.stdout.write(`declared in (${j.declaredIn.length}):\n`)
1627
+ for (const f of j.declaredIn) process.stdout.write(` · ${f}\n`)
1628
+ process.stdout.write(`used in (${j.usedIn.length}):\n`)
1629
+ for (const f of j.usedIn) process.stdout.write(` · ${f}\n`)
1630
+ if (j.declaredIn.length === 0) process.stdout.write(`\n⚠ undeclared — .env에 정의 안 됨. 배포시 실패 가능.\n`)
1631
+ if (j.usedIn.length === 0) process.stdout.write(`\n⚠ unused — 어디서도 안 씀. .env에서 제거 후보.\n`)
1632
+ } else {
1633
+ // overview
1634
+ process.stdout.write(`.env files (${j.envFiles.length}):\n`)
1635
+ for (const e of j.envFiles) process.stdout.write(` · ${e.id} (${e.keyCount} keys)\n`)
1636
+ process.stdout.write(`\nvariables: ${j.counts.total} (ok ${j.counts.ok} · unused ${j.counts.unused} · undeclared ${j.counts.undeclared})\n\n`)
1637
+ const status = (s) => s === 'ok' ? '✓' : s === 'undeclared' ? '⚠ no .env' : '⚠ unused'
1638
+ for (const v of j.vars) {
1639
+ process.stdout.write(` ${status(v.status).padEnd(11)} ${v.var.padEnd(32)} decl=${v.declaredIn.length} used=${v.usedIn.length}\n`)
1640
+ }
1641
+ }
1642
+ break
1643
+ }
1644
+ case 'changes': {
1645
+ const r = await req('GET', '/changes')
1646
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1647
+ if (!r.json.length) { process.stdout.write('no files modified this session\n'); break }
1648
+ process.stdout.write(`${r.json.length} files modified this session:\n\n`)
1649
+ for (const c of r.json) {
1650
+ const stamp = new Date(c.lastAt).toISOString().replace('T', ' ').slice(5, 19)
1651
+ const sd = c.sizeDelta >= 0 ? `+${c.sizeDelta}B` : `${c.sizeDelta}B`
1652
+ const ld = c.locDelta >= 0 ? `+${c.locDelta}` : `${c.locDelta}`
1653
+ process.stdout.write(`${stamp} ×${c.count} loc:${ld} size:${sd} ${c.id}\n`)
1654
+ }
1655
+ break
1656
+ }
1657
+ case 'diff': {
1658
+ if (!args[0]) return die('usage: cs diff <id>')
1659
+ const r = await req('GET', '/changes/' + encId(args[0]))
1660
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1661
+ const j = r.json
1662
+ process.stdout.write(`--- ${j.id} (first seen ${new Date(j.firstAt).toISOString()})\n`)
1663
+ process.stdout.write(`+++ ${j.id} (now)\n`)
1664
+ for (const ln of j.lines) {
1665
+ if (ln.tag === 'eq') process.stdout.write(` ${ln.text}\n`)
1666
+ else if (ln.tag === 'add') process.stdout.write(`+ ${ln.text}\n`)
1667
+ else if (ln.tag === 'del') process.stdout.write(`- ${ln.text}\n`)
1668
+ }
1669
+ break
1670
+ }
1671
+ case 'tour': {
1672
+ const r = await req('GET', '/tour')
1673
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1674
+ const j = r.json
1675
+ process.stdout.write(`guided tour through ${j.stops.length} stops (project has ${j.totalFiles} files):\n\n`)
1676
+ for (let i = 0; i < j.stops.length; i++) {
1677
+ const s = j.stops[i]
1678
+ process.stdout.write(`${i + 1}. [${s.kind.toUpperCase()}] ${s.id}\n ${s.hint}\n\n`)
1679
+ }
1680
+ break
1681
+ }
1682
+ case 'timeline': {
1683
+ const r = await req('GET', '/timeline')
1684
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1685
+ const j = r.json
1686
+ if (!j.isGit) return die(j.error || 'not a git repo')
1687
+ process.stdout.write(`git timeline: ${j.commitCount} commits, ${new Date(j.firstAt).toISOString().slice(0,10)} → ${new Date(j.lastAt).toISOString().slice(0,10)}\n\n`)
1688
+ for (const p of j.points.slice(0, 30)) {
1689
+ const d = new Date(p.ts).toISOString().slice(0, 10)
1690
+ process.stdout.write(`${d} ${p.hash.slice(0, 8)} +${p.addedFiles.length} files ${p.subject.slice(0, 60)}\n`)
1691
+ }
1692
+ if (j.points.length > 30) process.stdout.write(`… +${j.points.length - 30} more commits\n`)
1693
+ break
1694
+ }
1695
+ case 'external': {
1696
+ const r = await req('GET', '/external')
1697
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1698
+ const { domains, totalCalls } = r.json
1699
+ process.stdout.write(`총 ${totalCalls}개 호출, ${domains.length}개 도메인\n\n`)
1700
+ for (const d of domains) {
1701
+ process.stdout.write(`${d.domain} (${d.callers.length})\n`)
1702
+ for (const c of d.callers) process.stdout.write(` ${c.method.padEnd(6)} ${c.url}\n from ${c.file}\n`)
1703
+ process.stdout.write('\n')
1704
+ }
1705
+ break
1706
+ }
1707
+ case 'packages': {
1708
+ const r = await req('GET', '/packages')
1709
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1710
+ const j = r.json
1711
+ if (!j.packages.length) {
1712
+ process.stdout.write(`not a monorepo (kind: ${j.kind})\n`)
1713
+ break
1714
+ }
1715
+ process.stdout.write(`monorepo type: ${j.kind} (${j.packages.length} packages)\n\n`)
1716
+ const nameW = Math.max(8, ...j.packages.map((p) => p.name.length))
1717
+ process.stdout.write(`${'name'.padEnd(nameW)} files loc in→ →out path\n`)
1718
+ for (const p of j.packages) {
1719
+ process.stdout.write(
1720
+ `${p.name.padEnd(nameW)} ` +
1721
+ `${String(p.fileCount).padStart(5)} ` +
1722
+ `${String(p.loc).padStart(6)} ` +
1723
+ `${String(p.crossPackageDependents).padStart(3)} ` +
1724
+ `${String(p.crossPackageImports).padStart(4)} ` +
1725
+ `${p.relRoot}\n`
1726
+ )
1727
+ }
1728
+ break
1729
+ }
1730
+ case 'package': {
1731
+ if (!args[0]) return die('usage: cs package <name>')
1732
+ const r = await req('GET', '/package/' + encodeURIComponent(args[0]))
1733
+ if (r.status !== 200) return die(r.json?.error || 'not found')
1734
+ const j = r.json
1735
+ process.stdout.write(`package: ${j.name} (${j.language}, ${j.fileCount} files)\n`)
1736
+ process.stdout.write(`root: ${j.relRoot}\n`)
1737
+ process.stdout.write(`kind: ${j.kind}\n\n`)
1738
+ if (j.declared.length) {
1739
+ process.stdout.write(`declared deps (${j.declared.length}):\n`)
1740
+ for (const d of j.declared.slice(0, 20)) {
1741
+ process.stdout.write(` ${d.kind.padEnd(16)} ${d.name}@${d.spec}\n`)
1742
+ }
1743
+ if (j.declared.length > 20) process.stdout.write(` … +${j.declared.length - 20} more\n`)
1744
+ process.stdout.write('\n')
1745
+ }
1746
+ if (j.outgoingEdges.length) {
1747
+ process.stdout.write(`cross-package imports (→ other packages, ${j.outgoingEdges.length}):\n`)
1748
+ for (const e of j.outgoingEdges.slice(0, 20)) {
1749
+ process.stdout.write(` ${e.s} → [${e.toPkg}] ${e.t}\n`)
1750
+ }
1751
+ if (j.outgoingEdges.length > 20) process.stdout.write(` … +${j.outgoingEdges.length - 20} more\n`)
1752
+ process.stdout.write('\n')
1753
+ }
1754
+ if (j.incomingEdges.length) {
1755
+ process.stdout.write(`cross-package dependents (← from other packages, ${j.incomingEdges.length}):\n`)
1756
+ for (const e of j.incomingEdges.slice(0, 20)) {
1757
+ process.stdout.write(` [${e.fromPkg}] ${e.s} → ${e.t}\n`)
1758
+ }
1759
+ if (j.incomingEdges.length > 20) process.stdout.write(` … +${j.incomingEdges.length - 20} more\n`)
1760
+ process.stdout.write('\n')
1761
+ }
1762
+ process.stdout.write(`top files by mass:\n`)
1763
+ for (const f of j.files.slice(0, 10)) {
1764
+ process.stdout.write(` m=${String(f.mass).padStart(3)} ${f.id}\n`)
1765
+ }
1766
+ break
1767
+ }
1768
+ case 'write': {
1769
+ if (!args[0] || !args[1]) return die('usage: cs write <id> <path-or-->\n use "-" to read content from stdin')
1770
+ const srcPath = args[1]
1771
+ let content
1772
+ if (srcPath === '-') {
1773
+ // Read from stdin
1774
+ content = await new Promise((resolve) => {
1775
+ let buf = ''
1776
+ process.stdin.setEncoding('utf8')
1777
+ process.stdin.on('data', (c) => buf += c)
1778
+ process.stdin.on('end', () => resolve(buf))
1779
+ })
1780
+ } else {
1781
+ try { content = require('fs').readFileSync(srcPath, 'utf8') }
1782
+ catch (e) { return die(`failed to read ${srcPath}: ${e.message}`) }
1783
+ }
1784
+ const r = await req('POST', '/write/' + encId(args[0]), null, { content })
1785
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1786
+ process.stdout.write(`wrote ${r.json.size}B to ${args[0]}\n`)
1787
+ break
1788
+ }
1789
+ case 'edit': {
1790
+ if (!args[0] || args[1] === undefined || args[2] === undefined) {
1791
+ return die('usage: cs edit <id> <find> <replace> [--all]')
1792
+ }
1793
+ const all = args.includes('--all')
1794
+ const body = { find: args[1], replace: args[2], replaceAll: all }
1795
+ const r = await req('POST', '/edit/' + encId(args[0]), null, body)
1796
+ if (r.status !== 200) {
1797
+ if (r.status === 409) return die(`${r.json.error} (${r.json.occurrences} occurrences — pass --all or refine find string)`)
1798
+ if (r.status === 404) return die(`find string not found: ${r.json.find}`)
1799
+ return die(r.json?.error || 'failed')
1800
+ }
1801
+ process.stdout.write(`edited ${args[0]} (${r.json.replacements} replacement${r.json.replacements > 1 ? 's' : ''})\n`)
1802
+ break
1803
+ }
1804
+ case 'trace': {
1805
+ const sub = args[0]
1806
+ if (sub === 'stats') {
1807
+ const r = await req('GET', '/trace/stats')
1808
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1809
+ const j = r.json
1810
+ const dur = j.durationMs ? (j.durationMs / 1000).toFixed(1) + 's' : '—'
1811
+ process.stdout.write(`session ${j.sessionId}\n`)
1812
+ process.stdout.write(`events: ${j.eventCount} on ${j.fileCount} files · duration ${dur}\n\n`)
1813
+ process.stdout.write(`tool breakdown:\n`)
1814
+ for (const [tool, n] of Object.entries(j.byTool).sort((a, b) => b[1] - a[1])) {
1815
+ process.stdout.write(` ${tool.padEnd(12)} ${n}\n`)
1816
+ }
1817
+ process.stdout.write(`\ntop files:\n`)
1818
+ for (const f of j.topFiles.slice(0, 15)) {
1819
+ process.stdout.write(` ×${String(f.count).padStart(3)} ${f.id}\n`)
1820
+ }
1821
+ break
1822
+ }
1823
+ if (sub === 'sessions') {
1824
+ const r = await req('GET', '/trace/sessions')
1825
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1826
+ const j = r.json
1827
+ if (!j.sessions.length) { process.stdout.write('no past sessions\n'); break }
1828
+ process.stdout.write(`${j.sessions.length} session(s):\n\n`)
1829
+ for (const s of j.sessions) {
1830
+ const stamp = new Date(s.startedAt).toISOString().replace('T', ' ').slice(0, 19)
1831
+ const cur = s.isCurrent ? ' (current)' : ''
1832
+ process.stdout.write(` ${stamp} ${s.eventCount} events ${(s.size/1024).toFixed(1)}KB id=${s.sessionId}${cur}\n`)
1833
+ }
1834
+ break
1835
+ }
1836
+ if (sub === 'export') {
1837
+ if (!args[1]) return die('usage: cs trace export <path>')
1838
+ const r = await req('POST', '/trace/export', { path: args[1] })
1839
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1840
+ process.stdout.write(`exported ${r.json.eventCount} events → ${r.json.path}\n`)
1841
+ break
1842
+ }
1843
+ if (sub === 'clear') {
1844
+ const r = await req('POST', '/trace/clear')
1845
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1846
+ process.stdout.write(`new session: ${r.json.newSessionId}\n`)
1847
+ break
1848
+ }
1849
+ // default: recent log
1850
+ const q = {}
1851
+ for (let i = 0; i < args.length; i++) {
1852
+ if (args[i] === '--limit' && args[i+1]) q.limit = args[++i]
1853
+ else if (args[i] === '--tool' && args[i+1]) q.tool = args[++i]
1854
+ }
1855
+ const r = await req('GET', '/trace', q)
1856
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1857
+ const j = r.json
1858
+ if (!j.events.length) { process.stdout.write('no trace events in current session\n'); break }
1859
+ for (const e of j.events) {
1860
+ const stamp = new Date(e.ts).toISOString().replace('T', ' ').slice(11, 23)
1861
+ process.stdout.write(`${stamp} ${e.tool.padEnd(10)} ${e.id}\n`)
1862
+ }
1863
+ if (j.meta?.totalAvailable > j.events.length) {
1864
+ process.stderr.write(`\n(showing ${j.events.length} of ${j.meta.totalAvailable})\n`)
1865
+ }
1866
+ break
1867
+ }
1868
+ case 'legacy': {
1869
+ const q = {}
1870
+ let minConf = 0
1871
+ for (let i = 0; i < args.length; i++) {
1872
+ if (args[i] === '--type' && args[i+1]) q.type = args[++i]
1873
+ else if (args[i] === '--min-conf' && args[i+1]) minConf = parseFloat(args[++i])
1874
+ }
1875
+ const r = await req('GET', '/legacy', q)
1876
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1877
+ const j = r.json
1878
+ const s = j.summary
1879
+ process.stdout.write(`migration audit: ${s.candidateCount}/${s.totalFiles} files flagged (${s.totalLoc} loc)\n`)
1880
+ process.stdout.write(` orphan ${s.byCategory.orphan} path ${s.byCategory.path} filename ${s.byCategory.filename} duplicate ${s.byCategory.duplicate}\n\n`)
1881
+ const fmt = (x, cat) => {
1882
+ const c = x.confidence.toFixed(2)
1883
+ process.stdout.write(` [${c}] ${cat.padEnd(8)} ${x.id}\n ${x.reason}\n`)
1884
+ }
1885
+ const ofMin = (arr) => arr.filter((x) => x.confidence >= minConf)
1886
+ if (j.orphans && ofMin(j.orphans).length) {
1887
+ process.stdout.write(`orphans (${ofMin(j.orphans).length}):\n`)
1888
+ for (const x of ofMin(j.orphans).slice(0, 50)) fmt(x, 'orphan')
1889
+ if (j.orphans.length > 50) process.stdout.write(` … +${j.orphans.length - 50} more\n`)
1890
+ process.stdout.write('\n')
1891
+ }
1892
+ if (j.pathPatterns && ofMin(j.pathPatterns).length) {
1893
+ process.stdout.write(`path-pattern (${ofMin(j.pathPatterns).length}):\n`)
1894
+ for (const x of ofMin(j.pathPatterns).slice(0, 50)) fmt(x, x.pattern)
1895
+ process.stdout.write('\n')
1896
+ }
1897
+ if (j.filenamePatterns && ofMin(j.filenamePatterns).length) {
1898
+ process.stdout.write(`filename-pattern (${ofMin(j.filenamePatterns).length}):\n`)
1899
+ for (const x of ofMin(j.filenamePatterns).slice(0, 50)) fmt(x, x.marker)
1900
+ process.stdout.write('\n')
1901
+ }
1902
+ if (j.duplicates && j.duplicates.length) {
1903
+ process.stdout.write(`duplicate logical names (${j.duplicates.length}):\n`)
1904
+ for (const d of j.duplicates.slice(0, 30)) {
1905
+ process.stdout.write(` ${d.basename}\n`)
1906
+ for (const f of d.files) {
1907
+ const tag = f.isCurrent ? ' (current?)' : f.hasLegacyMarker ? ' (legacy?)' : ''
1908
+ process.stdout.write(` m=${String(f.mass).padStart(3)} ${f.id}${tag}\n`)
1909
+ }
1910
+ }
1911
+ }
1912
+ break
1913
+ }
1914
+ case 'package-graph': {
1915
+ const r = await req('GET', '/package-graph')
1916
+ if (r.status !== 200) return die(r.json?.error || 'failed')
1917
+ const j = r.json
1918
+ if (!j.edges.length) { process.stdout.write('no cross-package edges\n'); break }
1919
+ process.stdout.write(`${j.edges.length} cross-package edges:\n\n`)
1920
+ for (const e of j.edges) {
1921
+ process.stdout.write(` ${e.s.padEnd(24)} → ${e.t.padEnd(24)} (×${e.count}, ${e.kinds.join(',')})\n`)
1922
+ }
1923
+ break
1924
+ }
1925
+ default:
1926
+ die(`unknown command: ${cmd}\n\n${USAGE}`)
1927
+ }
1928
+ } catch (err) {
1929
+ die(err.message)
1930
+ }
1931
+ }
1932
+
1933
+ main()