dsh-claude-compat 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  <a href="README.zh-CN.md"><img src="https://img.shields.io/badge/🌐_简体中文-点我阅读中文-red?style=for-the-badge" alt="简体中文"></a>
9
9
  <img src="https://img.shields.io/npm/dm/dsh-claude-compat?style=flat-square&label=downloads/month&color=brightgreen" alt="npm downloads/month">
10
10
  <img src="https://img.shields.io/npm/dt/dsh-claude-compat?style=flat-square&label=total%20downloads&color=blue" alt="npm total downloads">
11
- <img src="https://img.shields.io/badge/version-0.8.1-blue?style=flat-square" alt="Version">
11
+ <img src="https://img.shields.io/badge/version-0.9.0-blue?style=flat-square" alt="Version">
12
12
  <img src="https://img.shields.io/badge/license-MIT-orange?style=flat-square" alt="License">
13
13
  </p>
14
14
 
@@ -120,6 +120,8 @@ Restart DSH (`dsh web`). Done — skills show up in `/`, rules are injected into
120
120
  | `enableHooks` | `true` | Run `.claude/settings.json` hooks (Pre/PostToolUse, UserPromptSubmit) |
121
121
  | `hooksTimeoutMs` | `60000` | Per-hook run timeout (UserPromptSubmit capped at 10s regardless) |
122
122
  | `enableAgents` | `true` | Surface `.claude/agents/*.md` as delegation-shim skills |
123
+ | `skillAliases` | `{}` | Non-kebab (e.g. Chinese) name → ASCII slug map, e.g. `{ "三脑": "san-nao" }`. See “Non-ASCII skill names” below |
124
+ | `aliasDescriptionPrefix` | `true` | When an alias is used, put the original name in front of the description: `san-nao: 三脑 — …` |
123
125
  | `rulesMaxBytes` | `65536` | Hard cap on total injected project rules text |
124
126
  | `userRulesMaxBytes` | `65536` | Hard cap on total injected `~/.claude/rules` text |
125
127
  | `projectRootMarkers` | `[".git"]` | Ancestor markers for project-root discovery |
@@ -145,6 +147,23 @@ Restart DSH (`dsh web`). Done — skills show up in `/`, rules are injected into
145
147
  - **Rules content**: rules are injected verbatim as instructions to the model. Only commit rules you want the model to follow — same trust level as `CLAUDE.md`.
146
148
  - **Catalog snapshot timing**: the skill catalog is snapshotted when a session is created. Skills installed or edited mid-session surface after `/reload-cc-plugins` (hot reload) or in the next session.
147
149
 
150
+ ### Non-ASCII skill names
151
+
152
+ DSH's skill-name grammar is fixed to kebab-case (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, defined in `@deepseek-ai/dsh-skill` and enforced by the registry before a candidate is read), so **a Chinese name can never reach the registry, no matter what this plugin emits** — emitting it anyway only moves the failure one layer down (`get()` returns `void 0`).
153
+
154
+ The plugin's answer is an explicit map from the original name to a legal slug, keeping the original name at the front of the description:
155
+
156
+ ```yaml
157
+ skillAliases:
158
+ 三脑: tri-brain
159
+ 读知乎: read-zhihu
160
+ ```
161
+
162
+ - The `name` becomes the slug (`/tri-brain` in the slash menu); the `description` becomes `三脑 — 三脑复核(…)`.
163
+ - **A legal name always beats an alias** — a skill DSH can already load is never renamed.
164
+ - Aliased entries keep their provider's rank, so a collision with a DSH-native or `~/.agents` skill is deduped exactly like any other duplicate (a `~/.claude` alias loses to an `~/.agents` skill of the same name rather than shadowing it). Lower `userSkillRank` below 500 to make `~/.claude` win.
165
+ - Un-mapped non-kebab names no longer vanish silently: one warning per file per process, with a paste-ready mapping example.
166
+
148
167
  ## Troubleshooting
149
168
 
150
169
  ### Known limitation: `/cc-resume` via the skill tool
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
  <a href="README.md"><img src="https://img.shields.io/badge/🌐_English-Click_me-red?style=for-the-badge" alt="English"></a>
9
9
  <img src="https://img.shields.io/npm/dm/dsh-claude-compat?style=flat-square&label=downloads/month&color=brightgreen" alt="npm downloads/month">
10
10
  <img src="https://img.shields.io/npm/dt/dsh-claude-compat?style=flat-square&label=total%20downloads&color=blue" alt="npm total downloads">
11
- <img src="https://img.shields.io/badge/version-0.8.1-blue?style=flat-square" alt="Version">
11
+ <img src="https://img.shields.io/badge/version-0.9.0-blue?style=flat-square" alt="Version">
12
12
  <img src="https://img.shields.io/badge/license-MIT-orange?style=flat-square" alt="License">
13
13
  </p>
