dsh-config-manager 0.1.78 → 0.1.80

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 (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -1,40 +1,75 @@
1
1
  /**
2
2
  * Claude Code 用户目录的**读盘**层(宿主侧,node:fs)。
3
3
  *
4
- * 与 claude-code.ts 的纯翻译层分开:纯函数好测;读盘只做「读得到就读、读不到如实报」,
5
- * 绝不猜测、绝不静默截断(超限的文件直接不带,并计入 unreadable)。
4
+ * 三个作用域(2026-10-18 审计后补齐):
5
+ * ① 用户级:`<configDir>/settings.json`、CLAUDE.md、skills、commands、projects/*.jsonl(用户级
6
+ * `agents/` 本层刻意不采集:types 的 ClaudeCodeInput 没有对应字段,读了也无处交付);
7
+ * ② 项目级:`~/.claude.json` 的 `projects` 键(Claude Code 自己写的权威清单)+ 调用方显式给出的
8
+ * `projectPaths`;每个项目读 `.claude/settings(.local).json`、CLAUDE(.local).md、`.claude/rules`、
9
+ * `.claude/skills`、`.claude/commands`、`.claude/agents`、`.mcp.json`,以及全局清单里该项目的
10
+ * `mcpServers`;
11
+ * ③ 插件:委托 `read-claude-plugins.ts`(只读元数据与技能文本,**绝不执行/复制插件代码**)。
6
12
  *
7
- * 安全边界:只读用户 home 下的固定位置(.claude / .claude.json),不跟随符号链接,
8
- * 单文件有字节上限;本层读到的**值**只交给纯翻译层,翻译层负责剥离凭据后才允许进包。
13
+ * 配置目录可被 `CLAUDE_CONFIG_DIR` 覆盖(**非空才生效**):命中即报 source-location-overridden,
14
+ * 并且**只读覆盖后的目录** —— 绝不回落到默认位置(回落会把「位置已被覆盖」这件事藏起来,
15
+ * 用户会以为读到的是 `~/.claude`)。
16
+ *
17
+ * 五条硬边界(与同域其它读盘层同口径):
18
+ * - **绝不跟随符号链接**(技能目录是链接时跳过并报 symlink-skipped;项目内直读文件同样先 lstat);
19
+ * - 单文件有字节上限,超限**不读**并如实上报,绝不截断;
20
+ * - 一切触顶(会话数 / 项目数 / 技能文件数)都报**稳定机器码**,绝不静默(audit-foreign F4 同族);
21
+ * - 读盘层只回数据与机器码,**绝不产生用户可见字符串**;本层读到的**值**交给翻译层剥离凭据后才进包;
22
+ * - 项目根**只取真实存在且是目录的**(不猜「项目就是 cwd」),已删除的项目安静跳过。
23
+ *
24
+ * 与 read-claude-plugins.ts 的关系:本文件动态 import 它(避免两个读盘模块的静态 import 环);
25
+ * 它反向静态 import 本文件的 readSkillUnits —— 静态依赖是单向的。
9
26
  */
10
27
  import fs from 'node:fs/promises';
11
28
  import type { Dirent } from 'node:fs';
12
29
  import path from 'node:path';
13
30
 
31
+ import { isRecord } from '../utils/guards.ts';
14
32
  import { dirWalkSkips, resolveLimit, type DirWalkStats } from './session-read.ts';
15
- import type { ClaudeCodeInput, ClaudeSessionInput, ClaudeSkillInput, ForeignLimitOverrides, ForeignSkip } from './types.ts';
33
+ import type {
34
+ ClaudeCodeInput,
35
+ ClaudeProjectInput,
36
+ ClaudeSessionInput,
37
+ ClaudeSkillInput,
38
+ ForeignLimitOverrides,
39
+ ForeignSkip,
40
+ } from './types.ts';
16
41
 
17
42
  export interface ClaudeCodeReadOptions {
18
43
  /** 用户 home(含 .claude 目录 / .claude.json) */
19
44
  homeDir: string;
45
+ /** 进程环境快照(只读):只有 CLAUDE_CONFIG_DIR 参与判定,绝不读凭据类变量 */
46
+ env?: Readonly<Record<string, string | undefined>>;
47
+ /** 调用方**显式**给出的项目根(优先于 ~/.claude.json 的 projects 键;缺省 = 不猜任何项目) */
48
+ projectPaths?: readonly string[];
20
49
  /** 单文件读取上限(默认 8 MiB);超过即不读,并如实计入 unreadable */
21
50
  maxFileBytes?: number;
22
51
  /** 单个 skill 目录的文件数上限(默认 200) */
23
52
  maxSkillFiles?: number;
24
- /** 会话文件数上限(默认 500) */
53
+ /** 会话文件数上限(默认 500);触顶报 source-unreadable/max-sessions-reached */
25
54
  maxSessionFiles?: number;
26
55
  /** 单个会话文件的上限(默认 32 MiB);超限即不读并计入 unreadable,绝不截断 */
27
56
  maxSessionFileBytes?: number;
57
+ /** 项目扫描上限(默认 50);触顶报 source-unreadable/max-projects-reached */
58
+ maxProjects?: number;
59
+ /** 插件数上限(默认 50,透传给 read-claude-plugins) */
60
+ maxPlugins?: number;
61
+ /** 单插件文件数上限(默认 200,透传给 read-claude-plugins) */
62
+ maxPluginFiles?: number;
28
63
  /** 可选上限覆盖(t36,装配层透传;缺省 = 上面各默认值逐字不变) */
29
64
  limits?: ForeignLimitOverrides;
30
65
  }
31
66
 
32
67
  export interface ClaudeCodeReadResult {
33
- /** 是否找到 Claude Code 的痕迹(~/.claude 目录或 ~/.claude.json) */
68
+ /** 是否找到 Claude Code 的痕迹(配置目录或 ~/.claude.json) */
34
69
  found: boolean;
35
70
  input: ClaudeCodeInput;
36
71
  skipped: ForeignSkip[];
37
- /** 读不到 / 超限 / JSON 解析失败的**相对路径**(不含任何内容) */
72
+ /** 读不到 / 超限 / JSON 解析失败的**相对路径**(不含任何内容、不含机器绝对路径) */
38
73
  unreadable: string[];
39
74
  }
40
75
 
@@ -42,8 +77,17 @@ const DEFAULT_MAX_FILE = 8 * 1024 * 1024;
42
77
  const DEFAULT_MAX_SKILL_FILES = 200;
43
78
  const DEFAULT_MAX_SESSION_FILES = 500;
44
79
  const DEFAULT_MAX_SESSION_FILE = 32 * 1024 * 1024;
80
+ const DEFAULT_MAX_PROJECTS = 50;
45
81
  /** 全局指令文件(CLAUDE.md)单独给上限:它是纯文本,正常只有几十 KB */
46
82
  const MEMORY_MAX_FILE = 1024 * 1024;
83
+ /** 目录走盘的最大下钻深度(与 session-read 的 WalkOptions 缺省一致) */
84
+ const WALK_MAX_DEPTH = 8;
85
+ /** 环境变量名(唯一判据) */
86
+ const ENV_CONFIG_DIR = 'CLAUDE_CONFIG_DIR';
87
+ /** 触顶 / 跳过的稳定机器码(进 ForeignSkip.detail,绝不进用户可见文案) */
88
+ const DETAIL_MAX_SESSIONS = 'max-sessions-reached';
89
+ const DETAIL_MAX_PROJECTS = 'max-projects-reached';
90
+ const DETAIL_SYMLINK = 'symlink-skipped';
47
91
 
48
92
  async function statOrNull(p: string) {
49
93
  try {
@@ -53,6 +97,25 @@ async function statOrNull(p: string) {
53
97
  }
54
98
  }
55
99
 
100
+ /** lstat:**不跟随**符号链接(项目内直读文件必须先看它) */
101
+ async function lstatOrNull(p: string) {
102
+ try {
103
+ return await fs.lstat(p);
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /** 目录条目(排序;三平台 readdir 顺序不同,排序让产物与测试确定) */
110
+ async function listDirents(dir: string): Promise<Dirent[]> {
111
+ try {
112
+ const dirents = await fs.readdir(dir, { withFileTypes: true });
113
+ return dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
114
+ } catch {
115
+ return [];
116
+ }
117
+ }
118
+
56
119
  interface JsonRead { ok: boolean; value?: unknown }
57
120
 
58
121
  async function readTextSafe(p: string, max: number): Promise<string | null> {
@@ -75,6 +138,23 @@ async function readJsonSafe(p: string, max: number): Promise<JsonRead> {
75
138
  }
76
139
  }
77
140
 
141
+ /** CLAUDE_CONFIG_DIR 的非空取值(空串 / 纯空白视为「没设置」——Claude Code 自身也是这个语义) */
142
+ function configDirOverrideOf(env: Readonly<Record<string, string | undefined>> | undefined): string | undefined {
143
+ if (env === undefined) return undefined;
144
+ const raw = env[ENV_CONFIG_DIR];
145
+ if (typeof raw !== 'string') return undefined;
146
+ const trimmed = raw.trim();
147
+ return trimmed === '' ? undefined : trimmed;
148
+ }
149
+
150
+ /** ~/.claude.json 的 lastVersionBase(来源侧版本标记;不是字符串就不猜) */
151
+ function lastVersionBaseOf(claudeJson: unknown): string | undefined {
152
+ if (!isRecord(claudeJson)) return undefined;
153
+ const value = claudeJson['lastVersionBase'];
154
+ if (typeof value !== 'string' || value.trim() === '') return undefined;
155
+ return value.trim();
156
+ }
157
+
78
158
  interface SkillFile { relativePath: string; data: Uint8Array }
79
159
 
80
160
  /** 递归收集一个 skill 目录下的普通文件(不跟随符号链接;条目数与单文件字节都有上限) */
@@ -124,106 +204,406 @@ async function walkFiles(
124
204
  return out;
125
205
  }
126
206
 
127
- async function readSkills(
128
- skillsDir: string,
129
- maxFiles: number,
130
- maxBytes: number,
131
- stats?: DirWalkStats,
132
- ): Promise<ClaudeSkillInput[]> {
133
- let dirents: Dirent[];
134
- try {
135
- dirents = await fs.readdir(skillsDir, { withFileTypes: true });
136
- } catch {
137
- return [];
138
- }
207
+ /** 技能目录读取的可选参数(用户级 / 项目级 / 插件级共用同一份口径) */
208
+ export interface SkillDirReadOptions {
209
+ /** 单个技能目录的文件数上限 */
210
+ maxFiles: number;
211
+ /** 单文件字节上限 */
212
+ maxBytes: number;
213
+ /** 位置标签前缀(**包内相对标签**,绝不含机器路径) */
214
+ origin: string;
215
+ /** 发现写这里(符号链接跳过 / 条数触顶 / 字节超限) */
216
+ skips: ForeignSkip[];
217
+ }
218
+
219
+ /**
220
+ * 读一个 **skills 目录**(只压平一层:`<skills>/<技能名>/**`)。
221
+ *
222
+ * 三条不静默(缺陷 2 修复):技能目录本身是符号链接 → source-unreadable + symlink-skipped;
223
+ * 条数触顶 → max-skill-files-reached;单文件超字节 → too-large。
224
+ * 结果按技能名排序(readdir 顺序在三个平台不同)。
225
+ */
226
+ export async function readSkillUnits(dir: string, opts: SkillDirReadOptions): Promise<ClaudeSkillInput[]> {
139
227
  const out: ClaudeSkillInput[] = [];
140
- for (const d of dirents) {
228
+ for (const d of await listDirents(dir)) {
229
+ if (d.isSymbolicLink()) {
230
+ // 缺陷 2:此前这里直接 continue(整块技能消失且一个字都不报)
231
+ opts.skips.push({ code: 'source-unreadable', origin: opts.origin + '/' + d.name, detail: DETAIL_SYMLINK });
232
+ continue;
233
+ }
141
234
  if (!d.isDirectory()) continue;
142
- const dir = path.join(skillsDir, d.name);
143
- const files = await walkFiles(dir, dir, maxFiles, maxBytes, stats);
235
+ const skillDir = path.join(dir, d.name);
236
+ const stats: DirWalkStats = {};
237
+ const files = await walkFiles(skillDir, skillDir, opts.maxFiles, opts.maxBytes, stats);
238
+ opts.skips.push(...dirWalkSkips(stats, opts.origin, opts.maxFiles));
144
239
  if (files.length === 0) continue;
145
240
  out.push({ name: d.name, files });
146
241
  }
242
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
147
243
  return out;
148
244
  }
149
245
 
150
246
  /**
151
- * 会话文件:只扫 <projects>/<项目>/<uuid>.jsonl 这一层(不递归子目录、不跟随符号链接)。
152
- * 读不到 / 超限的文件**不静默跳过**:进 unreadable 并变成一条 source-unreadable finding。
247
+ * 取「记录自带的 sessionId」:只扫前 64 KiB(够看清头部记录,不必为它把整份日志再解析一遍)。
248
+ * 取不到就返回 undefined —— 不猜、不伪造。
249
+ */
250
+ const RECORD_ID_SCAN_BYTES = 64 * 1024;
251
+ function recordSessionIdOf(text: string): string | undefined {
252
+ const head = text.length > RECORD_ID_SCAN_BYTES ? text.slice(0, RECORD_ID_SCAN_BYTES) : text;
253
+ const m = /"sessionId"\s*:\s*"([^"]+)"/.exec(head);
254
+ const hit = m === null ? undefined : m[1];
255
+ return hit === undefined || hit === '' ? undefined : hit;
256
+ }
257
+
258
+ /**
259
+ * 会话文件:只扫 `<projects>/<项目>/<uuid>.jsonl` 这一层(不递归子目录、不跟随符号链接)。
260
+ * 读不到 / 超限的文件**不静默跳过**:进 unreadable 并变成一条 source-unreadable finding;
261
+ * 条数触顶(缺陷 1 修复)回 truncated=true,由调用方推 max-sessions-reached。
153
262
  */
154
263
  async function readSessions(
155
264
  projectsDir: string,
156
265
  maxFiles: number,
157
266
  maxBytes: number,
158
- ): Promise<{ files: ClaudeSessionInput[]; unreadable: string[] }> {
267
+ ): Promise<{ files: ClaudeSessionInput[]; unreadable: string[]; truncated: boolean }> {
159
268
  const files: ClaudeSessionInput[] = [];
160
269
  const unreadable: string[] = [];
161
- let projectDirs: Dirent[];
162
- try {
163
- projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
164
- } catch {
165
- return { files, unreadable };
166
- }
167
- for (const pd of projectDirs) {
168
- if (files.length >= maxFiles) break;
270
+ let truncated = false;
271
+ for (const pd of await listDirents(projectsDir)) {
169
272
  if (!pd.isDirectory() || pd.isSymbolicLink()) continue;
170
273
  const dir = path.join(projectsDir, pd.name);
171
- let entries: Dirent[];
172
- try {
173
- entries = await fs.readdir(dir, { withFileTypes: true });
174
- } catch {
175
- continue;
176
- }
177
- for (const e of entries) {
178
- if (files.length >= maxFiles) break;
274
+ for (const e of await listDirents(dir)) {
179
275
  if (!e.isFile() || e.isSymbolicLink()) continue;
180
276
  if (!e.name.endsWith('.jsonl')) continue;
277
+ // 触顶判定放在「真的还有下一个候选文件」处:恰好等于上限但目录里没有下一个 → 不报码
278
+ if (files.length >= maxFiles) { truncated = true; break; }
181
279
  const rel = '.claude/projects/' + pd.name + '/' + e.name;
182
- const text = await readTextSafe(path.join(dir, e.name), maxBytes);
280
+ const abs = path.join(dir, e.name);
281
+ const text = await readTextSafe(abs, maxBytes);
183
282
  if (text === null) {
184
283
  unreadable.push(rel);
185
284
  continue;
186
285
  }
187
- files.push({ id: e.name.slice(0, e.name.length - '.jsonl'.length), text });
286
+ const id = e.name.slice(0, e.name.length - '.jsonl'.length);
287
+ // 记录自带的 sessionId(≠ 文件名 stem 时由翻译层报 session-id-mismatch,不静默)
288
+ const recordSessionId = recordSessionIdOf(text);
289
+ // 确定性墙钟回落 = 文件 mtime:记录自身有时间戳时它不被使用;没有时它让**同一来源两次
290
+ // 转码字节逐字相同** ⇒ 重复导入才会被判成 Skip 而不是 Conflict(审计缺陷 4 的接线口)。
291
+ const st = await statOrNull(abs);
292
+ files.push({
293
+ id,
294
+ text,
295
+ ...(recordSessionId !== undefined ? { recordSessionId } : {}),
296
+ ...(st !== null ? { createdAtMs: st.mtimeMs } : {}),
297
+ });
298
+ }
299
+ if (truncated) break;
300
+ }
301
+ return { files, unreadable, truncated };
302
+ }
303
+
304
+ /** 递归统计目录下的普通文件数(不跟随符号链接;深度上限与 walk 同口径) */
305
+ async function countFilesRecursive(dir: string): Promise<number> {
306
+ let count = 0;
307
+ const stack: { dir: string; depth: number }[] = [{ dir, depth: 0 }];
308
+ while (stack.length > 0) {
309
+ const cur = stack.pop();
310
+ if (cur === undefined) continue;
311
+ for (const d of await listDirents(cur.dir)) {
312
+ if (d.isSymbolicLink()) continue;
313
+ if (d.isDirectory()) {
314
+ if (cur.depth < WALK_MAX_DEPTH) stack.push({ dir: path.join(cur.dir, d.name), depth: cur.depth + 1 });
315
+ continue;
316
+ }
317
+ if (d.isFile()) count += 1;
318
+ }
319
+ }
320
+ return count;
321
+ }
322
+
323
+ /** `<dir>/*.md` 的「名字 + 文件名」(子代理定义只报告结构,不搬内容) */
324
+ async function readAgentNames(dir: string): Promise<{ name: string; file: string }[]> {
325
+ const out: { name: string; file: string }[] = [];
326
+ for (const d of await listDirents(dir)) {
327
+ if (d.isSymbolicLink() || !d.isFile() || !d.name.endsWith('.md')) continue;
328
+ out.push({ name: d.name.slice(0, 0 - '.md'.length), file: d.name });
329
+ }
330
+ out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
331
+ return out;
332
+ }
333
+
334
+ /** `<dir>/*.md` 的原文(规则文件带 frontmatter 的作用域由翻译层报 unsupported-rules) */
335
+ async function readRuleFiles(
336
+ dir: string,
337
+ maxBytes: number,
338
+ findings: ForeignSkip[],
339
+ ): Promise<{ name: string; text: string }[]> {
340
+ const out: { name: string; text: string }[] = [];
341
+ for (const d of await listDirents(dir)) {
342
+ if (d.isSymbolicLink() || !d.isFile() || !d.name.endsWith('.md')) continue;
343
+ const text = await readTextSafe(path.join(dir, d.name), maxBytes);
344
+ if (text === null) {
345
+ findings.push({ code: 'source-unreadable', origin: '.claude/rules/' + d.name });
346
+ continue;
188
347
  }
348
+ out.push({ name: d.name, text });
189
349
  }
190
- return { files, unreadable };
350
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
351
+ return out;
191
352
  }
192
353
 
193
- async function countCommands(commandsDir: string): Promise<number> {
194
- let dirents: Dirent[];
354
+ /** 直读一个项目内文件(**先 lstat**:符号链接一律不读、报码) */
355
+ async function probeProjectJson(
356
+ p: string,
357
+ label: string,
358
+ maxBytes: number,
359
+ findings: ForeignSkip[],
360
+ ): Promise<unknown | undefined> {
361
+ const st = await lstatOrNull(p);
362
+ if (st === null) return undefined;
363
+ if (st.isSymbolicLink()) {
364
+ findings.push({ code: 'source-unreadable', origin: label, detail: DETAIL_SYMLINK });
365
+ return undefined;
366
+ }
367
+ if (!st.isFile()) {
368
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'not-a-file' });
369
+ return undefined;
370
+ }
371
+ if (st.size === 0) {
372
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'empty' });
373
+ return undefined;
374
+ }
375
+ if (st.size > maxBytes) {
376
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'too-large' });
377
+ return undefined;
378
+ }
379
+ let text: string;
380
+ try {
381
+ text = await fs.readFile(p, 'utf8');
382
+ } catch {
383
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'read-error' });
384
+ return undefined;
385
+ }
386
+ try {
387
+ return JSON.parse(text);
388
+ } catch {
389
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'json-error' });
390
+ return undefined;
391
+ }
392
+ }
393
+
394
+ /** 直读一个项目内文本文件(**先 lstat**:符号链接一律不读、报码) */
395
+ async function probeProjectText(
396
+ p: string,
397
+ label: string,
398
+ maxBytes: number,
399
+ findings: ForeignSkip[],
400
+ ): Promise<string | undefined> {
401
+ const st = await lstatOrNull(p);
402
+ if (st === null) return undefined;
403
+ if (st.isSymbolicLink()) {
404
+ findings.push({ code: 'source-unreadable', origin: label, detail: DETAIL_SYMLINK });
405
+ return undefined;
406
+ }
407
+ if (!st.isFile()) {
408
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'not-a-file' });
409
+ return undefined;
410
+ }
411
+ if (st.size > maxBytes) {
412
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'too-large' });
413
+ return undefined;
414
+ }
195
415
  try {
196
- dirents = await fs.readdir(commandsDir, { withFileTypes: true });
416
+ return await fs.readFile(p, 'utf8');
197
417
  } catch {
198
- return 0;
418
+ findings.push({ code: 'source-unreadable', origin: label, detail: 'read-error' });
419
+ return undefined;
420
+ }
421
+ }
422
+
423
+ /** 项目候选(root 已 resolve;jsonKey = ~/.claude.json 里的原始键,用于取该项目的 mcpServers) */
424
+ interface ProjectCandidate {
425
+ root: string;
426
+ jsonKey?: string;
427
+ mcp?: unknown;
428
+ }
429
+
430
+ /**
431
+ * 收集项目根候选:**调用方显式给出的优先**,然后才是 `~/.claude.json` 的 projects 键(字典序)。
432
+ * 只做去重与排序,**不做存在性判定**(那一步需要 fs,见 scanProjectRoots)。
433
+ */
434
+ function collectProjectCandidates(
435
+ claudeJson: unknown,
436
+ explicit: readonly string[] | undefined,
437
+ ): ProjectCandidate[] {
438
+ const byRoot = new Map<string, ProjectCandidate>();
439
+ const order: string[] = [];
440
+ const add = (raw: string, jsonKey?: string, mcp?: unknown): void => {
441
+ const abs = path.resolve(raw);
442
+ const existing = byRoot.get(abs);
443
+ if (existing !== undefined) {
444
+ // 显式路径与清单键指向同一项目:补上清单里的 mcpServers(显式路径本身不带它)
445
+ if (existing.jsonKey === undefined && jsonKey !== undefined) {
446
+ existing.jsonKey = jsonKey;
447
+ existing.mcp = mcp;
448
+ }
449
+ return;
450
+ }
451
+ const candidate: ProjectCandidate = { root: abs };
452
+ if (jsonKey !== undefined) candidate.jsonKey = jsonKey;
453
+ if (mcp !== undefined) candidate.mcp = mcp;
454
+ byRoot.set(abs, candidate);
455
+ order.push(abs);
456
+ };
457
+ for (const p of explicit ?? []) {
458
+ if (typeof p === 'string' && p.trim() !== '') add(p);
459
+ }
460
+ if (isRecord(claudeJson)) {
461
+ const projects = claudeJson['projects'];
462
+ if (isRecord(projects)) {
463
+ for (const key of Object.keys(projects).sort()) {
464
+ const entry = projects[key];
465
+ add(key, key, isRecord(entry) ? entry['mcpServers'] : undefined);
466
+ }
467
+ }
199
468
  }
200
- let n = 0;
201
- for (const d of dirents) if (d.isFile()) n++;
202
- return n;
469
+ return order.map((abs) => byRoot.get(abs)).filter((c): c is ProjectCandidate => c !== undefined);
470
+ }
471
+
472
+ /**
473
+ * 读一个项目根。
474
+ *
475
+ * 纪律:`.claude` 不存在 → **只留 root 与 hasClaudeDir:false**(绝不产出空 finding:那是
476
+ * 「这个项目没用过 Claude Code」,不是错误);项目根不存在 / 不是目录 → 安静跳过(已删除的项目)。
477
+ */
478
+ async function scanProjectRoot(
479
+ candidate: ProjectCandidate,
480
+ maxFileBytes: number,
481
+ maxSkillFiles: number,
482
+ ): Promise<ClaudeProjectInput> {
483
+ const findings: ForeignSkip[] = [];
484
+ const out: ClaudeProjectInput = { root: candidate.root };
485
+ const claudeDir = path.join(candidate.root, '.claude');
486
+ const dirStat = await statOrNull(claudeDir);
487
+ const hasClaudeDir = dirStat !== null && dirStat.isDirectory();
488
+ out.hasClaudeDir = hasClaudeDir;
489
+ // 没有 .claude 的项目**只留 root + hasClaudeDir:false**(连 claude.json 里的 mcpServers 也不带:
490
+ // 那会把「这个项目没有 Claude 配置目录」与「它有项目级配置」混成同一种形态)
491
+ if (!hasClaudeDir) return out;
492
+ if (candidate.mcp !== undefined) out.projectMcp = candidate.mcp;
493
+
494
+ const settings = await probeProjectJson(path.join(claudeDir, 'settings.json'), '.claude/settings.json', maxFileBytes, findings);
495
+ if (settings !== undefined) out.settings = settings;
496
+ const settingsLocal = await probeProjectJson(path.join(claudeDir, 'settings.local.json'), '.claude/settings.local.json', maxFileBytes, findings);
497
+ if (settingsLocal !== undefined) out.settingsLocal = settingsLocal;
498
+
499
+ const memory = await probeProjectText(path.join(candidate.root, 'CLAUDE.md'), 'CLAUDE.md', MEMORY_MAX_FILE, findings);
500
+ if (memory !== undefined) out.memory = memory;
501
+ const memoryLocal = await probeProjectText(path.join(candidate.root, 'CLAUDE.local.md'), 'CLAUDE.local.md', MEMORY_MAX_FILE, findings);
502
+ if (memoryLocal !== undefined) out.memoryLocal = memoryLocal;
503
+
504
+ const rules = await readRuleFiles(path.join(claudeDir, 'rules'), maxFileBytes, findings);
505
+ if (rules.length > 0) out.rules = rules;
506
+
507
+ const skills = await readSkillUnits(path.join(claudeDir, 'skills'), {
508
+ maxFiles: maxSkillFiles,
509
+ maxBytes: maxFileBytes,
510
+ origin: '.claude/skills',
511
+ skips: findings,
512
+ });
513
+ if (skills.length > 0) out.skills = skills;
514
+
515
+ const commandCount = await countFilesRecursive(path.join(claudeDir, 'commands'));
516
+ if (commandCount > 0) out.commandCount = commandCount;
517
+
518
+ const agents = await readAgentNames(path.join(claudeDir, 'agents'));
519
+ if (agents.length > 0) out.agents = agents;
520
+
521
+ const mcpJson = await probeProjectJson(path.join(candidate.root, '.mcp.json'), '.mcp.json', maxFileBytes, findings);
522
+ if (mcpJson !== undefined) out.mcpJson = mcpJson;
523
+
524
+ if (findings.length > 0) out.readFindings = findings;
525
+ return out;
526
+ }
527
+
528
+ /**
529
+ * 扫项目根:**只收真实存在且是目录的**(按候选顺序,显式优先 + 清单字典序);
530
+ * 触顶(maxProjects)**必须报码**(origin='projects',count=上限),绝不静默少扫。
531
+ */
532
+ async function scanProjectRoots(
533
+ candidates: readonly ProjectCandidate[],
534
+ maxProjects: number,
535
+ maxFileBytes: number,
536
+ maxSkillFiles: number,
537
+ ): Promise<{ projects: ClaudeProjectInput[]; skipped: ForeignSkip[] }> {
538
+ const roots: ProjectCandidate[] = [];
539
+ let truncated = false;
540
+ for (const candidate of candidates) {
541
+ const st = await statOrNull(candidate.root);
542
+ if (st === null || !st.isDirectory()) continue;
543
+ if (roots.length >= maxProjects) { truncated = true; break; }
544
+ roots.push(candidate);
545
+ }
546
+ const projects: ClaudeProjectInput[] = [];
547
+ for (const root of roots) projects.push(await scanProjectRoot(root, maxFileBytes, maxSkillFiles));
548
+ const skipped: ForeignSkip[] = [];
549
+ if (truncated) {
550
+ skipped.push({ code: 'source-unreadable', origin: 'projects', detail: DETAIL_MAX_PROJECTS, count: maxProjects });
551
+ }
552
+ return { projects, skipped };
203
553
  }
