aihand 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +136 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-FDS2C2CZ.cjs +651 -0
  25. package/dist/cli-HHRGYPSM.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. package/index.js +0 -2
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env node
2
+ import type { Node } from 'web-tree-sitter'
3
+ import type { Config, RepodexData } from './types.js'
4
+ import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs'
5
+ import { dirname, join, resolve } from 'node:path'
6
+ import process from 'node:process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { createJiti } from 'jiti'
9
+ import pc from 'picocolors'
10
+ import { Parser } from 'web-tree-sitter'
11
+ import pkg from '../../package.json'
12
+ import { getLang } from './compact.js'
13
+ import { buildCallGraph } from './graph.js'
14
+ import { ensureMarkers, injectAll, renderSections } from './inject.js'
15
+ import { locate, parseInspPath, renderLocate } from './locate.js'
16
+ import { buildPanel, fmtTransitions, formatPanel } from './panel.js'
17
+ import { buildState, formatState } from './state.js'
18
+ import { fetchRuntimeKnobs, joinPanel } from './seam.js'
19
+ import {
20
+ computeOverviewTokens,
21
+ computePinnedSet,
22
+ computeSignaturesBudget,
23
+ estimateTokens,
24
+ formatOverview,
25
+ selectFullBlocks,
26
+ selectSignatureBlocks,
27
+ } from './pipeline.js'
28
+ import { buildBlocks, buildClassifier, formatStarredTree, formatTree, formatXml } from './render.js'
29
+ import { scan } from './scan.js'
30
+ import { scanSecrets } from './security.js'
31
+ import { allocate, buildImportGraph, computeCentrality, computeRecency, expandByBudget } from './signals.js'
32
+ import { configTemplate, DEFAULT_CONFIG } from './types.js'
33
+ import { CONFIG_PATH, isTsFile, loadConfig } from './config.js'
34
+
35
+ // aihand fusion: argv is injected by the unified router (src/cli.ts) after it
36
+ // strips the leading `read`/`refactor` segment. Falls back to process.argv when
37
+ // this module is run directly (dev: `tsx read/cli.ts`).
38
+ let args = process.argv.slice(2)
39
+
40
+ const ESC = String.fromCharCode(27)
41
+ // Visible length: skip ANSI color codes (ESC [ digits m).
42
+ function visibleLen(s: string): number {
43
+ let n = 0
44
+ for (let i = 0; i < s.length; i++) {
45
+ if (s[i] === ESC && s[i + 1] === '[') {
46
+ i += 2
47
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') i++
48
+ // i now at 'm' (or wherever the run ended); loop's i++ skips it
49
+ continue
50
+ }
51
+ n++
52
+ }
53
+ return n
54
+ }
55
+ function pad(s: string, len: number): string {
56
+ return s + ' '.repeat(Math.max(0, len - visibleLen(s)))
57
+ }
58
+
59
+ // A watch root is a file (not a dir) if its last path segment contains a dot.
60
+ const looksLikeFile = (root: string) => !root.endsWith('/') && (root.split('/').pop() ?? '').includes('.')
61
+
62
+ const SELF_PATH = fileURLToPath(import.meta.url)
63
+ // Running from src/ (tsx) vs dist/ (compiled): mark source runs so logs reveal which code is live.
64
+ const VERSION = `v${pkg.version}${SELF_PATH.includes('/src/') ? '+src' : ''}`
65
+ const BANNER = `${pc.bold(pc.magenta('aihand'))} ${pc.dim(VERSION)}`
66
+
67
+ /** Walk up from cwd until finding aihand.config.ts; chdir there so all paths resolve correctly */
68
+ function cdToRoot(): void {
69
+ let dir = process.cwd()
70
+ while (true) {
71
+ if (existsSync(join(dir, CONFIG_PATH))) {
72
+ if (dir !== process.cwd())
73
+ process.chdir(dir)
74
+ return
75
+ }
76
+ const parent = dirname(dir)
77
+ if (parent === dir)
78
+ return // filesystem root, no config found
79
+ dir = parent
80
+ }
81
+ }
82
+
83
+ async function initConfig(): Promise<string[]> {
84
+ console.log(`\n${BANNER} ${pc.dim('first run setup')}`)
85
+
86
+ // Detect existing AI context files; fall back to creating CLAUDE.md if none found
87
+ const ALL_TARGETS = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules', '.github/copilot-instructions.md', '.windsurfrules']
88
+ const existing = ALL_TARGETS.filter(f => existsSync(f))
89
+ const targets = existing.length ? existing : ['CLAUDE.md']
90
+
91
+ const rows: Row[] = []
92
+ for (const file of targets) {
93
+ const result = ensureMarkers(file)
94
+ const meta = result === 'created' ? 'created with markers' : result === 'marked' ? 'markers added' : 'already has markers'
95
+ rows.push({ file, meta })
96
+ }
97
+ printTable(rows)
98
+
99
+ // Write config
100
+ const body = configTemplate()
101
+ writeFileSync(CONFIG_PATH, `import { defineConfig } from 'aihand'\n\nexport default defineConfig({\n${body}\n})\n`)
102
+ console.log(` ${pc.green('✓')} Created ${CONFIG_PATH}`)
103
+
104
+ await bindVitePlugin()
105
+
106
+ // Set expectations before the first inject糊脸: what's about to land, who it's for, how to undo/shrink.
107
+ const list = targets.map(f => pc.cyan(f)).join(', ')
108
+ console.log(`
109
+ ${pc.bold('About to inject a codemap')} into ${list}
110
+ ${pc.dim('— a compact repo index for your AI assistant, not for you to read.')}
111
+
112
+ ${pc.dim('It lives between')} ${pc.yellow('<!-- AIDEV:START -->')} ${pc.dim('…')} ${pc.yellow('END')} ${pc.dim('markers — safe to delete anytime; it regenerates.')}
113
+ ${pc.dim('Too big? Lower')} ${pc.cyan('maxTokens')} ${pc.dim('in')} ${pc.cyan(CONFIG_PATH)}${pc.dim(', or set')} ${pc.cyan('injectDetailLevel: \'tree\'')} ${pc.dim('for names only.')}
114
+ `)
115
+
116
+ return targets
117
+ }
118
+
119
+ // Auto-wire the plugin into vite.config by parsing it with tree-sitter (same AST stack aihand
120
+ // already uses for signatures) — no regex, no string guessing. We locate the `plugins:` array and
121
+ // the last import via real syntax nodes, then splice at their byte offsets. Anything we can't pin
122
+ // down structurally falls back to printing a copy-paste snippet, because silently breaking a user's
123
+ // build config is a far worse first impression than a manual step.
124
+ async function bindVitePlugin(): Promise<void> {
125
+ const snippet = `${pc.dim('Add to your')} ${pc.cyan('vite.config')}${pc.dim(':')}
126
+ ${pc.cyan('import { aihand } from \'aihand/vite\'')}
127
+ ${pc.dim('// then inside plugins: [ ... ]')}
128
+ ${pc.cyan('aihand(),')} ${pc.dim('// apply: \'serve\' built in — no isServe guard needed')}`
129
+ const fail = (cfg: string) => console.log(` ${pc.yellow('!')} couldn't auto-wire ${cfg}.\n ${snippet}\n`)
130
+
131
+ const cfg = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'].find(existsSync)
132
+ if (!cfg)
133
+ return // not a vite project — nothing to bind
134
+
135
+ const src = readFileSync(cfg, 'utf-8')
136
+ if (src.includes('aihand')) {
137
+ console.log(` ${pc.green('✓')} vite plugin already wired in ${cfg}`)
138
+ return
139
+ }
140
+
141
+ await Parser.init()
142
+ const parser = new Parser()
143
+ const lang = await getLang(cfg.slice(cfg.lastIndexOf('.')))
144
+ if (!lang)
145
+ return fail(cfg)
146
+ parser.setLanguage(lang)
147
+ const tree = parser.parse(src)
148
+ if (!tree)
149
+ return fail(cfg)
150
+
151
+ // Walk the whole tree: remember the last import_statement, and the array that is the value of a
152
+ // `plugins:` pair. tree-sitter gives us exact byte offsets — no positional guessing.
153
+ let lastImportEnd = -1
154
+ let pluginsBracketEnd = -1
155
+ const visit = (node: Node) => {
156
+ if (node.type === 'import_statement')
157
+ lastImportEnd = node.endIndex
158
+ if (node.type === 'pair') {
159
+ const key = node.childForFieldName('key')
160
+ const value = node.childForFieldName('value')
161
+ if (key?.text === 'plugins' && value?.type === 'array')
162
+ pluginsBracketEnd = value.startIndex + 1 // just past the `[`
163
+ }
164
+ for (const child of node.children)
165
+ child && visit(child)
166
+ }
167
+ visit(tree.rootNode)
168
+
169
+ if (pluginsBracketEnd === -1 || lastImportEnd === -1 || lastImportEnd > pluginsBracketEnd)
170
+ return fail(cfg)
171
+
172
+ // Match the array's own indentation: copy the whitespace run that follows the `[`.
173
+ let indent = ''
174
+ for (let i = pluginsBracketEnd; src[i] === '\n' || src[i] === ' ' || src[i] === '\t'; i++)
175
+ indent = src[i] === '\n' ? '' : indent + src[i]
176
+
177
+ // Splice high offset first so the low one stays valid.
178
+ const withPlugin = `${src.slice(0, pluginsBracketEnd)}\n${indent}aihand(),${src.slice(pluginsBracketEnd)}`
179
+ const importLine = `\nimport { aihand } from 'aihand/vite'`
180
+ const wired = `${withPlugin.slice(0, lastImportEnd)}${importLine}${withPlugin.slice(lastImportEnd)}`
181
+ writeFileSync(cfg, wired)
182
+ console.log(` ${pc.green('✓')} Wired vite plugin into ${cfg}`)
183
+ }
184
+
185
+ function fmtTokens(n: number): string {
186
+ if (n >= 1_000_000)
187
+ return `${(n / 1_000_000).toFixed(1)}M`
188
+ if (n >= 1000)
189
+ return `${(n / 1000).toFixed(1)}K`
190
+ return String(n)
191
+ }
192
+
193
+ interface Row { file: string, info?: string, meta?: string }
194
+
195
+ function printTable(rows: Row[]) {
196
+ const colFile = Math.max(...rows.map(r => r.file.length)) + 2
197
+ for (const r of rows) {
198
+ const file = pad(r.file, colFile)
199
+ const extra = r.info ? pc.dim(r.info) : r.meta ? pc.dim(r.meta) : ''
200
+ console.log(` ${pc.green('✓')} ${file}${extra}`)
201
+ }
202
+ }
203
+
204
+ function readAll(paths: string[]): Map<string, string> {
205
+ const contents = new Map<string, string>()
206
+ for (const p of paths) {
207
+ try {
208
+ contents.set(p, readFileSync(p, 'utf-8'))
209
+ }
210
+ catch { /* skip unreadable files */ }
211
+ }
212
+ return contents
213
+ }
214
+
215
+ async function run(config: Config, opts: { quiet?: boolean } = {}): Promise<{ paths: string[], totalTokens: number, injected: number }> {
216
+ // --stdout: print the assembled codemap to stdout, never touch files (no markers, no inject,
217
+ // no decoration lines). After the map collapses to a pointer in CLAUDE.md, the full map is
218
+ // fetched on demand via this command — this is exactly what that pointer points at.
219
+ const toStdout = args.includes('--stdout')
220
+ const { quiet = toStdout } = opts
221
+ const perf = !quiet && args.includes('--perf')
222
+ const t0 = performance.now()
223
+ const mark = (label: string) => {
224
+ if (perf) {
225
+ const elapsed = (performance.now() - t0).toFixed(0)
226
+ console.log(pc.dim(` [${elapsed}ms] ${label}`))
227
+ }
228
+ }
229
+ // D0: Ensure markers exist in inject targets
230
+ if (!toStdout) {
231
+ for (const target of config.read.injectTargetFiles)
232
+ ensureMarkers(target)
233
+ }
234
+ mark('ensureMarkers')
235
+
236
+ // D1: Scan
237
+ const paths = scan(config)
238
+ mark(`scan (${paths.length} files)`)
239
+ if (!quiet)
240
+ console.log(`${BANNER} ${pc.dim(`${paths.length} files`)}`)
241
+
242
+ // Read all files once
243
+ const contents = readAll(paths)
244
+ mark('readAll')
245
+
246
+ // D2+D3: Classify + Compress
247
+ // Extraction level: always compact by default (overview needs content even when
248
+ // display level is 'tree'). fileDetailLevel overrides per-glob.
249
+ const classifyExtract = buildClassifier(config.read.fileDetailLevel, 'compact')
250
+ // Display level: drives what shows up in signatures / full sections.
251
+ // Defaults to injectDetailLevel; fileDetailLevel overrides per-glob.
252
+ const classifyDisplay = buildClassifier(config.read.fileDetailLevel, config.read.injectDetailLevel)
253
+ const blocks = await buildBlocks(paths, config, undefined, classifyExtract, contents)
254
+ mark('buildBlocks (compress)')
255
+
256
+ // Split into root (overview) and src (signatures) blocks
257
+ const rootBlocks = blocks.filter(b => !b.path.includes('/'))
258
+ const srcBlocks = blocks.filter(b => b.path.includes('/'))
259
+
260
+ // D4: Format — tree (needed for budget calc before allocation)
261
+ // When a Signatures/Full section will list file paths, the tree drops filenames (skeleton
262
+ // mode) to avoid duplicating the same path set in two sections.
263
+ const hasSignatures = srcBlocks.some(b => classifyDisplay(b.path) !== 'tree')
264
+
265
+ // Control panel — 从代码涌现的控制论模型(旋钮 + 状态),复用已读 contents 不回盘。
266
+ // 先于 tree 算出来:它言及的文件 = 关键文件,倒灌进 tree 标 ★,从控制面投影自动涌现重要度。
267
+ const knobs = await buildPanel(paths, contents)
268
+ const panel = knobs.length ? formatPanel(knobs) : undefined
269
+ const panelTokens = panel ? estimateTokens(panel) : 0
270
+ mark(`panel (${knobs.length} knobs)`)
271
+
272
+ // 状态显示(输出端):扫 store(返回类型带响应式 marker 的工厂)抽状态量全集——洗衣机面板的另一半。
273
+ const storeStates = await buildState(paths, config.runtime.storeMarker)
274
+ const stateFieldCount = storeStates.reduce((n, s) => n + s.fields.length, 0)
275
+ const state = storeStates.length ? formatState(storeStates) : undefined
276
+ const stateTokens = state ? estimateTokens(state) : 0
277
+ mark(`state (${stateFieldCount} fields)`)
278
+
279
+ // 关键文件 = 控制面言及的文件(旋钮 filePath ∪ store filePath)。
280
+ const starred = new Set<string>([...knobs.map(k => k.filePath), ...storeStates.map(s => s.filePath)])
281
+ const treeOpts = { skeletonOnly: hasSignatures, starred }
282
+ const activePaths = paths
283
+ // 有控制面 → tree 坍缩成「关键文件版图」:只显 ★ 文件 + 目录骨架,非★叶子折成 `… N more`。
284
+ // 全量物理布局是 AI 几乎不消费的噪音(找文件用 glob/grep),★ 才是入口。无控制面回落全量树。
285
+ const starredMode = starred.size > 0
286
+ let tree = starredMode
287
+ ? formatStarredTree(activePaths, starred)
288
+ : formatTree(activePaths, config.read.treeFoldThreshold, treeOpts)
289
+ let treeTokens = estimateTokens(tree)
290
+ mark('formatTree')
291
+
292
+ // Allocation result — used by formatXml to filter by level
293
+ let allocLevels: Map<string, import('./types.js').FileDetailLevel> | undefined
294
+
295
+ // Overview tokens — computed once, used in budget + display
296
+ const overviewTokens = computeOverviewTokens(rootBlocks)
297
+
298
+ // D3.5: Smart allocation (only when maxTokens is set)
299
+ if (config.read.maxTokens) {
300
+ const rootFileCount = paths.filter(p => !p.includes('/')).length
301
+ const sigBudget = computeSignaturesBudget(config.read.maxTokens, overviewTokens, rootFileCount)
302
+
303
+ const filePaths = srcBlocks.map(b => b.path)
304
+ const recency = computeRecency(filePaths)
305
+ const importGraph = buildImportGraph(srcBlocks)
306
+ const centrality = computeCentrality(filePaths, importGraph)
307
+ const pinned = computePinnedSet(filePaths, config.read.fileDetailLevel)
308
+ const result = allocate(srcBlocks, recency, centrality, sigBudget, pinned, p => classifyDisplay(p) === 'tree')
309
+ allocLevels = result.levels
310
+ mark('allocate')
311
+
312
+ // Still over budget after compact→tree? Collapse low-signal subtrees top-down: expand
313
+ // hot paths until the budget runs out, fold the rest to `dir/ (N files, M dirs)`.
314
+ // Gate on the REAL rendered tree size, not allocate()'s per-file estimate: formatTree
315
+ // already folds (`.ext ×N`, single-child chains), so the actual tree is far smaller than
316
+ // fileCount×treeEntryTokens. Folding off a phantom deficit shrinks a tree that already fit.
317
+ let collapsedCount = 0
318
+ const sigTokens = estimateTokens(formatXml(selectSignatureBlocks(srcBlocks, classifyDisplay), result.levels))
319
+ // starred 树已是最小关键版图,不参与预算折叠(它本就远低于预算)。
320
+ if (!starredMode && treeTokens + sigTokens > sigBudget) {
321
+ const srcPaths = srcBlocks.map(b => b.path)
322
+ const { kept, collapsedDirs } = expandByBudget(srcPaths, result.scores, result.contentTokens, sigBudget, result.treeEntryTokens)
323
+ collapsedCount = collapsedDirs.size
324
+ tree = formatTree(activePaths, config.read.treeFoldThreshold, { ...treeOpts, kept, collapsedDirs })
325
+ treeTokens = estimateTokens(tree)
326
+ mark('expandByBudget')
327
+ }
328
+
329
+ if (result.downgraded > 0 || collapsedCount > 0) {
330
+ const parts: string[] = []
331
+ if (result.downgraded > 0)
332
+ parts.push(`${result.downgraded} downgraded`)
333
+ if (collapsedCount > 0)
334
+ parts.push(`${collapsedCount} dirs collapsed`)
335
+ if (!quiet)
336
+ console.log(` ${pc.cyan('Smart allocation')} ${pc.dim(`${fmtTokens(result.totalBefore)} → ${fmtTokens(result.totalAfter)} tokens (${parts.join(', ')})`)}`)
337
+ }
338
+ }
339
+
340
+ // D4: Format sections
341
+ const overview = formatOverview(rootBlocks)
342
+ const signatureBlocks = selectSignatureBlocks(srcBlocks, classifyDisplay)
343
+ const signatures = formatXml(signatureBlocks, allocLevels)
344
+ const sigTokens = estimateTokens(signatures)
345
+ mark('formatXml (signatures)')
346
+
347
+ // Full blocks for the full section (and reused for security scan below).
348
+ const fullOnlyBlocks = selectFullBlocks(paths, contents, classifyDisplay)
349
+ const full = fullOnlyBlocks.length ? formatXml(fullOnlyBlocks) : undefined
350
+ // Security scan: all non-tree files regardless of display level (defense in depth).
351
+ const fullBlocks = paths
352
+ .filter(p => classifyDisplay(p) !== 'tree' && contents.get(p)?.trim())
353
+ .map(p => ({ path: p, level: 'full' as const, content: contents.get(p)! }))
354
+ mark('full blocks')
355
+
356
+ // Raw token count from file contents (no XML wrapper overhead)
357
+ let fullTokens = 0
358
+ for (const [, v] of contents) fullTokens += estimateTokens(v)
359
+
360
+ const data: RepodexData = {
361
+ overview,
362
+ ...(panel ? { panel } : {}),
363
+ ...(state ? { state } : {}),
364
+ tree,
365
+ signatures,
366
+ ...(full ? { full } : {}),
367
+ }
368
+
369
+ mark('done')
370
+
371
+ // Print section breakdown — match what actually gets injected
372
+ const rows: Row[] = [
373
+ { file: 'overview', info: `${fmtTokens(overviewTokens)} tokens` },
374
+ ]
375
+ let totalTokens = overviewTokens + treeTokens
376
+ if (panel) {
377
+ rows.push({ file: 'panel', info: `${knobs.length} knobs · ${fmtTokens(panelTokens)} tokens` })
378
+ totalTokens += panelTokens
379
+ }
380
+ if (state) {
381
+ rows.push({ file: 'state', info: `${stateFieldCount} fields · ${fmtTokens(stateTokens)} tokens` })
382
+ totalTokens += stateTokens
383
+ }
384
+ rows.push({ file: 'tree', info: `${fmtTokens(treeTokens)} tokens` })
385
+ if (signatures) {
386
+ rows.push({ file: 'signatures', info: `${fmtTokens(sigTokens)} tokens` })
387
+ totalTokens += sigTokens
388
+ }
389
+ if (full) {
390
+ const fullXmlTokens = estimateTokens(full)
391
+ rows.push({ file: 'full', info: `${fmtTokens(fullXmlTokens)} tokens` })
392
+ totalTokens += fullXmlTokens
393
+ }
394
+ if (!quiet) {
395
+ console.log(pc.bold(' Sections'))
396
+ printTable(rows)
397
+ }
398
+
399
+ // D5: Inject — or print to stdout. stdout forces injectCodemap:true (this command exists
400
+ // precisely to show the full map), going through the same renderSections as inject so
401
+ // "what you see == what was once injected".
402
+ let injected: string[] = []
403
+ if (toStdout) {
404
+ process.stdout.write(`${renderSections({ ...config, read: { ...config.read, injectCodemap: true } }, data)}\n`)
405
+ }
406
+ else {
407
+ injected = injectAll(config, data, config.read.injectTargetFiles)
408
+ if (injected.length && !quiet) {
409
+ console.log(pc.bold('\n Injected'))
410
+ printTable(injected.map(f => ({ file: f, meta: `injectDetailLevel=${config.read.injectDetailLevel}` })))
411
+ }
412
+ }
413
+ mark('inject')
414
+
415
+ // Security check
416
+ const secrets = scanSecrets(fullBlocks)
417
+ if (secrets.length && !quiet) {
418
+ console.log(`\n ${pc.bold(pc.red(`⚠ ${secrets.length} potential secret(s) in full output:`))}`)
419
+ for (const s of secrets)
420
+ console.log(` ${pc.yellow(`${s.path}:${s.line}`)} ${pc.dim(s.pattern)}`)
421
+ }
422
+
423
+ // Token distribution by directory (verbose only)
424
+ if (perf) {
425
+ const allBlocks = [...rootBlocks, ...srcBlocks]
426
+ if (allBlocks.length > 3) {
427
+ const dirTokens = new Map<string, number>()
428
+ for (const b of allBlocks) {
429
+ const parts = b.path.split('/')
430
+ const dir = parts.length > 2 ? `${parts[0]}/${parts[1]}/` : parts.length > 1 ? `${parts[0]}/` : '(root)'
431
+ dirTokens.set(dir, (dirTokens.get(dir) ?? 0) + estimateTokens(b.content))
432
+ }
433
+ const topDirs = [...dirTokens.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
434
+ console.log(pc.bold('\n Token distribution'))
435
+ const colDir = Math.max(...topDirs.map(([d]) => d.length)) + 2
436
+ for (const [dir, tokens] of topDirs) {
437
+ const pct = totalTokens > 0 ? ((tokens / totalTokens) * 100).toFixed(0) : '0'
438
+ console.log(` ${pad(dir, colDir)}${fmtTokens(tokens).padStart(6)} ${pc.dim(`${pct}%`)}`)
439
+ }
440
+ }
441
+ }
442
+
443
+ // Summary
444
+ if (!quiet) {
445
+ const savedPct = fullTokens > 0 ? ((1 - totalTokens / fullTokens) * 100).toFixed(1) : '0.0'
446
+ console.log(`\n ${pc.green(`${fmtTokens(totalTokens)} tokens injected`)} ${pc.dim(`— saved ${savedPct}% (${fmtTokens(fullTokens)} raw → ${fmtTokens(totalTokens)})`)}\n`)
447
+ }
448
+
449
+ return { paths, totalTokens, injected: injected.length }
450
+ }
451
+
452
+ async function main() {
453
+ // A directory with its own package.json but no aihand.config.ts is a fresh subproject:
454
+ // init here instead of climbing to an ancestor's config (which would index the wrong tree).
455
+ const initHere = existsSync('package.json') && !existsSync(CONFIG_PATH)
456
+ if (!initHere)
457
+ cdToRoot()
458
+
459
+ const useJson = args.includes('--json')
460
+
461
+ // Refactor: move-file <src> <dest> [--dry] — AST-level, rewrites all importers
462
+ if (args[0] === 'move-file') {
463
+ if (!args[1] || !args[2]) {
464
+ console.error('Usage: aihand refactor move-file <src> <dest> [--dry]')
465
+ process.exit(1)
466
+ }
467
+ const { moveFile } = await import('./refactor.js')
468
+ await moveFile(args[1], args[2], args.includes('--dry'))
469
+ return
470
+ }
471
+
472
+ // Refactor: rename <file> <oldName> <newName> [--dry] — type-checker-driven, all refs + alias imports
473
+ if (args[0] === 'rename') {
474
+ if (!args[1] || !args[2] || !args[3]) {
475
+ console.error('Usage: aihand refactor rename <file> <oldName> <newName> [--dry]')
476
+ process.exit(1)
477
+ }
478
+ const { renameSymbol } = await import('./refactor.js')
479
+ await renameSymbol(args[1], args[2], args[3], args.includes('--dry'))
480
+ return
481
+ }
482
+
483
+ // Refactor: move-symbol <fromFile> <name> <toFile> [--dry] — carries decl + deps, rewrites importers
484
+ if (args[0] === 'move-symbol') {
485
+ if (!args[1] || !args[2] || !args[3]) {
486
+ console.error('Usage: aihand refactor move-symbol <fromFile> <name> <toFile> [--dry]')
487
+ process.exit(1)
488
+ }
489
+ const { moveSymbol } = await import('./refactor.js')
490
+ await moveSymbol(args[1], args[2], args[3], args.includes('--dry'))
491
+ return
492
+ }
493
+
494
+ // source <file:line> — 一体化反向态射:运行时坐标(file:line,或直接喂 /dom 的
495
+ // data-insp-path) → 静态符号上下文。symbolAtLine 取精确 uid,callers/callees 按 uid。
496
+ if (args[0] === 'source') {
497
+ const config = await loadConfig()
498
+ const paths = scan(config)
499
+ const tsFiles = paths.filter(isTsFile)
500
+ const graph = await buildCallGraph(tsFiles)
501
+ if (!args[1]) {
502
+ console.error('Usage: aihand read source <file:line> (file:line 可直接是 /dom 的 data-insp-path)')
503
+ process.exit(1)
504
+ }
505
+ const { file, line } = parseInspPath(args[1])
506
+ if (!file || !Number.isFinite(line)) {
507
+ console.error(`Bad location: ${args[1]} — expected file:line (or data-insp-path)`)
508
+ process.exit(1)
509
+ }
510
+ const result = locate(graph, file, line)
511
+ if (!result) {
512
+ console.error(`No symbol covers ${file}:${line} (是否 scan 范围内的文件?行号是否在某符号区间内?)`)
513
+ process.exit(1)
514
+ }
515
+ console.log(renderLocate(result, useJson))
516
+ return
517
+ }
518
+
519
+ // tree — file-tree navigation map: "where files live / the layout". Pulled standalone instead
520
+ // of via --stdout's full map, so you don't drag 288 lines of panel+state just to glance at the
521
+ // file layout (kills the multi-layer routing).
522
+ if (args[0] === 'tree') {
523
+ const config = await loadConfig()
524
+ console.log(formatTree(scan(config), config.read.treeFoldThreshold))
525
+ return
526
+ }
527
+
528
+ // panel — 控制面:从代码涌现整个 App 的旋钮(系统控制论模型第一层)。
529
+ // --live[=port] 合缝:⋈ 运行时已渲染旋钮(label+file),每个旋钮标 ● live / ○ 未渲染。
530
+ if (args[0] === 'panel') {
531
+ const config = await loadConfig()
532
+ const staticKnobs = await buildPanel(scan(config))
533
+
534
+ const liveFlag = args.find(a => a === '--live' || a.startsWith('--live='))
535
+ let knobs: Array<typeof staticKnobs[number] & { live?: boolean }> = staticKnobs
536
+ let liveCount = 0
537
+ if (liveFlag) {
538
+ const port = Number(liveFlag.includes('=') ? liveFlag.split('=')[1] : 5173)
539
+ const runtime = await fetchRuntimeKnobs(port)
540
+ const joined = joinPanel(staticKnobs, runtime)
541
+ knobs = joined
542
+ liveCount = joined.filter(k => k.live).length
543
+ }
544
+
545
+ if (useJson) {
546
+ console.log(JSON.stringify(knobs, null, 2))
547
+ return
548
+ }
549
+ // 按文件分组打印,每个文件一段控制面。
550
+ const byFile = new Map<string, typeof knobs>()
551
+ for (const k of knobs) {
552
+ const arr = byFile.get(k.filePath) ?? []
553
+ arr.push(k)
554
+ byFile.set(k.filePath, arr)
555
+ }
556
+ const head = liveFlag
557
+ ? `控制面 — ${knobs.length} 个旋钮,${liveCount} 个此刻已渲染(● live),${byFile.size} 个文件\n`
558
+ : `控制面 — ${knobs.length} 个旋钮,${byFile.size} 个文件\n`
559
+ console.log(pc.bold(head))
560
+ for (const [file, ks] of byFile) {
561
+ console.log(pc.cyan(file))
562
+ for (const k of ks) {
563
+ const dot = !liveFlag ? '' : k.live ? `${pc.green('●')} ` : `${pc.dim('○')} `
564
+ const name = k.label ? `「${k.label}」` : pc.dim(`<${k.tag}>`)
565
+ console.log(` ${dot}${name} ${pc.dim(`${k.event} →`)} ${k.store}.{ ${fmtTransitions(k.transitions)} } ${pc.dim(`@${k.line}`)}`)
566
+ }
567
+ }
568
+ return
569
+ }
570
+
571
+ const watchMode = args.includes('--watch') || args.includes('-w')
572
+
573
+ if (!existsSync(CONFIG_PATH)) {
574
+ await initConfig()
575
+ }
576
+
577
+ const config = await loadConfig()
578
+ const { paths: files, totalTokens: initialTokens } = await run(config)
579
+ let lastTokens = initialTokens
580
+
581
+ if (!watchMode)
582
+ return
583
+
584
+ // Parent guard: if the spawning dev server dies uncatchably (SIGKILL/crash),
585
+ // its cleanup never runs and we'd be orphaned. An orphan gets reparented (to
586
+ // launchd/init), so ppid changes from its launch value — detect that and self-exit.
587
+ const parentAtLaunch = process.ppid
588
+ setInterval(() => {
589
+ if (process.ppid !== parentAtLaunch)
590
+ process.exit(0) // reparented → original parent gone, nothing to serve
591
+ }, 2000).unref()
592
+
593
+ // Watch source dirs + root files (e.g. package.json, aihand.config.ts)
594
+ const watchRoots = new Set<string>()
595
+ for (const f of files) {
596
+ const seg = f.split('/')[0]
597
+ watchRoots.add(seg)
598
+ }
599
+ watchRoots.add(CONFIG_PATH)
600
+
601
+ // Ignore self-output: injecting into target files would otherwise retrigger watch → infinite loop.
602
+ const ignored = new Set(config.read.injectTargetFiles.map(f => f.split('/').pop()))
603
+
604
+ const timeFmt = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: 'numeric', second: 'numeric' })
605
+ let timer: ReturnType<typeof setTimeout> | null = null
606
+ const changed = new Set<string>()
607
+ const onChange = (filename: string | Buffer | null, root: string, rootIsFile: boolean) => {
608
+ const fname = filename?.toString() ?? ''
609
+ const path = rootIsFile ? root : fname ? `${root}/${fname}` : root
610
+ if (ignored.has(path.split('/').pop()))
611
+ return
612
+ changed.add(path)
613
+ if (timer)
614
+ clearTimeout(timer)
615
+ timer = setTimeout(async () => {
616
+ const fresh = await loadConfig()
617
+ const t0 = performance.now()
618
+ const { totalTokens, injected } = await run(fresh, { quiet: true })
619
+ const list = [...changed]
620
+ changed.clear()
621
+ if (!injected)
622
+ return // nothing changed in the injected output — stay silent
623
+ const ms = (performance.now() - t0).toFixed(0)
624
+ const time = timeFmt.format(new Date())
625
+ const summary = list.join(', ')
626
+ const diff = totalTokens - lastTokens
627
+ lastTokens = totalTokens
628
+ const diffStr = diff === 0
629
+ ? pc.dim('±0')
630
+ : (diff > 0 ? pc.yellow : pc.cyan)(`${diff > 0 ? '+' : '-'}${fmtTokens(Math.abs(diff))}`)
631
+ console.log(`${pc.dim(time)} ${pc.dim(`[aihand ${VERSION}]`)} ${pc.green('update')} ${pc.dim(summary)} ${pc.dim(`${ms}ms`)} ${pc.dim('·')} ${pc.green(fmtTokens(totalTokens))} ${diffStr}`)
632
+ }, 300)
633
+ }
634
+
635
+ for (const root of watchRoots) {
636
+ if (!existsSync(root))
637
+ continue
638
+ const isFile = looksLikeFile(root)
639
+ watch(root, isFile ? {} : { recursive: true }, (_e, f) => onChange(f, root, isFile))
640
+ }
641
+
642
+ console.log(pc.dim(` watching ${[...watchRoots].join(', ')}...\n`))
643
+ }
644
+
645
+ // Exported entry for the unified router (src/cli.ts). Named distinctly from the
646
+ // internal injection-pipeline run() above.
647
+ export async function runCli(argv: string[]) {
648
+ args = argv
649
+ await main()
650
+ }