mcpick 0.0.15 → 0.0.17

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.
@@ -1,4 +1,5 @@
1
1
  import { access, readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
2
3
  import { atomic_json_write } from '../utils/atomic-write.js';
3
4
  import { get_claude_settings_path } from '../utils/paths.js';
4
5
  export async function read_claude_settings() {
@@ -49,4 +50,194 @@ export function build_enabled_plugins(plugins) {
49
50
  }
50
51
  return result;
51
52
  }
53
+ async function read_settings_file(path) {
54
+ try {
55
+ await access(path);
56
+ const content = await readFile(path, 'utf-8');
57
+ return JSON.parse(content);
58
+ }
59
+ catch {
60
+ return {};
61
+ }
62
+ }
63
+ function get_settings_paths() {
64
+ const home = process.env.HOME || process.env.USERPROFILE || '';
65
+ return [
66
+ {
67
+ scope: 'user',
68
+ path: resolve(home, '.claude', 'settings.json'),
69
+ },
70
+ {
71
+ scope: 'project',
72
+ path: resolve(process.cwd(), '.claude', 'settings.json'),
73
+ },
74
+ {
75
+ scope: 'project-local',
76
+ path: resolve(process.cwd(), '.claude', 'settings.local.json'),
77
+ },
78
+ ];
79
+ }
80
+ /**
81
+ * Read all hooks across all scopes (settings + plugins), flattened for display.
82
+ */
83
+ export async function get_all_hooks() {
84
+ const entries = [];
85
+ // Settings-based hooks
86
+ for (const { scope, path } of get_settings_paths()) {
87
+ const data = await read_settings_file(path);
88
+ const hooks = data.hooks;
89
+ if (!hooks)
90
+ continue;
91
+ for (const [event, matchers] of Object.entries(hooks)) {
92
+ if (!Array.isArray(matchers))
93
+ continue;
94
+ for (let mi = 0; mi < matchers.length; mi++) {
95
+ const m = matchers[mi];
96
+ if (!m.hooks?.length)
97
+ continue;
98
+ for (let hi = 0; hi < m.hooks.length; hi++) {
99
+ entries.push({
100
+ event: event,
101
+ matcher: m.matcher,
102
+ handler: m.hooks[hi],
103
+ scope,
104
+ source: scope,
105
+ matcher_index: mi,
106
+ hook_index: hi,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ }
112
+ // Plugin-based hooks
113
+ const plugin_hooks = await get_all_plugin_hooks();
114
+ entries.push(...plugin_hooks);
115
+ return entries;
116
+ }
117
+ /**
118
+ * Scan all installed plugins for hooks.json and return flattened hook entries.
119
+ * Checks both cache and marketplace source paths since Claude Code reads from both.
120
+ */
121
+ export async function get_all_plugin_hooks() {
122
+ const { read_installed_plugins } = await import('./plugin-cache.js');
123
+ const { get_marketplaces_dir } = await import('../utils/paths.js');
124
+ const installed = await read_installed_plugins();
125
+ const entries = [];
126
+ const seen_hooks = new Set();
127
+ for (const [plugin_key, installs] of Object.entries(installed.plugins)) {
128
+ if (!installs?.length)
129
+ continue;
130
+ const install = installs[0];
131
+ const at_index = plugin_key.lastIndexOf('@');
132
+ const plugin_name = at_index > 0 ? plugin_key.substring(0, at_index) : plugin_key;
133
+ const marketplace_name = at_index > 0 ? plugin_key.substring(at_index + 1) : '';
134
+ // Collect all hooks.json paths for this plugin (cache + marketplace source)
135
+ const hooks_paths = [
136
+ join(install.installPath, 'hooks', 'hooks.json'),
137
+ ];
138
+ // Also check marketplace source path
139
+ if (marketplace_name) {
140
+ hooks_paths.push(join(get_marketplaces_dir(), marketplace_name, 'plugins', plugin_name, 'hooks', 'hooks.json'));
141
+ }
142
+ for (const hooks_path of hooks_paths) {
143
+ let hooks_data;
144
+ try {
145
+ const content = await readFile(hooks_path, 'utf-8');
146
+ hooks_data = JSON.parse(content);
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ const hooks = (hooks_data.hooks || hooks_data);
152
+ for (const [event, matchers] of Object.entries(hooks)) {
153
+ if (!Array.isArray(matchers))
154
+ continue;
155
+ for (let mi = 0; mi < matchers.length; mi++) {
156
+ const m = matchers[mi];
157
+ if (!m.hooks?.length)
158
+ continue;
159
+ for (let hi = 0; hi < m.hooks.length; hi++) {
160
+ // Deduplicate: same plugin + event + handler type + command
161
+ const h = m.hooks[hi];
162
+ const dedup_key = `${plugin_key}:${event}:${h.type}:${h.command || h.url || h.prompt}`;
163
+ if (seen_hooks.has(dedup_key))
164
+ continue;
165
+ seen_hooks.add(dedup_key);
166
+ entries.push({
167
+ event: event,
168
+ matcher: m.matcher,
169
+ handler: h,
170
+ scope: 'user',
171
+ source: 'plugin',
172
+ matcher_index: mi,
173
+ hook_index: hi,
174
+ plugin_key,
175
+ hooks_json_path: hooks_path,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ return entries;
183
+ }
184
+ /**
185
+ * Remove a specific hook entry by scope/event/indices.
186
+ */
187
+ export async function remove_hook(entry) {
188
+ const scope_path = get_settings_paths().find((s) => s.scope === entry.scope);
189
+ if (!scope_path)
190
+ throw new Error(`Unknown scope: ${entry.scope}`);
191
+ await atomic_json_write(scope_path.path, (existing) => {
192
+ const hooks = existing.hooks;
193
+ if (!hooks)
194
+ return existing;
195
+ const matchers = hooks[entry.event];
196
+ if (!matchers?.[entry.matcher_index])
197
+ return existing;
198
+ const matcher = matchers[entry.matcher_index];
199
+ matcher.hooks.splice(entry.hook_index, 1);
200
+ // Clean up empty matchers
201
+ if (matcher.hooks.length === 0) {
202
+ matchers.splice(entry.matcher_index, 1);
203
+ }
204
+ // Clean up empty events
205
+ if (matchers.length === 0) {
206
+ delete hooks[entry.event];
207
+ }
208
+ // Clean up empty hooks object
209
+ if (Object.keys(hooks).length === 0) {
210
+ delete existing.hooks;
211
+ }
212
+ return existing;
213
+ });
214
+ }
215
+ /**
216
+ * Add a hook to a specific scope.
217
+ */
218
+ export async function add_hook(scope, event, matcher, handler) {
219
+ const scope_path = get_settings_paths().find((s) => s.scope === scope);
220
+ if (!scope_path)
221
+ throw new Error(`Unknown scope: ${scope}`);
222
+ await atomic_json_write(scope_path.path, (existing) => {
223
+ if (!existing.hooks)
224
+ existing.hooks = {};
225
+ const hooks = existing.hooks;
226
+ if (!hooks[event])
227
+ hooks[event] = [];
228
+ const matchers = hooks[event];
229
+ // Find existing matcher group or create new
230
+ const existing_matcher = matchers.find((m) => (m.matcher || undefined) === matcher);
231
+ if (existing_matcher) {
232
+ existing_matcher.hooks.push(handler);
233
+ }
234
+ else {
235
+ const new_matcher = { hooks: [handler] };
236
+ if (matcher)
237
+ new_matcher.matcher = matcher;
238
+ matchers.push(new_matcher);
239
+ }
240
+ return existing;
241
+ });
242
+ }
52
243
  //# sourceMappingURL=settings.js.map
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { backup_config } from './commands/backup.js';
5
5
  import { edit_config } from './commands/edit-config.js';
6
6
  import { edit_plugins } from './commands/edit-plugins.js';
7
7
  import { manage_cache } from './commands/manage-cache.js';
8
+ import { manage_hooks } from './commands/manage-hooks.js';
9
+ import { manage_marketplace } from './commands/manage-marketplace.js';
8
10
  import { restore_config } from './commands/restore.js';
9
11
  import { write_claude_config } from './core/config.js';
10
12
  import { list_profiles, load_profile, save_profile, } from './core/profile.js';
@@ -178,6 +180,16 @@ async function main() {
178
180
  label: 'Manage plugins',
179
181
  hint: 'Toggle, install, uninstall, or update plugins',
180
182
  },
183
+ {
184
+ value: 'manage-marketplace',
185
+ label: 'Manage marketplaces',
186
+ hint: 'Add, remove, or update plugin marketplaces',
187
+ },
188
+ {
189
+ value: 'manage-hooks',
190
+ label: 'Manage hooks',
191
+ hint: 'List, add, or remove event hooks',
192
+ },
181
193
  {
182
194
  value: 'manage-cache',
183
195
  label: 'Manage plugin cache',
@@ -226,6 +238,12 @@ async function main() {
226
238
  case 'edit-plugins':
227
239
  await edit_plugins();
228
240
  break;
241
+ case 'manage-marketplace':
242
+ await manage_marketplace();
243
+ break;
244
+ case 'manage-hooks':
245
+ await manage_hooks();
246
+ break;
229
247
  case 'manage-cache':
230
248
  await manage_cache();
231
249
  break;
@@ -276,6 +294,10 @@ const SUBCOMMANDS = new Set([
276
294
  'disable',
277
295
  'remove',
278
296
  'add',
297
+ 'add-json',
298
+ 'get',
299
+ 'reset-project-choices',
300
+ 'hooks',
279
301
  'backup',
280
302
  'restore',
281
303
  'profile',
@@ -192,7 +192,51 @@ export async function update_plugin_via_cli(key, scope = 'user') {
192
192
  /**
193
193
  * Add a marketplace via Claude CLI
194
194
  */
195
- export async function marketplace_add_via_cli(source, scope = 'user') {
195
+ /**
196
+ * Extract GitHub owner/repo from various source formats.
197
+ * Returns null if not a recognizable GitHub reference.
198
+ */
199
+ function parse_github_repo(source) {
200
+ // HTTPS URL: https://github.com/owner/repo[.git]
201
+ const https_match = source.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+)(?:\.git)?$/);
202
+ if (https_match)
203
+ return { owner: https_match[1], repo: https_match[2] };
204
+ // SSH URL: git@github.com:owner/repo[.git]
205
+ const ssh_match = source.match(/^git@github\.com:([^/]+)\/([^/.]+)(?:\.git)?$/);
206
+ if (ssh_match)
207
+ return { owner: ssh_match[1], repo: ssh_match[2] };
208
+ // Shorthand: owner/repo (no slashes beyond the one separator)
209
+ const shorthand_match = source.match(/^([^/\s]+)\/([^/\s]+)$/);
210
+ if (shorthand_match)
211
+ return { owner: shorthand_match[1], repo: shorthand_match[2] };
212
+ return null;
213
+ }
214
+ /**
215
+ * Validate that a GitHub repository exists and is accessible.
216
+ * Returns an error message if validation fails, null if OK.
217
+ */
218
+ async function validate_github_repo(owner, repo) {
219
+ try {
220
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
221
+ method: 'GET',
222
+ headers: { Accept: 'application/vnd.github.v3+json' },
223
+ });
224
+ if (response.status === 200)
225
+ return null;
226
+ if (response.status === 404) {
227
+ return `Repository '${owner}/${repo}' not found on GitHub. Check the name or ensure it's not private.`;
228
+ }
229
+ if (response.status === 403) {
230
+ return `Access denied for '${owner}/${repo}'. The repository may be private — configure a GitHub token or use SSH.`;
231
+ }
232
+ return `GitHub API returned status ${response.status} for '${owner}/${repo}'.`;
233
+ }
234
+ catch {
235
+ // Network error — skip validation and let the CLI attempt the clone
236
+ return null;
237
+ }
238
+ }
239
+ export async function marketplace_add_via_cli(source) {
196
240
  const cli_available = await check_claude_cli();
197
241
  if (!cli_available) {
198
242
  return {
@@ -200,12 +244,37 @@ export async function marketplace_add_via_cli(source, scope = 'user') {
200
244
  error: 'Claude CLI not found. Please install Claude Code CLI.',
201
245
  };
202
246
  }
247
+ // Validate GitHub repo exists before attempting clone
248
+ // Only validate shorthand (owner/repo) — explicit URLs imply the user knows the repo
249
+ const gh = parse_github_repo(source);
250
+ const is_shorthand = gh && !source.startsWith('http') && !source.startsWith('git@');
251
+ if (gh && is_shorthand) {
252
+ const validation_error = await validate_github_repo(gh.owner, gh.repo);
253
+ if (validation_error) {
254
+ return { success: false, error: validation_error };
255
+ }
256
+ }
203
257
  try {
204
- await execAsync(`claude plugin marketplace add ${shell_escape(source)} --scope ${scope}`);
258
+ await execAsync(`claude plugin marketplace add ${shell_escape(source)}`);
205
259
  return { success: true };
206
260
  }
207
261
  catch (error) {
208
262
  const message = error instanceof Error ? error.message : 'Unknown error';
263
+ // Provide clearer error messages for common failures
264
+ if (message.includes('SSH') ||
265
+ message.includes('Permission denied (publickey)')) {
266
+ return {
267
+ success: false,
268
+ error: `SSH authentication failed for '${source}'. Either:\n - Configure SSH keys: https://docs.github.com/en/authentication/connecting-to-github-with-ssh\n - Use HTTPS URL instead: https://github.com/${gh ? `${gh.owner}/${gh.repo}` : source}`,
269
+ };
270
+ }
271
+ if (message.includes('not found') ||
272
+ message.includes('does not exist')) {
273
+ return {
274
+ success: false,
275
+ error: `Repository '${source}' not found. Check the name and your access permissions.`,
276
+ };
277
+ }
209
278
  return {
210
279
  success: false,
211
280
  error: `Failed to add marketplace: ${message}`,
@@ -297,6 +366,98 @@ export function get_scope_description(scope) {
297
366
  return 'Global - all projects';
298
367
  }
299
368
  }
369
+ /**
370
+ * Validate a plugin or marketplace manifest via Claude CLI
371
+ */
372
+ export async function validate_plugin_via_cli(path) {
373
+ const cli_available = await check_claude_cli();
374
+ if (!cli_available) {
375
+ return {
376
+ success: false,
377
+ error: 'Claude CLI not found. Please install Claude Code CLI.',
378
+ };
379
+ }
380
+ try {
381
+ const { stdout } = await execAsync(`claude plugin validate ${shell_escape(path)}`);
382
+ return { success: true, stdout: stdout.trim() };
383
+ }
384
+ catch (error) {
385
+ const message = error instanceof Error ? error.message : 'Unknown error';
386
+ return {
387
+ success: false,
388
+ error: `Validation failed: ${message}`,
389
+ };
390
+ }
391
+ }
392
+ /**
393
+ * Get details about an MCP server via Claude CLI
394
+ */
395
+ export async function mcp_get_via_cli(name) {
396
+ const cli_available = await check_claude_cli();
397
+ if (!cli_available) {
398
+ return {
399
+ success: false,
400
+ error: 'Claude CLI not found. Please install Claude Code CLI.',
401
+ };
402
+ }
403
+ try {
404
+ const { stdout } = await execAsync(`claude mcp get ${shell_escape(name)}`);
405
+ return { success: true, stdout: stdout.trim() };
406
+ }
407
+ catch (error) {
408
+ const message = error instanceof Error ? error.message : 'Unknown error';
409
+ return {
410
+ success: false,
411
+ error: `Failed to get server details: ${message}`,
412
+ };
413
+ }
414
+ }
415
+ /**
416
+ * Add an MCP server from raw JSON via Claude CLI
417
+ */
418
+ export async function mcp_add_json_via_cli(name, json, scope = 'local') {
419
+ const cli_available = await check_claude_cli();
420
+ if (!cli_available) {
421
+ return {
422
+ success: false,
423
+ error: 'Claude CLI not found. Please install Claude Code CLI.',
424
+ };
425
+ }
426
+ try {
427
+ await execAsync(`claude mcp add-json ${shell_escape(name)} ${shell_escape(json)} --scope ${scope}`);
428
+ return { success: true };
429
+ }
430
+ catch (error) {
431
+ const message = error instanceof Error ? error.message : 'Unknown error';
432
+ return {
433
+ success: false,
434
+ error: `Failed to add server from JSON: ${message}`,
435
+ };
436
+ }
437
+ }
438
+ /**
439
+ * Reset project-scoped MCP server choices via Claude CLI
440
+ */
441
+ export async function mcp_reset_project_choices_via_cli() {
442
+ const cli_available = await check_claude_cli();
443
+ if (!cli_available) {
444
+ return {
445
+ success: false,
446
+ error: 'Claude CLI not found. Please install Claude Code CLI.',
447
+ };
448
+ }
449
+ try {
450
+ await execAsync('claude mcp reset-project-choices');
451
+ return { success: true };
452
+ }
453
+ catch (error) {
454
+ const message = error instanceof Error ? error.message : 'Unknown error';
455
+ return {
456
+ success: false,
457
+ error: `Failed to reset project choices: ${message}`,
458
+ };
459
+ }
460
+ }
300
461
  /**
301
462
  * Get scope options for select prompt
302
463
  */
@@ -105,6 +105,9 @@ export function get_plugin_cache_dir() {
105
105
  export function get_marketplaces_dir() {
106
106
  return join(get_plugins_dir(), 'marketplaces');
107
107
  }
108
+ export function get_disabled_hooks_path() {
109
+ return join(get_mcpick_dir(), 'disabled-hooks.json');
110
+ }
108
111
  export function get_marketplace_manifest_path(name) {
109
112
  return join(get_marketplaces_dir(), name, '.claude-plugin', 'marketplace.json');
110
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpick",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "Dynamic MCP server and plugin configuration manager for Claude Code",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",