204
554
 
205
555
  export async function readClaudeCode(opts: ClaudeCodeReadOptions): Promise<ClaudeCodeReadResult> {
206
556
  const home = opts.homeDir;
207
557
  const maxFileBytes = resolveLimit(opts.limits?.maxFileBytes, opts.maxFileBytes, DEFAULT_MAX_FILE);
208
558
  const maxSkillFiles = resolveLimit(opts.limits?.maxSkillFiles, opts.maxSkillFiles, DEFAULT_MAX_SKILL_FILES);
559
+ const maxSessionFiles = resolveLimit(opts.limits?.maxSessionFiles, opts.maxSessionFiles, DEFAULT_MAX_SESSION_FILES);
560
+ const maxSessionFileBytes = resolveLimit(undefined, opts.maxSessionFileBytes, DEFAULT_MAX_SESSION_FILE);
561
+ const maxProjects = resolveLimit(undefined, opts.maxProjects, DEFAULT_MAX_PROJECTS);
209
562
  const skipped: ForeignSkip[] = [];
210
563
  const unreadable: string[] = [];
211
564
  const input: ClaudeCodeInput = {};
212
565
 
213
- const claudeDir = path.join(home, '.claude');
566
+ /* ---------------- A. 配置目录(CLAUDE_CONFIG_DIR 覆盖) ---------------- */
567
+ const override = configDirOverrideOf(opts.env);
568
+ const claudeDir = override !== undefined ? path.resolve(override) : path.join(home, '.claude');
569
+ input.configDir = claudeDir;
570
+ if (override !== undefined) {
571
+ input.configDirOverridden = true;
572
+ skipped.push({ code: 'source-location-overridden', origin: 'settings' });
573
+ }
574
+
575
+ /**
576
+ * ~/.claude.json 的位置:未覆盖时只有 home 一处;覆盖时先看配置目录内(Claude Code 把全局
577
+ * 配置文件也放在配置目录里),再看 home —— 两处都只是**存在性**判定,绝不猜内容。
578
+ */
579
+ const legacyCandidates = override !== undefined
580
+ ? [path.join(claudeDir, '.claude.json'), path.join(home, '.claude.json')]
581
+ : [path.join(home, '.claude.json')];
582
+ let legacyPath: string | undefined;
583
+ for (const candidate of legacyCandidates) {
584
+ if ((await statOrNull(candidate)) !== null) { legacyPath = candidate; break; }
585
+ }
214
586
  const dirStat = await statOrNull(claudeDir);
215
587
  const hasDir = dirStat !== null && dirStat.isDirectory();
216
- const jsonPath = path.join(home, '.claude.json');
217
- const hasLegacyJson = (await statOrNull(jsonPath)) !== null;
218
- if (!hasDir && !hasLegacyJson) return { found: false, input, skipped, unreadable };
588
+ /** 显式给出的项目根也算「可能的痕迹」:用户级什么都没装、只有项目级 .claude 的情况真实存在 */
589
+ const hasExplicitProjects = (opts.projectPaths ?? []).some((p) => typeof p === 'string' && p.trim() !== '');
590
+ if (!hasDir && legacyPath === undefined && !hasExplicitProjects) {
591
+ return { found: false, input, skipped, unreadable };
592
+ }
219
593
 
220
- const jr = await readJsonSafe(jsonPath, maxFileBytes);
221
- if (jr.ok) input.claudeJson = jr.value;
222
- else if (hasLegacyJson) {
223
- unreadable.push('.claude.json');
224
- skipped.push({ code: 'source-unreadable', origin: '.claude.json' });
594
+ if (legacyPath !== undefined) {
595
+ const jr = await readJsonSafe(legacyPath, maxFileBytes);
596
+ if (jr.ok) {
597
+ input.claudeJson = jr.value;
598
+ const version = lastVersionBaseOf(jr.value);
599
+ if (version !== undefined) input.sourceVersion = version;
600
+ } else {
601
+ unreadable.push('.claude.json');
602
+ skipped.push({ code: 'source-unreadable', origin: '.claude.json' });
603
+ }
225
604
  }
226
605
 
606
+ /* ---------------- 用户级 ---------------- */
227
607
  const settingsPath = path.join(claudeDir, 'settings.json');
228
608
  const sr = await readJsonSafe(settingsPath, maxFileBytes);
229
609
  if (sr.ok) input.settings = sr.value;
@@ -240,25 +620,64 @@ export async function readClaudeCode(opts: ClaudeCodeReadOptions): Promise<Claud
240
620
  skipped.push({ code: 'source-unreadable', origin: '.claude/CLAUDE.md' });
241
621
  }
242
622
 
243
- const skillStats: DirWalkStats = {};
244
- const skills = await readSkills(path.join(claudeDir, 'skills'), maxSkillFiles, maxFileBytes, skillStats);
623
+ const skills = await readSkillUnits(path.join(claudeDir, 'skills'), {
624
+ maxFiles: maxSkillFiles,
625
+ maxBytes: maxFileBytes,
626
+ origin: 'skills',
627
+ skips: skipped,
628
+ });
245
629
  if (skills.length > 0) input.skills = skills;
246
- // t36:技能文件遍历的条数/字节触顶必须可见(origin 只用包内相对标签)
247
- skipped.push(...dirWalkSkips(skillStats, 'skills', maxSkillFiles));
248
630
 
249
- const commandCount = await countCommands(path.join(claudeDir, 'commands'));
631
+ const commandCount = await countFilesRecursive(path.join(claudeDir, 'commands'));
250
632
  if (commandCount > 0) input.commandCount = commandCount;
251
633
 
252
- const sessions = await readSessions(
253
- path.join(claudeDir, 'projects'),
254
- opts.maxSessionFiles ?? DEFAULT_MAX_SESSION_FILES,
255
- opts.maxSessionFileBytes ?? DEFAULT_MAX_SESSION_FILE,
256
- );
634
+ /**
635
+ * 用户级 `agents/` 刻意**不采集**:types 的 ClaudeCodeInput 没有对应字段(契约冻结),
636
+ * 读出来也无处交付 —— 采集了却只能丢掉,等于制造「读到了但包外看不见」的假象。
637
+ * 项目级 agents 有字段(ClaudeProjectInput.agents)且任务要求,故只做项目级。
638
+ */
639
+
640
+ const sessions = await readSessions(path.join(claudeDir, 'projects'), maxSessionFiles, maxSessionFileBytes);
257
641
  if (sessions.files.length > 0) input.sessions = sessions.files;
258
642
  for (const rel of sessions.unreadable) {
259
643
  unreadable.push(rel);
260
644
  skipped.push({ code: 'source-unreadable', origin: rel });
261
645
  }
646
+ if (sessions.truncated) {
647
+ // 缺陷 1:此前直接 break,条数触顶在包外完全不可见
648
+ skipped.push({ code: 'source-unreadable', origin: 'sessions', detail: DETAIL_MAX_SESSIONS, count: maxSessionFiles });
649
+ }
650
+
651
+ /* ---------------- B. 项目级 ---------------- */
652
+ const candidates = collectProjectCandidates(input.claudeJson, opts.projectPaths);
653
+ if (candidates.length > 0) {
654
+ const scanned = await scanProjectRoots(candidates, maxProjects, maxFileBytes, maxSkillFiles);
655
+ if (scanned.projects.length > 0) input.projects = scanned.projects;
656
+ skipped.push(...scanned.skipped);
657
+ }
262
658
 
659
+ /* ---------------- C. 插件(委托独立读盘模块,避免静态 import 环) ---------------- */
660
+ if (hasDir) {
661
+ const { readClaudePlugins } = await import('./read-claude-plugins.ts');
662
+ const pluginRead = await readClaudePlugins({
663
+ claudeDir,
664
+ settings: input.settings,
665
+ maxFileBytes,
666
+ limits: {
667
+ ...(opts.maxPlugins !== undefined ? { maxPlugins: opts.maxPlugins } : {}),
668
+ ...(opts.maxPluginFiles !== undefined ? { maxPluginFiles: opts.maxPluginFiles } : {}),
669
+ },
670
+ });
671
+ if (pluginRead.plugins.length > 0) input.plugins = pluginRead.plugins;
672
+ if (pluginRead.marketplaces.length > 0) input.marketplaces = pluginRead.marketplaces;
673
+ // 只有**没有归属单元**的发现走顶层 skipped(插件自身的发现挂在 plugin.readFindings 上)
674
+ skipped.push(...pluginRead.skipped);
675
+ }
676
+
677
+ /* 只有「项目级 .claude 存在」时才把 found 判成 true;否则如实说「没有痕迹」(绝不假装找到了) */
678
+ const projectsFound = (input.projects ?? []).some((project) => project.hasClaudeDir === true);
679
+ if (!hasDir && legacyPath === undefined && !projectsFound) {
680
+ return { found: false, input, skipped, unreadable };
681
+ }
263
682
  return { found: true, input, skipped, unreadable };
264
683
  }