@phnx-labs/agents-cli 1.16.0 → 1.17.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/CHANGELOG.md +71 -0
- package/dist/commands/browser.js +248 -9
- package/dist/commands/cloud.js +8 -0
- package/dist/commands/exec.js +70 -1
- package/dist/commands/import.d.ts +24 -0
- package/dist/commands/import.js +203 -0
- package/dist/commands/plugins.js +179 -5
- package/dist/commands/prune.js +6 -0
- package/dist/commands/secrets.js +117 -19
- package/dist/commands/view.js +21 -8
- package/dist/commands/workflows.d.ts +10 -0
- package/dist/commands/workflows.js +457 -0
- package/dist/index.js +34 -16
- package/dist/lib/browser/cdp.js +7 -4
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +37 -2
- package/dist/lib/browser/drivers/local.js +13 -2
- package/dist/lib/browser/input.d.ts +1 -0
- package/dist/lib/browser/input.js +3 -0
- package/dist/lib/browser/ipc.js +14 -0
- package/dist/lib/browser/profiles.d.ts +5 -0
- package/dist/lib/browser/profiles.js +45 -0
- package/dist/lib/browser/service.d.ts +10 -0
- package/dist/lib/browser/service.js +29 -1
- package/dist/lib/browser/types.d.ts +11 -1
- package/dist/lib/cloud/rush.d.ts +28 -1
- package/dist/lib/cloud/rush.js +68 -13
- package/dist/lib/commands.d.ts +0 -15
- package/dist/lib/commands.js +5 -5
- package/dist/lib/hooks.js +24 -11
- package/dist/lib/import.d.ts +91 -0
- package/dist/lib/import.js +179 -0
- package/dist/lib/migrate.js +59 -1
- package/dist/lib/permissions.d.ts +0 -58
- package/dist/lib/permissions.js +10 -10
- package/dist/lib/plugins.d.ts +75 -34
- package/dist/lib/plugins.js +640 -133
- package/dist/lib/resource-patterns.d.ts +41 -0
- package/dist/lib/resource-patterns.js +82 -0
- package/dist/lib/resources/index.d.ts +17 -0
- package/dist/lib/resources/index.js +7 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/resources/workflows.d.ts +24 -0
- package/dist/lib/resources/workflows.js +110 -0
- package/dist/lib/resources.d.ts +6 -1
- package/dist/lib/resources.js +12 -2
- package/dist/lib/session/db.d.ts +18 -0
- package/dist/lib/session/db.js +106 -7
- package/dist/lib/session/discover.d.ts +6 -0
- package/dist/lib/session/discover.js +28 -17
- package/dist/lib/shims.d.ts +3 -51
- package/dist/lib/shims.js +18 -10
- package/dist/lib/sqlite.js +10 -4
- package/dist/lib/state.d.ts +15 -2
- package/dist/lib/state.js +29 -8
- package/dist/lib/types.d.ts +43 -14
- package/dist/lib/versions.d.ts +3 -0
- package/dist/lib/versions.js +139 -27
- package/dist/lib/workflows.d.ts +79 -0
- package/dist/lib/workflows.js +233 -0
- package/package.json +1 -5
- package/scripts/postinstall.js +59 -58
- package/dist/commands/fork.d.ts +0 -10
- package/dist/commands/fork.js +0 -146
package/dist/lib/plugins.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin discovery, validation, and syncing.
|
|
3
3
|
*
|
|
4
|
-
* Plugins are bundles in ~/.agents/plugins/ that package skills, hooks,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Plugins are bundles in ~/.agents/.cache/plugins/ that package skills, hooks,
|
|
5
|
+
* commands, agents, bin scripts, MCP servers, and settings under a single
|
|
6
|
+
* manifest (plugin.json). This module discovers plugins, validates their
|
|
7
|
+
* manifests, and syncs their contents into agent version homes.
|
|
7
8
|
*/
|
|
8
9
|
import * as fs from 'fs';
|
|
9
10
|
import * as path from 'path';
|
|
11
|
+
import { execSync } from 'child_process';
|
|
10
12
|
import { getPluginsDir, getTrashPluginsDir } from './state.js';
|
|
11
13
|
import { listInstalledVersions, getVersionHomePath } from './versions.js';
|
|
12
14
|
import { AGENTS, PLUGINS_CAPABLE_AGENTS } from './agents.js';
|
|
13
15
|
const PLUGIN_MANIFEST_DIR = '.claude-plugin';
|
|
14
16
|
const PLUGIN_MANIFEST_FILE = 'plugin.json';
|
|
17
|
+
const USER_CONFIG_FILE = '.user-config.json';
|
|
18
|
+
const SOURCE_FILE = '.source';
|
|
15
19
|
/**
|
|
16
|
-
* Discover all plugins in ~/.agents/plugins/.
|
|
20
|
+
* Discover all plugins in ~/.agents/.cache/plugins/.
|
|
17
21
|
* A valid plugin has a .claude-plugin/plugin.json manifest.
|
|
18
22
|
*/
|
|
19
23
|
export function discoverPlugins() {
|
|
@@ -30,17 +34,25 @@ export function discoverPlugins() {
|
|
|
30
34
|
const manifest = loadPluginManifest(pluginRoot);
|
|
31
35
|
if (!manifest)
|
|
32
36
|
continue;
|
|
33
|
-
plugins.push(
|
|
34
|
-
name: manifest.name,
|
|
35
|
-
root: pluginRoot,
|
|
36
|
-
manifest,
|
|
37
|
-
skills: discoverPluginSkills(pluginRoot),
|
|
38
|
-
hooks: discoverPluginHooks(pluginRoot),
|
|
39
|
-
scripts: discoverPluginScripts(pluginRoot),
|
|
40
|
-
});
|
|
37
|
+
plugins.push(buildDiscoveredPlugin(pluginRoot, manifest));
|
|
41
38
|
}
|
|
42
39
|
return plugins;
|
|
43
40
|
}
|
|
41
|
+
export function buildDiscoveredPlugin(pluginRoot, manifest) {
|
|
42
|
+
return {
|
|
43
|
+
name: manifest.name,
|
|
44
|
+
root: pluginRoot,
|
|
45
|
+
manifest,
|
|
46
|
+
skills: discoverPluginSkills(pluginRoot),
|
|
47
|
+
hooks: discoverPluginHooks(pluginRoot),
|
|
48
|
+
scripts: discoverPluginScripts(pluginRoot),
|
|
49
|
+
commands: discoverPluginCommands(pluginRoot),
|
|
50
|
+
agentDefs: discoverPluginAgentDefs(pluginRoot),
|
|
51
|
+
bin: discoverPluginBin(pluginRoot),
|
|
52
|
+
hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')),
|
|
53
|
+
hasSettings: pluginHasNonPermissionSettings(pluginRoot),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
44
56
|
/**
|
|
45
57
|
* Load a plugin manifest from a plugin directory.
|
|
46
58
|
*/
|
|
@@ -83,9 +95,7 @@ export function pluginSupportsAgent(plugin, agent) {
|
|
|
83
95
|
}
|
|
84
96
|
return true;
|
|
85
97
|
}
|
|
86
|
-
|
|
87
|
-
* Discover skill directories inside a plugin.
|
|
88
|
-
*/
|
|
98
|
+
// ─── Discovery helpers ────────────────────────────────────────────────────────
|
|
89
99
|
function discoverPluginSkills(pluginRoot) {
|
|
90
100
|
const skillsDir = path.join(pluginRoot, 'skills');
|
|
91
101
|
if (!fs.existsSync(skillsDir))
|
|
@@ -94,9 +104,6 @@ function discoverPluginSkills(pluginRoot) {
|
|
|
94
104
|
.filter(d => d.isDirectory() && !d.name.startsWith('.'))
|
|
95
105
|
.map(d => d.name);
|
|
96
106
|
}
|
|
97
|
-
/**
|
|
98
|
-
* Discover hook definitions inside a plugin.
|
|
99
|
-
*/
|
|
100
107
|
function discoverPluginHooks(pluginRoot) {
|
|
101
108
|
const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
|
|
102
109
|
if (!fs.existsSync(hooksFile))
|
|
@@ -109,85 +116,228 @@ function discoverPluginHooks(pluginRoot) {
|
|
|
109
116
|
return [];
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
|
-
/**
|
|
113
|
-
* Discover scripts inside a plugin.
|
|
114
|
-
*/
|
|
115
119
|
function discoverPluginScripts(pluginRoot) {
|
|
116
120
|
const scriptsDir = path.join(pluginRoot, 'scripts');
|
|
117
121
|
if (!fs.existsSync(scriptsDir))
|
|
118
122
|
return [];
|
|
119
123
|
return fs.readdirSync(scriptsDir).filter(f => !f.startsWith('.'));
|
|
120
124
|
}
|
|
125
|
+
/** Discover command .md files inside a plugin's commands/ directory. */
|
|
126
|
+
export function discoverPluginCommands(pluginRoot) {
|
|
127
|
+
const commandsDir = path.join(pluginRoot, 'commands');
|
|
128
|
+
if (!fs.existsSync(commandsDir))
|
|
129
|
+
return [];
|
|
130
|
+
return fs.readdirSync(commandsDir)
|
|
131
|
+
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
|
132
|
+
.map(f => f.slice(0, -3));
|
|
133
|
+
}
|
|
134
|
+
/** Discover agent definition .md files inside a plugin's agents/ directory. */
|
|
135
|
+
export function discoverPluginAgentDefs(pluginRoot) {
|
|
136
|
+
const agentsDir = path.join(pluginRoot, 'agents');
|
|
137
|
+
if (!fs.existsSync(agentsDir))
|
|
138
|
+
return [];
|
|
139
|
+
return fs.readdirSync(agentsDir)
|
|
140
|
+
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
|
141
|
+
.map(f => f.slice(0, -3));
|
|
142
|
+
}
|
|
143
|
+
/** Discover executable files in a plugin's bin/ directory. */
|
|
144
|
+
export function discoverPluginBin(pluginRoot) {
|
|
145
|
+
const binDir = path.join(pluginRoot, 'bin');
|
|
146
|
+
if (!fs.existsSync(binDir))
|
|
147
|
+
return [];
|
|
148
|
+
return fs.readdirSync(binDir).filter(f => !f.startsWith('.'));
|
|
149
|
+
}
|
|
150
|
+
/** Return true if settings.json contains non-permission keys worth merging. */
|
|
151
|
+
function pluginHasNonPermissionSettings(pluginRoot) {
|
|
152
|
+
const settingsPath = path.join(pluginRoot, 'settings.json');
|
|
153
|
+
if (!fs.existsSync(settingsPath))
|
|
154
|
+
return false;
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
157
|
+
return Object.keys(parsed).some(k => k !== 'permissions');
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// ─── Variable expansion ───────────────────────────────────────────────────────
|
|
121
164
|
/**
|
|
122
165
|
* Expand plugin variables in a string.
|
|
123
166
|
*
|
|
124
167
|
* Variables:
|
|
125
|
-
* ${CLAUDE_PLUGIN_ROOT}
|
|
126
|
-
* ${CLAUDE_PLUGIN_DATA}
|
|
168
|
+
* ${CLAUDE_PLUGIN_ROOT} -> absolute path to plugin directory
|
|
169
|
+
* ${CLAUDE_PLUGIN_DATA} -> per-version data directory for this plugin
|
|
170
|
+
* ${user_config.<key>} -> value from plugin's .user-config.json
|
|
127
171
|
*/
|
|
128
|
-
export function expandPluginVars(str, pluginRoot, pluginName, agentId, versionHome) {
|
|
172
|
+
export function expandPluginVars(str, pluginRoot, pluginName, agentId, versionHome, userConfig) {
|
|
129
173
|
const dataDir = path.join(versionHome, `.${agentId}`, 'plugin-data', pluginName);
|
|
130
|
-
|
|
174
|
+
let result = str
|
|
131
175
|
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot)
|
|
132
176
|
.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, dataDir);
|
|
177
|
+
if (userConfig && Object.keys(userConfig).length > 0) {
|
|
178
|
+
result = result.replace(/\$\{user_config\.([^}]+)\}/g, (_, key) => {
|
|
179
|
+
return userConfig[key] ?? '';
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
// ─── userConfig storage ───────────────────────────────────────────────────────
|
|
185
|
+
/**
|
|
186
|
+
* Load persisted user config for a plugin from .user-config.json.
|
|
187
|
+
*/
|
|
188
|
+
export function loadUserConfig(pluginName) {
|
|
189
|
+
const configPath = path.join(getPluginsDir(), pluginName, USER_CONFIG_FILE);
|
|
190
|
+
if (!fs.existsSync(configPath))
|
|
191
|
+
return {};
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
133
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Persist user config for a plugin to .user-config.json.
|
|
201
|
+
*/
|
|
202
|
+
export function saveUserConfig(pluginName, config) {
|
|
203
|
+
const configPath = path.join(getPluginsDir(), pluginName, USER_CONFIG_FILE);
|
|
204
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
205
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
206
|
+
}
|
|
207
|
+
// ─── Dependency checking ──────────────────────────────────────────────────────
|
|
208
|
+
/**
|
|
209
|
+
* Check plugin dependencies against installed plugins.
|
|
210
|
+
* Returns names of missing dependencies (warning only — not a hard error).
|
|
211
|
+
*/
|
|
212
|
+
export function checkPluginDependencies(manifest) {
|
|
213
|
+
if (!manifest.dependencies || manifest.dependencies.length === 0)
|
|
214
|
+
return [];
|
|
215
|
+
const installed = new Set(discoverPlugins().map(p => p.name));
|
|
216
|
+
return manifest.dependencies.filter(dep => !installed.has(dep));
|
|
217
|
+
}
|
|
218
|
+
// ─── Main sync entry point ────────────────────────────────────────────────────
|
|
134
219
|
/**
|
|
135
220
|
* Sync a plugin to a specific agent version's home directory.
|
|
136
221
|
*
|
|
137
222
|
* For Claude:
|
|
138
|
-
* 1. Copy plugin skills into version's skills dir (prefixed: pluginName
|
|
139
|
-
* 2.
|
|
140
|
-
* 3.
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
223
|
+
* 1. Copy plugin skills into version's skills dir (prefixed: pluginName--skillName)
|
|
224
|
+
* 2. Copy plugin commands into version's commands dir (prefixed: pluginName--cmdName.md)
|
|
225
|
+
* 3. Copy plugin agent defs into version's agents dir (prefixed: pluginName--agentName.md)
|
|
226
|
+
* 4. Copy plugin bin/ into version home plugin-bin/<pluginName>/, note path in settings
|
|
227
|
+
* 5. Read hooks/hooks.json, expand vars, merge into settings.json hooks
|
|
228
|
+
* 6. Read .mcp.json, expand vars, merge mcpServers into settings.json
|
|
229
|
+
* 7. Read settings.json, merge non-permission keys non-destructively into settings.json
|
|
230
|
+
* 8. Read settings.json permissions, expand vars, merge into settings.json
|
|
144
231
|
*/
|
|
145
232
|
export function syncPluginToVersion(plugin, agent, versionHome) {
|
|
146
|
-
const result = {
|
|
233
|
+
const result = {
|
|
234
|
+
success: false,
|
|
235
|
+
skills: [],
|
|
236
|
+
commands: [],
|
|
237
|
+
agentDefs: [],
|
|
238
|
+
bin: [],
|
|
239
|
+
hooks: [],
|
|
240
|
+
permissions: false,
|
|
241
|
+
mcp: false,
|
|
242
|
+
settings: false,
|
|
243
|
+
};
|
|
147
244
|
if (!pluginSupportsAgent(plugin, agent)) {
|
|
148
245
|
return result;
|
|
149
246
|
}
|
|
247
|
+
const userConfig = loadUserConfig(plugin.name);
|
|
150
248
|
// 1. Sync skills
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
249
|
+
result.skills = syncPluginSkills(plugin, agent, versionHome, userConfig);
|
|
250
|
+
// 2. Sync commands (Claude + compatible agents only)
|
|
251
|
+
if (agent === 'claude' || agent === 'openclaw') {
|
|
252
|
+
result.commands = syncPluginCommands(plugin, agent, versionHome, userConfig);
|
|
253
|
+
}
|
|
254
|
+
// 3. Sync agent defs (Claude only)
|
|
255
|
+
if (agent === 'claude') {
|
|
256
|
+
result.agentDefs = syncPluginAgentDefs(plugin, agent, versionHome, userConfig);
|
|
257
|
+
}
|
|
258
|
+
// 4. Sync bin executables (Claude only for now)
|
|
259
|
+
if (agent === 'claude') {
|
|
260
|
+
result.bin = syncPluginBin(plugin, agent, versionHome);
|
|
261
|
+
}
|
|
262
|
+
// 5. Sync hooks (Claude only - uses settings.json hook registration)
|
|
263
|
+
if (agent === 'claude') {
|
|
264
|
+
result.hooks = syncPluginHooks(plugin, agent, versionHome, userConfig);
|
|
265
|
+
}
|
|
266
|
+
// 6. Sync MCP servers (Claude only)
|
|
267
|
+
if (agent === 'claude') {
|
|
268
|
+
result.mcp = syncPluginMcp(plugin, agent, versionHome, userConfig);
|
|
269
|
+
}
|
|
270
|
+
// 7. Merge non-permission settings keys non-destructively (Claude only)
|
|
154
271
|
if (agent === 'claude') {
|
|
155
|
-
|
|
156
|
-
result.hooks = hooksResult;
|
|
272
|
+
result.settings = syncPluginSettings(plugin, agent, versionHome);
|
|
157
273
|
}
|
|
158
|
-
//
|
|
274
|
+
// 8. Sync permissions (Claude only - uses settings.json permissions)
|
|
159
275
|
if (agent === 'claude') {
|
|
160
|
-
result.permissions = syncPluginPermissions(plugin, agent, versionHome);
|
|
276
|
+
result.permissions = syncPluginPermissions(plugin, agent, versionHome, userConfig);
|
|
161
277
|
}
|
|
162
|
-
result.success =
|
|
278
|
+
result.success =
|
|
279
|
+
result.skills.length > 0 ||
|
|
280
|
+
result.commands.length > 0 ||
|
|
281
|
+
result.agentDefs.length > 0 ||
|
|
282
|
+
result.bin.length > 0 ||
|
|
283
|
+
result.hooks.length > 0 ||
|
|
284
|
+
result.mcp ||
|
|
285
|
+
result.settings ||
|
|
286
|
+
result.permissions;
|
|
163
287
|
return result;
|
|
164
288
|
}
|
|
289
|
+
// ─── Individual sync functions ────────────────────────────────────────────────
|
|
165
290
|
/**
|
|
166
291
|
* Copy plugin skills into the version's skills directory.
|
|
167
|
-
* Skills are prefixed with the plugin name: pluginName
|
|
292
|
+
* Skills are prefixed with the plugin name: pluginName--skillName
|
|
168
293
|
*/
|
|
169
|
-
function syncPluginSkills(plugin, agent, versionHome) {
|
|
294
|
+
function syncPluginSkills(plugin, agent, versionHome, userConfig) {
|
|
170
295
|
const synced = [];
|
|
171
296
|
const pluginSkillsDir = path.join(plugin.root, 'skills');
|
|
172
297
|
if (!fs.existsSync(pluginSkillsDir))
|
|
173
298
|
return synced;
|
|
174
|
-
const agentConfig = AGENTS[agent];
|
|
175
299
|
const targetSkillsDir = path.join(versionHome, `.${agent}`, 'skills');
|
|
176
300
|
fs.mkdirSync(targetSkillsDir, { recursive: true });
|
|
177
301
|
for (const skillName of plugin.skills) {
|
|
178
302
|
const srcDir = path.join(pluginSkillsDir, skillName);
|
|
179
|
-
|
|
180
|
-
const prefixedName = `${plugin.name}:${skillName}`;
|
|
181
|
-
// Use colon-to-dash for filesystem (colons not allowed on some systems)
|
|
182
|
-
const fsSafeName = prefixedName.replace(/:/g, '--');
|
|
303
|
+
const fsSafeName = `${plugin.name}--${skillName}`;
|
|
183
304
|
const destDir = path.join(targetSkillsDir, fsSafeName);
|
|
184
305
|
try {
|
|
185
|
-
// Remove existing and copy fresh
|
|
186
306
|
if (fs.existsSync(destDir)) {
|
|
187
307
|
fs.rmSync(destDir, { recursive: true, force: true });
|
|
188
308
|
}
|
|
189
|
-
copyDirWithVarExpansion(srcDir, destDir, plugin.root, plugin.name, agent, versionHome);
|
|
190
|
-
synced.push(
|
|
309
|
+
copyDirWithVarExpansion(srcDir, destDir, plugin.root, plugin.name, agent, versionHome, userConfig);
|
|
310
|
+
synced.push(`${plugin.name}:${skillName}`);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Skip on error
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return synced;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Copy plugin commands into the version's commands directory.
|
|
320
|
+
* Commands are namespaced: pluginName--commandName.md
|
|
321
|
+
*/
|
|
322
|
+
function syncPluginCommands(plugin, agent, versionHome, userConfig) {
|
|
323
|
+
const synced = [];
|
|
324
|
+
const pluginCommandsDir = path.join(plugin.root, 'commands');
|
|
325
|
+
if (!fs.existsSync(pluginCommandsDir) || plugin.commands.length === 0)
|
|
326
|
+
return synced;
|
|
327
|
+
const agentConfig = AGENTS[agent];
|
|
328
|
+
const commandsTarget = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
|
|
329
|
+
fs.mkdirSync(commandsTarget, { recursive: true });
|
|
330
|
+
for (const cmdName of plugin.commands) {
|
|
331
|
+
const srcFile = path.join(pluginCommandsDir, `${cmdName}.md`);
|
|
332
|
+
if (!fs.existsSync(srcFile))
|
|
333
|
+
continue;
|
|
334
|
+
const destName = `${plugin.name}--${cmdName}.md`;
|
|
335
|
+
const destFile = path.join(commandsTarget, destName);
|
|
336
|
+
try {
|
|
337
|
+
let content = fs.readFileSync(srcFile, 'utf-8');
|
|
338
|
+
content = expandPluginVars(content, plugin.root, plugin.name, agent, versionHome, userConfig);
|
|
339
|
+
fs.writeFileSync(destFile, content, 'utf-8');
|
|
340
|
+
synced.push(`${plugin.name}:${cmdName}`);
|
|
191
341
|
}
|
|
192
342
|
catch {
|
|
193
343
|
// Skip on error
|
|
@@ -195,12 +345,93 @@ function syncPluginSkills(plugin, agent, versionHome) {
|
|
|
195
345
|
}
|
|
196
346
|
return synced;
|
|
197
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Copy plugin agent definitions into the version's agents directory.
|
|
350
|
+
* Agent defs are namespaced: pluginName--agentName.md
|
|
351
|
+
*/
|
|
352
|
+
function syncPluginAgentDefs(plugin, agent, versionHome, userConfig) {
|
|
353
|
+
const synced = [];
|
|
354
|
+
const pluginAgentsDir = path.join(plugin.root, 'agents');
|
|
355
|
+
if (!fs.existsSync(pluginAgentsDir) || plugin.agentDefs.length === 0)
|
|
356
|
+
return synced;
|
|
357
|
+
const agentsTarget = path.join(versionHome, `.${agent}`, 'agents');
|
|
358
|
+
fs.mkdirSync(agentsTarget, { recursive: true });
|
|
359
|
+
for (const agentDefName of plugin.agentDefs) {
|
|
360
|
+
const srcFile = path.join(pluginAgentsDir, `${agentDefName}.md`);
|
|
361
|
+
if (!fs.existsSync(srcFile))
|
|
362
|
+
continue;
|
|
363
|
+
const destName = `${plugin.name}--${agentDefName}.md`;
|
|
364
|
+
const destFile = path.join(agentsTarget, destName);
|
|
365
|
+
try {
|
|
366
|
+
let content = fs.readFileSync(srcFile, 'utf-8');
|
|
367
|
+
content = expandPluginVars(content, plugin.root, plugin.name, agent, versionHome, userConfig);
|
|
368
|
+
fs.writeFileSync(destFile, content, 'utf-8');
|
|
369
|
+
synced.push(`${plugin.name}:${agentDefName}`);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// Skip on error
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return synced;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Copy plugin bin executables into the version home plugin-bin/<pluginName>/ directory.
|
|
379
|
+
* Records the bin path in settings.json under pluginBinPaths so callers can add it to PATH.
|
|
380
|
+
*/
|
|
381
|
+
function syncPluginBin(plugin, agent, versionHome) {
|
|
382
|
+
const synced = [];
|
|
383
|
+
const pluginBinDir = path.join(plugin.root, 'bin');
|
|
384
|
+
if (!fs.existsSync(pluginBinDir) || plugin.bin.length === 0)
|
|
385
|
+
return synced;
|
|
386
|
+
const targetBinDir = path.join(versionHome, `.${agent}`, 'plugin-bin', plugin.name);
|
|
387
|
+
fs.mkdirSync(targetBinDir, { recursive: true });
|
|
388
|
+
for (const binFile of plugin.bin) {
|
|
389
|
+
const srcFile = path.join(pluginBinDir, binFile);
|
|
390
|
+
if (!fs.existsSync(srcFile))
|
|
391
|
+
continue;
|
|
392
|
+
const destFile = path.join(targetBinDir, binFile);
|
|
393
|
+
try {
|
|
394
|
+
fs.copyFileSync(srcFile, destFile);
|
|
395
|
+
const stat = fs.statSync(srcFile);
|
|
396
|
+
fs.chmodSync(destFile, stat.mode | 0o111);
|
|
397
|
+
synced.push(binFile);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// Skip on error
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (synced.length === 0)
|
|
404
|
+
return synced;
|
|
405
|
+
// Note the bin path in settings.json so the calling shim can add it to PATH.
|
|
406
|
+
const configDir = path.join(versionHome, `.${agent}`);
|
|
407
|
+
const settingsPath = path.join(configDir, 'settings.json');
|
|
408
|
+
let settings = {};
|
|
409
|
+
if (fs.existsSync(settingsPath)) {
|
|
410
|
+
try {
|
|
411
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
412
|
+
}
|
|
413
|
+
catch { /* start fresh */ }
|
|
414
|
+
}
|
|
415
|
+
if (!Array.isArray(settings.pluginBinPaths)) {
|
|
416
|
+
settings.pluginBinPaths = [];
|
|
417
|
+
}
|
|
418
|
+
const binPaths = settings.pluginBinPaths;
|
|
419
|
+
if (!binPaths.includes(targetBinDir)) {
|
|
420
|
+
binPaths.push(targetBinDir);
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
424
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
425
|
+
}
|
|
426
|
+
catch { /* ignore write errors */ }
|
|
427
|
+
return synced;
|
|
428
|
+
}
|
|
198
429
|
/**
|
|
199
430
|
* Merge plugin hooks into Claude's settings.json.
|
|
200
431
|
* Reads the plugin's hooks/hooks.json and merges each event's hooks
|
|
201
432
|
* into the version's settings.json, expanding variables.
|
|
202
433
|
*/
|
|
203
|
-
function syncPluginHooks(plugin, agent, versionHome) {
|
|
434
|
+
function syncPluginHooks(plugin, agent, versionHome, userConfig) {
|
|
204
435
|
const synced = [];
|
|
205
436
|
const hooksFile = path.join(plugin.root, 'hooks', 'hooks.json');
|
|
206
437
|
if (!fs.existsSync(hooksFile))
|
|
@@ -212,7 +443,6 @@ function syncPluginHooks(plugin, agent, versionHome) {
|
|
|
212
443
|
catch {
|
|
213
444
|
return synced;
|
|
214
445
|
}
|
|
215
|
-
// Read existing settings.json
|
|
216
446
|
const configDir = path.join(versionHome, `.${agent}`);
|
|
217
447
|
const settingsPath = path.join(configDir, 'settings.json');
|
|
218
448
|
let settings = {};
|
|
@@ -220,15 +450,12 @@ function syncPluginHooks(plugin, agent, versionHome) {
|
|
|
220
450
|
try {
|
|
221
451
|
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
222
452
|
}
|
|
223
|
-
catch {
|
|
224
|
-
// Start fresh if parse fails
|
|
225
|
-
}
|
|
453
|
+
catch { /* start fresh */ }
|
|
226
454
|
}
|
|
227
455
|
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
228
456
|
settings.hooks = {};
|
|
229
457
|
}
|
|
230
458
|
const hooksConfig = settings.hooks;
|
|
231
|
-
// Merge each event from the plugin
|
|
232
459
|
for (const [event, matcherGroups] of Object.entries(pluginHooks)) {
|
|
233
460
|
if (!hooksConfig[event]) {
|
|
234
461
|
hooksConfig[event] = [];
|
|
@@ -238,9 +465,8 @@ function syncPluginHooks(plugin, agent, versionHome) {
|
|
|
238
465
|
const matcher = group.matcher || '';
|
|
239
466
|
const expandedHooks = (group.hooks || []).map(h => ({
|
|
240
467
|
...h,
|
|
241
|
-
command: expandPluginVars(h.command, plugin.root, plugin.name, agent, versionHome),
|
|
468
|
+
command: expandPluginVars(h.command, plugin.root, plugin.name, agent, versionHome, userConfig),
|
|
242
469
|
}));
|
|
243
|
-
// Find or create matcher group
|
|
244
470
|
let matcherGroup = eventEntries.find(e => (e.matcher || '') === matcher);
|
|
245
471
|
if (!matcherGroup) {
|
|
246
472
|
matcherGroup = { matcher, hooks: [] };
|
|
@@ -249,7 +475,6 @@ function syncPluginHooks(plugin, agent, versionHome) {
|
|
|
249
475
|
if (!matcherGroup.hooks) {
|
|
250
476
|
matcherGroup.hooks = [];
|
|
251
477
|
}
|
|
252
|
-
// Add hooks that aren't already registered (by command path)
|
|
253
478
|
for (const hook of expandedHooks) {
|
|
254
479
|
const exists = matcherGroup.hooks.some(h => h.command === hook.command);
|
|
255
480
|
if (!exists) {
|
|
@@ -259,21 +484,116 @@ function syncPluginHooks(plugin, agent, versionHome) {
|
|
|
259
484
|
}
|
|
260
485
|
synced.push(event);
|
|
261
486
|
}
|
|
262
|
-
// Write back
|
|
263
487
|
try {
|
|
264
488
|
fs.mkdirSync(configDir, { recursive: true });
|
|
265
489
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
266
490
|
}
|
|
491
|
+
catch { /* ignore write errors */ }
|
|
492
|
+
return synced;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Merge plugin .mcp.json MCP server definitions into Claude's settings.json.
|
|
496
|
+
* Server names are namespaced as pluginName--serverName to avoid collisions.
|
|
497
|
+
* Expands ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, ${user_config.*} in args and env.
|
|
498
|
+
*/
|
|
499
|
+
function syncPluginMcp(plugin, agent, versionHome, userConfig) {
|
|
500
|
+
const mcpFile = path.join(plugin.root, '.mcp.json');
|
|
501
|
+
if (!fs.existsSync(mcpFile))
|
|
502
|
+
return false;
|
|
503
|
+
let pluginMcp;
|
|
504
|
+
try {
|
|
505
|
+
pluginMcp = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
|
|
506
|
+
}
|
|
267
507
|
catch {
|
|
268
|
-
|
|
508
|
+
return false;
|
|
269
509
|
}
|
|
270
|
-
|
|
510
|
+
const servers = pluginMcp.mcpServers;
|
|
511
|
+
if (!servers || Object.keys(servers).length === 0)
|
|
512
|
+
return false;
|
|
513
|
+
const configDir = path.join(versionHome, `.${agent}`);
|
|
514
|
+
const settingsPath = path.join(configDir, 'settings.json');
|
|
515
|
+
let settings = {};
|
|
516
|
+
if (fs.existsSync(settingsPath)) {
|
|
517
|
+
try {
|
|
518
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
519
|
+
}
|
|
520
|
+
catch { /* start fresh */ }
|
|
521
|
+
}
|
|
522
|
+
if (!settings.mcpServers || typeof settings.mcpServers !== 'object') {
|
|
523
|
+
settings.mcpServers = {};
|
|
524
|
+
}
|
|
525
|
+
const existing = settings.mcpServers;
|
|
526
|
+
let merged = false;
|
|
527
|
+
for (const [serverName, serverConfig] of Object.entries(servers)) {
|
|
528
|
+
const namespacedName = `${plugin.name}--${serverName}`;
|
|
529
|
+
// Expand variables inside the server config
|
|
530
|
+
const configStr = expandPluginVars(JSON.stringify(serverConfig), plugin.root, plugin.name, agent, versionHome, userConfig);
|
|
531
|
+
existing[namespacedName] = JSON.parse(configStr);
|
|
532
|
+
merged = true;
|
|
533
|
+
}
|
|
534
|
+
if (merged) {
|
|
535
|
+
try {
|
|
536
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
537
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return merged;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Merge non-permission keys from plugin's settings.json non-destructively into agent settings.
|
|
547
|
+
* Only adds keys that don't already exist — never overwrites user config.
|
|
548
|
+
* Permission keys are handled separately by syncPluginPermissions.
|
|
549
|
+
*/
|
|
550
|
+
function syncPluginSettings(plugin, agent, versionHome) {
|
|
551
|
+
const pluginSettingsPath = path.join(plugin.root, 'settings.json');
|
|
552
|
+
if (!fs.existsSync(pluginSettingsPath))
|
|
553
|
+
return false;
|
|
554
|
+
let pluginSettings;
|
|
555
|
+
try {
|
|
556
|
+
pluginSettings = JSON.parse(fs.readFileSync(pluginSettingsPath, 'utf-8'));
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
// Exclude permissions — those are handled by syncPluginPermissions
|
|
562
|
+
const keysToMerge = Object.entries(pluginSettings).filter(([k]) => k !== 'permissions');
|
|
563
|
+
if (keysToMerge.length === 0)
|
|
564
|
+
return false;
|
|
565
|
+
const configDir = path.join(versionHome, `.${agent}`);
|
|
566
|
+
const settingsPath = path.join(configDir, 'settings.json');
|
|
567
|
+
let settings = {};
|
|
568
|
+
if (fs.existsSync(settingsPath)) {
|
|
569
|
+
try {
|
|
570
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
571
|
+
}
|
|
572
|
+
catch { /* start fresh */ }
|
|
573
|
+
}
|
|
574
|
+
let changed = false;
|
|
575
|
+
for (const [key, value] of keysToMerge) {
|
|
576
|
+
if (!(key in settings)) {
|
|
577
|
+
settings[key] = value;
|
|
578
|
+
changed = true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (changed) {
|
|
582
|
+
try {
|
|
583
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
584
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return changed;
|
|
271
591
|
}
|
|
272
592
|
/**
|
|
273
593
|
* Merge plugin permissions into Claude's settings.json.
|
|
274
|
-
* Reads the plugin's settings.json and merges permissions.allow entries.
|
|
594
|
+
* Reads the plugin's settings.json and merges permissions.allow / deny entries.
|
|
275
595
|
*/
|
|
276
|
-
function syncPluginPermissions(plugin, agent, versionHome) {
|
|
596
|
+
function syncPluginPermissions(plugin, agent, versionHome, userConfig) {
|
|
277
597
|
const pluginSettingsPath = path.join(plugin.root, 'settings.json');
|
|
278
598
|
if (!fs.existsSync(pluginSettingsPath))
|
|
279
599
|
return false;
|
|
@@ -288,10 +608,8 @@ function syncPluginPermissions(plugin, agent, versionHome) {
|
|
|
288
608
|
const pluginDeny = pluginSettings.permissions?.deny || [];
|
|
289
609
|
if (pluginAllow.length === 0 && pluginDeny.length === 0)
|
|
290
610
|
return false;
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
const expandedDeny = pluginDeny.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome));
|
|
294
|
-
// Read existing settings.json
|
|
611
|
+
const expandedAllow = pluginAllow.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome, userConfig));
|
|
612
|
+
const expandedDeny = pluginDeny.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome, userConfig));
|
|
295
613
|
const configDir = path.join(versionHome, `.${agent}`);
|
|
296
614
|
const settingsPath = path.join(configDir, 'settings.json');
|
|
297
615
|
let settings = {};
|
|
@@ -299,9 +617,7 @@ function syncPluginPermissions(plugin, agent, versionHome) {
|
|
|
299
617
|
try {
|
|
300
618
|
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
301
619
|
}
|
|
302
|
-
catch {
|
|
303
|
-
// Start fresh
|
|
304
|
-
}
|
|
620
|
+
catch { /* start fresh */ }
|
|
305
621
|
}
|
|
306
622
|
if (!settings.permissions || typeof settings.permissions !== 'object') {
|
|
307
623
|
settings.permissions = { allow: [], deny: [] };
|
|
@@ -311,19 +627,16 @@ function syncPluginPermissions(plugin, agent, versionHome) {
|
|
|
311
627
|
perms.allow = [];
|
|
312
628
|
if (!perms.deny)
|
|
313
629
|
perms.deny = [];
|
|
314
|
-
// Merge allow rules (deduplicate)
|
|
315
630
|
for (const rule of expandedAllow) {
|
|
316
631
|
if (!perms.allow.includes(rule)) {
|
|
317
632
|
perms.allow.push(rule);
|
|
318
633
|
}
|
|
319
634
|
}
|
|
320
|
-
// Merge deny rules (deduplicate)
|
|
321
635
|
for (const rule of expandedDeny) {
|
|
322
636
|
if (!perms.deny.includes(rule)) {
|
|
323
637
|
perms.deny.push(rule);
|
|
324
638
|
}
|
|
325
639
|
}
|
|
326
|
-
// Write back
|
|
327
640
|
try {
|
|
328
641
|
fs.mkdirSync(configDir, { recursive: true });
|
|
329
642
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
@@ -333,11 +646,12 @@ function syncPluginPermissions(plugin, agent, versionHome) {
|
|
|
333
646
|
return false;
|
|
334
647
|
}
|
|
335
648
|
}
|
|
649
|
+
// ─── Utility ──────────────────────────────────────────────────────────────────
|
|
336
650
|
/**
|
|
337
|
-
* Copy a directory recursively, expanding plugin variables in file contents.
|
|
651
|
+
* Copy a directory recursively, expanding plugin variables in text file contents.
|
|
338
652
|
* Only expands variables in text files (.md, .json, .sh, .py, .js, .ts, .yaml, .yml, .toml).
|
|
339
653
|
*/
|
|
340
|
-
function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versionHome) {
|
|
654
|
+
function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versionHome, userConfig) {
|
|
341
655
|
fs.mkdirSync(dest, { recursive: true });
|
|
342
656
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
343
657
|
const textExtensions = new Set(['.md', '.json', '.sh', '.py', '.js', '.ts', '.yaml', '.yml', '.toml', '.txt']);
|
|
@@ -345,21 +659,18 @@ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versi
|
|
|
345
659
|
const srcPath = path.join(src, entry.name);
|
|
346
660
|
const destPath = path.join(dest, entry.name);
|
|
347
661
|
if (entry.isDirectory()) {
|
|
348
|
-
copyDirWithVarExpansion(srcPath, destPath, pluginRoot, pluginName, agent, versionHome);
|
|
662
|
+
copyDirWithVarExpansion(srcPath, destPath, pluginRoot, pluginName, agent, versionHome, userConfig);
|
|
349
663
|
}
|
|
350
664
|
else {
|
|
351
665
|
const ext = path.extname(entry.name).toLowerCase();
|
|
352
666
|
if (textExtensions.has(ext)) {
|
|
353
|
-
// Expand variables in text files
|
|
354
667
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
355
|
-
content = expandPluginVars(content, pluginRoot, pluginName, agent, versionHome);
|
|
668
|
+
content = expandPluginVars(content, pluginRoot, pluginName, agent, versionHome, userConfig);
|
|
356
669
|
fs.writeFileSync(destPath, content, 'utf-8');
|
|
357
670
|
}
|
|
358
671
|
else {
|
|
359
|
-
// Binary copy
|
|
360
672
|
fs.copyFileSync(srcPath, destPath);
|
|
361
673
|
}
|
|
362
|
-
// Preserve executable permission
|
|
363
674
|
const stat = fs.statSync(srcPath);
|
|
364
675
|
if (stat.mode & 0o111) {
|
|
365
676
|
fs.chmodSync(destPath, stat.mode);
|
|
@@ -367,41 +678,68 @@ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versi
|
|
|
367
678
|
}
|
|
368
679
|
}
|
|
369
680
|
}
|
|
681
|
+
// ─── Sync status ──────────────────────────────────────────────────────────────
|
|
370
682
|
/**
|
|
371
683
|
* Check if a plugin is synced to a version by inspecting the version home.
|
|
372
|
-
* Checks
|
|
373
|
-
* and plugin permissions in settings.json.
|
|
684
|
+
* Checks skills, commands, agent defs, bin, hook commands, and permissions.
|
|
374
685
|
*/
|
|
375
686
|
export function isPluginSynced(plugin, agent, versionHome) {
|
|
376
|
-
|
|
687
|
+
const prefix = `${plugin.name}--`;
|
|
688
|
+
// Check 1: plugin skill directories
|
|
377
689
|
if (plugin.skills.length > 0) {
|
|
378
690
|
const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
|
|
379
691
|
if (fs.existsSync(skillsDir)) {
|
|
380
692
|
for (const skillName of plugin.skills) {
|
|
381
|
-
|
|
382
|
-
|
|
693
|
+
if (fs.existsSync(path.join(skillsDir, `${prefix}${skillName}`))) {
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
// Check 2: plugin command files
|
|
700
|
+
if (plugin.commands.length > 0 && (agent === 'claude' || agent === 'openclaw')) {
|
|
701
|
+
const agentConfig = AGENTS[agent];
|
|
702
|
+
const commandsDir = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
|
|
703
|
+
if (fs.existsSync(commandsDir)) {
|
|
704
|
+
for (const cmdName of plugin.commands) {
|
|
705
|
+
if (fs.existsSync(path.join(commandsDir, `${prefix}${cmdName}.md`))) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// Check 3: plugin agent definition files
|
|
712
|
+
if (plugin.agentDefs.length > 0 && agent === 'claude') {
|
|
713
|
+
const agentsDir = path.join(versionHome, `.${agent}`, 'agents');
|
|
714
|
+
if (fs.existsSync(agentsDir)) {
|
|
715
|
+
for (const agentDefName of plugin.agentDefs) {
|
|
716
|
+
if (fs.existsSync(path.join(agentsDir, `${prefix}${agentDefName}.md`))) {
|
|
383
717
|
return true;
|
|
384
718
|
}
|
|
385
719
|
}
|
|
386
720
|
}
|
|
387
721
|
}
|
|
388
|
-
// Check
|
|
722
|
+
// Check 4: plugin bin directory
|
|
723
|
+
if (plugin.bin.length > 0 && agent === 'claude') {
|
|
724
|
+
const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', plugin.name);
|
|
725
|
+
if (fs.existsSync(binDir)) {
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
// Check 5: plugin hooks registered in settings.json (commands referencing plugin root)
|
|
389
730
|
if (plugin.hooks.length > 0 && agent === 'claude') {
|
|
390
731
|
const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
|
|
391
732
|
if (fs.existsSync(settingsPath)) {
|
|
392
733
|
try {
|
|
393
734
|
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
394
|
-
// Check if any hook command references this plugin's root path
|
|
395
735
|
if (content.includes(plugin.root)) {
|
|
396
736
|
return true;
|
|
397
737
|
}
|
|
398
738
|
}
|
|
399
|
-
catch {
|
|
400
|
-
// Ignore read errors
|
|
401
|
-
}
|
|
739
|
+
catch { /* ignore */ }
|
|
402
740
|
}
|
|
403
741
|
}
|
|
404
|
-
// Check
|
|
742
|
+
// Check 6: plugin permissions in settings.json
|
|
405
743
|
if (agent === 'claude') {
|
|
406
744
|
const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
|
|
407
745
|
if (fs.existsSync(settingsPath)) {
|
|
@@ -411,48 +749,100 @@ export function isPluginSynced(plugin, agent, versionHome) {
|
|
|
411
749
|
if (allow.some((rule) => rule.includes(plugin.root))) {
|
|
412
750
|
return true;
|
|
413
751
|
}
|
|
752
|
+
// Check MCP servers
|
|
753
|
+
const mcpServers = settings.mcpServers;
|
|
754
|
+
if (mcpServers) {
|
|
755
|
+
const hasNamespacedServer = Object.keys(mcpServers).some(k => k.startsWith(prefix));
|
|
756
|
+
if (hasNamespacedServer)
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
414
759
|
}
|
|
415
|
-
catch {
|
|
416
|
-
// Ignore parse errors
|
|
417
|
-
}
|
|
760
|
+
catch { /* ignore */ }
|
|
418
761
|
}
|
|
419
762
|
}
|
|
420
763
|
return false;
|
|
421
764
|
}
|
|
765
|
+
// ─── Removal ─────────────────────────────────────────────────────────────────
|
|
422
766
|
/**
|
|
423
767
|
* Remove a plugin from a specific agent version's home directory.
|
|
424
|
-
* Inverse of syncPluginToVersion
|
|
425
|
-
* 1. Delete synced skill directories (pluginName--*)
|
|
426
|
-
* 2. Strip hook entries whose commands reference the plugin root
|
|
427
|
-
* 3. Strip permission rules that reference the plugin root
|
|
768
|
+
* Inverse of syncPluginToVersion.
|
|
428
769
|
*
|
|
429
|
-
* Works whether or not the plugin source still exists on disk
|
|
430
|
-
* matches by the plugin's name and root path rather than re-reading manifests.
|
|
770
|
+
* Works whether or not the plugin source still exists on disk.
|
|
431
771
|
*/
|
|
432
772
|
export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHome) {
|
|
433
|
-
const result = {
|
|
773
|
+
const result = {
|
|
774
|
+
skills: [],
|
|
775
|
+
commands: [],
|
|
776
|
+
agentDefs: [],
|
|
777
|
+
bin: [],
|
|
778
|
+
hooks: [],
|
|
779
|
+
permissions: 0,
|
|
780
|
+
mcp: 0,
|
|
781
|
+
};
|
|
782
|
+
const prefix = `${pluginName}--`;
|
|
434
783
|
// 1. Remove synced skill dirs
|
|
435
784
|
const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
|
|
436
785
|
if (fs.existsSync(skillsDir)) {
|
|
437
|
-
const prefix = `${pluginName}--`;
|
|
438
786
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
439
|
-
if (!entry.isDirectory())
|
|
440
|
-
continue;
|
|
441
|
-
if (!entry.name.startsWith(prefix))
|
|
787
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix))
|
|
442
788
|
continue;
|
|
443
789
|
try {
|
|
444
790
|
fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
|
|
445
791
|
result.skills.push(entry.name);
|
|
446
792
|
}
|
|
447
|
-
catch {
|
|
448
|
-
|
|
793
|
+
catch { /* skip on error */ }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// 2. Remove synced command files
|
|
797
|
+
if (agent === 'claude' || agent === 'openclaw') {
|
|
798
|
+
const agentConfig = AGENTS[agent];
|
|
799
|
+
const commandsDir = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
|
|
800
|
+
if (fs.existsSync(commandsDir)) {
|
|
801
|
+
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
|
|
802
|
+
if (!entry.isFile())
|
|
803
|
+
continue;
|
|
804
|
+
if (!entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
|
|
805
|
+
continue;
|
|
806
|
+
try {
|
|
807
|
+
fs.unlinkSync(path.join(commandsDir, entry.name));
|
|
808
|
+
result.commands.push(entry.name);
|
|
809
|
+
}
|
|
810
|
+
catch { /* skip on error */ }
|
|
449
811
|
}
|
|
450
812
|
}
|
|
451
813
|
}
|
|
814
|
+
// 3. Remove synced agent def files
|
|
815
|
+
if (agent === 'claude') {
|
|
816
|
+
const agentsDir = path.join(versionHome, `.${agent}`, 'agents');
|
|
817
|
+
if (fs.existsSync(agentsDir)) {
|
|
818
|
+
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
|
819
|
+
if (!entry.isFile())
|
|
820
|
+
continue;
|
|
821
|
+
if (!entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
|
|
822
|
+
continue;
|
|
823
|
+
try {
|
|
824
|
+
fs.unlinkSync(path.join(agentsDir, entry.name));
|
|
825
|
+
result.agentDefs.push(entry.name);
|
|
826
|
+
}
|
|
827
|
+
catch { /* skip on error */ }
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// 4. Remove plugin-bin directory
|
|
832
|
+
if (agent === 'claude') {
|
|
833
|
+
const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', pluginName);
|
|
834
|
+
if (fs.existsSync(binDir)) {
|
|
835
|
+
try {
|
|
836
|
+
fs.rmSync(binDir, { recursive: true, force: true });
|
|
837
|
+
result.bin.push(binDir);
|
|
838
|
+
}
|
|
839
|
+
catch { /* skip on error */ }
|
|
840
|
+
}
|
|
841
|
+
}
|
|
452
842
|
if (agent !== 'claude') {
|
|
453
843
|
return result;
|
|
454
844
|
}
|
|
455
|
-
//
|
|
845
|
+
// 5 + 6 + 7: edit settings.json — strip hooks, permissions, mcpServers matching plugin
|
|
456
846
|
const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
|
|
457
847
|
if (!fs.existsSync(settingsPath))
|
|
458
848
|
return result;
|
|
@@ -484,13 +874,11 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
|
|
|
484
874
|
if (group.hooks.length !== originalLen)
|
|
485
875
|
changed = true;
|
|
486
876
|
}
|
|
487
|
-
// Drop matcher groups whose hooks array is now empty
|
|
488
877
|
const kept = eventEntries.filter(g => Array.isArray(g.hooks) && g.hooks.length > 0);
|
|
489
878
|
if (kept.length !== eventEntries.length) {
|
|
490
879
|
hooksConfig[event] = kept;
|
|
491
880
|
changed = true;
|
|
492
881
|
}
|
|
493
|
-
// Drop the event key entirely if it has no matcher groups left
|
|
494
882
|
if (Array.isArray(hooksConfig[event]) && hooksConfig[event].length === 0) {
|
|
495
883
|
delete hooksConfig[event];
|
|
496
884
|
changed = true;
|
|
@@ -516,21 +904,37 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
|
|
|
516
904
|
}
|
|
517
905
|
}
|
|
518
906
|
}
|
|
907
|
+
// Strip namespaced MCP servers added by this plugin
|
|
908
|
+
const mcpServers = settings.mcpServers;
|
|
909
|
+
if (mcpServers && typeof mcpServers === 'object') {
|
|
910
|
+
for (const serverName of Object.keys(mcpServers)) {
|
|
911
|
+
if (serverName.startsWith(prefix)) {
|
|
912
|
+
delete mcpServers[serverName];
|
|
913
|
+
result.mcp += 1;
|
|
914
|
+
changed = true;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Strip bin path from pluginBinPaths
|
|
919
|
+
if (Array.isArray(settings.pluginBinPaths)) {
|
|
920
|
+
const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', pluginName);
|
|
921
|
+
const before = settings.pluginBinPaths.length;
|
|
922
|
+
settings.pluginBinPaths = settings.pluginBinPaths.filter(p => p !== binDir);
|
|
923
|
+
if (settings.pluginBinPaths.length !== before)
|
|
924
|
+
changed = true;
|
|
925
|
+
}
|
|
519
926
|
if (changed) {
|
|
520
927
|
try {
|
|
521
928
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
522
929
|
}
|
|
523
|
-
catch {
|
|
524
|
-
// Ignore write errors
|
|
525
|
-
}
|
|
930
|
+
catch { /* ignore write errors */ }
|
|
526
931
|
}
|
|
527
932
|
return result;
|
|
528
933
|
}
|
|
934
|
+
// ─── Orphan cleanup ───────────────────────────────────────────────────────────
|
|
529
935
|
/**
|
|
530
936
|
* Remove orphaned plugin skill directories from a version home.
|
|
531
937
|
* Soft-deletes to ~/.agents/.trash/plugins/.
|
|
532
|
-
* An orphan is a skill dir with the plugin prefix pattern (name--skill)
|
|
533
|
-
* where the plugin no longer exists in ~/.agents/plugins/.
|
|
534
938
|
*/
|
|
535
939
|
export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames, version) {
|
|
536
940
|
const removed = [];
|
|
@@ -541,7 +945,6 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
|
|
|
541
945
|
for (const entry of entries) {
|
|
542
946
|
if (!entry.isDirectory())
|
|
543
947
|
continue;
|
|
544
|
-
// Plugin skill dirs use the pattern: pluginName--skillName
|
|
545
948
|
const dashIdx = entry.name.indexOf('--');
|
|
546
949
|
if (dashIdx === -1)
|
|
547
950
|
continue;
|
|
@@ -555,17 +958,11 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
|
|
|
555
958
|
fs.renameSync(path.join(skillsDir, entry.name), trashDest);
|
|
556
959
|
removed.push(entry.name);
|
|
557
960
|
}
|
|
558
|
-
catch {
|
|
559
|
-
// Skip on error
|
|
560
|
-
}
|
|
961
|
+
catch { /* skip on error */ }
|
|
561
962
|
}
|
|
562
963
|
}
|
|
563
964
|
return removed;
|
|
564
965
|
}
|
|
565
|
-
/**
|
|
566
|
-
* Compare a version home's plugin skills against discovered plugins.
|
|
567
|
-
* Returns orphan plugin skill names (pattern: pluginName--skillName).
|
|
568
|
-
*/
|
|
569
966
|
export function diffVersionPlugins(agent, version) {
|
|
570
967
|
const versionHome = getVersionHomePath(agent, version);
|
|
571
968
|
const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
|
|
@@ -574,8 +971,7 @@ export function diffVersionPlugins(agent, version) {
|
|
|
574
971
|
return { agent, version, orphans };
|
|
575
972
|
}
|
|
576
973
|
const activePlugins = new Set(discoverPlugins().map(p => p.name));
|
|
577
|
-
const
|
|
578
|
-
for (const entry of entries) {
|
|
974
|
+
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
579
975
|
if (!entry.isDirectory())
|
|
580
976
|
continue;
|
|
581
977
|
const dashIdx = entry.name.indexOf('--');
|
|
@@ -588,9 +984,6 @@ export function diffVersionPlugins(agent, version) {
|
|
|
588
984
|
}
|
|
589
985
|
return { agent, version, orphans: orphans.sort() };
|
|
590
986
|
}
|
|
591
|
-
/**
|
|
592
|
-
* Iterate all (agent, version) pairs that support plugins and are installed.
|
|
593
|
-
*/
|
|
594
987
|
export function iterPluginsCapableVersions(filter) {
|
|
595
988
|
const pairs = [];
|
|
596
989
|
const agents = filter?.agent ? [filter.agent] : PLUGINS_CAPABLE_AGENTS;
|
|
@@ -606,10 +999,6 @@ export function iterPluginsCapableVersions(filter) {
|
|
|
606
999
|
}
|
|
607
1000
|
return pairs;
|
|
608
1001
|
}
|
|
609
|
-
/**
|
|
610
|
-
* Remove a single orphan plugin skill from a version home.
|
|
611
|
-
* Soft-deletes to ~/.agents/.trash/plugins/.
|
|
612
|
-
*/
|
|
613
1002
|
export function removePluginSkillFromVersion(agent, version, skillName) {
|
|
614
1003
|
const versionHome = getVersionHomePath(agent, version);
|
|
615
1004
|
const skillPath = path.join(versionHome, `.${agent}`, 'skills', skillName);
|
|
@@ -628,3 +1017,121 @@ export function removePluginSkillFromVersion(agent, version, skillName) {
|
|
|
628
1017
|
}
|
|
629
1018
|
return { success: true };
|
|
630
1019
|
}
|
|
1020
|
+
// ─── Install / Update ─────────────────────────────────────────────────────────
|
|
1021
|
+
/**
|
|
1022
|
+
* Parse an install spec of the form `name@source` or just `source`.
|
|
1023
|
+
* Source can be a git URL or an absolute/relative local path.
|
|
1024
|
+
*/
|
|
1025
|
+
export function parseInstallSpec(spec) {
|
|
1026
|
+
// Check for name@source form
|
|
1027
|
+
const atIdx = spec.indexOf('@');
|
|
1028
|
+
if (atIdx > 0) {
|
|
1029
|
+
const name = spec.slice(0, atIdx);
|
|
1030
|
+
const source = spec.slice(atIdx + 1);
|
|
1031
|
+
return { name, source };
|
|
1032
|
+
}
|
|
1033
|
+
return { name: null, source: spec };
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Install a plugin from a git URL or local path.
|
|
1037
|
+
* Clones/copies to ~/.agents/.cache/plugins/<name>/.
|
|
1038
|
+
* Returns the installed plugin name and root path.
|
|
1039
|
+
*/
|
|
1040
|
+
export async function installPlugin(spec) {
|
|
1041
|
+
const { name: specName, source } = parseInstallSpec(spec);
|
|
1042
|
+
// Resolve local path (handle ~)
|
|
1043
|
+
const isLocalPath = source.startsWith('/') || source.startsWith('./') || source.startsWith('../') || source.startsWith('~');
|
|
1044
|
+
const resolvedSource = isLocalPath
|
|
1045
|
+
? source.replace(/^~/, process.env.HOME || '~')
|
|
1046
|
+
: source;
|
|
1047
|
+
const pluginsDir = getPluginsDir();
|
|
1048
|
+
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
1049
|
+
// If local path, load manifest to get the name
|
|
1050
|
+
let targetName = specName;
|
|
1051
|
+
if (!targetName) {
|
|
1052
|
+
if (isLocalPath) {
|
|
1053
|
+
const manifest = loadPluginManifest(resolvedSource);
|
|
1054
|
+
if (!manifest)
|
|
1055
|
+
throw new Error(`No valid plugin.json found at ${resolvedSource}`);
|
|
1056
|
+
targetName = manifest.name;
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
// Derive from git URL: last path segment without .git
|
|
1060
|
+
targetName = path.basename(resolvedSource).replace(/\.git$/, '');
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
const targetRoot = path.join(pluginsDir, targetName);
|
|
1064
|
+
const isNew = !fs.existsSync(targetRoot);
|
|
1065
|
+
if (isLocalPath) {
|
|
1066
|
+
// Copy local directory
|
|
1067
|
+
if (fs.existsSync(targetRoot)) {
|
|
1068
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1069
|
+
}
|
|
1070
|
+
fs.cpSync(resolvedSource, targetRoot, { recursive: true });
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
// Git clone
|
|
1074
|
+
if (fs.existsSync(targetRoot)) {
|
|
1075
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1076
|
+
}
|
|
1077
|
+
execSync(`git clone --depth 1 ${JSON.stringify(resolvedSource)} ${JSON.stringify(targetRoot)}`, {
|
|
1078
|
+
stdio: 'pipe',
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
// Validate manifest
|
|
1082
|
+
const manifest = loadPluginManifest(targetRoot);
|
|
1083
|
+
if (!manifest) {
|
|
1084
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1085
|
+
throw new Error(`Installed source has no valid .claude-plugin/plugin.json`);
|
|
1086
|
+
}
|
|
1087
|
+
// Persist source for future updates
|
|
1088
|
+
fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath }), 'utf-8');
|
|
1089
|
+
return { name: manifest.name, root: targetRoot, isNew };
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Update an installed plugin by re-pulling from its original source.
|
|
1093
|
+
* Returns true if the update succeeded.
|
|
1094
|
+
*/
|
|
1095
|
+
export async function updatePlugin(name) {
|
|
1096
|
+
const plugin = getPlugin(name);
|
|
1097
|
+
if (!plugin) {
|
|
1098
|
+
return { success: false, error: `Plugin '${name}' not found` };
|
|
1099
|
+
}
|
|
1100
|
+
const sourceFile = path.join(plugin.root, SOURCE_FILE);
|
|
1101
|
+
if (!fs.existsSync(sourceFile)) {
|
|
1102
|
+
return { success: false, error: `No source recorded for '${name}' — was it installed with 'agents plugins install'?` };
|
|
1103
|
+
}
|
|
1104
|
+
let sourceInfo;
|
|
1105
|
+
try {
|
|
1106
|
+
sourceInfo = JSON.parse(fs.readFileSync(sourceFile, 'utf-8'));
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
return { success: false, error: `Could not read source info for '${name}'` };
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
if (sourceInfo.isGit) {
|
|
1113
|
+
execSync(`git -C ${JSON.stringify(plugin.root)} pull --ff-only`, { stdio: 'pipe' });
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
const resolvedSource = sourceInfo.source.replace(/^~/, process.env.HOME || '~');
|
|
1117
|
+
if (!fs.existsSync(resolvedSource)) {
|
|
1118
|
+
return { success: false, error: `Source path no longer exists: ${resolvedSource}` };
|
|
1119
|
+
}
|
|
1120
|
+
// Preserve .user-config.json and .source during re-copy
|
|
1121
|
+
const userConfigPath = path.join(plugin.root, USER_CONFIG_FILE);
|
|
1122
|
+
const userConfigBackup = fs.existsSync(userConfigPath)
|
|
1123
|
+
? fs.readFileSync(userConfigPath, 'utf-8')
|
|
1124
|
+
: null;
|
|
1125
|
+
fs.rmSync(plugin.root, { recursive: true, force: true });
|
|
1126
|
+
fs.cpSync(resolvedSource, plugin.root, { recursive: true });
|
|
1127
|
+
fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify(sourceInfo), 'utf-8');
|
|
1128
|
+
if (userConfigBackup !== null) {
|
|
1129
|
+
fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
catch (err) {
|
|
1134
|
+
return { success: false, error: err.message };
|
|
1135
|
+
}
|
|
1136
|
+
return { success: true };
|
|
1137
|
+
}
|