context-plugins 0.3.0 → 0.4.0

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 CHANGED
@@ -23,8 +23,39 @@ context-plugins uninstall <plugin> [options] # remove it again
23
23
  context-plugins update # refresh everything already installed
24
24
  context-plugins list # what the marketplace offers
25
25
  context-plugins installed # what this machine has
26
+ context-plugins doctor # check this machine can install
26
27
  ```
27
28
 
29
+ ## Something not working?
30
+
31
+ `doctor` checks everything an install depends on and says which part is unhappy:
32
+
33
+ ```
34
+ $ npx context-plugins doctor
35
+
36
+ Environment
37
+ ✓ Node.js v20.11.0
38
+ ✓ git git version 2.43.0
39
+
40
+ Editors
41
+ ✓ Claude Code claude on PATH
42
+ ✓ Cursor ~/.cursor
43
+ ! VS Code not installed (looked in ~/.config/Code/User)
44
+
45
+ Marketplace
46
+ ✓ Reachable raw.githubusercontent.com
47
+ ✓ Registry context-plugins, 44 plugins
48
+ ✓ API budget 59 of 60 requests left
49
+
50
+ Local state
51
+ ✓ State directory ~/.context-plugins (writable)
52
+ ✓ Installed 2 plugins
53
+ ```
54
+
55
+ `✓` fine, `!` works but worth knowing, `x` blocks an install. It exits non-zero
56
+ only when something blocks, so it can be used in a script; add `--json` for machine-readable
57
+ output.
58
+
28
59
  | Option | Default | Description |
29
60
  | --- | --- | --- |
30
61
  | `--repo <owner/repo>` | the bundled marketplace | Install from a different marketplace |
package/bin/cli.js CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ // Checked before anything else loads. Node below 18 has no global fetch and no
5
+ // node:readline/promises, so without this the failure is a bare
6
+ // "fetch is not defined" from somewhere deep in the fetch path.
7
+ require('../src/require-node')();
8
+
4
9
  require('../src/cli')
5
10
  .run(process.argv.slice(2))
6
11
  .then((code) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-plugins",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Install a plugin from a plugin marketplace into Claude Code, Cursor, and VS Code with one command.",
5
5
  "keywords": [
6
6
  "claude-code",
package/run.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * variables still take precedence over it.
9
9
  */
10
10
  module.exports = function runWithProfile(profile = {}, argv = process.argv.slice(2)) {
11
+ require('./src/require-node')();
11
12
  return require('./src/cli')
12
13
  .run(argv, profile)
13
14
  .then((code) => {
package/src/catalog.js CHANGED
@@ -6,6 +6,9 @@ const { UserError, assertRepo, assertRef, stripBom, suggest } = require('./util'
6
6
  // We prefer the Claude one and fall back to Cursor's.
7
7
  const REGISTRY_FILES = ['.claude-plugin/marketplace.json', '.cursor-plugin/marketplace.json'];
8
8
 
9
+ // Claude Code's marketplace schema: kebab-case identifier, no spaces.
10
+ const MARKETPLACE_RE = /^[a-z0-9]+(?:[-_.][a-z0-9]+)*$/i;
11
+
9
12
  const rawUrl = (repo, ref, filePath) =>
10
13
  `https://raw.githubusercontent.com/${repo}/${ref}/${filePath}`;
11
14
 
@@ -16,13 +19,29 @@ function ghHeaders(env = process.env) {
16
19
  return headers;
17
20
  }
18
21
 
22
+ /**
23
+ * Node's built-in fetch ignores HTTP_PROXY / HTTPS_PROXY, so on a network that
24
+ * requires a proxy this fails even though `git` - which does read the proxy
25
+ * settings - would succeed. Worth saying, because the raw error is just a
26
+ * connect timeout.
27
+ */
28
+ function networkHint(env = process.env) {
29
+ const proxied = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy;
30
+ if (proxied) {
31
+ return 'A proxy is configured, but Node does not apply it to its own requests. Check your network, or see the docs for NODE_USE_ENV_PROXY.';
32
+ }
33
+ return 'Check your network connection, or whether access to github.com is blocked.';
34
+ }
35
+
19
36
  /** GET JSON, returning null for 404 so a missing registry is not an error. */
