dsh-claude-compat 0.8.4 → 0.9.1

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.4-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.4-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.4",
3
+ "version": "0.9.1",
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",
@@ -17,7 +17,10 @@
17
17
  "README.md",
18
18
  "README.zh-CN.md",
19
19
  "cordis.patch.yml",
20
- "scripts"
20
+ "scripts/cc-resume.mjs",
21
+ "scripts/cc-export.mjs",
22
+ "scripts/zstd-compat.mjs",
23
+ "scripts/dsh-restart.sh"
21
24
  ],
22
25
  "keywords": [
23
26
  "dsh",
@@ -56,7 +59,7 @@
56
59
  "node": ">=20"
57
60
  },
58
61
  "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"
62
+ "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
63
  },
61
64
  "bin": {
62
65
  "dsh-claude-compat-restart": "scripts/dsh-restart.sh"
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/index.js CHANGED
@@ -47,9 +47,14 @@ 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';
@@ -87,6 +92,15 @@ export const Config = z.object({
87
92
  enableHooks: z.boolean().default(true),
88
93
  hooksTimeoutMs: z.number().default(60000),
89
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),
90
104
  // Claude Code plugin-marketplace installs (~/.claude/plugins). Skills,
91
105
  // commands and agents surface at pluginSkillRank (750: the long tail —
92
106
  // everything else wins collisions). Plugin MCP servers are opt-in via
@@ -107,6 +121,21 @@ const STAR_URL = 'https://github.com/biedongbin/dsh-claude-compat';
107
121
  // is the intended "did this help? star it" cadence without spamming.
108
122
  let starNudged = false;
109
123
 
124
+ // Body of the reload-cc-plugins / reload-skills skills. Inline skill bodies
125
+ // travel in the locator (`{ kind: 'inline', body }`) — the DSH registry loads
126
+ // definitions exclusively through `provider.get(candidate)`, so the body must
127
+ // be reachable from the candidate itself, never from a closure or disk.
128
+ const RELOAD_BODY = `# Catalog reloaded ✅
129
+
130
+ The DSH skill catalog cache was just dropped and observers notified.
131
+ Freshly installed/removed skills are now visible in this session (the skill
132
+ picker refreshes automatically on the next catalog read).
133
+
134
+ Notes:
135
+ - New skills from \`/plugin install\` or edits under \`.claude\` are live now.
136
+ - MCP servers shipped by plugins still require a DSH restart (process-lifetime mount).
137
+ - \`/reload-cc-plugins\` and \`/reload-skills\` are aliases.`;
138
+
110
139
  export function apply(ctx, config = {}) {
111
140
  // One-shot "found this useful? star it" nudge, surfaced exactly where the
112
141
  // value lands: the first profile activation of this process.
@@ -169,6 +198,11 @@ class ClaudeCompatSkillProvider {
169
198
  this.userSkillRank = config.userSkillRank ?? 700;
170
199
  this.userSkillSource = config.userSkillSource ?? 'user-claude';
171
200
  this.userClaudeDir = resolveUserClaudeDir(config.userClaudeDir);
201
+ this.aliases = normalizeAliases(config.skillAliases);
202
+ this.aliasOptions = {
203
+ aliases: this.aliases,
204
+ tagOriginal: config.aliasDescriptionPrefix !== false,
205
+ };
172
206
  this.control = control;
173
207
  control.signal.addEventListener('abort', () => {}, { once: true });
174
208
  }
@@ -207,7 +241,8 @@ class ClaudeCompatSkillProvider {
207
241
  if (this.config.enablePlugins !== false) {
208
242
  const pluginCandidates = await discoverPluginContent(
209
243
  this.config.pluginsRoot, this.name, this.config.pluginSkillSource ?? 'claude-plugin',
210
- this.config.pluginSkillRank ?? 750, { enableAgents: this.config.enableAgents });
244
+ this.config.pluginSkillRank ?? 750,
245
+ { enableAgents: this.config.enableAgents, ...this.aliasOptions });
211
246
  candidates.push(...pluginCandidates);
212
247
  }
213
248
  return candidates;
@@ -216,57 +251,34 @@ class ClaudeCompatSkillProvider {
216
251
  // Invalidate fires when the definition is actually loaded (get()), not at
217
252
  // list() time — loading the reload skill IS the reload.
218
253
  reloadSkillDef(name, description) {
219
- return {
220
- name,
221
- description,
222
- invocation: { modelInvocable: true, userInvocable: true },
223
- provider: 'claude-compat',
224
- source: 'claude-compat-manager',
225
- rank: this.config.pluginManagerRank ?? 40,
226
- locator: { kind: 'inline', path: import.meta.url },
227
- get: async () => {
228
- try { this.control?.invalidate?.(); } catch { /* best-effort */ }
229
- return {
230
- name,
231
- description,
232
- body: `# Catalog reloaded ✅
233
-
234
- The DSH skill catalog cache was just dropped and observers notified.
235
- Freshly installed/removed skills are now visible in this session (the skill
236
- picker refreshes automatically on the next catalog read).
237
-
238
- Notes:
239
- - New skills from \`/plugin install\` or edits under \`.claude\` are live now.
240
- - MCP servers shipped by plugins still require a DSH restart (process-lifetime mount).
241
- - \`/reload-cc-plugins\` and \`/reload-skills\` are aliases.`,
242
- };
243
- },
244
- };
254
+ return this.managerSkillCandidate(name, description, undefined, RELOAD_BODY, { invalidate: true });
245
255
  }
246
256
 
247
- managerSkillCandidate(name, description, whenToUse, body) {
257
+ managerSkillCandidate(name, description, whenToUse, body, { invalidate = false } = {}) {
248
258
  return {
249
259
  name,
250
260
  description,
251
- whenToUse,
261
+ ...(whenToUse !== undefined ? { whenToUse } : {}),
252
262
  invocation: { modelInvocable: true, userInvocable: true },
253
- provider: 'claude-compat',
263
+ provider: this.name,
254
264
  source: 'claude-compat-manager',
255
265
  rank: this.config.pluginManagerRank ?? 40,
256
- locator: { kind: 'inline', path: import.meta.url },
257
- get: async () => ({ name, description, body }),
266
+ // kind: 'inline' makes provider.get() serve `body` directly instead of
267
+ // reading a path from disk. No `path` — import.meta.url is a file:// URL
268
+ // and the module source is not the skill body.
269
+ locator: { kind: 'inline', body, invalidate },
258
270
  };
259
271
  }
260
272
 
261
273
  async addRootCandidates(candidates, claudeDir, source, rank) {
262
274
  const provider = this.name;
263
- const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank)];
275
+ const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank, this.aliasOptions)];
264
276
  for (const [dir, fn] of [
265
277
  ['commands', discoverCommands],
266
278
  ['agents', discoverAgents],
267
279
  ]) {
268
280
  if (dir === 'agents' && this.config.enableAgents === false) continue;
269
- tasks.push(fn(join(claudeDir, dir), provider, source, rank));
281
+ tasks.push(fn(join(claudeDir, dir), provider, source, rank, this.aliasOptions));
270
282
  }
271
283
  const batches = await Promise.all(tasks);
272
284
  for (const batch of batches) candidates.push(...batch);
@@ -274,6 +286,25 @@ Notes:
274
286
 
275
287
  async get(candidate) {
276
288
  const locator = candidate.locator;
289
+ if (locator?.kind === 'inline') {
290
+ // Inline manager skills (cc-plugin, reload-*, cc-resume, cc-export):
291
+ // the body ships in the locator. Loading a reload skill IS the reload —
292
+ // drop the catalog cache so the next list() recollects fresh state.
293
+ // (Not in list(): that would fire on every catalog collection and
294
+ // defeat the cache entirely.)
295
+ if (locator.invalidate) {
296
+ try { this.control?.invalidate?.(); } catch { /* best-effort */ }
297
+ }
298
+ return {
299
+ name: candidate.name,
300
+ description: candidate.description,
301
+ ...(candidate.whenToUse !== undefined ? { whenToUse: candidate.whenToUse } : {}),
302
+ invocation: candidate.invocation,
303
+ source: candidate.source,
304
+ provider: this.name,
305
+ content: locator.body,
306
+ };
307
+ }
277
308
  const raw = await readTextSafe(locator.path);
278
309
  if (raw === undefined) return undefined;
279
310
  const parsed = parseFrontmatter(raw);
@@ -300,7 +331,7 @@ Notes:
300
331
 
301
332
  // ─── discovery: <root>/.claude/skills (recursive, ≤3 levels) ─────────────────
302
333
 
303
- export async function discoverSkills(rootDir, providerName, source, rank) {
334
+ export async function discoverSkills(rootDir, providerName, source, rank, aliasOptions) {
304
335
  const { readdir } = await import('node:fs/promises');
305
336
  const out = [];
306
337
  if (!(await pathExistsSafe(rootDir))) return out;
@@ -314,7 +345,8 @@ export async function discoverSkills(rootDir, providerName, source, rank) {
314
345
  } catch { return; }
315
346
  if (entries.some(e => e.isFile() && e.name === 'SKILL.md')) {
316
347
  const skillPath = join(dir, 'SKILL.md');
317
- const c = await parseSkillCandidateFile(skillPath, providerName, source, rank, prefix || undefined);
348
+ const c = await parseSkillCandidateFile(
349
+ skillPath, providerName, source, rank, prefix || undefined, aliasOptions);
318
350
  if (c !== undefined) out.push(c);
319
351
  return; // bundle — don't descend
320
352
  }
@@ -330,7 +362,7 @@ export async function discoverSkills(rootDir, providerName, source, rank) {
330
362
 
331
363
  // ─── discovery: <root>/.claude/commands (flat) ───────────────────────────────
332
364
 
333
- export async function discoverCommands(rootDir, providerName, source, rank) {
365
+ export async function discoverCommands(rootDir, providerName, source, rank, aliasOptions) {
334
366
  const { readdir } = await import('node:fs/promises');
335
367
  const out = [];
336
368
  if (!(await pathExistsSafe(rootDir))) return out;
@@ -342,14 +374,20 @@ export async function discoverCommands(rootDir, providerName, source, rank) {
342
374
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
343
375
  const path = join(rootDir, entry.name);
344
376
  const stem = entry.name.slice(0, -3);
345
- if (!isSkillName(stem)) continue; // non-kebab names rejected by registry anyway
377
+ const resolved = resolveCandidateName([stem], aliasOptions?.aliases);
378
+ if (resolved === undefined) {
379
+ warnUnmapped('command', stem, path);
380
+ continue;
381
+ }
346
382
  const raw = await readTextSafe(path);
347
383
  if (raw === undefined) continue;
348
384
  const parsed = parseFrontmatter(raw);
349
- const description = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem);
385
+ const baseDescription = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem);
350
386
  out.push({
351
- name: stem,
352
- description,
387
+ name: resolved.name,
388
+ description: aliasOptions?.tagOriginal === false
389
+ ? baseDescription
390
+ : withOriginalName(baseDescription, resolved.original),
353
391
  invocation: { modelInvocable: true, userInvocable: true },
354
392
  provider: providerName,
355
393
  source,
@@ -362,7 +400,7 @@ export async function discoverCommands(rootDir, providerName, source, rank) {
362
400
  return out;
363
401
  }
364
402
 
365
- async function parseSkillCandidateFile(path, providerName, source, rank, flatName) {
403
+ async function parseSkillCandidateFile(path, providerName, source, rank, flatName, aliasOptions) {
366
404
  const raw = await readTextSafe(path);
367
405
  if (raw === undefined) return undefined;
368
406
  const parsed = parseFrontmatter(raw);
@@ -373,18 +411,23 @@ async function parseSkillCandidateFile(path, providerName, source, rank, flatNam
373
411
  // Prefer frontmatter name when it is a valid kebab-case skill name. Some Claude
374
412
  // skills use names with ':' or other chars DSH rejects (e.g.
375
413
  // "salus:ai-robot-coding-env-check"); for those, fall back to the flattened
376
- // directory name, which is virtually always kebab-case.
377
- let name;
378
- if (fmName !== undefined && isSkillName(fmName)) name = fmName;
379
- else if (flatName !== undefined && isSkillName(flatName)) name = flatName;
380
- if (name === undefined) return undefined;
414
+ // directory name, which is virtually always kebab-case. Names that are not
415
+ // kebab-case at all (e.g. Chinese) can still be surfaced through an explicit
416
+ // `skillAliases` mapping — see src/aliases.js.
417
+ const resolved = resolveCandidateName([fmName, flatName], aliasOptions?.aliases);
418
+ if (resolved === undefined) {
419
+ warnUnmapped('skill', fmName ?? flatName ?? path, path);
420
+ return undefined;
421
+ }
381
422
  let invocation;
382
423
  try { invocation = parseInvocationPolicy(parsed.data); }
383
424
  catch { invocation = { modelInvocable: true, userInvocable: true }; }
384
425
  const whenToUse = stringField(parsed.data, 'whenToUse');
385
426
  return {
386
- name,
387
- description,
427
+ name: resolved.name,
428
+ description: aliasOptions?.tagOriginal === false
429
+ ? description
430
+ : withOriginalName(description, resolved.original),
388
431
  ...(whenToUse !== undefined ? { whenToUse } : {}),
389
432
  invocation,
390
433
  provider: providerName,
@@ -7,16 +7,16 @@
7
7
  // Claude-native locations (~/.claude/plugins/*, settings.json enabledPlugins)
8
8
  // so both Claude Code and this plugin's discovery bridge read the same truth.
9
9
  //
10
- // Commands (mirroring `claude plugin` semantics):
11
- // /plugin — list installed (status incl. enabled/disabled)
12
- // /plugin <name>[@market] — one-shot install
13
- // /plugin install <name>[@market]
14
- // /plugin uninstall|remove <name>
15
- // /plugin enable <name>
16
- // /plugin disable <name>
17
- // /plugin update [name] — one or all
18
- // /plugin search <term> — scan known marketplaces
19
- // /plugin marketplace list|add|remove|update [...]
10
+ // Commands (mirroring `claude plugin` semantics, invoked as /cc-plugin):
11
+ // /cc-plugin — list installed (status incl. enabled/disabled)
12
+ // /cc-plugin <name>[@market] — one-shot install
13
+ // /cc-plugin install <name>[@market]
14
+ // /cc-plugin uninstall|remove <name>
15
+ // /cc-plugin enable <name>
16
+ // /cc-plugin disable <name>
17
+ // /cc-plugin update [name] — one or all
18
+ // /cc-plugin search <term> — scan known marketplaces
19
+ // /cc-plugin marketplace list|add|remove|update [...]
20
20
  //
21
21
  // Safety: never mutates installed_plugins.json / known_marketplaces.json /
22
22
  // settings.json directly without a timestamped backup beside the file; the
@@ -38,22 +38,23 @@ Claude Code and DSH see the same plugins. Newly installed plugin content
38
38
  appears in the DSH skill catalog on next reconcile (MCP servers need a DSH restart).
39
39
 
40
40
  ## Commands (invoked as /cc-plugin <args>)
41
- - \`/plugin\` — list installed plugins with enable/disable status
42
- - \`/plugin <name>[@marketplace]\` — one-shot install (e.g. \`/plugin ralph@omc\`)
43
- - \`/plugin install <name>[@marketplace]\`
44
- - \`/plugin uninstall <name>\` (alias: remove)
45
- - \`/plugin enable <name>\` / \`/plugin disable <name>\`
46
- - \`/plugin update [name]\` — update one plugin or all
47
- - \`/plugin search <term>\` — search all known marketplaces
48
- - \`/plugin marketplace list\`
49
- - \`/plugin marketplace add <github-repo-or-url>\`
50
- - \`/plugin marketplace remove <name>\`
51
- - \`/plugin marketplace update [name]\`
41
+ - \`/cc-plugin\` — list installed plugins with enable/disable status
42
+ - \`/cc-plugin <name>[@marketplace]\` — one-shot install (e.g. \`/cc-plugin ralph@omc\`)
43
+ - \`/cc-plugin install <name>[@marketplace]\`
44
+ - \`/cc-plugin uninstall <name>\` (alias: remove)
45
+ - \`/cc-plugin enable <name>\` / \`/cc-plugin disable <name>\`
46
+ - \`/cc-plugin update [name]\` — update one plugin or all
47
+ - \`/cc-plugin search <term>\` — search all known marketplaces
48
+ - \`/cc-plugin marketplace list\`
49
+ - \`/cc-plugin marketplace add <github-repo-or-url>\`
50
+ - \`/cc-plugin marketplace remove <name>\`
51
+ - \`/cc-plugin marketplace update [name]\`
52
52
 
53
53
  ## Execution
54
- Prefers the \`claude plugin\` CLI when available; falls back to direct
55
- JSON/git manipulation (with automatic timestamped backups of every file it
56
- touches). Report which engine ran in the result.`;
54
+ Run \`claude plugin <args>\` via bash (e.g. \`claude plugin install ralph@omc\`).
55
+ If the CLI is unavailable, fall back to direct JSON/git manipulation (with
56
+ automatic timestamped backups of every file it touches). Report which engine
57
+ ran in the result.`;
57
58
 
58
59
  /** Run `claude plugin ...` args; resolve {ok, stdout} or {ok:false, error}. */
59
60
  async function claudePlugin(args, timeoutMs = 120_000) {
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,
@@ -1,36 +0,0 @@
1
- // gen-downloads-chart.mjs — fetch last 30 days of npm downloads and render a
2
- // simple SVG bar chart to .github/assets/downloads.svg. Run by CI daily.
3
- import { writeFileSync, mkdirSync } from 'node:fs';
4
- import { dirname } from 'node:path';
5
-
6
- const PKG = 'dsh-claude-compat';
7
- const DAYS = 30;
8
- const iso = (d) => d.toISOString().slice(0, 10);
9
- const end = new Date();
10
- const start = new Date(Date.now() - (DAYS - 1) * 86400000);
11
- const res = await fetch(`https://api.npmjs.org/downloads/range/${iso(start)}:${iso(end)}/${PKG}`);
12
- if (!res.ok) throw new Error(`npm API ${res.status}`);
13
- const { downloads } = await res.json();
14
- const days = downloads.map((d) => ({ date: d.day, n: d.downloads }));
15
- const total = days.reduce((s, d) => s + d.n, 0);
16
- const max = Math.max(1, ...days.map((d) => d.n));
17
-
18
- const W = 760, H = 180, PAD = 30, CW = (W - PAD * 2) / days.length;
19
- const bars = days.map((d, i) => {
20
- const h = Math.round((d.n / max) * (H - 70));
21
- const x = PAD + i * CW, y = H - 40 - h;
22
- return `<rect x="${x.toFixed(1)}" y="${y}" width="${(CW - 2).toFixed(1)}" height="${h}" rx="2" fill="#22c55e"><title>${d.date}: ${d.n}</title></rect>`;
23
- }).join('\n ');
24
-
25
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif">
26
- <text x="${PAD}" y="24" font-size="15" font-weight="600" fill="#1f2937">${PKG} — npm downloads (last ${DAYS} days)</text>
27
- <text x="${W - PAD}" y="24" font-size="13" text-anchor="end" fill="#374151">total ${total.toLocaleString()} · max ${max.toLocaleString()}/day</text>
28
- ${bars}
29
- <text x="${PAD}" y="${H - 15}" font-size="11" fill="#6b7280">${days[0].date}</text>
30
- <text x="${W - PAD}" y="${H - 15}" font-size="11" text-anchor="end" fill="#6b7280">${days[days.length - 1].date}</text>
31
- </svg>\n`;
32
-
33
- const out = new URL('../.github/assets/downloads.svg', import.meta.url);
34
- mkdirSync(dirname(out.pathname), { recursive: true });
35
- writeFileSync(out.pathname, svg);
36
- console.log(`chart written: total=${total} max=${max}`);