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/lib.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Shared helpers for dsh-claude-compat (path resolution, sync/async root
|
|
2
|
+
// discovery, reader utilities, frontmatter parsing). Extracted from index.js
|
|
3
|
+
// so mcp.js / hooks.js / agents.js can reuse them without circular imports.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { parse } from 'yaml';
|
|
10
|
+
|
|
11
|
+
export function resolveUserClaudeDir(dir) {
|
|
12
|
+
if (dir === undefined || dir === null || dir === '') return join(homedir(), '.claude');
|
|
13
|
+
if (dir === '~') return homedir();
|
|
14
|
+
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
|
|
15
|
+
return resolve(dir);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function findProjectRoot(cwd, markers = ['.git']) {
|
|
19
|
+
let current = resolve(cwd);
|
|
20
|
+
while (true) {
|
|
21
|
+
for (const marker of markers) {
|
|
22
|
+
if (await pathExists(join(current, marker))) return current;
|
|
23
|
+
}
|
|
24
|
+
const parent = dirname(current);
|
|
25
|
+
if (parent === current) return undefined;
|
|
26
|
+
current = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function findProjectRootSync(cwd, markers = ['.git']) {
|
|
31
|
+
let current = resolve(cwd);
|
|
32
|
+
while (true) {
|
|
33
|
+
for (const marker of markers) {
|
|
34
|
+
if (existsSync(join(current, marker))) return current;
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(current);
|
|
37
|
+
if (parent === current) return undefined;
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function pathExists(path) {
|
|
43
|
+
try { await stat(path); return true; } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readTextSafe(path) {
|
|
47
|
+
try { return await readFile(path, { encoding: 'utf8' }); }
|
|
48
|
+
catch { return undefined; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readTextSafeSync(path) {
|
|
52
|
+
try { return readFileSync(path, 'utf8'); } catch { return undefined; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listMdFiles(dir) {
|
|
56
|
+
let entries;
|
|
57
|
+
try { entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' }); }
|
|
58
|
+
catch { return []; }
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (e.isFile() && e.name.endsWith('.md')) files.push(join(dir, e.name));
|
|
62
|
+
}
|
|
63
|
+
files.sort();
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listMdFilesSync(dir) {
|
|
68
|
+
let entries;
|
|
69
|
+
try { entries = readdirSync(dir); } catch { return []; }
|
|
70
|
+
const files = [];
|
|
71
|
+
for (const name of entries) {
|
|
72
|
+
if (!name.endsWith('.md')) continue;
|
|
73
|
+
const p = join(dir, name);
|
|
74
|
+
try { if (statSync(p).isFile()) files.push(p); } catch {}
|
|
75
|
+
}
|
|
76
|
+
files.sort();
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Frontmatter: `---` line, YAML block, closing `---` line, body after. Returns
|
|
81
|
+
// { data, body } on success, undefined on anything malformed.
|
|
82
|
+
export function parseFrontmatter(raw) {
|
|
83
|
+
const firstLineEnd = raw.indexOf('\n');
|
|
84
|
+
if (firstLineEnd < 0) return undefined;
|
|
85
|
+
if (raw.slice(0, firstLineEnd).replace(/\r$/, '') !== '---') return undefined;
|
|
86
|
+
const start = firstLineEnd + 1;
|
|
87
|
+
const closing = findClosingFrontmatter(raw, start);
|
|
88
|
+
if (closing === undefined) return undefined;
|
|
89
|
+
let parsed;
|
|
90
|
+
try { parsed = parse(raw.slice(start, closing.start)); }
|
|
91
|
+
catch { return undefined; }
|
|
92
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
|
|
93
|
+
return { data: parsed, body: raw.slice(closing.bodyStart) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function stringField(data, key) {
|
|
97
|
+
const v = data[key];
|
|
98
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function truthy(v) {
|
|
102
|
+
if (typeof v === 'boolean') return v;
|
|
103
|
+
if (typeof v === 'string') {
|
|
104
|
+
const s = v.toLowerCase();
|
|
105
|
+
return s === 'true' || s === 'yes' || s === 'on' || s === '1';
|
|
106
|
+
}
|
|
107
|
+
if (typeof v === 'number') return v !== 0;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findClosingFrontmatter(raw, start) {
|
|
112
|
+
let lineStart = start;
|
|
113
|
+
while (lineStart <= raw.length) {
|
|
114
|
+
const nextNewline = raw.indexOf('\n', lineStart);
|
|
115
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
|
|
116
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
117
|
+
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 };
|
|
118
|
+
}
|
|
119
|
+
if (nextNewline < 0) return undefined;
|
|
120
|
+
lineStart = nextNewline + 1;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// dsh-claude-compat: MCP servers from <projectRoot>/.mcp.json (Claude Code
|
|
2
|
+
// format). Read once at DSH startup from the launch workspace; each server is
|
|
3
|
+
// mounted as an @deepseek-ai/dsh-mcp-client plugin instance (stdio for
|
|
4
|
+
// `command` entries, streamable-http for `url`/`type:'http'|'sse'` entries).
|
|
5
|
+
//
|
|
6
|
+
// Failure handling is deliberately non-fatal: an unreadable/malformed .mcp.json
|
|
7
|
+
// or an absent dsh-mcp-client package logs a warning and skips, never crashing
|
|
8
|
+
// the harness. failOnStartupError only forwards to the mcp-client plugin.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
findProjectRootSync,
|
|
14
|
+
pathExists,
|
|
15
|
+
} from './lib.js';
|
|
16
|
+
|
|
17
|
+
// Pure translation of Claude Code's .mcp.json body into dsh-mcp-client plugin
|
|
18
|
+
// configs, keyed by server name. Malformed entries are skipped and reported.
|
|
19
|
+
export function translateMcpServers(rawJson, { failOnStartupError = false } = {}) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try { parsed = JSON.parse(rawJson); }
|
|
22
|
+
catch {
|
|
23
|
+
return { servers: [], warns: [`invalid-json: ${String(rawJson).slice(0, 100)}`] };
|
|
24
|
+
}
|
|
25
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
26
|
+
return { servers: [], warns: ['invalid-json: root must be an object'] };
|
|
27
|
+
}
|
|
28
|
+
const servers = parsed.mcpServers;
|
|
29
|
+
if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) {
|
|
30
|
+
return { servers: [], warns: ['mcpServers: missing or not an object'] };
|
|
31
|
+
}
|
|
32
|
+
const out = [];
|
|
33
|
+
const warns = [];
|
|
34
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
35
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
36
|
+
warns.push(`${name}: malformed entry, skipped`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// serverName must match /^[A-Za-z0-9_-]{1,32}$/ (dsh-mcp-client contract).
|
|
40
|
+
const serverName = name.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32);
|
|
41
|
+
if (serverName.length === 0) {
|
|
42
|
+
warns.push(`${name}: empty server name, skipped`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const base = {
|
|
46
|
+
serverName,
|
|
47
|
+
toolCallTimeoutMs: 60_000,
|
|
48
|
+
failOnStartupError,
|
|
49
|
+
};
|
|
50
|
+
if (typeof entry.command === 'string' && entry.command.length > 0) {
|
|
51
|
+
out.push({
|
|
52
|
+
...base,
|
|
53
|
+
transport: 'stdio',
|
|
54
|
+
command: entry.command,
|
|
55
|
+
args: Array.isArray(entry.args) ? entry.args.filter((a) => typeof a === 'string') : [],
|
|
56
|
+
env: stringDict(entry.env),
|
|
57
|
+
cwd: typeof entry.cwd === 'string' ? entry.cwd : '',
|
|
58
|
+
});
|
|
59
|
+
} else if (typeof entry.url === 'string' && entry.url.length > 0) {
|
|
60
|
+
out.push({
|
|
61
|
+
...base,
|
|
62
|
+
transport: 'streamable-http',
|
|
63
|
+
url: entry.url,
|
|
64
|
+
headers: stringDict(entry.headers),
|
|
65
|
+
});
|
|
66
|
+
} else if (entry.type === 'http' || entry.type === 'sse') {
|
|
67
|
+
warns.push(`${name}: type '${entry.type}' without a url, skipped`);
|
|
68
|
+
} else {
|
|
69
|
+
warns.push(`${name}: neither command nor url, skipped`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { servers: out, warns };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stringDict(v) {
|
|
76
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) return {};
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const [k, val] of Object.entries(v)) {
|
|
79
|
+
if (typeof val === 'string') out[k] = val;
|
|
80
|
+
else if (typeof val === 'number' || typeof val === 'boolean') out[k] = String(val);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Mount one mcp-client per server listed in <projectRoot>/.mcp.json. Lifecycle:
|
|
86
|
+
// process-lifetime by design — the file is read once at DSH startup from the
|
|
87
|
+
// launch workspace via process.cwd()-based root discovery; deviating from the
|
|
88
|
+
// per-session cwd rules (which are session-scoped) is intended and documented.
|
|
89
|
+
export async function mountMcpServers(ctx, config) {
|
|
90
|
+
const projectRoot = findProjectRootSync(process.cwd(), config.projectRootMarkers);
|
|
91
|
+
if (projectRoot === undefined) return;
|
|
92
|
+
const mcpPath = join(projectRoot, '.mcp.json');
|
|
93
|
+
if (!(await pathExists(mcpPath))) return;
|
|
94
|
+
let raw;
|
|
95
|
+
try { raw = readFileSync(mcpPath, 'utf8'); } catch (error) {
|
|
96
|
+
console.warn(`dsh-claude-compat: .mcp.json unreadable: ${error?.message ?? error}`, '(skipping MCP)');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const { servers, warns } = translateMcpServers(raw, {
|
|
100
|
+
failOnStartupError: config.mcpFailOnStartupError ?? false,
|
|
101
|
+
});
|
|
102
|
+
warns.forEach((w) => console.warn(`dsh-claude-compat: .mcp.json: ${w}`));
|
|
103
|
+
await mountMcpConfigs(ctx, servers);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Mount pre-translated server configs (shared by .mcp.json and plugin MCP).
|
|
107
|
+
export async function mountMcpConfigs(ctx, servers) {
|
|
108
|
+
if (servers.length === 0) return;
|
|
109
|
+
let mcpClientModule;
|
|
110
|
+
try {
|
|
111
|
+
mcpClientModule = await import('@deepseek-ai/dsh-mcp-client');
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.warn('dsh-claude-compat: @deepseek-ai/dsh-mcp-client not installed', '(MCP disabled)', error?.message ?? error);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const mcpClient = mcpClientModule.default ?? mcpClientModule;
|
|
117
|
+
const mounted = new Set();
|
|
118
|
+
for (const server of servers) {
|
|
119
|
+
if (mounted.has(server.serverName)) {
|
|
120
|
+
console.warn(`dsh-claude-compat: duplicate serverName "${server.serverName}" skipped`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
mounted.add(server.serverName);
|
|
124
|
+
// ctx.plugin returns a Fiber (PromiseLike) in real cordis; guard for
|
|
125
|
+
// non-promise returns in tests/embeddings.
|
|
126
|
+
const fiber = ctx.plugin(mcpClient, server);
|
|
127
|
+
if (fiber && typeof fiber.catch === 'function') {
|
|
128
|
+
fiber.catch((error) => {
|
|
129
|
+
console.warn(`dsh-claude-compat: server "${server.serverName}" failed to mount: ${error?.message ?? error}`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// dsh-claude-compat: /plugin management skill (v0.6.0).
|
|
2
|
+
//
|
|
3
|
+
// Full Claude-Code-equivalent plugin management surfaced as a user-invocable
|
|
4
|
+
// DSH skill named "plugin". Execution engine: claude CLI first (`claude
|
|
5
|
+
// plugin ...`, present on PATH in most Claude Code setups), self-implemented
|
|
6
|
+
// JSON+git fallback when the CLI is unavailable. All state lives in the
|
|
7
|
+
// Claude-native locations (~/.claude/plugins/*, settings.json enabledPlugins)
|
|
8
|
+
// so both Claude Code and this plugin's discovery bridge read the same truth.
|
|
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 [...]
|
|
20
|
+
//
|
|
21
|
+
// Safety: never mutates installed_plugins.json / known_marketplaces.json /
|
|
22
|
+
// settings.json directly without a timestamped backup beside the file; the
|
|
23
|
+
// claude-CLI path performs no direct mutation at all (CLI owns the writes).
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process';
|
|
26
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, writeFileSync, cpSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { promisify } from 'node:util';
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
|
|
33
|
+
const HELP = `# Claude Code Plugin Manager (dsh-claude-compat)
|
|
34
|
+
|
|
35
|
+
Manage Claude Code plugins and marketplaces. State is stored in Claude-native
|
|
36
|
+
locations (~/.claude/plugins, ~/.claude/settings.json enabledPlugins), so
|
|
37
|
+
Claude Code and DSH see the same plugins. Newly installed plugin content
|
|
38
|
+
appears in the DSH skill catalog on next reconcile (MCP servers need a DSH restart).
|
|
39
|
+
|
|
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]\`
|
|
52
|
+
|
|
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.`;
|
|
57
|
+
|
|
58
|
+
/** Run `claude plugin ...` args; resolve {ok, stdout} or {ok:false, error}. */
|
|
59
|
+
async function claudePlugin(args, timeoutMs = 120_000) {
|
|
60
|
+
return execFileAsync('claude', ['plugin', ...args], { timeout: timeoutMs, encoding: 'utf8' })
|
|
61
|
+
.then(({ stdout }) => ({ ok: true, stdout: stdout ?? '' }))
|
|
62
|
+
.catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pluginsRoot() {
|
|
66
|
+
return join(homedir(), '.claude', 'plugins');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function backupFile(path) {
|
|
70
|
+
if (!existsSync(path)) return undefined;
|
|
71
|
+
const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
72
|
+
copyFileSync(path, backup);
|
|
73
|
+
return backup;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readJson(path, fallback) {
|
|
77
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
78
|
+
catch { return fallback; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Pure: parse `claude plugin list` output into [{name, marketplace, version, scope, enabled}]. */
|
|
82
|
+
export function parsePluginList(output) {
|
|
83
|
+
const plugins = [];
|
|
84
|
+
const lines = String(output ?? '').split('\n');
|
|
85
|
+
let current;
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const entry = line.match(/^\s*❯\s*(\S+)@(\S+)\s*$/);
|
|
88
|
+
if (entry) {
|
|
89
|
+
if (current) plugins.push(current);
|
|
90
|
+
current = { name: entry[1], marketplace: entry[2] };
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!current) continue;
|
|
94
|
+
const version = line.match(/^\s*Version:\s*(\S+)/);
|
|
95
|
+
if (version) current.version = version[1];
|
|
96
|
+
const scope = line.match(/^\s*Scope:\s*(\S+)/);
|
|
97
|
+
if (scope) current.scope = scope[1];
|
|
98
|
+
const status = line.match(/^\s*Status:\s*(✔|✘)\s*(\S+)/);
|
|
99
|
+
if (status) current.enabled = status[2] === 'enabled';
|
|
100
|
+
}
|
|
101
|
+
if (current) plugins.push(current);
|
|
102
|
+
return plugins;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pure: list plugin entries from installed_plugins.json + enabledPlugins map. */
|
|
106
|
+
export function listFromDisk(pluginsDir = pluginsRoot(), settingsPath = join(homedir(), '.claude', 'settings.json')) {
|
|
107
|
+
const installed = readJson(join(pluginsDir, 'installed_plugins.json'), { version: 2, plugins: {} });
|
|
108
|
+
const settings = readJson(settingsPath, {});
|
|
109
|
+
const enabledMap = settings.enabledPlugins ?? {};
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const [key, entries] of Object.entries(installed.plugins ?? {})) {
|
|
112
|
+
const last = Array.isArray(entries) ? entries[entries.length - 1] : entries;
|
|
113
|
+
if (!last) continue;
|
|
114
|
+
const [name, marketplace] = key.split('@');
|
|
115
|
+
const installedEnabled = enabledMap[key];
|
|
116
|
+
out.push({
|
|
117
|
+
name,
|
|
118
|
+
marketplace,
|
|
119
|
+
version: last.version,
|
|
120
|
+
scope: last.scope,
|
|
121
|
+
// enabledPlugins is authoritative when the key exists; default enabled.
|
|
122
|
+
enabled: installedEnabled === undefined ? true : Boolean(installedEnabled),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Pure: search marketplace.json catalogs under marketplaces/ for a term. */
|
|
129
|
+
export function searchMarketplaces(term, pluginsDir = pluginsRoot()) {
|
|
130
|
+
const marketRoot = join(pluginsDir, 'marketplaces');
|
|
131
|
+
const needle = String(term ?? '').toLowerCase();
|
|
132
|
+
const hits = [];
|
|
133
|
+
let dirs = [];
|
|
134
|
+
try { dirs = readdirSync(marketRoot, { withFileTypes: true }); } catch { return hits; }
|
|
135
|
+
for (const dir of dirs) {
|
|
136
|
+
if (!dir.isDirectory()) continue;
|
|
137
|
+
const manifest = join(marketRoot, dir.name, '.claude-plugin', 'marketplace.json');
|
|
138
|
+
if (!existsSync(manifest)) continue;
|
|
139
|
+
const json = readJson(manifest, null);
|
|
140
|
+
for (const plugin of json?.plugins ?? []) {
|
|
141
|
+
const haystack = `${plugin.name} ${plugin.description ?? ''} ${(plugin.tags ?? []).join(' ')}`.toLowerCase();
|
|
142
|
+
if (needle === '' || haystack.includes(needle)) {
|
|
143
|
+
hits.push({
|
|
144
|
+
name: plugin.name,
|
|
145
|
+
marketplace: dir.name,
|
|
146
|
+
description: plugin.description ?? '',
|
|
147
|
+
version: plugin.version,
|
|
148
|
+
installId: `${plugin.name}@${dir.name}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return hits;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fallback install for no-CLI environments: clone the marketplace repo is
|
|
158
|
+
* already present under marketplaces/<name>; copy plugin source into cache and
|
|
159
|
+
* register in installed_plugins.json + enabledPlugins.
|
|
160
|
+
*/
|
|
161
|
+
export async function fallbackInstall(installId, pluginsDir = pluginsRoot(), settingsPath = join(homedir(), '.claude', 'settings.json')) {
|
|
162
|
+
const [pluginName, marketName] = String(installId).split('@');
|
|
163
|
+
if (!marketName) return { ok: false, error: 'fallback install needs name@marketplace (marketplace repo must already be cloned)' };
|
|
164
|
+
const marketJson = join(pluginsDir, 'marketplaces', marketName, '.claude-plugin', 'marketplace.json');
|
|
165
|
+
const catalog = readJson(marketJson, null);
|
|
166
|
+
const entry = (catalog?.plugins ?? []).find((p) => p.name === pluginName);
|
|
167
|
+
if (!entry) return { ok: false, error: `plugin "${pluginName}" not found in marketplace "${marketName}"` };
|
|
168
|
+
const version = entry.version ?? 'unknown';
|
|
169
|
+
const sourceRel = String(entry.source ?? './').replace(/^\.\//, '');
|
|
170
|
+
const from = join(pluginsDir, 'marketplaces', marketName, sourceRel);
|
|
171
|
+
if (!existsSync(from)) return { ok: false, error: `plugin source missing in marketplace clone: ${sourceRel}` };
|
|
172
|
+
const dest = join(pluginsDir, 'cache', marketName, pluginName, version);
|
|
173
|
+
mkdirSync(dest, { recursive: true });
|
|
174
|
+
cpSync(from, dest, { recursive: true });
|
|
175
|
+
const installedPath = join(pluginsDir, 'installed_plugins.json');
|
|
176
|
+
backupFile(installedPath);
|
|
177
|
+
const installed = readJson(installedPath, { version: 2, plugins: {} });
|
|
178
|
+
const key = `${pluginName}@${marketName}`;
|
|
179
|
+
installed.plugins[key] = installed.plugins[key] ?? [];
|
|
180
|
+
installed.plugins[key].push({
|
|
181
|
+
scope: 'user',
|
|
182
|
+
installPath: dest,
|
|
183
|
+
version,
|
|
184
|
+
installedAt: new Date().toISOString(),
|
|
185
|
+
lastUpdated: new Date().toISOString(),
|
|
186
|
+
});
|
|
187
|
+
writeFileSync(installedPath, JSON.stringify(installed, null, 2) + '\n');
|
|
188
|
+
backupFile(settingsPath);
|
|
189
|
+
const settings = readJson(settingsPath, {});
|
|
190
|
+
settings.enabledPlugins = settings.enabledPlugins ?? {};
|
|
191
|
+
settings.enabledPlugins[key] = true;
|
|
192
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
193
|
+
return { ok: true, engine: 'fallback', installPath: dest, version };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Fallback enable/disable: flip enabledPlugins in settings.json (backed up). */
|
|
197
|
+
export function fallbackToggle(pluginRef, enable, pluginsDir = pluginsRoot(), settingsPath = join(homedir(), '.claude', 'settings.json')) {
|
|
198
|
+
const installed = readJson(join(pluginsDir, 'installed_plugins.json'), { plugins: {} });
|
|
199
|
+
const keys = Object.keys(installed.plugins ?? {});
|
|
200
|
+
const key = keys.find((k) => k === pluginRef || k.startsWith(`${pluginRef}@`));
|
|
201
|
+
if (!key) return { ok: false, error: `plugin not found: ${pluginRef}` };
|
|
202
|
+
backupFile(settingsPath);
|
|
203
|
+
const settings = readJson(settingsPath, {});
|
|
204
|
+
settings.enabledPlugins = settings.enabledPlugins ?? {};
|
|
205
|
+
settings.enabledPlugins[key] = Boolean(enable);
|
|
206
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
207
|
+
return { ok: true, engine: 'fallback', key, enabled: Boolean(enable) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Main dispatcher. Input: the raw argument string after "/plugin".
|
|
212
|
+
* Returns markdown text for the model to present.
|
|
213
|
+
*/
|
|
214
|
+
export async function runPluginCommand(input, deps = {}) {
|
|
215
|
+
const claudeFn = deps.claude ?? claudePlugin;
|
|
216
|
+
const args = String(input ?? '').trim().split(/\s+/).filter(Boolean);
|
|
217
|
+
if (args.length === 0 || args[0] === 'help') return HELP;
|
|
218
|
+
|
|
219
|
+
const [cmd, ...rest] = args;
|
|
220
|
+
|
|
221
|
+
// `/plugin <name>[@market]` one-shot install.
|
|
222
|
+
const isOneShot = !['install', 'uninstall', 'remove', 'enable', 'disable',
|
|
223
|
+
'update', 'search', 'marketplace', 'list'].includes(cmd);
|
|
224
|
+
if (isOneShot) return doInstall(cmd, claudeFn);
|
|
225
|
+
|
|
226
|
+
if (cmd === 'list') {
|
|
227
|
+
const res = await claudeFn(['list']);
|
|
228
|
+
if (res.ok) {
|
|
229
|
+
const parsed = parsePluginList(res.stdout);
|
|
230
|
+
if (parsed.length === 0) return 'No plugins installed.';
|
|
231
|
+
return ['Installed plugins:', '',
|
|
232
|
+
...parsed.map((p) => `- **${p.name}@${p.marketplace}** v${p.version} [${p.scope}] ${p.enabled ? '✔ enabled' : '✘ disabled'}`),
|
|
233
|
+
'', 'Enable/disable with `/plugin enable|disable <name>`; install with `/plugin <name>@<marketplace>`.'].join('\n');
|
|
234
|
+
}
|
|
235
|
+
const parsed = listFromDisk();
|
|
236
|
+
if (parsed.length === 0) return 'No plugins installed (CLI unavailable, disk scan found none).';
|
|
237
|
+
return ['Installed plugins (disk scan; claude CLI unavailable):', '',
|
|
238
|
+
...parsed.map((p) => `- **${p.name}@${p.marketplace}** v${p.version} [${p.scope}] ${p.enabled ? '✔ enabled' : '✘ disabled'}`)].join('\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (cmd === 'search') {
|
|
242
|
+
if (rest.length === 0) return 'Usage: `/plugin search <term>`';
|
|
243
|
+
const hits = searchMarketplaces(rest.join(' '));
|
|
244
|
+
if (hits.length === 0) return `No marketplace plugin matches "${rest.join(' ')}".`;
|
|
245
|
+
return [`Marketplace matches for "${rest.join(' ')}":`, '',
|
|
246
|
+
...hits.slice(0, 30).map((h) => `- **${h.installId}** v${h.version ?? '?'} — ${h.description.slice(0, 100)}`),
|
|
247
|
+
'', 'Install with `/plugin <name>@<marketplace>`.'].join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (cmd === 'marketplace') {
|
|
251
|
+
const [sub, ...mrest] = rest;
|
|
252
|
+
const subArgs = { list: ['marketplace', 'list'], add: ['marketplace', 'add', ...mrest], remove: ['marketplace', 'remove', ...mrest], rm: ['marketplace', 'remove', ...mrest], update: ['marketplace', 'update', ...mrest] }[sub];
|
|
253
|
+
if (!subArgs) return 'Usage: `/plugin marketplace list|add|remove|update [...]`';
|
|
254
|
+
const res = await claudeFn(subArgs, 300_000);
|
|
255
|
+
if (res.ok) return `Marketplace ${sub} OK (engine: claude CLI):\n\n${res.stdout.trim()}`;
|
|
256
|
+
return `Marketplace ${sub} failed. Engine: claude CLI (unavailable or errored).\n\n${res.error}\n\nFallback for marketplace mutation is not implemented (manual: git clone into ~/.claude/plugins/marketplaces/<name> and register in known_marketplaces.json).`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const passThrough = {
|
|
260
|
+
install: ['install', ...rest],
|
|
261
|
+
uninstall: ['uninstall', ...rest],
|
|
262
|
+
remove: ['uninstall', ...rest],
|
|
263
|
+
enable: ['enable', ...rest],
|
|
264
|
+
disable: ['disable', ...rest],
|
|
265
|
+
}[cmd];
|
|
266
|
+
|
|
267
|
+
if (cmd === 'update') {
|
|
268
|
+
const res = await claudeFn(['update', ...rest], 300_000);
|
|
269
|
+
if (res.ok) return `Update OK (engine: claude CLI):\n\n${res.stdout.trim()}\n\nRestart DSH to apply MCP changes; skills refresh automatically.`;
|
|
270
|
+
return `Update failed (engine: claude CLI):\n\n${res.error}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (passThrough) {
|
|
274
|
+
if (passThrough[0] === 'install') return doInstall(rest.join(' '), claudeFn);
|
|
275
|
+
const res = await claudeFn(passThrough, 300_000);
|
|
276
|
+
if (res.ok) return `\`${cmd} ${rest.join(' ')}\` OK (engine: claude CLI):\n\n${res.stdout.trim()}`;
|
|
277
|
+
if ((cmd === 'enable' || cmd === 'disable') && /ENOENT|not found|spawn/i.test(res.error)) {
|
|
278
|
+
const fb = fallbackToggle(rest[0], cmd === 'enable');
|
|
279
|
+
if (fb.ok) return `\`${cmd} ${rest[0]}\` OK (engine: fallback, settings.json backed up): ${fb.key} → ${fb.enabled ? 'enabled' : 'disabled'}`;
|
|
280
|
+
return `Both engines failed:\n- claude CLI: ${res.error}\n- fallback: ${fb.error}`;
|
|
281
|
+
}
|
|
282
|
+
return `\`${cmd} ${rest.join(' ')}\` failed (engine: claude CLI):\n\n${res.error}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return HELP;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function doInstall(installId, claudeFn) {
|
|
289
|
+
if (!installId) return 'Usage: `/plugin <name>[@marketplace]`';
|
|
290
|
+
const res = await claudeFn(['install', installId], 300_000);
|
|
291
|
+
if (res.ok) {
|
|
292
|
+
return [`Installed **${installId}** (engine: claude CLI).`, '', res.stdout.trim(),
|
|
293
|
+
'', 'Skills/commands appear in the catalog on next reconcile; restart DSH if the plugin ships MCP servers.'].join('\n');
|
|
294
|
+
}
|
|
295
|
+
const fb = await fallbackInstall(installId);
|
|
296
|
+
if (fb.ok) {
|
|
297
|
+
return [`Installed **${installId}** (engine: fallback; installed_plugins.json + settings.json backed up).`,
|
|
298
|
+
'', `installPath: ${fb.installPath}`, '',
|
|
299
|
+
'Skills/commands appear in the catalog on next reconcile; restart DSH if the plugin ships MCP servers.'].join('\n');
|
|
300
|
+
}
|
|
301
|
+
return [`Install failed for **${installId}**:`, '', `- claude CLI: ${res.error}`, `- fallback: ${fb.error}`].join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const PLUGIN_SKILL_NAME = 'plugin';
|
|
305
|
+
export const PLUGIN_SKILL_BODY = HELP;
|