dsh-plugin-tool-management 0.10.0 → 0.11.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 (74) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +64 -49
  3. package/README_EN.md +58 -37
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +105 -12
  21. package/lib/client.js +2763 -546
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -20
  24. package/lib/context-inject.js +11 -0
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +28 -2
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +804 -3887
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +10 -3
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/{rules → memories}/archive-engine.js +1 -1
  37. package/lib/{rules → memories}/archive.js +1 -1
  38. package/lib/memories/constants.js +128 -0
  39. package/lib/memories/index-io.js +330 -0
  40. package/lib/memories/projection.js +280 -0
  41. package/lib/memories/service.js +686 -0
  42. package/lib/memories/snapshot.js +672 -0
  43. package/lib/ops/candidates.js +64 -0
  44. package/lib/ops/compat.js +136 -0
  45. package/lib/ops/ctx.js +9 -0
  46. package/lib/ops/memory.js +678 -0
  47. package/lib/ops/prompts.js +107 -0
  48. package/lib/ops/scene-records.js +460 -0
  49. package/lib/ops/scene-sync.js +17 -0
  50. package/lib/ops/sessions.js +603 -0
  51. package/lib/ops/trash.js +140 -0
  52. package/lib/paths.js +103 -0
  53. package/lib/prompts/preset-id.js +49 -0
  54. package/lib/{agents-md → prompts}/service.js +1 -1
  55. package/lib/request-gate.js +320 -0
  56. package/lib/scene-prompt-sync.js +4 -4
  57. package/lib/scenes/candidates.js +344 -0
  58. package/lib/{history → sessions}/bridge.js +15 -5
  59. package/lib/sessions/history.js +323 -0
  60. package/lib/{history → sessions}/tombstone.js +1 -1
  61. package/lib/{history → sessions}/workspace.js +92 -24
  62. package/lib/skills/core.js +74 -39
  63. package/lib/skills/readonly-discovery.js +4 -1
  64. package/lib/skills/service.js +98 -13
  65. package/lib/subagents/service.js +199 -55
  66. package/lib/tools/deps.js +8 -0
  67. package/lib/tools/mcp.js +110 -0
  68. package/lib/tools/memory.js +87 -0
  69. package/lib/tools/prompt.js +70 -0
  70. package/lib/tools/skills.js +139 -0
  71. package/lib/tools/subagent.js +40 -0
  72. package/package.json +7 -7
  73. package/lib/agents-md/preset-id.js +0 -49
  74. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,70 @@
