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 +20 -1
- package/README.zh-CN.md +20 -1
- package/package.json +6 -3
- package/src/agents.js +15 -7
- package/src/aliases.js +99 -0
- package/src/index.js +93 -50
- package/src/plugin-manager.js +25 -24
- package/src/plugins.js +20 -12
- package/scripts/gen-downloads-chart.mjs +0 -36
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.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
|
-
|
|
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
|
|
@@ -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,
|
|
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:
|
|
263
|
+
provider: this.name,
|
|
254
264
|
source: 'claude-compat-manager',
|
|
255
265
|
rank: this.config.pluginManagerRank ?? 40,
|
|
256
|
-
|
|
257
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
385
|
+
const baseDescription = parsed === undefined ? stem : (stringField(parsed.data, 'description') ?? stem);
|
|
350
386
|
out.push({
|
|
351
|
-
name:
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (
|
|
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,
|
package/src/plugin-manager.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
touches). Report which engine
|
|
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
|
|
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,
|
|
@@ -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}`);
|