20
37
  async function getJson(url, { env = process.env, fetchImpl = fetch } = {}) {
21
38
  let res;
22
39
  try {
23
40
  res = await fetchImpl(url, { headers: ghHeaders(env), redirect: 'follow' });
24
41
  } catch (err) {
25
- throw new UserError(`Network request failed: ${url}`, { hint: err.message });
42
+ throw new UserError(`Could not reach ${new URL(url).host}: ${err.message}`, {
43
+ hint: networkHint(env),
44
+ });
26
45
  }
27
46
  if (res.status === 404) return null;
28
47
  if (!res.ok) {
@@ -108,6 +127,18 @@ async function resolvePlugin({ repo, ref, plugin, marketplace = null, label, dep
108
127
  });
109
128
  }
110
129
 
130
+ // Claude Code requires a kebab-case marketplace id. Catching it here names the
131
+ // real problem; otherwise the failure surfaces much later as a bare
132
+ // "plugin not found in marketplace" from the Claude CLI.
133
+ if (!MARKETPLACE_RE.test(resolvedMarketplace)) {
134
+ throw new UserError(
135
+ `Marketplace name '${resolvedMarketplace}' is not a valid identifier.`,
136
+ {
137
+ hint: `It must be kebab-case with no spaces (e.g. my-marketplace). Fix 'name' in ${REGISTRY_FILES[0]}.`,
138
+ },
139
+ );
140
+ }
141
+
111
142
  return {
112
143
  plugin,
113
144
  repo,
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ const { resolveBrand } = require('./brand');
7
7
  const { NAMES, byName } = require('./harness');
8
8
  const { UserError } = require('./util');
9
9
  const { installPlugin, uninstallPlugin, updateAll, listPlugins } = require('./install');
10
+ const { diagnose } = require('./doctor');
10
11
  const pkg = require('../package.json');
11
12
 
12
13
  const VALUE_FLAGS = new Set(['repo', 'ref', 'marketplace', 'targets']);
@@ -86,6 +87,7 @@ Usage
86
87
  ${bin} update
87
88
  ${bin} list
88
89
  ${bin} installed
90
+ ${bin} doctor
89
91
 
90
92
  Options
91
93
  --repo <owner/repo> Use a different marketplace (default: ${brand.label})
@@ -241,6 +243,37 @@ async function run(argv = process.argv.slice(2), profile = {}) {
241
243
  log.info(`Install one with \`${bin} install <plugin>\`.`);
242
244
  return 0;
243
245
  }
246
+ case 'doctor': {
247
+ const report = await diagnose({ brand });
248
+ if (flags.json) {
249
+ log.plain(JSON.stringify(report, null, 2));
250
+ return report.ok ? 0 : 1;
251
+ }
252
+
253
+ const labels = report.groups.flatMap((g) => g.checks.map((c) => c.label.length));
254
+ const labelWidth = Math.min(Math.max(...labels, 8), 22);
255
+ for (const group of report.groups) {
256
+ log.step(group.title);
257
+ for (const c of group.checks) {
258
+ const symbol = { ok: log.MARK, warn: '!', fail: 'x' }[c.status];
259
+ const paint = c.status === 'ok' ? log.dim : (s) => s;
260
+ log.plain(` ${symbol} ${c.label.padEnd(labelWidth)} ${paint(c.detail)}`);
261
+ if (c.hint) log.info(c.hint);
262
+ }
263
+ }
264
+
265
+ log.plain('');
266
+ log.rule();
267
+ if (report.failures) {
268
+ log.error(`${log.plural(report.failures, 'problem')} found.`);
269
+ } else if (report.warnings) {
270
+ log.ok(`No problems. ${log.plural(report.warnings, 'warning')}.`);
271
+ } else {
272
+ log.ok('Everything checks out.');
273
+ }
274
+ log.plain('');
275
+ return report.ok ? 0 : 1;
276
+ }
244
277
  case 'installed': {
245
278
  const entries = manifest.list(paths.manifestPath());
246
279
  if (flags.json) {
package/src/doctor.js ADDED
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const paths = require('./paths');
7
+ const manifest = require('./manifest');
8
+ const { HARNESSES } = require('./harness');
9
+ const { loadCatalog, ghHeaders, rawUrl, REGISTRY_FILES } = require('./catalog');
10
+ const { which, run, shortPath, ensureDir, rmrf } = require('./util');
11
+
12
+ const MIN_NODE = 18;
13
+ const MARKETPLACE_RE = /^[a-z0-9]+(?:[-_.][a-z0-9]+)*$/i;
14
+
15
+ const ok = (label, detail) => ({ status: 'ok', label, detail });
16
+ const warn = (label, detail, hint) => ({ status: 'warn', label, detail, hint });
17
+ const fail = (label, detail, hint) => ({ status: 'fail', label, detail, hint });
18
+
19
+ async function checkEnvironment(deps) {
20
+ const whichImpl = deps.which || which;
21
+ const runImpl = deps.run || run;
22
+ const env = deps.env || process.env;
23
+ const checks = [];
24
+
25
+ const version = process.versions.node;
26
+ const major = parseInt(version.split('.')[0], 10);
27
+ checks.push(
28
+ major >= MIN_NODE
29
+ ? ok('Node.js', `v${version}`)
30
+ : fail('Node.js', `v${version}`, `Version ${MIN_NODE} or newer is required.`),
31
+ );
32
+
33
+ const git = whichImpl('git', env);
34
+ if (git) {
35
+ let detail = shortPath(git);
36
+ try {
37
+ const res = await runImpl(git, ['--version']);
38
+ if (res.code === 0 && res.stdout.trim()) detail = res.stdout.trim();
39
+ } catch {
40
+ /* the path alone is enough */
41
+ }
42
+ checks.push(ok('git', detail));
43
+ } else {
44
+ checks.push(
45
+ warn('git', 'not found', 'Plugins download through the GitHub API instead, which is rate limited to 60 requests an hour.'),
46
+ );
47
+ }
48
+
49
+ const proxy = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy;
50
+ if (proxy) {
51
+ checks.push(
52
+ warn(
53
+ 'Proxy',
54
+ 'configured for the shell',
55
+ 'Node does not apply HTTP_PROXY or HTTPS_PROXY to its own requests, so downloads may fail here even where git succeeds.',
56
+ ),
57
+ );
58
+ }
59
+
60
+ return checks;
61
+ }
62
+
63
+ function checkEditors(pathOpts) {
64
+ const checks = HARNESSES.map((h) =>
65
+ h.detect(pathOpts)
66
+ ? ok(h.title, h.location(pathOpts))
67
+ : warn(h.title, `not installed (looked in ${h.location(pathOpts)})`),
68
+ );
69
+ if (!checks.some((c) => c.status === 'ok')) {
70
+ checks.push(
71
+ fail(
72
+ 'Any editor',
73
+ 'none found',
74
+ 'Install Claude Code, Cursor, or VS Code - there is nowhere to install a plugin.',
75
+ ),
76
+ );
77
+ }
78
+ return checks;
79
+ }
80
+
81
+ async function checkMarketplace(brand, deps) {
82
+ const fetchImpl = deps.fetchImpl || fetch;
83
+ const env = deps.env || process.env;
84
+ const checks = [];
85
+
86
+ let catalog = null;
87
+ try {
88
+ catalog = await loadCatalog({ repo: brand.repo, ref: brand.ref, deps });
89
+ checks.push(ok('Reachable', new URL(rawUrl(brand.repo, brand.ref, REGISTRY_FILES[0])).host));
90
+ } catch (err) {
91
+ checks.push(fail('Reachable', err.message, err.hint));
92
+ return checks;
93
+ }
94
+
95
+ if (!catalog) {
96
+ checks.push(fail('Registry', `no ${REGISTRY_FILES[0]} found`, 'Check --repo and --ref.'));
97
+ return checks;
98
+ }
99
+
100
+ const name = catalog.marketplace;
101
+ checks.push(
102
+ name && MARKETPLACE_RE.test(name)
103
+ ? ok('Registry', `${name}, ${catalog.plugins.length} plugins`)
104
+ : fail(
105
+ 'Registry',
106
+ `name ${JSON.stringify(name)} is not a valid identifier`,
107
+ `It must be kebab-case with no spaces. Fix 'name' in ${REGISTRY_FILES[0]}.`,
108
+ ),
109
+ );
110
+
111
+ // Only meaningful when the API is the download path; with git it is unused.
112
+ try {
113
+ const res = await fetchImpl('https://api.github.com/rate_limit', { headers: ghHeaders(env) });
114
+ if (res.ok) {
115
+ const body = await res.json();
116
+ const core = body.resources && body.resources.core;
117
+ if (core) {
118
+ const detail = `${core.remaining} of ${core.limit} requests left`;
119
+ checks.push(core.remaining > 10 ? ok('API budget', detail) : warn('API budget', detail, 'Set GITHUB_TOKEN, or install git to avoid the API entirely.'));
120
+ }
121
+ }
122
+ } catch {
123
+ /* advisory only */
124
+ }
125
+
126
+ return checks;
127
+ }
128
+
129
+ function checkState(pathOpts) {
130
+ const dir = paths.stateDir(pathOpts);
131
+ const checks = [];
132
+ try {
133
+ ensureDir(dir);
134
+ const probe = path.join(dir, `.write-probe-${process.pid}`);
135
+ fs.writeFileSync(probe, 'ok');
136
+ rmrf(probe);
137
+ checks.push(ok('State directory', `${shortPath(dir)} (writable)`));
138
+ } catch (err) {
139
+ checks.push(
140
+ fail('State directory', `${shortPath(dir)} is not writable`, err.message),
141
+ );
142
+ }
143
+
144
+ try {
145
+ const entries = manifest.list(paths.manifestPath(pathOpts));
146
+ const detail = entries.length ? `${entries.length} ${entries.length === 1 ? 'plugin' : 'plugins'}` : 'none yet';
147
+ checks.push(ok('Installed', detail));
148
+ } catch (err) {
149
+ checks.push(warn('Installed', 'could not read installed.json', err.message));
150
+ }
151
+ return checks;
152
+ }
153
+
154
+ /** Collect every check, grouped for display. Never throws: a broken check is a result. */
155
+ async function diagnose({ brand, deps = {}, pathOpts } = {}) {
156
+ const groups = [
157
+ { title: 'Environment', checks: await checkEnvironment(deps) },
158
+ { title: 'Editors', checks: checkEditors(pathOpts) },
159
+ { title: 'Marketplace', checks: await checkMarketplace(brand, deps) },
160
+ { title: 'Local state', checks: checkState(pathOpts) },
161
+ ];
162
+ const all = groups.flatMap((g) => g.checks);
163
+ return {
164
+ groups,
165
+ failures: all.filter((c) => c.status === 'fail').length,
166
+ warnings: all.filter((c) => c.status === 'warn').length,
167
+ ok: !all.some((c) => c.status === 'fail'),
168
+ };
169
+ }
170
+
171
+ module.exports = { diagnose, MIN_NODE };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const log = require('../log');
4
- const { which, run, UserError } = require('../util');
4
+ const { which, run, UserError, stripBom } = require('../util');
5
5
 
6
6
  // Claude Code installs from the marketplace itself - no local copy needed.
7
7
  const name = 'claude';
@@ -15,6 +15,26 @@ const detect = (opts) => Boolean(cli(opts));
15
15
  const tail = (res) =>
16
16
  (res.stderr || res.stdout || '').trim().split('\n').slice(-3).join(' ').trim();
17
17
 
18
+ /**
19
+ * The name Claude Code knows this marketplace by.
20
+ *
21
+ * Claude keys a marketplace by the name it carried when it was added, which can
22
+ * drift from the current `name` in marketplace.json. Installing with the name
23
+ * from the file then fails with a bare "plugin not found in marketplace", so ask
24
+ * Claude what it calls the entry for this repository.
25
+ */
26
+ async function registeredName(claude, repo) {
27
+ const res = await run(claude, ['plugin', 'marketplace', 'list', '--json']);
28
+ if (res.code !== 0) return null;
29
+ try {
30
+ const entries = JSON.parse(stripBom(res.stdout));
31
+ const hit = entries.find((m) => m.repo === repo || String(m.url || '').includes(repo));
32
+ return hit && hit.name ? hit.name : null;
33
+ } catch {
34
+ return null; // older CLI without --json
35
+ }
36
+ }
37
+
18
38
  async function install({ plugin, marketplace, repo }, opts) {
19
39
  const claude = cli(opts);
20
40
  if (!claude) {
@@ -24,10 +44,14 @@ async function install({ plugin, marketplace, repo }, opts) {
24
44
 
25
45
  const added = await run(claude, ['plugin', 'marketplace', 'add', repo]);
26
46
  if (added.code !== 0) {
27
- log.info(`marketplace add returned ${added.code} (likely already added) - continuing.`);
47
+ log.debug(`marketplace add returned ${added.code} (likely already added) - continuing.`);
28
48
  }
29
49
 
30
- const target = `${plugin}@${marketplace}`;
50
+ const known = await registeredName(claude, repo);
51
+ if (known && known !== marketplace) {
52
+ log.debug(`Claude knows this marketplace as '${known}', not '${marketplace}'.`);
53
+ }
54
+ const target = `${plugin}@${known || marketplace}`;
31
55
  const res = await run(claude, ['plugin', 'install', target, '--scope', 'user']);
32
56
  if (res.code !== 0) {
33
57
  throw new UserError(`claude plugin install ${target} failed (exit ${res.code}). ${tail(res)}`.trim());
@@ -37,13 +61,14 @@ async function install({ plugin, marketplace, repo }, opts) {
37
61
  return true;
38
62
  }
39
63
 
40
- async function uninstall({ plugin, marketplace }, opts) {
64
+ async function uninstall({ plugin, marketplace, repo }, opts) {
41
65
  const claude = cli(opts);
42
66
  if (!claude) {
43
67
  log.warn("'claude' CLI not on PATH - skipping Claude Code.");
44
68
  return false;
45
69
  }
46
- const target = `${plugin}@${marketplace}`;
70
+ const known = await registeredName(claude, repo);
71
+ const target = `${plugin}@${known || marketplace}`;
47
72
  const res = await run(claude, ['plugin', 'uninstall', target, '--scope', 'user']);
48
73
  if (res.code !== 0) {
49
74
  log.warn(`claude plugin uninstall ${target} returned ${res.code}. ${tail(res)}`.trim());
@@ -54,4 +79,6 @@ async function uninstall({ plugin, marketplace }, opts) {
54
79
  return true;
55
80
  }
56
81
 
57
- module.exports = { name, title, detect, install, uninstall, needsSource: false };
82
+ const location = () => 'claude on PATH';
83
+
84
+ module.exports = { name, title, detect, location, install, uninstall, needsSource: false };
@@ -44,4 +44,6 @@ async function uninstall({ plugin }, opts) {
44
44
  return true;
45
45
  }
46
46
 
47
- module.exports = { name, title, detect, install, uninstall, needsSource: true, destFor };
47
+ const location = (opts) => shortPath(paths.cursorRoot(opts));
48
+
49
+ module.exports = { name, title, detect, location, install, uninstall, needsSource: true, destFor };
@@ -56,4 +56,6 @@ async function uninstall({ plugin }, opts) {
56
56
  return true;
57
57
  }
58
58
 
59
- module.exports = { name, title, detect, install, uninstall, needsSource: true, destFor };
59
+ const location = (opts) => shortPath(paths.vscodeUserDir(opts));
60
+
61
+ module.exports = { name, title, detect, location, install, uninstall, needsSource: true, destFor };
package/src/install.js CHANGED
@@ -5,7 +5,7 @@ const paths = require('./paths');
5
5
  const manifest = require('./manifest');
6
6
  const { resolvePlugin, loadCatalog } = require('./catalog');
7
7
  const { materialize } = require('./fetch');
8
- const { byName, resolveTargets } = require('./harness');
8
+ const { byName, resolveTargets, NAMES } = require('./harness');
9
9
  const { isInteractive, createPrompter } = require('./prompt');
10
10
  const { assertPlugin, UserError } = require('./util');
11
11
 
@@ -94,18 +94,37 @@ async function installPlugin({
94
94
  log.rule();
95
95
 
96
96
  log.step('[Harnesses]');
97
+ const explicit = Array.isArray(targets) && targets.length > 0;
97
98
  const available = requested.filter((name) => byName(name).detect(pathOpts));
98
- for (const name of requested) {
99
- if (!available.includes(name)) log.info(`${byName(name).title} not detected - skipping.`);
99
+ const missing = requested.filter((name) => !available.includes(name));
100
+
101
+ for (const name of missing) {
102
+ const h = byName(name);
103
+ log.info(`${h.title} is not installed (looked in ${h.location(pathOpts)}).`);
100
104
  }
105
+
106
+ // Nothing to install into is a failure, not a quiet no-op. Saying which
107
+ // editor was asked for beats a generic "no harness found" when the user
108
+ // named one explicitly.
101
109
  if (!available.length) {
102
- log.plain('');
103
- log.warn('No supported harness found. Install Claude Code, Cursor, or VS Code first.');
104
- return { plugin, targets: [], marketplace: resolved.marketplace, ref: effectiveRef };
110
+ const names = missing.map((n) => byName(n).title);
111
+ throw new UserError(
112
+ explicit
113
+ ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} not installed on this machine.`
114
+ : 'No supported editor found on this machine.',
115
+ {
116
+ hint: explicit
117
+ ? `Install it first, or choose another with --targets ${NAMES.join(',')}.`
118
+ : 'Install Claude Code, Cursor, or VS Code, then run this again.',
119
+ },
120
+ );
121
+ }
122
+ if (missing.length) {
123
+ log.info(`Continuing with ${available.map((n) => byName(n).title).join(', ')}.`);
105
124
  }
106
125
 
107
126
  const want = await chooseHarnesses(available, {
108
- explicit: Array.isArray(targets) && targets.length > 0,
127
+ explicit,
109
128
  assumeYes,
110
129
  confirm: deps.confirm,
111
130
  });
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ // Deliberately plain, old-syntax JavaScript with no imports: this has to parse
4
+ // and run on whatever ancient Node the user happens to have.
5
+
6
+ var MINIMUM = 18;
7
+
8
+ module.exports = function requireNode(exit) {
9
+ var version = process.versions.node;
10
+ var major = parseInt(version.split('.')[0], 10);
11
+ if (major >= MINIMUM) return true;
12
+
13
+ process.stderr.write(
14
+ 'context-plugins requires Node.js ' +
15
+ MINIMUM +
16
+ ' or newer, but this is Node.js ' +
17
+ version +
18
+ '.\n' +
19
+ 'Install a newer Node.js from https://nodejs.org and run this again.\n',
20
+ );
21
+ (exit || process.exit)(1);
22
+ return false;
23
+ };