1
+ // AGENTS.md 预设域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // prompt_manager_list / prompt_manager_apply。
3
+ //
4
+ // 模型可查、可切,**不能造 / 不能删** —— 避免模型乱删用户预设。
5
+ import { join } from 'node:path';
6
+ import { text } from './deps.js';
7
+ export function buildPromptTools(deps) {
8
+ const { defineTool, register } = deps;
9
+ register(defineTool({
10
+ name: 'prompt_manager_list',
11
+ description: 'List AGENTS.md presets (id, active state, file path). Read that file to see a preset body. The preset in effect right now is injected into your context each turn (the「本机提示词」system-reminder, whose「来源:」line names the file). Defaults to the preset currently in effect; pass all=true for the whole library.',
12
+ parameters: {
13
+ all: { type: 'boolean', description: 'Include inactive presets (default false).' },
14
+ },
15
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
16
+ async execute(args, exec) {
17
+ const showAll = Boolean(args && args.all === true);
18
+ const r = await deps.promptsService.list();
19
+ if (!r.ok)
20
+ throw new Error(r.error);
21
+ const all = r.presets || [];
22
+ // 默认只列「当前这份 ~/.dsh/AGENTS.md 是从哪个预设来的」(用户裁定 2026-09-16)。
23
+ // 判定分两级:内容逐字节相同 = 生效;否则看服务层记下的「最近一次应用」——
24
+ // 用户手改过全局文件时,第二级仍能给出答案,不必退化成全列(那会白烧上下文)。
25
+ const active = all.filter((p) => p.active === true);
26
+ const fallback = active.length ? active : all.filter((p) => p.lastApplied === true);
27
+ const shown = showAll ? all : fallback;
28
+ // 路径这一列是「名单 → 全文」的闭环:预设正文既不进提示词、也没有读取工具,
29
+ // 给出文件路径让模型用宿主的文件工具读,比再造一个 read 工具省一条常驻 schema。
30
+ const summary = shown.map((p) => {
31
+ const mark = p.active ? ' [active]'
32
+ : p.lastApplied ? ' [last applied — ~/.dsh/AGENTS.md has changed since]'
33
+ : '';
34
+ return p.id + mark + ' | ' + join(deps.promptsDir, p.id, 'AGENTS.md');
35
+ });
36
+ const header = 'AGENTS.md presets: ' + (showAll
37
+ ? all.length + ' in the library'
38
+ : fallback.length
39
+ ? fallback.length + (active.length ? ' active' : ' last applied') + ' of ' + all.length +
40
+ (fallback.length === all.length ? '' : ' (pass all=true for the whole library)')
41
+ : '0 active of ' + all.length + ' (pass all=true for the whole library)') + '\n';
42
+ // 同上:AGENTS.md 由官方 dsh-agent-instructions 行承载(极简没挂这一行),
43
+ // 这一行不在时文件内容不会进上下文。
44
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
45
+ return header + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
46
+ },
47
+ }));
48
+ register(defineTool({
49
+ name: 'prompt_manager_apply',
50
+ description: 'Apply one AGENTS.md preset by id; effective on the next turn. If a scene currently drives the baseline, this does NOT refuse: it rebinds that scene\'s prompt binding to the preset and re-syncs the scene, so ~/.dsh/AGENTS.md ends up holding the scene\'s (new) binding — tell the user which scene was rebound. Otherwise the preset is written to ~/.dsh/AGENTS.md directly. A locked scene blocks the call.',
51
+ parameters: {
52
+ id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
53
+ },
54
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
55
+ async execute(args) {
56
+ const blocked = await deps.lockedSceneGuard();
57
+ if (blocked)
58
+ throw new Error(blocked);
59
+ const r = await deps.applyPresetGuarded(args.id);
60
+ if (!r.ok)
61
+ throw new Error(r.error);
62
+ // 场景驱动时写的是「场景绑定」,回执必须说清改的是哪个场景 —— 只说「已应用到
63
+ // AGENTS.md」会让模型对用户谎报(用户不知道自己的场景绑定被换掉了)。
64
+ if (r.viaScene) {
65
+ return 'OK: preset ' + args.id + ' is now the prompt binding of scene 「' + (r.scene || '') + '」 and the scene was re-synced (effective next turn; ~/.dsh/AGENTS.md now carries that scene\'s binding).';
66
+ }
67
+ return 'OK: preset ' + args.id + ' applied to ~/.dsh/AGENTS.md (next session; current session unchanged' + (r.backedUp ? '; previous backed up to __last-applied__' : '') + ')';
68
+ },
69
+ }));
70
+ }
@@ -0,0 +1,139 @@
1
+ // 技能域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // skill_manager_list / skill_manager_set_enabled / skill_manager_create。
3
+ import { text } from './deps.js';
4
+ export function buildSkillTools(deps) {
5
+ const { defineTool, register } = deps;
6
+ // Skill-facing tools: list, toggle, create. create is gated by the
7
+ // tools/pre-execute hook below (the model must ask before writing files).
8
+ register(defineTool({
9
+ name: 'skill_manager_list',
10
+ description: 'List DSH skills with enabled state, effective/shadowed status and source file path. The injected「本机技能目录」system-reminder carries callable skills and summaries only; use this tool to get a source file path (read the file for the full body) and to see entries that are off. Defaults to enabled skills only; pass all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
11
+ parameters: {
12
+ all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
13
+ },
14
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
15
+ async execute(args, exec) {
16
+ const showAll = Boolean(args && args.all === true);
17
+ const r = await deps.skillsOps['skill-state']({});
18
+ if (!r || r.ok === false)
19
+ throw new Error((r && r.error) || 'skill-state failed');
20
+ const data = r.data || {};
21
+ // 同名(canonical = declaredName || name)跨根重复时只有**一份**生效,排序规则是
22
+ // 「同名首选 > 来源优先级 rank」,**与启用状态无关**(core 的 groupLoadableSkillsByName)。
23
+ // 于是「启用被覆盖的那份」是静默无效的:工具会回 OK,技能却依然不可用 —— 用户实测
24
+ // 踩到的正是这个坑(同一技能在 dsh/agents/claude/custom 各有一份,启错根等于没启)。
25
+ // 这里把生效/被覆盖如实标出来,让模型(和人)不必猜。
26
+ //
27
+ // 默认只列启用的(2026-09-16 用户裁定):本机 36 条里只有 1 条启用,全列一次约 4.3k
28
+ // 字符且永久留在 transcript。表头始终给出全量口径(多少条 / 多少同名组),所以
29
+ // 「还有没列出来的」不会变成盲区 —— 要看就传 all=true。
30
+ const rows = [];
31
+ const counts = new Map();
32
+ for (const root of data.roots || []) {
33
+ for (const skill of root.skills || []) {
34
+ const key = String(skill.declaredName || skill.name || '');
35
+ counts.set(key, (counts.get(key) || 0) + 1);
36
+ // `enabled` 只写在「胜出者」上(core 的 markWinners);影子副本与不可加载的技能
37
+ // 没有这个字段。原先直接读 skill.enabled,这些技能一律被报成 disabled ——
38
+ // 这里改用与界面同口径的兜底推导(client 的 isSkillEnabled)。
39
+ const enabled = skill.enabled !== undefined
40
+ ? skill.enabled === true
41
+ : (skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable && skill.managerEnabled !== false);
42
+ const marks = [];
43
+ if (skill.shadowedBy && skill.shadowedBy.root)
44
+ marks.push('shadowed by ' + skill.shadowedBy.root);
45
+ if (skill.preferred === true)
46
+ marks.push('preferred');
47
+ rows.push({
48
+ root: String(root.key || ''),
49
+ enabled,
50
+ text: (skill.name || skill.declaredName || '') + ' | ' + (root.key || '') + ' | ' + (enabled ? 'enabled' : 'disabled') +
51
+ (marks.length ? ' | ' + marks.join(' ') : '') +
52
+ (skill.path ? ' | ' + skill.path : ''),
53
+ });
54
+ }
55
+ }
56
+ const dupGroups = [...counts.values()].filter((n) => n > 1).length;
57
+ const shown = showAll ? rows : rows.filter((row) => row.enabled);
58
+ const header = 'Skills: ' + (showAll
59
+ ? rows.length + ' entries'
60
+ : shown.length + ' enabled of ' + rows.length + ' entries') +
61
+ (dupGroups ? ' · ' + dupGroups + ' duplicated names' : '') +
62
+ (showAll || shown.length === rows.length ? '' : ' (pass all=true to include disabled and shadowed entries)') + '\n';
63
+ // 注入边界:预设没挂 dsh-tool-skill(如极简)时官方技能目录不在,本插件的
64
+ // 「技能目录」注入域会在开关打开时兜底(见上面的注入通道);两者都没有时模型看到的
65
+ // 只是这份名单 —— 说清边界,别让它以为上下文里已经有技能正文(正文用路径读)。
66
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
67
+ return header + (shown.map((row) => row.text).join('\n') || '(none)') + notice;
68
+ },
69
+ }));
70
+ register(defineTool({
71
+ name: 'skill_manager_set_enabled',
72
+ description: 'Enable or disable one DSH skill (manager policy only; source files are never modified). Enabling a shadowed copy has no effect.',
73
+ parameters: {
74
+ name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
75
+ enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
76
+ root: { type: 'string', description: 'Source root key (dsh/agents/codex/claude or a project key); default dsh.' },
77
+ },
78
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
79
+ async execute(args) {
80
+ const blocked = await deps.lockedSceneGuard();
81
+ if (blocked)
82
+ throw new Error(blocked);
83
+ const root = String(args.root || 'dsh');
84
+ const op = args.enabled ? 'skill-enable' : 'skill-disable';
85
+ const r = await deps.skillsOps[op]({ name: args.name, root });
86
+ if (!r || r.ok === false)
87
+ throw new Error((r && r.error) || 'skill toggle failed');
88
+ // 场景未锁定时同步进当前场景档案(与 handlers 层同一套同步)。
89
+ const syncErr = await deps.syncSwitchToScene(op, { name: args.name, root, enabled: args.enabled === true });
90
+ // 静默无效是这个工具最容易骗人的地方:同名胜负只由「首选 > 来源优先级」决定,
91
+ // 与启用状态无关。对影子副本操作会返回 OK,但技能依然不可用。如实说一句。
92
+ let shadowedBy = '';
93
+ try {
94
+ const state = await deps.skillsOps['skill-state']({});
95
+ for (const rt of (state && state.data && state.data.roots) || []) {
96
+ for (const skill of rt.skills || []) {
97
+ if (String(skill.declaredName || skill.name || '') !== String(args.name))
98
+ continue;
99
+ if (String(rt.key || '') !== root)
100
+ continue;
101
+ if (skill.shadowedBy && skill.shadowedBy.root)
102
+ shadowedBy = String(skill.shadowedBy.root);
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ shadowedBy = '';
108
+ }
109
+ const warn = shadowedBy
110
+ ? ' — BUT this copy is shadowed by the same-name skill in "' + shadowedBy + '", so it stays inactive: enable that copy instead, or make this root the preferred copy in the panel'
111
+ : '';
112
+ const sceneWarn = syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '';
113
+ return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn + sceneWarn;
114
+ },
115
+ }));
116
+ register(defineTool({
117
+ name: 'skill_manager_create',
118
+ // 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
119
+ // tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
120
+ // root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
121
+ description: 'Create a new DSH skill under DSH_HOME/tool-management/skills. Use only when the user explicitly asks to create or save a reusable skill.',
122
+ parameters: {
123
+ name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
124
+ description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
125
+ body: { type: 'string', required: true, description: 'Markdown instructions that form the skill body.' },
126
+ },
127
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
128
+ async execute(args) {
129
+ const blocked = await deps.lockedSceneGuard();
130
+ if (blocked)
131
+ throw new Error(blocked);
132
+ const r = await deps.skillsOps['skill-create']({ name: args.name, description: args.description, body: args.body });
133
+ if (!r || r.ok === false)
134
+ throw new Error((r && r.error) || 'skill create failed');
135
+ const data = r.data || {};
136
+ return 'Created DSH skill ' + (data.name || args.name) + ' at ' + (data.path || '(unknown)');
137
+ },
138
+ }));
139
+ }
@@ -0,0 +1,40 @@
1
+ // 子智能体域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // subagent_manager_list / subagent_manager_run。
3
+ //
4
+ // exec.agent / exec.signal 由工具运行时提供(parent 与取消信号的官方通道)。
5
+ //
6
+ // 两个工具**各自** try/catch:一个注册失败不该把另一个也带走,而且失败必须说得出
7
+ // 「是哪一个没注册上」——只打一行日志时,模型侧只会「查无此工具」、界面毫无痕迹。
8
+ import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from '../subagents/tools.js';
9
+ export function buildSubagentTools(deps) {
10
+ const { defineTool, register } = deps;
11
+ const recordFailure = (name, e) => {
12
+ deps.failures.list.push({ name, reason: deps.message(e) });
13
+ if (deps.failures.logged)
14
+ return;
15
+ deps.failures.logged = true;
16
+ console.error('[dsh-plugin-tool-management] subagent tool registration failed:', name, deps.message(e));
17
+ };
18
+ try {
19
+ // 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
20
+ // 裸 register 会把未编译的参数声明直接发给模型 API。
21
+ register(defineTool(defineSubagentManagerListTool({
22
+ list: () => deps.subagentService.list(),
23
+ sceneLists: deps.sceneLists,
24
+ // 与其余发现型工具同口径:压制型预设下不注入时,人设目录不在模型上下文里,
25
+ // 只有工具可用。挂上边界提示,模型才不会把「看不到人设」当成「没有人设」。
26
+ noticeFor: (exec) => deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions()),
27
+ })));
28
+ }
29
+ catch (e) {
30
+ recordFailure('subagent_manager_list', e);
31
+ }
32
+ try {
33
+ // 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
34
+ // 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
35
+ register(defineTool(defineSubagentManagerRunTool({ ...deps.subagentService, sceneLists: deps.sceneLists, toolFilterFor: deps.toolFilterFor })));
36
+ }
37
+ catch (e) {
38
+ recordFailure('subagent_manager_run', e);
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "DSH plugin: one settings panel for MCP servers, skills, scenes, memories, subagents, AGENTS.md presets and archived sessions — scene memory injects into the system prompt, scene lock freezes all five domains, deleted workspaces re-register with one click. Settings UI + HTTP API + 14 model tools (mcp_manager_*, skill_manager_*, prompt_manager_*, memory_manager_*, subagent_*). Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,7 +11,7 @@
11
11
  "exports": {
12
12
  ".": "./lib/index.js",
13
13
  "./client": "./lib/client.js",
14
- "./workspace": "./lib/history/workspace.js",
14
+ "./workspace": "./lib/sessions/workspace.js",
15
15
  "./package.json": "./package.json"
16
16
  },
17
17
  "files": [
@@ -32,15 +32,15 @@
32
32
  "access": "public"
33
33
  },
34
34
  "scripts": {
35
- "build": "tsc -p tsconfig.json && node scripts/sync-client.mjs",
36
- "build:client": "node scripts/sync-client.mjs",
37
- "prepublishOnly": "npm run build",
35
+ "build": "tsc -p tsconfig.json && node scripts/sync-client.mjs && node scripts/sync-profile.mjs",
36
+ "prepublishOnly": "npm run build && npm run lint && node --test test/client-exports.test.mjs",
38
37
  "lint": "node --check lib/client.js && node --check lib/index.js",
39
- "check:i18n": "node scripts/check-i18n.mjs src/client.js",
38
+ "typecheck:client": "node scripts/sync-client.mjs && tsc -p tsconfig.client.json",
39
+ "check:i18n": "node scripts/check-i18n.mjs lib/client.js",
40
40
  "check:host": "node scripts/host-deps.mjs && node scripts/doctor.mjs",
41
41
  "host-deps": "node scripts/host-deps.mjs --fix",
42
42
  "doctor": "node scripts/doctor.mjs",
43
- "test": "npm run build && npm run check:i18n && node --test test/contracts.test.mjs test/client-exports.test.mjs test/client-render.test.mjs",
43
+ "test": "npm run build && npm run check:i18n && node --test test/contracts.test.mjs test/client-exports.test.mjs",
44
44
  "sync:profile": "node scripts/sync-profile.mjs"
45
45
  },
46
46
  "keywords": [
@@ -1,49 +0,0 @@
1
- // dsh-plugin-tool-management —— 提示词预设 id 的唯一口径。
2
- //
3
- // 用户裁定(2026-09-15):「id 应该什么都能写」——不再限定小写字母/数字/连字符,
4
- // 中文、空格、下划线、点都可以。id 同时也是**磁盘目录名**,所以只保留两类硬约束:
5
- // ① 文件系统安全:不含路径分隔符与 Windows 非法字符、不以点开头/结尾、
6
- // 不是 `.` / `..`、不是 Windows 保留设备名;
7
- // ② 一个保留字:`__last-applied__`(apply 的备份槽,不是用户预设)。
8
- // 这套口径由提示词服务(agents-md/ 目录)与场景绑定(`scenes[].prompt`)共用,避免两处漂移。
9
- // 约定镜像 @deepseek-ai/dsh-agent-presets 的「id 即目录名」,但放宽了字符集。
10
- /** id 长度上限(字符数;目录名过长在 Windows 上还受 MAX_PATH 约束)。 */
11
- export const PRESET_ID_MAX = 64;
12
- /** apply 的备份槽:不是用户预设,任何入口都不得当作预设 id。 */
13
- export const LAST_APPLIED_PRESET_ID = '__last-applied__';
14
- /** Windows 保留设备名(不区分大小写,带扩展名同样非法)。 */
15
- const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
16
- /**
17
- * 校验并归一化一个预设 id。
18
- * @param raw - 用户输入或来自索引的原始值。
19
- * @returns `{ ok: true, id }`(已 trim)或 `{ ok: false, error }`(给人看的中文原因)。
20
- */
21
- export function normalizePresetId(raw) {
22
- const id = String(raw ?? '').trim();
23
- if (id === '')
24
- return { ok: false, error: 'id 不能为空' };
25
- if (id.length > PRESET_ID_MAX)
26
- return { ok: false, error: `id 过长(≤${PRESET_ID_MAX} 字符,当前 ${id.length})` };
27
- if (id === LAST_APPLIED_PRESET_ID)
28
- return { ok: false, error: `「${LAST_APPLIED_PRESET_ID}」是「应用」的备份槽,不能用作预设 id` };
29
- if (id === '.' || id === '..')
30
- return { ok: false, error: 'id 不能是「.」或「..」' };
31
- if (id.startsWith('.'))
32
- return { ok: false, error: 'id 不能以「.」开头' };
33
- if (/[\\/]/.test(id))
34
- return { ok: false, error: 'id 不能含路径分隔符(/ 或 \\)' };
35
- if (/[<>:"|?*]/.test(id))
36
- return { ok: false, error: 'id 不能含 < > : " | ? * 这些字符' };
37
- if (/[.\s]$/.test(id))
38
- return { ok: false, error: 'id 不能以点或空格结尾(Windows 会静默去掉)' };
39
- if (WINDOWS_RESERVED.test(id))
40
- return { ok: false, error: `「${id}」是 Windows 保留设备名` };
41
- // 控制字符(含换行/制表)会让目录名与日志不可读,直接拒绝。
42
- if (/[\u0000-\u001f\u007f]/.test(id))
43
- return { ok: false, error: 'id 不能含控制字符' };
44
- return { ok: true, id };
45
- }
46
- /** 便捷判定(索引解析等只关心"能不能用"的地方)。 */
47
- export function isValidPresetId(raw) {
48
- return normalizePresetId(raw).ok;
49
- }