14
14
 
@@ -120,6 +120,8 @@ dsh plugin --profile web add github:biedongbin/dsh-claude-compat
120
120
  | `enableHooks` | `true` | 运行 `.claude/settings.json` hooks(Pre/PostToolUse、UserPromptSubmit) |
121
121
  | `hooksTimeoutMs` | `60000` | 单个 hook 运行超时(UserPromptSubmit 无论如何上限 10s) |
122
122
  | `enableAgents` | `true` | 把 `.claude/agents/*.md` 暴露为委派 shim skill |
123
+ | `skillAliases` | `{}` | 非 kebab 名(中文等)→ ASCII slug 的映射,如 `{ "三脑": "san-nao" }`。见下方「中文 / 非 ASCII 技能名」 |
124
+ | `aliasDescriptionPrefix` | `true` | 用了 alias 时把原名放在描述最前:`san-nao: 三脑 — …` |
123
125
  | `rulesMaxBytes` | `65536` | 注入项目 rules 总量硬上限 |
124
126
  | `userRulesMaxBytes` | `65536` | 注入 `~/.claude/rules` 总量硬上限 |
125
127
  | `projectRootMarkers` | `[".git"]` | 项目根发现的祖先标记 |
@@ -145,6 +147,23 @@ dsh plugin --profile web add github:biedongbin/dsh-claude-compat
145
147
  - **Rules 内容**:rules 原文注入为模型指令。只提交你想让模型遵循的 rule —— 信任级别同 `CLAUDE.md`。
146
148
  - **Catalog 快照时机**:skill catalog 在会话创建时快照。会话中途安装/修改的 skill 通过 `/reload-cc-plugins` 热生效,或下一会话生效。
147
149
 
150
+ ### 中文 / 非 ASCII 技能名
151
+
152
+ DSH 的技能名文法写死为 kebab-case(`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`,定义在 `@deepseek-ai/dsh-skill`,registry 取用前先校验),所以**中文名无论插件怎么发都进不了 registry** —— 插件层放行也没用,下一层立刻 `return void 0`。
153
+
154
+ 本插件的做法:把中文名显式映射成一个合法 slug,原名保留在描述最前,目录里读起来仍是中文:
155
+
156
+ ```yaml
157
+ skillAliases:
158
+ 三脑: tri-brain
159
+ 读知乎: read-zhihu
160
+ ```
161
+
162
+ - 映射后 `name` 用 slug(斜杠菜单里是 `/tri-brain`),`description` 变成 `三脑 — 三脑复核(…)`。
163
+ - **合法名永远压过映射**:本来就能加载的技能不会被 alias 改名。
164
+ - 别名条目沿用原 provider 的 rank,所以与 DSH 原生或 `~/.agents` 同名时,按原有优先级去重(`~/.claude` 的 alias 输给 `~/.agents` 同名项,不会顶掉它)。想让 `~/.claude` 赢就把 `userSkillRank` 调到 500 以下。
165
+ - 没配映射的非 kebab 名不再静默消失:每个文件每进程告警一次,并直接给出可粘贴的映射示例。
166
+
148
167
  ## 故障排查
149
168
 
150
169
  ### 已知限制:通过 skill 工具调用 `/cc-resume`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-claude-compat",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "DeepSeek Harness plugin: bridge Claude Code's .claude/ directories (project and ~/.claude: skills, commands, rules) into DSH native skill registry and message-stream rules injection.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -56,7 +56,7 @@
56
56
  "node": ">=20"
57
57
  },
58
58
  "scripts": {
59
- "check": "node --check src/index.js && node --check src/lib.js && node --check src/mcp.js && node --check src/hooks.js && node --check src/agents.js && node --check src/plugins.js && node --test test/self-check.test.js test/v040.test.js test/v050.test.js"
59
+ "check": "node --check src/index.js && node --check src/lib.js && node --check src/mcp.js && node --check src/hooks.js && node --check src/agents.js && node --check src/plugins.js && node --check src/aliases.js && node --test test/self-check.test.js test/v040.test.js test/v050.test.js test/v060.test.js test/v070.test.js test/v080.test.js test/v090.test.js"
60
60
  },
