dsh-claude-compat 0.3.0 → 0.6.2
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 +58 -10
- package/README.zh-CN.md +58 -10
- package/package.json +20 -8
- package/scripts/dsh-restart.sh +107 -0
- package/src/agents.js +77 -0
- package/src/hooks.js +275 -0
- package/src/index.js +162 -120
- package/src/lib.js +123 -0
- package/src/mcp.js +133 -0
- package/src/plugin-manager.js +305 -0
- package/src/plugins.js +170 -0
package/src/plugins.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// dsh-claude-compat: installed Claude Code plugins (~/.claude/plugins).
|
|
2
|
+
//
|
|
3
|
+
// Claude Code's plugin-marketplace installs plugins into
|
|
4
|
+
// <pluginsRoot>/cache/<marketplace>/<name>/<version>/ and records them in
|
|
5
|
+
// <pluginsRoot>/installed_plugins.json. Each install root carries a
|
|
6
|
+
// .claude-plugin/plugin.json manifest listing skills dirs / commands dir /
|
|
7
|
+
// agents dir / an .mcp.json. We surface skills/commands/agents as DSH skill
|
|
8
|
+
// candidates at pluginSkillRank (default 750 — the long tail, below
|
|
9
|
+
// user-claude 700 and bundled 600), keeping original names; existing dedupe
|
|
10
|
+
// resolves collisions. Plugin MCP servers mount only when enablePluginMcp is
|
|
11
|
+
// opted in (mounting third-party MCP servers is a bigger trust step than
|
|
12
|
+
// listing skills).
|
|
13
|
+
|
|
14
|
+
import { readFileSync as rfs } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
pathExists,
|
|
18
|
+
readTextSafe,
|
|
19
|
+
resolveUserClaudeDir,
|
|
20
|
+
parseFrontmatter,
|
|
21
|
+
stringField,
|
|
22
|
+
} from './lib.js';
|
|
23
|
+
import { translateMcpServers } from './mcp.js';
|
|
24
|
+
|
|
25
|
+
// Parse installed_plugins.json → [{ key, name, marketplace, installPath,
|
|
26
|
+
// version }] for scope==='user' entries. Dedupe per key, keeping the last
|
|
27
|
+
// entry (Claude Code appends reinstall records; last is freshest).
|
|
28
|
+
export function readInstalledClaudePlugins(pluginsRoot) {
|
|
29
|
+
const root = resolveUserClaudeDir(pluginsRoot === undefined ? '~/.claude/plugins' : pluginsRoot);
|
|
30
|
+
const manifestPath = join(root, 'installed_plugins.json');
|
|
31
|
+
let parsed;
|
|
32
|
+
try { parsed = JSON.parse(claReadSync(manifestPath)); }
|
|
33
|
+
catch { return []; }
|
|
34
|
+
if (typeof parsed !== 'object' || parsed === null) return [];
|
|
35
|
+
const plugins = parsed.plugins;
|
|
36
|
+
if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) return [];
|
|
37
|
+
const byKey = new Map();
|
|
38
|
+
for (const [key, entries] of Object.entries(plugins)) {
|
|
39
|
+
if (!Array.isArray(entries)) continue;
|
|
40
|
+
const record = entries.findLast((e) => e?.scope === 'user');
|
|
41
|
+
if (record === undefined || typeof record.installPath !== 'string') continue;
|
|
42
|
+
const at = key.lastIndexOf('@');
|
|
43
|
+
byKey.set(key, {
|
|
44
|
+
key,
|
|
45
|
+
name: at > 0 ? key.slice(0, at) : key,
|
|
46
|
+
marketplace: at > 0 ? key.slice(at + 1) : '',
|
|
47
|
+
installPath: record.installPath,
|
|
48
|
+
version: typeof record.version === 'string' ? record.version : 'unknown',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return [...byKey.values()];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// readFileSync kept local so readInstalledClaudePlugins stays sync-simple.
|
|
55
|
+
function claReadSync(p) { return rfs(p, 'utf8'); }
|
|
56
|
+
|
|
57
|
+
// Discover skill/command/agent candidates from all installed plugins. When a
|
|
58
|
+
// plugin has a .claude-plugin/plugin.json manifest, its listed dirs are used;
|
|
59
|
+
// otherwise fall back to scanning <installPath>/skills and <installPath>/commands.
|
|
60
|
+
// Claude Code's discoverCommands drops commands whose only description is the
|
|
61
|
+
// empty string; plugin marketplaces ship many such stubs, so this wrapper
|
|
62
|
+
// re-surfaces them with the filename stem as the description.
|
|
63
|
+
export async function discoverPluginContent(pluginsRoot, providerName, source, rank, { enableAgents } = {}) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const plugins = readInstalledClaudePlugins(pluginsRoot);
|
|
66
|
+
const { discoverSkills, discoverCommands } = await import('./index.js');
|
|
67
|
+
const { discoverAgents } = await import('./agents.js');
|
|
68
|
+
const patchedCommands = async (dir) => {
|
|
69
|
+
const batch = await discoverCommands(dir, providerName, source, rank);
|
|
70
|
+
if (batch.length > 0) return batch;
|
|
71
|
+
const kept = new Set(batch.map((c) => c.path));
|
|
72
|
+
const extra = await discoverCommandsAllowEmpty(dir, providerName, source, rank);
|
|
73
|
+
return [...batch, ...extra.filter((c) => !kept.has(c.path))];
|
|
74
|
+
};
|
|
75
|
+
for (const plugin of plugins) {
|
|
76
|
+
const { installPath } = plugin;
|
|
77
|
+
const manifest = await readPluginManifest(installPath);
|
|
78
|
+
// Dedupe within each KIND only: omc-style plugins ship both skills/ask and
|
|
79
|
+
// commands/ask.md — same-name cross-kind pairs are surfaced separately and
|
|
80
|
+
// resolved by registry dedupe (skills are emitted first, so the skill wins,
|
|
81
|
+
// mirroring Claude Code's own behavior).
|
|
82
|
+
const seenByKind = { skills: new Set(), commands: new Set(), agents: new Set() };
|
|
83
|
+
const push = (kind) => (batch) => {
|
|
84
|
+
for (const c of batch) {
|
|
85
|
+
if (seenByKind[kind].has(c.name)) continue;
|
|
86
|
+
seenByKind[kind].add(c.name);
|
|
87
|
+
out.push(c);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
if (manifest !== undefined) {
|
|
91
|
+
const skillDirs = manifestDirs(manifest, 'skills', installPath);
|
|
92
|
+
for (const dir of skillDirs) push('skills')(await discoverSkills(dir, providerName, source, rank));
|
|
93
|
+
const commandsDir = typeof manifest.commands === 'string' ? resolve(installPath, manifest.commands) : undefined;
|
|
94
|
+
if (commandsDir !== undefined) push('commands')(await patchedCommands(commandsDir));
|
|
95
|
+
} else {
|
|
96
|
+
push('skills')(await discoverSkills(join(installPath, 'skills'), providerName, source, rank));
|
|
97
|
+
push('commands')(await patchedCommands(join(installPath, 'commands')));
|
|
98
|
+
}
|
|
99
|
+
if (enableAgents !== false) {
|
|
100
|
+
const agentsDir = join(installPath, 'agents');
|
|
101
|
+
if (await pathExists(agentsDir)) push('agents')(await discoverAgents(agentsDir, providerName, source, rank));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Like discoverCommands but keeps empty-description stubs (stem as description).
|
|
108
|
+
async function discoverCommandsAllowEmpty(rootDir, providerName, source, rank) {
|
|
109
|
+
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
|
+
let entries;
|
|
113
|
+
try { entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' }); }
|
|
114
|
+
catch { return out; }
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
117
|
+
const stem = entry.name.slice(0, -3);
|
|
118
|
+
if (!isSkillName(stem)) continue;
|
|
119
|
+
const raw = await readTextSafe(join(rootDir, entry.name));
|
|
120
|
+
if (raw === undefined) continue;
|
|
121
|
+
const parsed = parseFrontmatter(raw);
|
|
122
|
+
if (parsed === undefined) continue; // no frontmatter at all → skip (index.js variant covers body-only)
|
|
123
|
+
const description = stringField(parsed.data, 'description');
|
|
124
|
+
if (description !== undefined) continue; // non-empty handled by index.js variant
|
|
125
|
+
out.push({
|
|
126
|
+
name: stem,
|
|
127
|
+
description: stem,
|
|
128
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
129
|
+
provider: providerName,
|
|
130
|
+
source,
|
|
131
|
+
rank,
|
|
132
|
+
locator: { path: join(rootDir, entry.name), directory: rootDir },
|
|
133
|
+
resourceBase: { kind: 'directory', path: rootDir },
|
|
134
|
+
path: join(rootDir, entry.name),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readPluginManifest(installPath) {
|
|
141
|
+
const raw = await readTextSafe(join(installPath, '.claude-plugin', 'plugin.json'));
|
|
142
|
+
if (raw === undefined) return undefined;
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
|
146
|
+
} catch { return undefined; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function manifestDirs(manifest, field, installPath) {
|
|
150
|
+
const v = manifest[field];
|
|
151
|
+
if (typeof v === 'string') return [resolve(installPath, v)];
|
|
152
|
+
if (Array.isArray(v)) {
|
|
153
|
+
return v.filter((d) => typeof d === 'string').map((d) => resolve(installPath, d));
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Translate a plugin's MCP config into dsh-mcp-client server configs. Returns
|
|
159
|
+
// { servers, warns } (empty when no/invalid mcpServers field). Substitutes the
|
|
160
|
+
// ${CLAUDE_PLUGIN_ROOT} placeholder Claude Code defines for plugin processes.
|
|
161
|
+
export function translatePluginMcp(manifest, installPath, opts = {}) {
|
|
162
|
+
const ref = manifest?.mcpServers;
|
|
163
|
+
if (typeof ref !== 'string') return { servers: [], warns: [] };
|
|
164
|
+
const raw = readTextSafeSync(resolve(installPath, ref));
|
|
165
|
+
if (raw === undefined) return { servers: [], warns: [`mcpServers file not readable: ${ref}`] };
|
|
166
|
+
const substituted = raw.replaceAll('${CLAUDE_PLUGIN_ROOT}', installPath);
|
|
167
|
+
return translateMcpServers(substituted, opts);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readTextSafeSync(p) { try { return rfs(p, 'utf8'); } catch { return undefined; } }
|