dsh-claude-compat 0.8.4 → 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 +20 -1
- package/README.zh-CN.md +20 -1
- package/package.json +2 -2
- package/scripts/deploy-local.sh +71 -0
- package/src/agents.js +15 -7
- package/src/aliases.js +99 -0
- package/src/index.js +51 -19
- package/src/plugins.js +20 -12
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
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
|
|
@@ -169,6 +183,11 @@ class ClaudeCompatSkillProvider {
|
|
|
169
183
|
this.userSkillRank = config.userSkillRank ?? 700;
|
|
170
184
|
this.userSkillSource = config.userSkillSource ?? 'user-claude';
|
|
171
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
|
+
};
|
|
172
191
|
this.control = control;
|
|
173
192
|
control.signal.addEventListener('abort', () => {}, { once: true });
|
|
174
193
|
}
|
|
@@ -207,7 +226,8 @@ class ClaudeCompatSkillProvider {
|
|
|
207
226
|
if (this.config.enablePlugins !== false) {
|
|
208
227
|
const pluginCandidates = await discoverPluginContent(
|
|
209
228
|
this.config.pluginsRoot, this.name, this.config.pluginSkillSource ?? 'claude-plugin',
|
|
210
|
-
this.config.pluginSkillRank ?? 750,
|
|
229
|
+
this.config.pluginSkillRank ?? 750,
|
|
230
|
+
{ enableAgents: this.config.enableAgents, ...this.aliasOptions });
|
|
211
231
|
candidates.push(...pluginCandidates);
|
|
212
232
|
}
|
|
213
233
|
return candidates;
|
|
@@ -260,13 +280,13 @@ Notes:
|
|
|
260
280
|
|
|
261
281
|
async addRootCandidates(candidates, claudeDir, source, rank) {
|
|
262
282
|
const provider = this.name;
|
|
263
|
-
const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank)];
|
|
283
|
+
const tasks = [discoverSkills(join(claudeDir, 'skills'), provider, source, rank, this.aliasOptions)];
|
|
264
284
|
for (const [dir, fn] of [
|
|
265
285
|
['commands', discoverCommands],
|
|
266
286
|
['agents', discoverAgents],
|
|
267
287
|
]) {
|
|
268
288
|
if (dir === 'agents' && this.config.enableAgents === false) continue;
|
|
269
|
-
tasks.push(fn(join(claudeDir, dir), provider, source, rank));
|
|
289
|
+
tasks.push(fn(join(claudeDir, dir), provider, source, rank, this.aliasOptions));
|
|
270
290
|
}
|
|
271
291
|
const batches = await Promise.all(tasks);
|
|
272
292
|
for (const batch of batches) candidates.push(...batch);
|
|
@@ -300,7 +320,7 @@ Notes:
|
|
|
300
320
|
|
|
301
321
|
// ─── discovery: <root>/.claude/skills (recursive, ≤3 levels) ─────────────────
|
|
302
322
|
|
|
303
|
-
export async function discoverSkills(rootDir, providerName, source, rank) {
|
|
323
|
+
export async function discoverSkills(rootDir, providerName, source, rank, aliasOptions) {
|
|
304
324
|
const { readdir } = await import('node:fs/promises');
|
|
305
325
|
const out = [];
|
|
306
326
|
if (!(await pathExistsSafe(rootDir))) return out;
|
|
@@ -314,7 +334,8 @@ export async function discoverSkills(rootDir, providerName, source, rank) {
|
|
|
314
334
|
} catch { return; }
|
|
315
335
|
if (entries.some(e => e.isFile() && e.name === 'SKILL.md')) {
|
|
316
336
|
const skillPath = join(dir, 'SKILL.md');
|
|
317
|
-
const c = await parseSkillCandidateFile(
|
|
337
|
+
const c = await parseSkillCandidateFile(
|
|
338
|
+
skillPath, providerName, source, rank, prefix || undefined, aliasOptions);
|
|
318
339
|
if (c !== undefined) out.push(c);
|
|
319
340
|
return; // bundle — don't descend
|
|
320
341
|
}
|
|
@@ -330,7 +351,7 @@ export async function discoverSkills(rootDir, providerName, source, rank) {
|
|
|
330
351
|
|
|
331
352
|
// ─── discovery: <root>/.claude/commands (flat) ───────────────────────────────
|
|
332
353
|
|
|
333
|
-
export async function discoverCommands(rootDir, providerName, source, rank) {
|
|
354
|
+
export async function discoverCommands(rootDir, providerName, source, rank, aliasOptions) {
|
|
334
355
|
const { readdir } = await import('node:fs/promises');
|
|
335
356
|
const out = [];
|
|
336
357
|
if (!(await pathExistsSafe(rootDir))) return out;
|
|
@@ -342,14 +363,20 @@ export async function discoverCommands(rootDir, providerName, source, rank) {
|
|
|
342
363
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
343
364
|
const path = join(rootDir, entry.name);
|
|
344
365
|
const stem = entry.name.slice(0, -3);
|
|
345
|
-
|
|
366
|
+
const resolved = resolveCandidateName([stem], aliasOptions?.aliases);
|
|
367
|
+
if (resolved === undefined) {
|
|
368
|
+
warnUnmapped('command', stem, path);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
346
371
|
const raw = await readTextSafe(path);
|
|
347
372
|
if (raw === undefined) continue;
|
|
348
373
|
const parsed = parseFrontmatter(raw);
|
|
349
|
-
const
|
|
374
|
+
const baseDescription = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem);
|
|
350
375
|
out.push({
|
|
351
|
-
name:
|
|
352
|
-
description
|
|
376
|
+
name: resolved.name,
|
|
377
|
+
description: aliasOptions?.tagOriginal === false
|
|
378
|
+
? baseDescription
|
|
379
|
+
: withOriginalName(baseDescription, resolved.original),
|
|
353
380
|
invocation: { modelInvocable: true, userInvocable: true },
|
|
354
381
|
provider: providerName,
|
|
355
382
|
source,
|
|
@@ -362,7 +389,7 @@ export async function discoverCommands(rootDir, providerName, source, rank) {
|
|
|
362
389
|
return out;
|
|
363
390
|
}
|
|
364
391
|
|
|
365
|
-
async function parseSkillCandidateFile(path, providerName, source, rank, flatName) {
|
|
392
|
+
async function parseSkillCandidateFile(path, providerName, source, rank, flatName, aliasOptions) {
|
|
366
393
|
const raw = await readTextSafe(path);
|
|
367
394
|
if (raw === undefined) return undefined;
|
|
368
395
|
const parsed = parseFrontmatter(raw);
|
|
@@ -373,18 +400,23 @@ async function parseSkillCandidateFile(path, providerName, source, rank, flatNam
|
|
|
373
400
|
// Prefer frontmatter name when it is a valid kebab-case skill name. Some Claude
|
|
374
401
|
// skills use names with ':' or other chars DSH rejects (e.g.
|
|
375
402
|
// "salus:ai-robot-coding-env-check"); for those, fall back to the flattened
|
|
376
|
-
// directory name, which is virtually always kebab-case.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (
|
|
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
|
+
}
|
|
381
411
|
let invocation;
|
|
382
412
|
try { invocation = parseInvocationPolicy(parsed.data); }
|
|
383
413
|
catch { invocation = { modelInvocable: true, userInvocable: true }; }
|
|
384
414
|
const whenToUse = stringField(parsed.data, 'whenToUse');
|
|
385
415
|
return {
|
|
386
|
-
name,
|
|
387
|
-
description
|
|
416
|
+
name: resolved.name,
|
|
417
|
+
description: aliasOptions?.tagOriginal === false
|
|
418
|
+
? description
|
|
419
|
+
: withOriginalName(description, resolved.original),
|
|
388
420
|
...(whenToUse !== undefined ? { whenToUse } : {}),
|
|
389
421
|
invocation,
|
|
390
422
|
provider: providerName,
|
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
|
|
111
|
-
|
|
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
|
-
|
|
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:
|
|
127
|
-
description
|
|
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,
|