61
61
  "bin": {
62
62
  "dsh-claude-compat-restart": "scripts/dsh-restart.sh"
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # Deploy this checkout into the local DSH profile.
3
+ #
4
+ # Editing ~/src/dsh-claude-compat does NOT change what DSH runs: DSH imports the
5
+ # copy under the profile's node_modules, and only at process start. This script
6
+ # copies the source over that copy and reminds you to restart.
7
+ #
8
+ # bash scripts/deploy-local.sh # default profile: desktop
9
+ # bash scripts/deploy-local.sh --profile web
10
+ # bash scripts/deploy-local.sh --no-backup
11
+ #
12
+ # Environment: DSH_HOME (default ~/.dsh), DSH_PROFILE (default desktop).
13
+ set -euo pipefail
14
+
15
+ SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
16
+ DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
17
+ PROFILE="${DSH_PROFILE:-desktop}"
18
+ BACKUP=1
19
+
20
+ usage() {
21
+ sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
22
+ }
23
+
24
+ while [[ $# -gt 0 ]]; do
25
+ case "$1" in
26
+ --profile) PROFILE="${2:?--profile 需要参数}"; shift 2 ;;
27
+ --no-backup) BACKUP=0; shift ;;
28
+ -h|--help) usage; exit 0 ;;
29
+ *) echo "未知参数: $1(用 --help 看用法)" >&2; exit 1 ;;
30
+ esac
31
+ done
32
+
33
+ TARGET="$DSH_HOME/profiles/$PROFILE/node_modules/dsh-claude-compat"
34
+ if [[ ! -d "$TARGET" ]]; then
35
+ cat >&2 <<EOF
36
+ 找不到已安装的插件:
37
+ $TARGET
38
+
39
+ 先把它装进 profile:
40
+ "/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh" \\
41
+ plugin --profile $PROFILE add dsh-claude-compat@latest
42
+ EOF
43
+ exit 1
44
+ fi
45
+
46
+ # A deployed copy older than the checkout is the exact failure this script
47
+ # exists to prevent; say what is being overwritten so it is recoverable.
48
+ if [[ $BACKUP -eq 1 ]]; then
49
+ BK="$DSH_HOME/backups/dsh-claude-compat-$(date +%Y%m%d-%H%M%S)"
50
+ mkdir -p "$BK"
51
+ cp -a "$TARGET"/. "$BK"/
52
+ echo "备份 → $BK"
53
+ fi
54
+
55
+ # --delete keeps the deployed src an exact mirror (stale modules would otherwise
56
+ # linger and get imported).
57
+ rsync -a --delete "$SRC_DIR/src/" "$TARGET/src/"
58
+ for f in README.md README.zh-CN.md CHANGELOG.md CHANGELOG.zh-CN.md; do
59
+ [[ -f "$SRC_DIR/$f" ]] && cp -a "$SRC_DIR/$f" "$TARGET/"
60
+ done
61
+
62
+ if diff -r "$SRC_DIR/src" "$TARGET/src" >/dev/null; then
63
+ echo "✓ src 已同步 → $TARGET"
64
+ else
65
+ echo "✗ 复制后校验不一致,请检查权限" >&2
66
+ exit 1
67
+ fi
68
+
69
+ echo
70
+ echo "⚠ 插件在 DSH 启动时 import,改完必须重启 DSH 进程才生效(不是刷新窗口)。"
71
+ echo " 配置项(cordis.patch.yml)同理。"
package/src/agents.js CHANGED
@@ -7,20 +7,20 @@
7
7
  // the provider's rank (50 < 700) plus the shared dedupe flow.
8
8
 
9
9
  import { join } from 'node:path';
10
- import { isSkillName } from '@deepseek-ai/dsh-skill';
11
10
  import {
12
11
  readTextSafe,
13
12
  stringField,
14
13
  parseFrontmatter,
15
14
  pathExists,
16
15
  } from './lib.js';
16
+ import { resolveCandidateName, warnUnmapped, withOriginalName } from './aliases.js';
17
17
 
18
18
  const AGENT_HEADER = 'Agent delegation: use the subagent/background-agent capability with this persona and instructions.';
19
19
 
20
20
  // Discover agent candidates from one agents dir (flat: *.md). Returns DSH skill
21
21
  // candidates in the same shape as discoverSkills/discoverCommands so the
22
22
  // registry's dedupe treats them uniformly.
