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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +686 -0
- package/LICENSES.md +141 -0
- package/README.md +331 -0
- package/electron/main.cjs +2849 -0
- package/electron/plugin-loader.cjs +184 -0
- package/electron/preload.cjs +108 -0
- package/package.json +216 -0
- package/packages/core/bin/codesynapt-mcp.cjs +611 -0
- package/packages/core/bin/codesynapt.cjs +1933 -0
- package/packages/core/legacy.js +300 -0
- package/packages/core/lib/control-server.cjs +1539 -0
- package/packages/core/lib/embedding.cjs +89 -0
- package/packages/core/lib/logger.cjs +63 -0
- package/packages/core/lib/search-cache.cjs +140 -0
- package/packages/core/lib/search-worker.cjs +255 -0
- package/packages/core/lib/search.cjs +211 -0
- package/packages/core/lib/symbol-graph.cjs +402 -0
- package/packages/core/lib/symbol-parser-js.cjs +542 -0
- package/packages/core/lib/symbol-parser-misc.cjs +394 -0
- package/packages/core/lib/symbol-parser-py.cjs +215 -0
- package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
- package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
- package/packages/core/monorepo.js +310 -0
- package/packages/core/parser.js +2234 -0
- package/packages/core/scanner.js +623 -0
- package/plugin-api/LICENSE +21 -0
- package/plugin-api/README.md +114 -0
- package/plugin-api/docs/01-getting-started.md +197 -0
- package/plugin-api/docs/02-concepts.md +269 -0
- package/plugin-api/docs/api-reference.md +463 -0
- package/plugin-api/docs/troubleshooting.md +332 -0
- package/plugin-api/docs/types/exporter.md +377 -0
- package/plugin-api/docs/types/theme.md +312 -0
- package/plugin-api/examples/hello-world-plugin/README.md +70 -0
- package/plugin-api/examples/hello-world-plugin/main.js +36 -0
- package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
- package/plugin-api/examples/mermaid-exporter/README.md +125 -0
- package/plugin-api/examples/mermaid-exporter/main.js +58 -0
- package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
- package/plugin-api/examples/rust-parser/README.md +71 -0
- package/plugin-api/examples/rust-parser/main.js +123 -0
- package/plugin-api/examples/rust-parser/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/README.md +95 -0
- package/plugin-api/examples/sunset-theme/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/theme.css +31 -0
- package/plugin-api/package.json +20 -0
- package/plugin-api/types.d.ts +395 -0
- package/public/app.js +6837 -0
- package/public/backend.js +285 -0
- package/public/index.html +647 -0
- package/public/plugin-host.js +321 -0
- package/public/style.css +4359 -0
- package/public/vendor/three.module.js +53044 -0
- package/scripts/competitor-watch.mjs +144 -0
- package/scripts/copy-vendor.js +21 -0
- package/scripts/download-bundled-node.cjs +53 -0
- package/scripts/fuses-after-pack.cjs +34 -0
- package/scripts/license-check.js +119 -0
- package/scripts/perf-test.js +200 -0
- 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()
|