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.
- package/lib/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code **插件**目录的读盘层(宿主侧只读;2026-10-18 补)。
|
|
3
|
+
*
|
|
4
|
+
* 真实布局(本机实测取证;只记录键名,绝不过问值):
|
|
5
|
+
* - `plugins/known_marketplaces.json`:`{ "<市场名>": { source: { source, repo }, installLocation, lastUpdated } }`;
|
|
6
|
+
* - `plugins/marketplaces/<市场>/.claude-plugin/marketplace.json`:`{ name, description, owner, plugins: [...] }`;
|
|
7
|
+
* - `plugins/marketplaces/<市场>/plugins/<插件>/`(或 `external_plugins/<插件>/`);
|
|
8
|
+
* - 插件目录:`.claude-plugin/plugin.json`、commands/、skills/、agents/、hooks/、.mcp.json
|
|
9
|
+
* (`.mcp.json` 形如 `{ "<server>": { type, url, headers } }` —— **headers 里可能是真凭据**)。
|
|
10
|
+
*
|
|
11
|
+
* 三条结构性纪律:
|
|
12
|
+
* ① **绝不执行、绝不读取插件里的可执行代码内容**:commands/agents/hooks 只做「文件名 / 文件数」
|
|
13
|
+
* 统计;允许进包的只有技能文本与 `.mcp.json`(且 .mcp.json 原样交给翻译层,凭据剥离在
|
|
14
|
+
* kernel 的剥离器里做 —— 本层不复制、不改写凭据,只保证「不额外扩散」);
|
|
15
|
+
* ② **不猜**:启用清单只认两个有形状可自证的位置(settings.enabledPlugins 与 plugins/config.json
|
|
16
|
+
* 的可识别形状),认不出来就报 plugins-config-unknown-shape,绝不把任意 JSON 当插件清单;
|
|
17
|
+
* ③ **不静默**:市场和插件都读不到 / 找不到安装目录 / 触顶(插件数、单插件文件数)一律报稳定机器码。
|
|
18
|
+
*
|
|
19
|
+
* 归属规则(与 read-claude-code 一致):能落到某个插件上的发现写进该插件的 readFindings(由翻译层
|
|
20
|
+
* 原样带出);落不到插件上的(市场清单、启用清单、找不到安装目录、插件数触顶)进顶层 skipped。
|
|
21
|
+
*
|
|
22
|
+
* 为什么本文件不 import `node:fs`:`src/foreign/file-budget.test.ts` 的 B 档要求 import fs 的模块
|
|
23
|
+
* **恰好**等于白名单。这里复用 `session-read.ts`(共享只读内核)的原语,实现同样「只读、不跟随
|
|
24
|
+
* 符号链接、有上限」的行为,而不新增一个 fs 入口。
|
|
25
|
+
*/
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { isRecord } from '../utils/guards.ts';
|
|
29
|
+
import { stripUserInfo } from './kernel.ts';
|
|
30
|
+
import { listDirSafe, readJsonSafe, statOrNull, toPosixPath, walkFiles } from './session-read.ts';
|
|
31
|
+
import { readSkillUnits } from './read-claude-code.ts';
|
|
32
|
+
import type { ClaudePluginInput, ClaudeSkillInput, ForeignSkip } from './types.ts';
|
|
33
|
+
|
|
34
|
+
/** 插件数上限触顶(count = 上限) */
|
|
35
|
+
export const MAX_PLUGINS_REACHED = 'max-plugins-reached';
|
|
36
|
+
/** 单插件文件数上限触顶(count = 上限) */
|
|
37
|
+
export const MAX_PLUGIN_FILES_REACHED = 'max-plugin-files-reached';
|
|
38
|
+
/** plugins/config.json 存在但形状认不出来(**绝不猜**) */
|
|
39
|
+
export const PLUGINS_CONFIG_UNKNOWN_SHAPE = 'plugins-config-unknown-shape';
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
42
|
+
const DEFAULT_MAX_PLUGINS = 50;
|
|
43
|
+
const DEFAULT_MAX_PLUGIN_FILES = 200;
|
|
44
|
+
/** 目录走盘深度(与 session-read 的 WalkOptions 缺省一致) */
|
|
45
|
+
const WALK_MAX_DEPTH = 8;
|
|
46
|
+
/** 跳过原因:符号链接(与 read-claude-code 同码同义) */
|
|
47
|
+
const DETAIL_SYMLINK = 'symlink-skipped';
|
|
48
|
+
|
|
49
|
+
export interface ClaudePluginReadLimits {
|
|
50
|
+
/** 启用的插件数上限(默认 50) */
|
|
51
|
+
readonly maxPlugins?: number;
|
|
52
|
+
/** 单个插件的文件数上限(默认 200;触顶报 max-plugin-files-reached) */
|
|
53
|
+
readonly maxPluginFiles?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ClaudePluginReadOptions {
|
|
57
|
+
/** Claude 配置目录(`<home>/.claude` 或 CLAUDE_CONFIG_DIR 覆盖值) */
|
|
58
|
+
readonly claudeDir: string;
|
|
59
|
+
/** settings.json 的已解析值(enabledPlugins 的权威来源) */
|
|
60
|
+
readonly settings: unknown;
|
|
61
|
+
readonly limits?: ClaudePluginReadLimits;
|
|
62
|
+
/** 单文件读取上限(默认 8 MiB;与同域读盘层同口径) */
|
|
63
|
+
readonly maxFileBytes?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ClaudePluginReadResult {
|
|
67
|
+
/** 读到的插件(只含有安装目录、能自证的最小元数据与组件) */
|
|
68
|
+
plugins: ClaudePluginInput[];
|
|
69
|
+
/** 已登记的市场(name + 来源仓库;URL 的 userinfo 已剥离) */
|
|
70
|
+
marketplaces: { name: string; repo?: string }[];
|
|
71
|
+
/** 落不到具体插件上的发现(市场清单 / 启用清单 / 找不到安装目录 / 插件数触顶) */
|
|
72
|
+
skipped: ForeignSkip[];
|
|
73
|
+
/** 计数(marketplaces / enabledPlugins / plugins / pluginSkills / pluginCommands / pluginAgents / pluginHooks) */
|
|
74
|
+
counts: Record<string, number>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface MarketplaceRecord {
|
|
78
|
+
name: string;
|
|
79
|
+
repo?: string;
|
|
80
|
+
/** 市场安装位置(绝对路径;**只用于定位插件目录**,绝不进返回值) */
|
|
81
|
+
installLocation?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 有上限的整数解析:只用安全正整数(脏值一律回落缺省) */
|
|
85
|
+
function positiveLimit(value: number | undefined, fallback: number): number {
|
|
86
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** source.repo 或 source.url(两者都可能带 userinfo;剥离在调用处统一做) */
|
|
90
|
+
function repoOfSource(source: Record<string, unknown>): string | undefined {
|
|
91
|
+
const repo = source['repo'];
|
|
92
|
+
if (typeof repo === 'string' && repo.trim() !== '') return repo.trim();
|
|
93
|
+
const url = source['url'];
|
|
94
|
+
if (typeof url === 'string' && url.trim() !== '') return url.trim();
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 读 `plugins/known_marketplaces.json`。
|
|
100
|
+
* 文件不存在 = 没有登记任何市场(正常状态,不报码);存在但读不出来 / 形状不对 = source-unreadable。
|
|
101
|
+
*/
|
|
102
|
+
async function readKnownMarketplaces(
|
|
103
|
+
pluginsDir: string,
|
|
104
|
+
maxFileBytes: number,
|
|
105
|
+
skipped: ForeignSkip[],
|
|
106
|
+
): Promise<MarketplaceRecord[]> {
|
|
107
|
+
const file = path.join(pluginsDir, 'known_marketplaces.json');
|
|
108
|
+
const jr = await readJsonSafe(file, maxFileBytes);
|
|
109
|
+
if (!jr.ok) {
|
|
110
|
+
if (jr.problem !== 'missing') {
|
|
111
|
+
skipped.push({ code: 'source-unreadable', origin: 'plugins/known_marketplaces.json', detail: jr.problem });
|
|
112
|
+
}
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
if (!isRecord(jr.value)) {
|
|
116
|
+
skipped.push({
|
|
117
|
+
code: 'source-unreadable',
|
|
118
|
+
origin: 'plugins/known_marketplaces.json',
|
|
119
|
+
detail: 'not-mapping',
|
|
120
|
+
});
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const out: MarketplaceRecord[] = [];
|
|
124
|
+
for (const name of Object.keys(jr.value).sort()) {
|
|
125
|
+
const raw = jr.value[name];
|
|
126
|
+
const record: MarketplaceRecord = { name };
|
|
127
|
+
if (isRecord(raw)) {
|
|
128
|
+
const source = raw['source'];
|
|
129
|
+
if (isRecord(source)) {
|
|
130
|
+
const repo = repoOfSource(source);
|
|
131
|
+
// **userinfo 绝不进包**:https://user:token@host/x.git → https://host/x.git
|
|
132
|
+
if (repo !== undefined) record.repo = stripUserInfo(repo).url;
|
|
133
|
+
}
|
|
134
|
+
const installLocation = raw['installLocation'];
|
|
135
|
+
if (typeof installLocation === 'string' && installLocation.trim() !== '') {
|
|
136
|
+
record.installLocation = path.resolve(installLocation.trim());
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
out.push(record);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** settings.enabledPlugins 的启用键(**真值才算启用**;非对象 = 没有这份清单) */
|
|
145
|
+
function enabledSpecsFromSettings(settings: unknown): string[] {
|
|
146
|
+
if (!isRecord(settings)) return [];
|
|
147
|
+
const enabled = settings['enabledPlugins'];
|
|
148
|
+
if (!isRecord(enabled)) return [];
|
|
149
|
+
return Object.keys(enabled)
|
|
150
|
+
.filter((key) => Boolean(enabled[key]))
|
|
151
|
+
.sort();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 按**形状自证**识别 plugins/config.json(认不出来返回 null,绝不猜):
|
|
156
|
+
* - `{ plugins: string[] }`:插件名清单;
|
|
157
|
+
* - `{ plugins: { [name@market]: boolean } }`:启用表;
|
|
158
|
+
* - `{ enabledPlugins: { ... } }`:与 settings 同形的启用表;
|
|
159
|
+
* - 顶层直接就是 `{ "name@market": true }`(键必须都带 @)。
|
|
160
|
+
*/
|
|
161
|
+
function recognizePluginsConfig(value: unknown): string[] | null {
|
|
162
|
+
if (!isRecord(value)) return null;
|
|
163
|
+
const plugins = value['plugins'];
|
|
164
|
+
if (Array.isArray(plugins)) {
|
|
165
|
+
if (plugins.length === 0) return [];
|
|
166
|
+
if (!plugins.every((item) => typeof item === 'string' && item.trim() !== '')) return null;
|
|
167
|
+
return [...new Set(plugins.map((item) => (item as string).trim()))].sort();
|
|
168
|
+
}
|
|
169
|
+
if (isRecord(plugins)) {
|
|
170
|
+
if (!Object.values(plugins).every((item) => typeof item === 'boolean')) return null;
|
|
171
|
+
return Object.keys(plugins).filter((key) => plugins[key] === true).sort();
|
|
172
|
+
}
|
|
173
|
+
const enabled = value['enabledPlugins'];
|
|
174
|
+
if (isRecord(enabled)) {
|
|
175
|
+
if (!Object.values(enabled).every((item) => typeof item === 'boolean')) return null;
|
|
176
|
+
return Object.keys(enabled).filter((key) => enabled[key] === true).sort();
|
|
177
|
+
}
|
|
178
|
+
const keys = Object.keys(value);
|
|
179
|
+
if (keys.length > 0 && keys.every((key) => key.includes('@')) && keys.every((key) => typeof value[key] === 'boolean')) {
|
|
180
|
+
return keys.filter((key) => value[key] === true).sort();
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 启用插件清单:settings.enabledPlugins 优先(有内容就用它),否则按形状读 plugins/config.json。
|
|
187
|
+
* 两者都没有 → 空数组、不报码(诚实:没有启用的插件)。
|
|
188
|
+
*/
|
|
189
|
+
async function enabledPluginSpecs(
|
|
190
|
+
pluginsDir: string,
|
|
191
|
+
settings: unknown,
|
|
192
|
+
maxFileBytes: number,
|
|
193
|
+
skipped: ForeignSkip[],
|
|
194
|
+
): Promise<string[]> {
|
|
195
|
+
const fromSettings = enabledSpecsFromSettings(settings);
|
|
196
|
+
if (fromSettings.length > 0) return fromSettings;
|
|
197
|
+
const configPath = path.join(pluginsDir, 'config.json');
|
|
198
|
+
const jr = await readJsonSafe(configPath, maxFileBytes);
|
|
199
|
+
if (!jr.ok) {
|
|
200
|
+
if (jr.problem !== 'missing') {
|
|
201
|
+
skipped.push({ code: 'source-unreadable', origin: 'plugins/config.json', detail: jr.problem });
|
|
202
|
+
}
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const recognized = recognizePluginsConfig(jr.value);
|
|
206
|
+
if (recognized === null) {
|
|
207
|
+
skipped.push({ code: 'source-unreadable', origin: 'plugins/config.json', detail: PLUGINS_CONFIG_UNKNOWN_SHAPE });
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
return recognized;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `name@marketplace` → { name, marketplace };没有 @ 或 @ 在边界上 = 只有名字 */
|
|
214
|
+
function splitPluginSpec(spec: string): { name: string; marketplace?: string } {
|
|
215
|
+
const at = spec.lastIndexOf('@');
|
|
216
|
+
if (at <= 0 || at === spec.length - 1) return { name: spec };
|
|
217
|
+
return { name: spec.slice(0, at), marketplace: spec.slice(at + 1) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** dirent 的最小形状(刻意不 import node:fs:B 档白名单要求 import fs 的模块恰好等于白名单) */
|
|
221
|
+
interface DirEntryLike {
|
|
222
|
+
isDirectory: () => boolean;
|
|
223
|
+
isSymbolicLink: () => boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 某个父目录下名为 name 的条目(**不跟链接**:dirent 自带 isSymbolicLink) */
|
|
227
|
+
async function direntOf(target: string): Promise<DirEntryLike | null> {
|
|
228
|
+
const parent = path.dirname(target);
|
|
229
|
+
const base = path.basename(target);
|
|
230
|
+
for (const entry of await listDirSafe(parent)) {
|
|
231
|
+
if (entry.name === base) return entry;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
type Located =
|
|
237
|
+
| { kind: 'dir'; dir: string }
|
|
238
|
+
| { kind: 'symlink' }
|
|
239
|
+
| { kind: 'missing' };
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 定位插件安装目录(顺序:市场下 plugins/ → external_plugins/ → 市场 installLocation 的同名目录及其
|
|
243
|
+
* 两种标准子目录)。命中符号链接 → symlink(不跟随);都不在 → missing(由调用方报码)。
|
|
244
|
+
*/
|
|
245
|
+
async function locatePluginDir(
|
|
246
|
+
pluginsDir: string,
|
|
247
|
+
name: string,
|
|
248
|
+
marketplace: string | undefined,
|
|
249
|
+
market: MarketplaceRecord | undefined,
|
|
250
|
+
): Promise<Located> {
|
|
251
|
+
const candidates: string[] = [];
|
|
252
|
+
if (marketplace !== undefined) {
|
|
253
|
+
const marketDir = path.join(pluginsDir, 'marketplaces', marketplace);
|
|
254
|
+
candidates.push(path.join(marketDir, 'plugins', name));
|
|
255
|
+
candidates.push(path.join(marketDir, 'external_plugins', name));
|
|
256
|
+
} else {
|
|
257
|
+
candidates.push(path.join(pluginsDir, name));
|
|
258
|
+
}
|
|
259
|
+
if (market?.installLocation !== undefined) {
|
|
260
|
+
candidates.push(path.join(market.installLocation, name));
|
|
261
|
+
candidates.push(path.join(market.installLocation, 'plugins', name));
|
|
262
|
+
candidates.push(path.join(market.installLocation, 'external_plugins', name));
|
|
263
|
+
}
|
|
264
|
+
let symlinkHit = false;
|
|
265
|
+
for (const candidate of candidates) {
|
|
266
|
+
const entry = await direntOf(candidate);
|
|
267
|
+
if (entry === null) continue;
|
|
268
|
+
if (entry.isSymbolicLink()) { symlinkHit = true; continue; }
|
|
269
|
+
if (entry.isDirectory()) return { kind: 'dir', dir: candidate };
|
|
270
|
+
}
|
|
271
|
+
return symlinkHit ? { kind: 'symlink' } : { kind: 'missing' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 插件自述(.claude-plugin/plugin.json):解析失败用目录名并报码,绝不猜 */
|
|
275
|
+
async function readPluginManifest(
|
|
276
|
+
pluginDir: string,
|
|
277
|
+
fallbackName: string,
|
|
278
|
+
maxFileBytes: number,
|
|
279
|
+
findings: ForeignSkip[],
|
|
280
|
+
): Promise<{ name: string; version?: string }> {
|
|
281
|
+
const out: { name: string; version?: string } = { name: fallbackName };
|
|
282
|
+
const manifestPath = path.join(pluginDir, '.claude-plugin', 'plugin.json');
|
|
283
|
+
const jr = await readJsonSafe(manifestPath, maxFileBytes);
|
|
284
|
+
if (!jr.ok) {
|
|
285
|
+
if (jr.problem !== 'missing') {
|
|
286
|
+
findings.push({ code: 'source-unreadable', origin: 'plugin:' + fallbackName + '/plugin.json', detail: jr.problem });
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
if (!isRecord(jr.value)) {
|
|
291
|
+
findings.push({ code: 'source-unreadable', origin: 'plugin:' + fallbackName + '/plugin.json', detail: 'not-mapping' });
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
const name = jr.value['name'];
|
|
295
|
+
if (typeof name === 'string' && name.trim() !== '') out.name = name.trim();
|
|
296
|
+
const version = jr.value['version'];
|
|
297
|
+
if (typeof version === 'string' && version.trim() !== '') out.version = version.trim();
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 读一个已定位的插件目录。**只读元数据与组件清单**:
|
|
303
|
+
* - skills:技能文本(唯一允许进包的文件内容,凭据扫描在翻译层);
|
|
304
|
+
* - .mcp.json:原样交翻译层(凭据剥离在 kernel);
|
|
305
|
+
* - commands / agents / hooks:**只数文件 / 只取文件名**,内容一个字节都不读。
|
|
306
|
+
*/
|
|
307
|
+
async function readPluginAt(
|
|
308
|
+
pluginDir: string,
|
|
309
|
+
specName: string,
|
|
310
|
+
marketplace: string | undefined,
|
|
311
|
+
market: MarketplaceRecord | undefined,
|
|
312
|
+
options: { maxPluginFiles: number; maxFileBytes: number; installRel?: string },
|
|
313
|
+
): Promise<ClaudePluginInput> {
|
|
314
|
+
const findings: ForeignSkip[] = [];
|
|
315
|
+
const manifest = await readPluginManifest(pluginDir, specName, options.maxFileBytes, findings);
|
|
316
|
+
const plugin: ClaudePluginInput = { name: manifest.name, components: [] };
|
|
317
|
+
if (manifest.version !== undefined) plugin.version = manifest.version;
|
|
318
|
+
if (options.installRel !== undefined) plugin.installRel = options.installRel;
|
|
319
|
+
if (marketplace !== undefined) plugin.marketplace = marketplace;
|
|
320
|
+
if (market?.repo !== undefined) plugin.repo = market.repo;
|
|
321
|
+
|
|
322
|
+
/* skills(有总量上限:跨技能累计,触顶报 max-plugin-files-reached) */
|
|
323
|
+
const skills: ClaudeSkillInput[] = [];
|
|
324
|
+
let filesUsed = 0;
|
|
325
|
+
let filesTruncated = false;
|
|
326
|
+
const units = await readSkillUnits(path.join(pluginDir, 'skills'), {
|
|
327
|
+
maxFiles: options.maxPluginFiles,
|
|
328
|
+
maxBytes: options.maxFileBytes,
|
|
329
|
+
origin: 'plugin:' + specName + '/skills',
|
|
330
|
+
skips: findings,
|
|
331
|
+
});
|
|
332
|
+
for (const unit of units) {
|
|
333
|
+
if (filesUsed >= options.maxPluginFiles) { filesTruncated = true; break; }
|
|
334
|
+
const room = options.maxPluginFiles - filesUsed;
|
|
335
|
+
const files = unit.files.slice(0, room);
|
|
336
|
+
if (files.length < unit.files.length) filesTruncated = true;
|
|
337
|
+
if (files.length === 0) continue;
|
|
338
|
+
skills.push({ name: unit.name, files });
|
|
339
|
+
filesUsed += files.length;
|
|
340
|
+
}
|
|
341
|
+
if (skills.length > 0) {
|
|
342
|
+
plugin.skills = skills;
|
|
343
|
+
plugin.components.push('skills');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* .mcp.json:原始 JSON 交给翻译层(本层不读 headers 的值语义,也不改写) */
|
|
347
|
+
const mcp = await readJsonSafe(path.join(pluginDir, '.mcp.json'), options.maxFileBytes);
|
|
348
|
+
if (mcp.ok) {
|
|
349
|
+
plugin.mcpJson = mcp.value;
|
|
350
|
+
plugin.components.push('mcp');
|
|
351
|
+
} else if (mcp.problem !== 'missing') {
|
|
352
|
+
findings.push({ code: 'source-unreadable', origin: 'plugin:' + specName + '/.mcp.json', detail: mcp.problem });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* commands/**:递归计数(只数,不读内容) */
|
|
356
|
+
const commandFiles = await walkFiles(path.join(pluginDir, 'commands'), {
|
|
357
|
+
maxDepth: WALK_MAX_DEPTH,
|
|
358
|
+
maxFiles: options.maxPluginFiles,
|
|
359
|
+
});
|
|
360
|
+
if (commandFiles.length > 0) {
|
|
361
|
+
plugin.commandCount = commandFiles.length;
|
|
362
|
+
plugin.components.push('commands');
|
|
363
|
+
}
|
|
364
|
+
if (commandFiles.length >= options.maxPluginFiles) filesTruncated = true;
|
|
365
|
+
|
|
366
|
+
/* agents/*.md:只取文件名(不读内容) */
|
|
367
|
+
const agentFiles = await walkFiles(path.join(pluginDir, 'agents'), {
|
|
368
|
+
maxDepth: 0,
|
|
369
|
+
maxFiles: options.maxPluginFiles,
|
|
370
|
+
match: (name) => name.endsWith('.md'),
|
|
371
|
+
});
|
|
372
|
+
if (agentFiles.length > 0) {
|
|
373
|
+
plugin.agents = agentFiles.map((file) => file.name).sort();
|
|
374
|
+
plugin.components.push('agents');
|
|
375
|
+
}
|
|
376
|
+
if (agentFiles.length >= options.maxPluginFiles) filesTruncated = true;
|
|
377
|
+
|
|
378
|
+
/* hooks/ 与 hooks.json:**只数文件数**(hooks 是执行面,内容一律不读) */
|
|
379
|
+
const hookFiles = await walkFiles(path.join(pluginDir, 'hooks'), {
|
|
380
|
+
maxDepth: WALK_MAX_DEPTH,
|
|
381
|
+
maxFiles: options.maxPluginFiles,
|
|
382
|
+
});
|
|
383
|
+
let hookCount = hookFiles.length;
|
|
384
|
+
if (hookCount >= options.maxPluginFiles) filesTruncated = true;
|
|
385
|
+
const hooksJson = await statOrNull(path.join(pluginDir, 'hooks.json'));
|
|
386
|
+
if (hooksJson !== null && hooksJson.isFile()) hookCount += 1;
|
|
387
|
+
if (hookCount > 0) {
|
|
388
|
+
plugin.hookFiles = hookCount;
|
|
389
|
+
plugin.components.push('hooks');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (filesTruncated) {
|
|
393
|
+
findings.push({
|
|
394
|
+
code: 'source-unreadable',
|
|
395
|
+
origin: 'plugin:' + specName,
|
|
396
|
+
detail: MAX_PLUGIN_FILES_REACHED,
|
|
397
|
+
count: options.maxPluginFiles,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (findings.length > 0) plugin.readFindings = findings;
|
|
401
|
+
return plugin;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function readClaudePlugins(opts: ClaudePluginReadOptions): Promise<ClaudePluginReadResult> {
|
|
405
|
+
const skipped: ForeignSkip[] = [];
|
|
406
|
+
const plugins: ClaudePluginInput[] = [];
|
|
407
|
+
const counts: Record<string, number> = {};
|
|
408
|
+
const maxFileBytes = positiveLimit(opts.maxFileBytes, DEFAULT_MAX_FILE_BYTES);
|
|
409
|
+
const maxPlugins = positiveLimit(opts.limits?.maxPlugins, DEFAULT_MAX_PLUGINS);
|
|
410
|
+
const maxPluginFiles = positiveLimit(opts.limits?.maxPluginFiles, DEFAULT_MAX_PLUGIN_FILES);
|
|
411
|
+
const pluginsDir = path.join(opts.claudeDir, 'plugins');
|
|
412
|
+
|
|
413
|
+
const markets = await readKnownMarketplaces(pluginsDir, maxFileBytes, skipped);
|
|
414
|
+
const marketByName = new Map(markets.map((market) => [market.name, market]));
|
|
415
|
+
const specs = await enabledPluginSpecs(pluginsDir, opts.settings, maxFileBytes, skipped);
|
|
416
|
+
|
|
417
|
+
counts['marketplaces'] = markets.length;
|
|
418
|
+
// 「启用条数」:值为真值的键(含找不到安装目录的那些,它们会在 skipped 里各留一条)
|
|
419
|
+
counts['enabledPlugins'] = specs.length;
|
|
420
|
+
|
|
421
|
+
const limited = specs.slice(0, maxPlugins);
|
|
422
|
+
if (specs.length > maxPlugins) {
|
|
423
|
+
skipped.push({ code: 'source-unreadable', origin: 'plugins', detail: MAX_PLUGINS_REACHED, count: maxPlugins });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let pluginSkills = 0;
|
|
427
|
+
let pluginCommands = 0;
|
|
428
|
+
let pluginAgents = 0;
|
|
429
|
+
let pluginHooks = 0;
|
|
430
|
+
for (const spec of limited) {
|
|
431
|
+
const { name, marketplace } = splitPluginSpec(spec);
|
|
432
|
+
const market = marketplace !== undefined ? marketByName.get(marketplace) : undefined;
|
|
433
|
+
const located = await locatePluginDir(pluginsDir, name, marketplace, market);
|
|
434
|
+
if (located.kind === 'missing') {
|
|
435
|
+
// 有启用记录但找不到安装目录:如实报码(不产出半成品条目)
|
|
436
|
+
skipped.push({ code: 'source-unreadable', origin: 'plugin:' + name });
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (located.kind === 'symlink') {
|
|
440
|
+
skipped.push({ code: 'source-unreadable', origin: 'plugin:' + name, detail: DETAIL_SYMLINK });
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
// 安装位置只进溯源元数据(相对 plugins 目录的 POSIX 路径;逃出该目录时**省略**而不是回传机器路径)
|
|
444
|
+
const rel = toPosixPath(path.relative(pluginsDir, located.dir));
|
|
445
|
+
const installRel = rel !== '' && !rel.startsWith('..') ? rel : undefined;
|
|
446
|
+
const plugin = await readPluginAt(located.dir, name, marketplace, market, {
|
|
447
|
+
maxPluginFiles,
|
|
448
|
+
maxFileBytes,
|
|
449
|
+
...(installRel !== undefined ? { installRel } : {}),
|
|
450
|
+
});
|
|
451
|
+
plugins.push(plugin);
|
|
452
|
+
pluginSkills += plugin.skills?.length ?? 0;
|
|
453
|
+
pluginCommands += plugin.commandCount ?? 0;
|
|
454
|
+
pluginAgents += plugin.agents?.length ?? 0;
|
|
455
|
+
pluginHooks += plugin.hookFiles ?? 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
counts['plugins'] = plugins.length;
|
|
459
|
+
counts['pluginSkills'] = pluginSkills;
|
|
460
|
+
counts['pluginCommands'] = pluginCommands;
|
|
461
|
+
counts['pluginAgents'] = pluginAgents;
|
|
462
|
+
counts['pluginHooks'] = pluginHooks;
|
|
463
|
+
|
|
464
|
+
return { plugins, marketplaces: markets.map((market) => (market.repo !== undefined ? { name: market.name, repo: market.repo } : { name: market.name })), skipped, counts };
|
|
465
|
+
}
|
package/src/foreign/registry.ts
CHANGED
|
@@ -480,11 +480,34 @@ export function builtinForeignSources(): readonly ForeignSource[] {
|
|
|
480
480
|
{ rel: '.claude/skills', dir: true },
|
|
481
481
|
{ rel: '.claude/commands', dir: true },
|
|
482
482
|
{ rel: '.claude/projects', dir: true },
|
|
483
|
+
// 2026-10-18 补齐:插件目录 / 子代理定义 / 规则目录(本机实测存在 .claude/plugins 与 .claude/hooks)
|
|
484
|
+
{ rel: '.claude/plugins', dir: true },
|
|
485
|
+
{ rel: '.claude/agents', dir: true },
|
|
486
|
+
{ rel: '.claude/rules', dir: true },
|
|
483
487
|
]);
|
|
484
|
-
|
|
488
|
+
const paths: string[] = [...probed.paths];
|
|
489
|
+
const skipped: ForeignSkip[] = [...probed.skipped];
|
|
490
|
+
// CLAUDE_CONFIG_DIR 整体替换配置目录(文档取证:code.claude.com/docs/en/settings)。
|
|
491
|
+
// **不改既有 home 相对标签**(那会动 UI 文案与既有断言),只在覆盖生效时**追加**一组
|
|
492
|
+
// 带前缀的标签;真正的读取与 source-location-overridden 报码由读盘层负责。
|
|
493
|
+
const override = ctx.env['CLAUDE_CONFIG_DIR'];
|
|
494
|
+
if (override !== undefined && override.trim() !== '') {
|
|
495
|
+
const alt = await probeTree(override, [
|
|
496
|
+
{ rel: 'settings.json' },
|
|
497
|
+
{ rel: 'CLAUDE.md' },
|
|
498
|
+
{ rel: 'skills', dir: true },
|
|
499
|
+
], 'CLAUDE_CONFIG_DIR/');
|
|
500
|
+
paths.push(...alt.paths);
|
|
501
|
+
skipped.push(...alt.skipped);
|
|
502
|
+
}
|
|
503
|
+
return { found: paths.length > 0, paths, skipped };
|
|
485
504
|
},
|
|
486
505
|
async build(ctx) {
|
|
487
|
-
const read = await readClaudeCode({
|
|
506
|
+
const read = await readClaudeCode({
|
|
507
|
+
homeDir: ctx.homeDir,
|
|
508
|
+
env: ctx.env,
|
|
509
|
+
...(ctx.limits !== undefined ? { limits: ctx.limits } : {}),
|
|
510
|
+
});
|
|
488
511
|
// 会话转码版本必须由宿主解析后传入(绝不猜);缺省 = 一条都不转并整批报码
|
|
489
512
|
if (ctx.targetSessionFormatVersion !== undefined) {
|
|
490
513
|
read.input.targetSessionFormatVersion = ctx.targetSessionFormatVersion;
|
|
@@ -187,3 +187,68 @@ test('标题行:非 user 来源且整场没有人类 user/message → 不产
|
|
|
187
187
|
assert.equal(rows.some((r) => r.type === 'session/title'), false);
|
|
188
188
|
assert.ok(rows.length > 0, '助手消息本身仍要产出(标题只是装饰)');
|
|
189
189
|
});
|
|
190
|
+
/* ---------------- 未闭合的工具调用(DSH 的 interrupted-turn 修复,形状前提) ---------------- */
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 形状护栏(2026-10-18,task-4):**tool/call 没有 tool/result 时不得伪造任何结算行**。
|
|
194
|
+
*
|
|
195
|
+
* 为什么必须钉住:DSH 自己的修复逻辑(interrupted-turn)只认两样东西 —— ① assistant/message
|
|
196
|
+
* 的 message.content 里有 tool-call 块(它据此知道「这个调用被广告过」)、② 这段流里
|
|
197
|
+
* **没有**对应的 tool/result。若为了「看起来完整」补一条空 result,DSH 会认为调用已结算,
|
|
198
|
+
* 中断状态反而丢失;若 content 里没有 tool-call 块,DSH 连「这里有未决调用」都看不到
|
|
199
|
+
* (deriveEventMessage 对空 content 返回 null)。
|
|
200
|
+
*
|
|
201
|
+
* 本用例刻意**只断言形状**(不引入 DSH 在场依赖):产物必须保持可读、seq 连续、后续事件照常。
|
|
202
|
+
*/
|
|
203
|
+
test('未闭合工具调用:不伪造 result、callId 非空、后续事件与 seq 仍合法(中断由 DSH 修复)', () => {
|
|
204
|
+
const s = stats();
|
|
205
|
+
const rows = synthesizeDshRows(
|
|
206
|
+
{
|
|
207
|
+
id: 's2',
|
|
208
|
+
cwd: 'D:/proj',
|
|
209
|
+
createdAt: NOW,
|
|
210
|
+
records: 3,
|
|
211
|
+
messages: [
|
|
212
|
+
irMessage(irFallbackMessageId(0), 'user', [irTextBlock('开始')], { time: NOW }),
|
|
213
|
+
// 只有工具调用、没有正文:content 仍必须携带 tool-call 块(否则整条助手消息会消失)
|
|
214
|
+
irMessage(irFallbackMessageId(1), 'assistant', [irToolCallBlock('c9', 'terminal', { command: 'sleep 100' })], {
|
|
215
|
+
time: NOW + 1,
|
|
216
|
+
source: { model: 'claude-sonnet-4' },
|
|
217
|
+
}),
|
|
218
|
+
// 中断之后用户继续说话:后续事件不得因为未闭合调用而错乱
|
|
219
|
+
irMessage(irFallbackMessageId(2), 'user', [irTextBlock('中断后我打字')], { time: NOW + 2 }),
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
{ title: 't', titleSource: { kind: 'fallback' }, emptyBlocks: [], provider: 'claude-code', reasoningEffort: 'medium', maxTokens: 8192 },
|
|
223
|
+
s,
|
|
224
|
+
NOW + 3,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
assert.deepEqual(
|
|
228
|
+
rows.map((r) => r.type),
|
|
229
|
+
[
|
|
230
|
+
'user/message', 'session/title', 'turn/start', 'request/header', 'step/start',
|
|
231
|
+
'assistant/message', 'tool/call', 'step/end', 'turn/end', 'user/message',
|
|
232
|
+
],
|
|
233
|
+
'未闭合调用不改变事件序列形状:只在 step 里留下一条 tool/call,随后照常收尾并继续下一轮',
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const calls = rows.filter((r) => r.type === 'tool/call');
|
|
237
|
+
assert.equal(calls.length, 1);
|
|
238
|
+
const callId = (calls[0]?.data as { callId: string }).callId;
|
|
239
|
+
assert.ok(typeof callId === 'string' && callId !== '', 'callId 必须非空(空 callId 会被 DSH 前台拒读)');
|
|
240
|
+
assert.equal(rows.some((r) => r.type === 'tool/result'), false, '绝不伪造空 result:伪造等于替 DSH 结掉这笔中断');
|
|
241
|
+
assert.equal(s.toolResults, 0, 'toolResults 计数保持 0(没有结果就是没有)');
|
|
242
|
+
assert.equal(s.toolCalls, 1);
|
|
243
|
+
|
|
244
|
+
// DSH interrupted-turn 修复的**唯一依据**:content 里的 tool-call 块必须与事件逐字同 id
|
|
245
|
+
const assistant = rows.find((r) => r.type === 'assistant/message');
|
|
246
|
+
const content = (assistant?.data as { message: { content: { type: string; id?: string }[] } }).message.content;
|
|
247
|
+
assert.deepEqual(content.map((b) => b.type), ['tool-call'], '没有正文的助手消息也必须携带 tool-call 块');
|
|
248
|
+
assert.equal(content[0]?.id, callId, 'content 块 id 与 tool/call 的 callId 必须逐字相同');
|
|
249
|
+
|
|
250
|
+
// 日志本身仍合法:seq 从 0 连续递增,中断之后的用户消息照常产出
|
|
251
|
+
assert.deepEqual(rows.map((r) => r.seq), rows.map((_, i) => i), 'seq 必须仍连续递增');
|
|
252
|
+
assert.equal(rows.filter((r) => r.type === 'user/message').length, 2, '中断之后的消息不得被吞掉');
|
|
253
|
+
assert.equal(rows[rows.length - 1]?.type, 'user/message');
|
|
254
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 真值表护栏(design doc §8.2 与 truth-table.ts 文件头都声称有「truth-table.test.ts」做
|
|
3
|
+
* 「真值表 ↔ 各来源 probePaths 逐平台交叉核对」——审计发现**这个文件此前并不存在**,
|
|
4
|
+
* 6 个配置类来源的 probePaths 因此没有任何交叉核对。本文件补齐它。
|
|
5
|
+
*
|
|
6
|
+
* 三条断言(都是源码级真值,不读真机 home):
|
|
7
|
+
* ① 真值表与来源词表**同序同集合**(顺序 = UI/CLI 列表顺序的事实源);
|
|
8
|
+
* ② 配置类来源的 probePaths 必须逐条等于真值表条目按平台展开的结果(未识别令牌原样保留 ⇒ 立刻红);
|
|
9
|
+
* ③ 来源的 evidence 必须逐条等于真值表里的 evidence(**不得在来源定义里自己填更好看的一档**)。
|
|
10
|
+
*
|
|
11
|
+
* 会话类 24 个来源的 probePaths 由各自 wiring 提供(session-source.ts 装配),
|
|
12
|
+
* 本文件只核对它们的真值表条目存在且取证强度一致。
|
|
13
|
+
*/
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import test from 'node:test';
|
|
16
|
+
|
|
17
|
+
import { builtinForeignSources, FOREIGN_SOURCE_IDS } from './registry.ts';
|
|
18
|
+
import { FOREIGN_SOURCE_IDS_FROM_SHAPES } from './source-modules.ts';
|
|
19
|
+
import { FOREIGN_TRUTH_TABLES, expandTruthList, TRUTH_PROBES } from './truth-table.ts';
|
|
20
|
+
import type { ForeignPlatform } from './platform-paths.ts';
|
|
21
|
+
|
|
22
|
+
const PLATFORMS: readonly ForeignPlatform[] = ['win32', 'darwin', 'linux'];
|
|
23
|
+
|
|
24
|
+
/** 配置类来源(其余 24 个是会话类,probePaths 由各自 wiring 提供) */
|
|
25
|
+
const CONFIG_CLASS: ReadonlySet<string> = new Set([
|
|
26
|
+
'claude-code', 'hermes', 'cursor', 'codex', 'copilot', 'antigravity',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
test('V1 真值表与来源词表同序同集合(不得漂移)', () => {
|
|
30
|
+
const tableIds = FOREIGN_TRUTH_TABLES.map((e) => e.id);
|
|
31
|
+
assert.equal(new Set(tableIds).size, tableIds.length, '真值表 id 必须唯一');
|
|
32
|
+
assert.deepEqual(tableIds, [...FOREIGN_SOURCE_IDS], '真值表顺序必须等于来源词表顺序');
|
|
33
|
+
assert.deepEqual(tableIds, [...FOREIGN_SOURCE_IDS_FROM_SHAPES], '真值表顺序必须等于形状表顺序');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('V2 每个条目三平台都有非空默认位置,且没有未展开令牌', () => {
|
|
37
|
+
for (const entry of FOREIGN_TRUTH_TABLES) {
|
|
38
|
+
for (const p of PLATFORMS) {
|
|
39
|
+
const list = entry.defaults[p];
|
|
40
|
+
assert.ok(Array.isArray(list), entry.id + ' ' + p + ' 默认位置不是数组');
|
|
41
|
+
// autoRoots:false = 该来源**没有**自动根(如 chatgpt 只能由用户显式给路径)——空表是刻意的
|
|
42
|
+
if (entry.autoRoots) {
|
|
43
|
+
assert.ok(list.length > 0, entry.id + ' ' + p + ' 声明有自动根却没有任何默认位置');
|
|
44
|
+
}
|
|
45
|
+
for (const expanded of expandTruthList(p, list)) {
|
|
46
|
+
assert.ok(!expanded.includes('<'), entry.id + ' ' + p + ' 存在未展开令牌:' + expanded);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
assert.equal(typeof entry.autoRoots, 'boolean', entry.id + ' autoRoots 必须是布尔');
|
|
50
|
+
assert.ok(entry.refs.length > 0, entry.id + ' refs 必须写明依据');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('V3 配置类来源 probePaths 必须逐条等于真值表展开结果', () => {
|
|
55
|
+
const sources = builtinForeignSources().filter((s) => CONFIG_CLASS.has(s.id));
|
|
56
|
+
assert.equal(sources.length, CONFIG_CLASS.size, '配置类来源个数与常量表不一致');
|
|
57
|
+
for (const source of sources) {
|
|
58
|
+
const entry = FOREIGN_TRUTH_TABLES.find((e) => e.id === source.id);
|
|
59
|
+
assert.ok(entry !== undefined, source.id + ' 缺真值表条目');
|
|
60
|
+
for (const p of PLATFORMS) {
|
|
61
|
+
const probe = TRUTH_PROBES[p];
|
|
62
|
+
const actual = source.probePaths({ homeDir: probe.homeDir, env: {}, platform: p });
|
|
63
|
+
assert.deepEqual([...actual], expandTruthList(p, entry.defaults[p]), source.id + ' ' + p + ' probePaths 与真值表不一致');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('V4 来源的 evidence 必须逐条等于真值表(不得美化取证强度)', () => {
|
|
69
|
+
for (const source of builtinForeignSources()) {
|
|
70
|
+
const entry = FOREIGN_TRUTH_TABLES.find((e) => e.id === source.id);
|
|
71
|
+
assert.ok(entry !== undefined, source.id + ' 缺真值表条目');
|
|
72
|
+
assert.equal(source.evidence, entry.evidence, source.id + ' evidence 与真值表不一致');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('V5 CLAUDE_CONFIG_DIR 是 claude-code 的真值表 envKeys(文档取证必须落到数据里)', () => {
|
|
77
|
+
const entry = FOREIGN_TRUTH_TABLES.find((e) => e.id === 'claude-code');
|
|
78
|
+
assert.ok(entry !== undefined);
|
|
79
|
+
const keys = (entry.envKeys ?? []).map((k) => k.key);
|
|
80
|
+
assert.ok(keys.includes('CLAUDE_CONFIG_DIR'), 'claude-code 必须登记 CLAUDE_CONFIG_DIR');
|
|
81
|
+
});
|
|
@@ -119,12 +119,15 @@ export const FOREIGN_TRUTH_TABLES: readonly ForeignTruthTableEntry[] = [
|
|
|
119
119
|
defaults: all3(
|
|
120
120
|
'<home>/.claude', '<home>/.claude.json', '<home>/.claude/settings.json',
|
|
121
121
|
'<home>/.claude/CLAUDE.md', '<home>/.claude/skills', '<home>/.claude/commands', '<home>/.claude/projects',
|
|
122
|
+
// 2026-10-18 实测补齐:插件注册表(known_marketplaces.json)/ 子代理定义 / 规则目录
|
|
123
|
+
'<home>/.claude/plugins', '<home>/.claude/plugins/known_marketplaces.json',
|
|
124
|
+
'<home>/.claude/agents', '<home>/.claude/rules',
|
|
122
125
|
),
|
|
123
|
-
envKeys: [],
|
|
126
|
+
envKeys: [{ key: 'CLAUDE_CONFIG_DIR', mode: 'replace', note: '整体替换配置目录(命中即报 source-location-overridden)' }],
|
|
124
127
|
autoRoots: true,
|
|
125
128
|
evidence: 'measured',
|
|
126
|
-
refs: 'chat-import §3.1 claude 行(projects + Claude-3p 第二根);本仓本机实测(~/.claude、~/.claude.json
|
|
127
|
-
dynamic: '会话在 ~/.claude/projects/<slug>/<uuid>.jsonl(v1
|
|
129
|
+
refs: 'chat-import §3.1 claude 行(projects + Claude-3p 第二根);本仓本机实测(~/.claude、~/.claude.json 存在);2026-10-18 本机实测 plugins/known_marketplaces.json 与 marketplaces/<市场>/.claude-plugin/{marketplace,plugin}.json 结构',
|
|
130
|
+
dynamic: '会话在 ~/.claude/projects/<slug>/<uuid>.jsonl(v1 已实现会话转码);项目级配置根来自 ~/.claude.json 的 projects 键(**只取真实存在的目录,绝不猜路径**);插件启用清单来自 settings.json 的 enabledPlugins(本机缺省为空 = 没有启用的插件,不报码)',
|
|
128
131
|
},
|
|
129
132
|
{
|
|
130
133
|
id: 'hermes',
|