23
- export async function discoverAgents(rootDir, providerName, source, rank) {
23
+ export async function discoverAgents(rootDir, providerName, source, rank, aliasOptions) {
24
24
  const out = [];
25
25
  if (!(await pathExists(rootDir))) return out;
26
26
  let entries;
@@ -36,14 +36,22 @@ export async function discoverAgents(rootDir, providerName, source, rank) {
36
36
  const parsed = parseFrontmatter(raw);
37
37
  const data = parsed?.data ?? {};
38
38
  const stem = entry.name.slice(0, -3);
39
- let name = stringField(data, 'name');
40
- if (name === undefined || !isSkillName(name)) name = stem;
41
- if (!isSkillName(name)) continue; // non-kebab names rejected by registry anyway
42
- const description = stringField(data, 'description')
39
+ // Frontmatter name first, then the filename stem — a non-kebab name falls
40
+ // back to a skillAliases slug (see src/aliases.js).
41
+ const fmName = stringField(data, 'name');
42
+ const resolved = resolveCandidateName([fmName, stem], aliasOptions?.aliases);
43
+ if (resolved === undefined) {
44
+ warnUnmapped('agent', fmName ?? stem, path);
45
+ continue;
46
+ }
47
+ const baseDescription = stringField(data, 'description')
43
48
  ?? firstLine(parsed?.body ?? raw, 100);
49
+ const description = aliasOptions?.tagOriginal === false
50
+ ? baseDescription
51
+ : withOriginalName(baseDescription, resolved.original);
44
52
  const tools = stringField(data, 'tools');
45
53
  out.push({
46
- name,
54
+ name: resolved.name,
47
55
  description,
48
56
  whenToUse: description,
49
57
  invocation: { modelInvocable: true, userInvocable: true },
package/src/aliases.js ADDED
@@ -0,0 +1,99 @@
1
+ // dsh-claude-compat: non-ASCII (Chinese) skill names.
2
+ //
3
+ // DSH's registry only accepts kebab-case names. The grammar lives in
4
+ // `@deepseek-ai/dsh-skill`:
5
+ //
6
+ // const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ // // registry.get(name): if (!isSkillName(name)) return void 0;
8
+ //
9
+ // so a skill whose frontmatter name or directory is written in Chinese can
10
+ // never be reached under that name, no matter what this plugin emits. The
11
+ // only thing the plugin can do is stop dropping it silently: map it to an
12
+ // explicit ASCII slug through the `skillAliases` config and keep the original
13
+ // name visible at the front of the description, so the catalog still reads
14
+ // "san-nao: 三脑 — …".
15
+ //
16
+ // Aliased entries keep the provider's own rank, so the existing dedupe ladder
17
+ // still applies: an alias that collides with a higher-priority skill (a DSH
18
+ // native name, or ~/.agents) loses exactly like any other duplicate instead of
19
+ // shadowing it.
20
+
21
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
22
+
23
+ /** Whether `name` satisfies DSH's public skill-name grammar. */
24
+ export function isKebabName(name) {
25
+ return typeof name === 'string' && SKILL_NAME.test(name);
26
+ }
27
+
28
+ /**
29
+ * Keep only usable `skillAliases` entries: plain string → string where the
30
+ * target is itself a legal skill name. Bad targets are dropped rather than
31
+ * emitted and rejected later by the registry.
32
+ */
33
+ export function normalizeAliases(raw) {
34
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {};
35
+ const out = {};
36
+ for (const [from, to] of Object.entries(raw)) {
37
+ if (typeof from !== 'string' || from === '') continue;
38
+ if (typeof to !== 'string' || !isKebabName(to)) continue;
39
+ out[from] = to;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Pick the DSH skill name for one candidate.
46
+ *
47
+ * @param candidates names to try, in priority order (frontmatter `name`, then
48
+ * the flattened directory name / filename stem).
49
+ * @param aliases normalized alias map.
50
+ * @returns `{ name, original }` — `original` is the non-kebab name an alias
51
+ * replaced, or `undefined` when the natural name already works. `undefined`
52
+ * when nothing usable was found (the caller should skip and warn).
53
+ */
54
+ export function resolveCandidateName(candidates, aliases) {
55
+ const list = (candidates ?? []).filter((n) => typeof n === 'string' && n !== '');
56
+ // A legal name always beats an alias — an alias must never rename a skill
57
+ // that DSH can already load under its own name.
58
+ for (const candidate of list) {
59
+ if (isKebabName(candidate)) return { name: candidate, original: undefined };
60
+ }
61
+ for (const candidate of list) {
62
+ const mapped = aliases?.[candidate];
63
+ if (mapped !== undefined) return { name: mapped, original: candidate };
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * Put the original (Chinese) name back in front of the description so it shows
70
+ * up in the skill catalog and the slash menu: "三脑 — 三脑复核流程…".
71
+ * Idempotent, so a caller that resolves twice cannot double-prefix.
72
+ */
73
+ export function withOriginalName(description, original) {
74
+ if (original === undefined) return description;
75
+ if (typeof description !== 'string' || description === '') return description;
76
+ const prefix = `${original} — `;
77
+ return description.startsWith(prefix) ? description : `${prefix}${description}`;
78
+ }
79
+
80
+ // One warning per skipped file per process: discovery runs on every catalog
81
+ // collect, and an un-mapped Chinese skill must not flood the log.
82
+ const warned = new Set();
83
+
84
+ /** Warn once that `name` cannot be surfaced, and how to fix it. */
85
+ export function warnUnmapped(kind, name, path) {
86
+ const key = `${kind}\u0000${path}`;
87
+ if (warned.has(key)) return;
88
+ warned.add(key);
89
+ console.warn(
90
+ `dsh-claude-compat: ${kind} "${name}" (${path}) is not a kebab-case name and has no `
91
+ + `skillAliases mapping, so DSH cannot load it. Add one to the plugin config, e.g. `
92
+ + `skillAliases: { "${name}": "your-slug" }`,
93
+ );
94
+ }
95
+
96
+ /** Test seam: forget which paths have already warned. */
97
+ export function resetWarnings() {
98
+ warned.clear();
99
+ }
package/src/hooks.js CHANGED
@@ -13,6 +13,7 @@ import { readFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
15
15
  import {
16
+ CLAUDE_COMPAT_SOURCE_KIND,
16
17
  findProjectRootSync,
17
18
  resolveUserClaudeDir,
18
19
  } from './lib.js';
@@ -108,7 +109,7 @@ export function mapPostHookOutput(exitCode, stdout, { warn = console.warn } = {}
108
109
  kind: 'accept',
109
110
  additionalContexts: [createUserMessage({
110
111
  content: [{ type: 'text', text: additional }],
111
- source: { kind: 'plugin', plugin: 'dsh-claude-compat', form: 'hook-context' },
112
+ source: { kind: CLAUDE_COMPAT_SOURCE_KIND, form: 'hook-context' },
112
113
  })],
113
114
  };
114
115
  }
@@ -339,7 +340,7 @@ async function runPromptHooks(hooks, agent, config) {
339
340
  if (text === '') continue;
340
341
  extra.push(createUserMessage({
341
342
  content: [{ type: 'text', text }],
342
- source: { kind: 'plugin', plugin: 'dsh-claude-compat', form: 'hook-context' },
343
+ source: { kind: CLAUDE_COMPAT_SOURCE_KIND, form: 'hook-context' },
343
344
  }));
344
345
  }
345
346
  return extra.length > 0 ? extra : undefined;
package/src/index.js CHANGED
@@ -47,13 +47,19 @@ import { readFileSync } from 'node:fs';
47
47
  import { stat } from 'node:fs/promises';
48
48
  import { dirname, join } from 'node:path';
49
49
  import z from '@deepseek-ai/schemastery';
50
- import { isSkillName } from '@deepseek-ai/dsh-skill';
51
50
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
52
51
  import { discoverAgents, renderAgentContent } from './agents.js';
52
+ import {
53
+ normalizeAliases,
54
+ resolveCandidateName,
55
+ warnUnmapped,
56
+ withOriginalName,
57
+ } from './aliases.js';
53
58
  import { registerHooks } from './hooks.js';
54
59
  import { mountMcpServers, mountMcpConfigs } from './mcp.js';
55
60
  import { discoverPluginContent, readInstalledClaudePlugins, translatePluginMcp } from './plugins.js';
56
61
  import {
62
+ CLAUDE_COMPAT_SOURCE_KIND,
57
63
  findProjectRoot,
58
64
  findProjectRootSync,
59
65
  listMdFilesSync,
@@ -86,6 +92,15 @@ export const Config = z.object({
86
92
  enableHooks: z.boolean().default(true),
87
93
  hooksTimeoutMs: z.number().default(60000),
88
94
  enableAgents: z.boolean().default(true),
95
+ // Non-ASCII (e.g. Chinese) skill/command/agent names can never satisfy DSH's
96
+ // kebab-case grammar, so they are dropped by the registry. `skillAliases`
97
+ // maps the original name to an ASCII slug the registry accepts —
98
+ // { "三脑": "san-nao" } — and the original name is kept at the front of the
99
+ // description so the catalog still reads "san-nao: 三脑 — …". Aliased entries
100
+ // keep their provider rank, so dedupe against DSH-native and ~/.agents names
101
+ // behaves exactly like any other collision.
102
+ skillAliases: z.dict(z.string()).default({}),
103
+ aliasDescriptionPrefix: z.boolean().default(true),
89
104
  // Claude Code plugin-marketplace installs (~/.claude/plugins). Skills,
90
105
  // commands and agents surface at pluginSkillRank (750: the long tail —
91
106
  // everything else wins collisions). Plugin MCP servers are opt-in via
@@ -168,6 +183,11 @@ class ClaudeCompatSkillProvider {
168
183
  this.userSkillRank = config.userSkillRank ?? 700;
169
184
  this.userSkillSource = config.userSkillSource ?? 'user-claude';
170
185
  this.userClaudeDir = resolveUserClaudeDir(config.userClaudeDir);
186
+ this.aliases = normalizeAliases(config.skillAliases);
187
+ this.aliasOptions = {
188
+ aliases: this.aliases,
189
+ tagOriginal: config.aliasDescriptionPrefix !== false,
190
+ };
171
191
  this.control = control;
172
192
  control.signal.addEventListener('abort', () => {}, { once: true });
173
193
  }
@@ -206,7 +226,8 @@ class ClaudeCompatSkillProvider {
206
226
  if (this.config.enablePlugins !== false) {
207
227
  const pluginCandidates = await discoverPluginContent(
208
228
  this.config.pluginsRoot, this.name, this.config.pluginSkillSource ?? 'claude-plugin',
209
- this.config.pluginSkillRank ?? 750, { enableAgents: this.config.enableAgents });
229
+ this.config.pluginSkillRank ?? 750,
230
+ { enableAgents: this.config.enableAgents, ...this.aliasOptions });
210
231
  candidates.push(...pluginCandidates);
211
232
  }
212
233
  return candidates;
@@ -259,13 +280,13 @@ Notes:
259
280
 
260
281
  async addRootCandidates(candidates, claudeDir, source, rank) {
261
282
  const provider = this.name;
262
- const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank)];
283
+ const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank, this.aliasOptions)];
263
284
  for (const [dir, fn] of [
264
285
  ['commands', discoverCommands],
265
286
  ['agents', discoverAgents],
266
287
  ]) {
267
288
  if (dir === 'agents' && this.config.enableAgents === false) continue;
268
- tasks.push(fn(join(claudeDir, dir), provider, source, rank));
289
+ tasks.push(fn(join(claudeDir, dir), provider, source, rank, this.aliasOptions));
269
290
  }
270
291
  const batches = await Promise.all(tasks);
271
292
  for (const batch of batches) candidates.push(...batch);
@@ -299,7 +320,7 @@ Notes:
299
320
 
300
321
  // ─── discovery: <root>/.claude/skills (recursive, ≤3 levels) ─────────────────
301
322
 
302
- export async function discoverSkills(rootDir, providerName, source, rank) {
323
+ export async function discoverSkills(rootDir, providerName, source, rank, aliasOptions) {
303
324
  const { readdir } = await import('node:fs/promises');
304
325
  const out = [];
305
326
  if (!(await pathExistsSafe(rootDir))) return out;
@@ -313,7 +334,8 @@ export async function discoverSkills(rootDir, providerName, source, rank) {
313
334
  } catch { return; }
314
335
  if (entries.some(e => e.isFile() && e.name === 'SKILL.md')) {
315
336
  const skillPath = join(dir, 'SKILL.md');
316
- const c = await parseSkillCandidateFile(skillPath, providerName, source, rank, prefix || undefined);
337
+ const c = await parseSkillCandidateFile(
338
+ skillPath, providerName, source, rank, prefix || undefined, aliasOptions);
317
339
  if (c !== undefined) out.push(c);
318
340
  return; // bundle — don't descend
319
341
  }
@@ -329,7 +351,7 @@ export async function discoverSkills(rootDir, providerName, source, rank) {
329
351
 
330
352
  // ─── discovery: <root>/.claude/commands (flat) ───────────────────────────────
331
353
 
332
- export async function discoverCommands(rootDir, providerName, source, rank) {
354
+ export async function discoverCommands(rootDir, providerName, source, rank, aliasOptions) {
333
355
  const { readdir } = await import('node:fs/promises');
334
356
  const out = [];
335
357
  if (!(await pathExistsSafe(rootDir))) return out;
@@ -341,14 +363,20 @@ export async function discoverCommands(rootDir, providerName, source, rank) {
341
363
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
342
364
  const path = join(rootDir, entry.name);
343
365
  const stem = entry.name.slice(0, -3);
344
- if (!isSkillName(stem)) continue; // non-kebab names rejected by registry anyway
366
+ const resolved = resolveCandidateName([stem], aliasOptions?.aliases);
367
+ if (resolved === undefined) {
368
+ warnUnmapped('command', stem, path);
369
+ continue;
370
+ }
345
371
  const raw = await readTextSafe(path);
346
372
  if (raw === undefined) continue;
347
373
  const parsed = parseFrontmatter(raw);
348
- const description = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem);
374
+ const baseDescription = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem);
349
375
  out.push({
350
- name: stem,
351
- description,
376
+ name: resolved.name,
377
+ description: aliasOptions?.tagOriginal === false
378
+ ? baseDescription
379
+ : withOriginalName(baseDescription, resolved.original),
352
380
  invocation: { modelInvocable: true, userInvocable: true },
353
381
  provider: providerName,
354
382
  source,
@@ -361,7 +389,7 @@ export async function discoverCommands(rootDir, providerName, source, rank) {
361
389
  return out;
362
390
  }
363
391
 
364
- async function parseSkillCandidateFile(path, providerName, source, rank, flatName) {
392
+ async function parseSkillCandidateFile(path, providerName, source, rank, flatName, aliasOptions) {
365
393
  const raw = await readTextSafe(path);
366
394
  if (raw === undefined) return undefined;
367
395
  const parsed = parseFrontmatter(raw);
@@ -372,18 +400,23 @@ async function parseSkillCandidateFile(path, providerName, source, rank, flatNam
372
400
  // Prefer frontmatter name when it is a valid kebab-case skill name. Some Claude
373
401
  // skills use names with ':' or other chars DSH rejects (e.g.
374
402
  // "salus:ai-robot-coding-env-check"); for those, fall back to the flattened
375
- // directory name, which is virtually always kebab-case.
376
- let name;
377
- if (fmName !== undefined && isSkillName(fmName)) name = fmName;
378
- else if (flatName !== undefined && isSkillName(flatName)) name = flatName;
379
- if (name === undefined) return undefined;
403
+ // directory name, which is virtually always kebab-case. Names that are not
404
+ // kebab-case at all (e.g. Chinese) can still be surfaced through an explicit
405
+ // `skillAliases` mapping — see src/aliases.js.
406
+ const resolved = resolveCandidateName([fmName, flatName], aliasOptions?.aliases);
407
+ if (resolved === undefined) {
408
+ warnUnmapped('skill', fmName ?? flatName ?? path, path);
409
+ return undefined;
410
+ }
380
411
  let invocation;
381
412
  try { invocation = parseInvocationPolicy(parsed.data); }
382
413
  catch { invocation = { modelInvocable: true, userInvocable: true }; }
383
414
  const whenToUse = stringField(parsed.data, 'whenToUse');
384
415
  return {
385
- name,
386
- description,
416
+ name: resolved.name,
417
+ description: aliasOptions?.tagOriginal === false
418
+ ? description
419
+ : withOriginalName(description, resolved.original),
387
420
  ...(whenToUse !== undefined ? { whenToUse } : {}),
388
421
  invocation,
389
422
  provider: providerName,
@@ -405,7 +438,7 @@ function registerRulesSection(ctx, config) {
405
438
  ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
406
439
  const decision = await next();
407
440
  if (decision.kind !== 'enter') return decision;
408
- const present = (list) => list.some((m) => m?.source?.kind === 'plugin' && m.source.plugin === 'dsh-claude-compat');
441
+ const present = (list) => list.some((m) => m?.source?.kind === CLAUDE_COMPAT_SOURCE_KIND);
409
442
  // dsh 0.1.2 made the event log private — exact lookup is eventAt(seq),
410
443
  // the same API dsh core uses for its own surface scans. Legacy dsh
411
444
  // exposed a plain session.events array; keep an optional fallback.
@@ -414,8 +447,7 @@ function registerRulesSection(ctx, config) {
414
447
  || agent.session.surface.nodes.some((seq) => {
415
448
  const event = agent.session.eventAt?.(seq) ?? agent.session.events?.[seq];
416
449
  return event?.type === 'user/message'
417
- && event.data?.source?.kind === 'plugin'
418
- && event.data.source.plugin === 'dsh-claude-compat';
450
+ && event.data?.source?.kind === CLAUDE_COMPAT_SOURCE_KIND;
419
451
  });
420
452
  if (alreadyInjected) return decision;
421
453
  // Session cwd, NOT process.cwd() — the DSH process may be launched from
@@ -430,7 +462,7 @@ function registerRulesSection(ctx, config) {
430
462
  // of the message array. Not in system prompt.
431
463
  cache.set(cwd, createUserMessage({
432
464
  content: [{ type: 'text', text }],
433
- source: { kind: 'plugin', plugin: 'dsh-claude-compat', form: 'rules' },
465
+ source: { kind: CLAUDE_COMPAT_SOURCE_KIND, form: 'rules' },
434
466
  }));
435
467
  }
436
468
  return {
package/src/lib.js CHANGED
@@ -8,6 +8,15 @@ import { dirname, join, resolve } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { parse } from 'yaml';
10
10
 
11
+ // Producer-owned source kind for every message this plugin injects. dsh's
12
+ // session format v4 admission (session-format-v3-to-v4/src/message-sources.ts)
13
+ // rejects the retired V3 `kind: 'plugin'` wrapper: a third-party producer must
14
+ // emit its own kind. dsh derives exactly this string for us — `producerKind()`
15
+ // falls through RENAMED_PRODUCERS and RELEASED_SAME_NAME_PRODUCERS to
16
+ // `plugin:${plugin}` — so a V3 log migrated by dsh and a message we emit today
17
+ // carry the identical kind. Emission and dedup must both use this constant.
18
+ export const CLAUDE_COMPAT_SOURCE_KIND = 'plugin:dsh-claude-compat';
19
+
11
20
  export function resolveUserClaudeDir(dir) {
12
21
  if (dir === undefined || dir === null || dir === '') return join(homedir(), '.claude');
13
22
  if (dir === '~') return homedir();
package/src/plugins.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  stringField,
22
22
  } from './lib.js';
23
23
  import { translateMcpServers } from './mcp.js';
24
+ import { resolveCandidateName, warnUnmapped } from './aliases.js';
24
25
 
25
26
  // Parse installed_plugins.json → [{ key, name, marketplace, installPath,
26
27
  // version }] for scope==='user' entries. Dedupe per key, keeping the last
@@ -60,16 +61,17 @@ function claReadSync(p) { return rfs(p, 'utf8'); }
60
61
  // Claude Code's discoverCommands drops commands whose only description is the
61
62
  // empty string; plugin marketplaces ship many such stubs, so this wrapper
62
63
  // re-surfaces them with the filename stem as the description.
63
- export async function discoverPluginContent(pluginsRoot, providerName, source, rank, { enableAgents } = {}) {
64
+ export async function discoverPluginContent(pluginsRoot, providerName, source, rank, { enableAgents, aliases, tagOriginal } = {}) {
64
65
  const out = [];
65
66
  const plugins = readInstalledClaudePlugins(pluginsRoot);
66
67
  const { discoverSkills, discoverCommands } = await import('./index.js');
67
68
  const { discoverAgents } = await import('./agents.js');
69
+ const aliasOptions = { aliases, tagOriginal };
68
70
  const patchedCommands = async (dir) => {
69
- const batch = await discoverCommands(dir, providerName, source, rank);
71
+ const batch = await discoverCommands(dir, providerName, source, rank, aliasOptions);
70
72
  if (batch.length > 0) return batch;
71
73
  const kept = new Set(batch.map((c) => c.path));
72
- const extra = await discoverCommandsAllowEmpty(dir, providerName, source, rank);
74
+ const extra = await discoverCommandsAllowEmpty(dir, providerName, source, rank, aliasOptions);
73
75
  return [...batch, ...extra.filter((c) => !kept.has(c.path))];
74
76
  };
75
77
  for (const plugin of plugins) {
@@ -89,33 +91,37 @@ export async function discoverPluginContent(pluginsRoot, providerName, source, r
89
91
  };
90
92
  if (manifest !== undefined) {
91
93
  const skillDirs = manifestDirs(manifest, 'skills', installPath);
92
- for (const dir of skillDirs) push('skills')(await discoverSkills(dir, providerName, source, rank));
94
+ for (const dir of skillDirs) push('skills')(await discoverSkills(dir, providerName, source, rank, aliasOptions));
93
95
  const commandsDir = typeof manifest.commands === 'string' ? resolve(installPath, manifest.commands) : undefined;
94
96
  if (commandsDir !== undefined) push('commands')(await patchedCommands(commandsDir));
95
97
  } else {
96
- push('skills')(await discoverSkills(join(installPath, 'skills'), providerName, source, rank));
98
+ push('skills')(await discoverSkills(join(installPath, 'skills'), providerName, source, rank, aliasOptions));
97
99
  push('commands')(await patchedCommands(join(installPath, 'commands')));
98
100
  }
99
101
  if (enableAgents !== false) {
100
102
  const agentsDir = join(installPath, 'agents');
101
- if (await pathExists(agentsDir)) push('agents')(await discoverAgents(agentsDir, providerName, source, rank));
103
+ if (await pathExists(agentsDir)) push('agents')(await discoverAgents(agentsDir, providerName, source, rank, aliasOptions));
102
104
  }
103
105
  }
104
106
  return out;
105
107
  }
106
108
 
107
109
  // Like discoverCommands but keeps empty-description stubs (stem as description).
108
- async function discoverCommandsAllowEmpty(rootDir, providerName, source, rank) {
110
+ async function discoverCommandsAllowEmpty(rootDir, providerName, source, rank, aliasOptions) {
109
111
  const { readdir } = await import('node:fs/promises');
110
- const { isSkillName } = await import('@deepseek-ai/dsh-skill');
111
- const out = []; if (!(await pathExists(rootDir))) return out;
112
+ const out = [];
113
+ if (!(await pathExists(rootDir))) return out;
112
114
  let entries;
113
115
  try { entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' }); }
114
116
  catch { return out; }
115
117
  for (const entry of entries) {
116
118
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
117
119
  const stem = entry.name.slice(0, -3);
118
- if (!isSkillName(stem)) continue;
120
+ const resolved = resolveCandidateName([stem], aliasOptions?.aliases);
121
+ if (resolved === undefined) {
122
+ warnUnmapped('command', stem, join(rootDir, entry.name));
123
+ continue;
124
+ }
119
125
  const raw = await readTextSafe(join(rootDir, entry.name));
120
126
  if (raw === undefined) continue;
121
127
  const parsed = parseFrontmatter(raw);
@@ -123,8 +129,10 @@ async function discoverCommandsAllowEmpty(rootDir, providerName, source, rank) {
123
129
  const description = stringField(parsed.data, 'description');
124
130
  if (description !== undefined) continue; // non-empty handled by index.js variant
125
131
  out.push({
126
- name: stem,
127
- description: stem,
132
+ name: resolved.name,
133
+ // The stub description IS the name; when an alias was used the original
134
+ // (Chinese) name is the useful label, not the slug.
135
+ description: resolved.original ?? stem,
128
136
  invocation: { modelInvocable: true, userInvocable: true },
129
137
  provider: providerName,
130
138
  source,