context-plugins 0.2.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,11 +23,42 @@ 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
- | `--repo <owner/repo>` | `context-plugins/plugin-marketplace` | Marketplace repository |
61
+ | `--repo <owner/repo>` | the bundled marketplace | Install from a different marketplace |
31
62
  | `--ref <branch\|tag\|sha>` | `main` | Version to install from |
32
63
  | `--marketplace <name>` | read from `marketplace.json` | Marketplace name |
33
64
  | `--targets <list>` | ask | `claude`, `cursor`, `vscode`, or `all` — skips the prompt |
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.2.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/brand.js CHANGED
@@ -49,6 +49,13 @@ function resolveBrand({
49
49
  } = {}) {
50
50
  const rc = readRc(cwd) || readRc(home) || {};
51
51
 
52
+ const displayName = firstSet(
53
+ env.CP_DISPLAY_NAME,
54
+ rc.displayName,
55
+ profile.displayName,
56
+ DEFAULT_PROFILE.displayName,
57
+ );
58
+
52
59
  const brand = {
53
60
  repo: firstSet(flags.repo, env.CP_REPO, rc.repo, profile.repo, DEFAULT_PROFILE.repo),
54
61
  ref: firstSet(flags.ref, env.CP_REF, rc.ref, profile.ref, DEFAULT_PROFILE.ref),
@@ -60,11 +67,14 @@ function resolveBrand({
60
67
  profile.id,
61
68
  DEFAULT_PROFILE.id,
62
69
  ) || null,
63
- displayName: firstSet(
64
- env.CP_DISPLAY_NAME,
65
- rc.displayName,
66
- profile.displayName,
67
- DEFAULT_PROFILE.displayName,
70
+ displayName,
71
+ // What the user sees in place of the repository. The repository is an
72
+ // implementation detail of where plugins are stored.
73
+ label: firstSet(
74
+ env.CP_MARKETPLACE_LABEL,
75
+ rc.marketplaceLabel,
76
+ profile.label,
77
+ `${displayName} Marketplace`,
68
78
  ),
69
79
  bin: firstSet(profile.bin, DEFAULT_PROFILE.bin),
70
80
  };
package/src/catalog.js CHANGED
@@ -1,11 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { UserError, assertRepo, assertRef, stripBom } = require('./util');
3
+ const { UserError, assertRepo, assertRef, stripBom, suggest } = require('./util');
4
4
 
5
5
  // Claude Code and Cursor read the same registry shape from different folders.
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) {
@@ -83,7 +102,9 @@ function sourcePathFor(entry, plugin) {
83
102
  * Resolve everything the installers need for one plugin:
84
103
  * the marketplace name (derived unless overridden) and the folder inside the repo.
85
104
  */
86
- async function resolvePlugin({ repo, ref, plugin, marketplace = null, deps = {} }) {
105
+ async function resolvePlugin({ repo, ref, plugin, marketplace = null, label, deps = {} }) {
106
+ // `label` is what the user sees; the repository stays an internal detail.
107
+ const shown = label || `${repo}@${ref}`;
87
108
  const catalog = await loadCatalog({ repo, ref, deps });
88
109
  const entry = entryFor(catalog, plugin);
89
110
 
@@ -91,18 +112,33 @@ async function resolvePlugin({ repo, ref, plugin, marketplace = null, deps = {}
91
112
  const known = catalog.plugins
92
113
  .map((p) => (typeof p === 'string' ? p : p && p.name))
93
114
  .filter(Boolean);
94
- throw new UserError(`Plugin '${plugin}' is not listed in ${repo}@${ref}.`, {
95
- hint: known.length ? `Available: ${known.slice(0, 12).join(', ')}` : undefined,
115
+ const close = suggest(plugin, known);
116
+ throw new UserError(`Plugin '${plugin}' is not listed in ${shown}.`, {
117
+ hint: close.length
118
+ ? `Did you mean: ${close.join(', ')}? Run 'list' to see all ${known.length}.`
119
+ : `Run 'list' to see the ${known.length} available plugins.`,
96
120
  });
97
121
  }
98
122
 
99
123
  const resolvedMarketplace = marketplace || (catalog && catalog.marketplace);
100
124
  if (!resolvedMarketplace) {
101
- throw new UserError(`Could not determine the marketplace name for ${repo}@${ref}.`, {
125
+ throw new UserError(`Could not determine the marketplace name for ${shown}.`, {
102
126
  hint: `No 'name' in ${REGISTRY_FILES[0]}. Pass --marketplace <name>.`,
103
127
  });
104
128
  }
105
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
+
106
142
  return {
107
143
  plugin,
108
144
  repo,
package/src/cli.js CHANGED
@@ -4,13 +4,17 @@ const log = require('./log');
4
4
  const paths = require('./paths');
5
5
  const manifest = require('./manifest');
6
6
  const { resolveBrand } = require('./brand');
7
- const { NAMES } = require('./harness');
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']);
13
- const BOOL_FLAGS = new Set(['force', 'yes', 'verbose', 'quiet', 'json', 'help', 'version']);
14
+ const BOOL_FLAGS = new Set(['force', 'yes', 'long', 'verbose', 'quiet', 'json', 'help', 'version']);
15
+
16
+ // A plugin id longer than this is treated as an outlier when sizing the list grid.
17
+ const OUTLIER_NAME = 36;
14
18
 
15
19
  const camel = (s) => s.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
16
20
  const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
@@ -83,14 +87,16 @@ Usage
83
87
  ${bin} update
84
88
  ${bin} list
85
89
  ${bin} installed
90
+ ${bin} doctor
86
91
 
87
92
  Options
88
- --repo <owner/repo> Marketplace repository (default: ${brand.repo})
93
+ --repo <owner/repo> Use a different marketplace (default: ${brand.label})
89
94
  --ref <branch|tag|sha> Version to install from (default: ${brand.ref})
90
- --marketplace <name> Marketplace name (default: read from marketplace.json)
95
+ --marketplace <name> Marketplace name (default: read from the marketplace)
91
96
  --targets <list> Comma-separated: ${NAMES.join(', ')}, all (skips the prompt)
92
97
  -y, --yes Accept every detected harness without asking
93
98
  --force Replace a plugin installed from another marketplace
99
+ --long Show plugin descriptions (list)
94
100
  --json Machine-readable output (list, installed)
95
101
  --verbose Show underlying git / CLI detail
96
102
  --quiet Suppress progress output
@@ -196,15 +202,78 @@ async function run(argv = process.argv.slice(2), profile = {}) {
196
202
  log.plain(JSON.stringify(result, null, 2));
197
203
  return 0;
198
204
  }
199
- log.banner(`${result.plugins.length} plugin(s) in ${result.repo} (marketplace: ${result.marketplace})`);
200
- for (const p of result.plugins) {
201
- log.plain(` ${p.installed ? '*' : ' '} ${p.name}`);
202
- if (p.description) log.info(p.description);
205
+ const plugins = [...result.plugins].sort((a, b) => a.name.localeCompare(b.name));
206
+ log.banner(`${log.plural(plugins.length, 'plugin')} in ${result.label}`);
207
+ log.plain('');
208
+
209
+ if (flags.long) {
210
+ // Full detail, one plugin per block.
211
+ for (const p of plugins) {
212
+ const mark = p.installed ? log.MARK : ' ';
213
+ log.plain(` ${mark} ${log.bold(p.name)}`);
214
+ if (p.description) log.info(p.description);
215
+ }
216
+ } else {
217
+ // A grid of names. Descriptions in a marketplace are long and largely
218
+ // boilerplate, so a truncated column of them shows the same prefix on
219
+ // every row and crowds out the one thing that differs.
220
+ // Sized to the longest name that is not an outlier. Using the true
221
+ // maximum lets one very long id set the width for every column and
222
+ // collapse the grid; using a percentile makes a third of the rows
223
+ // ragged. Ignoring only the outliers keeps every ordinary row aligned.
224
+ const lengths = plugins.map((p) => p.name.length);
225
+ const cell = Math.max(16, ...lengths.filter((l) => l <= OUTLIER_NAME)) + 3;
226
+ const cols = Math.max(1, Math.floor((log.width(120) - 2) / cell));
227
+ const rows = Math.ceil(plugins.length / cols);
228
+ for (let r = 0; r < rows; r += 1) {
229
+ let line = ' ';
230
+ for (let c = 0; c < cols; c += 1) {
231
+ const p = plugins[c * rows + r]; // column-major keeps A-Z reading down
232
+ if (!p) continue;
233
+ line += `${p.installed ? log.MARK : ' '} ${p.name.padEnd(cell - 2)}`;
234
+ }
235
+ log.plain(line.trimEnd());
236
+ }
203
237
  }
238
+
204
239
  log.plain('');
205
- log.info('* = installed on this machine');
240
+ const count = plugins.filter((p) => p.installed).length;
241
+ if (count) log.info(`${log.MARK} installed on this machine (${count})`);
242
+ if (!flags.long) log.info(`Run \`${bin} list --long\` for descriptions.`);
243
+ log.info(`Install one with \`${bin} install <plugin>\`.`);
206
244
  return 0;
207
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
+ }
208
277
  case 'installed': {
209
278
  const entries = manifest.list(paths.manifestPath());
210
279
  if (flags.json) {
@@ -212,14 +281,19 @@ async function run(argv = process.argv.slice(2), profile = {}) {
212
281
  return 0;
213
282
  }
214
283
  if (!entries.length) {
215
- log.info('No plugins installed by this tool yet.');
284
+ log.info('No plugins installed yet.');
285
+ log.info(`Browse what is available with: ${bin} list`);
216
286
  return 0;
217
287
  }
218
- log.banner(`${entries.length} plugin(s) installed`);
288
+ log.banner(`${log.plural(entries.length, 'plugin')} installed`);
289
+ log.plain('');
290
+ const idWidth = Math.min(Math.max(...entries.map((e) => e.plugin.length), 4), 42);
219
291
  for (const e of entries) {
220
- log.plain(` ${e.plugin} [${(e.targets || []).join(', ')}]`);
221
- log.info(`${e.repo}@${e.ref} (marketplace: ${e.marketplace})`);
292
+ const where = (e.targets || []).map((n) => (byName(n) ? byName(n).title : n));
293
+ log.plain(` ${e.plugin.padEnd(idWidth)} ${log.dim(where.join(', '))}`);
294
+ log.debug(`${e.repo}@${e.ref} (marketplace: ${e.marketplace})`);
222
295
  }
296
+ log.plain('');
223
297
  return 0;
224
298
  }
225
299
  default:
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 };
package/src/fetch.js CHANGED
@@ -52,7 +52,8 @@ async function materialize({ repo, ref, sourcePath, deps = {} }) {
52
52
  async function viaGit({ git, repo, ref, sourcePath, work }) {
53
53
  const clone = path.join(work, 'repo');
54
54
  const url = `https://github.com/${repo}.git`;
55
- log.info(`Fetching marketplace via git (${repo}@${ref}) ...`);
55
+ log.info('Fetching marketplace via git ...');
56
+ log.debug(`${url} (${ref}), sparse path ${sourcePath}`);
56
57
 
57
58
  const base = ['clone', '--quiet', '--depth', '1', '--filter=blob:none', '--sparse'];
58
59
 
@@ -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 };
@@ -5,7 +5,7 @@ const path = require('path');
5
5
 
6
6
  const log = require('../log');
7
7
  const paths = require('../paths');
8
- const { replaceDir, rmrf, exists } = require('../util');
8
+ const { replaceDir, rmrf, exists, shortPath } = require('../util');
9
9
 
10
10
  const name = 'cursor';
11
11
  const title = 'Cursor';
@@ -27,7 +27,7 @@ async function install({ plugin, srcDir }, opts) {
27
27
  fs.mkdirSync(path.dirname(dest), { recursive: true });
28
28
  replaceDir(srcDir, dest);
29
29
 
30
- log.ok(`Installed -> ${dest}`);
30
+ log.ok(`Installed -> ${shortPath(dest)}`);
31
31
  log.info('Please reload Cursor: Ctrl+Shift+P (Cmd+Shift+P) -> Developer: Reload Window');
32
32
  return true;
33
33
  }
@@ -35,13 +35,15 @@ async function install({ plugin, srcDir }, opts) {
35
35
  async function uninstall({ plugin }, opts) {
36
36
  const dest = destFor(plugin, opts);
37
37
  if (!exists(dest)) {
38
- log.info(`Nothing to remove at ${dest}`);
38
+ log.info(`Nothing to remove at ${shortPath(dest)}`);
39
39
  return false;
40
40
  }
41
41
  rmrf(dest);
42
- log.ok(`Removed -> ${dest}`);
42
+ log.ok(`Removed -> ${shortPath(dest)}`);
43
43
  log.info('Please reload Cursor: Ctrl+Shift+P (Cmd+Shift+P) -> Developer: Reload Window');
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 };
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  const log = require('../log');
6
6
  const paths = require('../paths');
7
7
  const { addPluginLocation, removePluginLocation } = require('../settings-merge');
8
- const { replaceDir, rmrf, exists } = require('../util');
8
+ const { replaceDir, rmrf, exists, shortPath } = require('../util');
9
9
 
10
10
  // VS Code loads a plugin from anywhere on disk once the folder is listed in
11
11
  // `chat.pluginLocations`, so we keep our own copy under the state dir rather
@@ -19,7 +19,7 @@ const destFor = (plugin, opts) => path.join(paths.vscodeStoreDir(opts), plugin);
19
19
 
20
20
  async function install({ plugin, srcDir }, opts) {
21
21
  if (!detect(opts)) {
22
- log.warn(`${paths.vscodeUserDir(opts)} not found - VS Code not installed, skipping.`);
22
+ log.warn(`${shortPath(paths.vscodeUserDir(opts))} not found - VS Code not installed, skipping.`);
23
23
  return false;
24
24
  }
25
25
 
@@ -29,9 +29,9 @@ async function install({ plugin, srcDir }, opts) {
29
29
  const settings = paths.vscodeSettingsPath(opts);
30
30
  const result = addPluginLocation(settings, dest);
31
31
 
32
- log.ok(`Installed -> ${dest}`);
33
- if (result.action === 'already') log.info(`Already registered in ${settings}`);
34
- else log.info(`Registered in chat.pluginLocations (${settings})`);
32
+ log.ok(`Installed -> ${shortPath(dest)}`);
33
+ if (result.action === 'already') log.info(`Already registered in ${shortPath(settings)}`);
34
+ else log.info(`Registered in chat.pluginLocations (${shortPath(settings)})`);
35
35
  // The backup always happens; it is only worth mentioning when asked for detail.
36
36
  if (result.backup) log.debug(`Backed up settings.json -> ${path.basename(result.backup)}`);
37
37
  log.info('Please reload VS Code: Ctrl+Shift+P (Cmd+Shift+P) -> Developer: Reload Window');
@@ -46,14 +46,16 @@ async function uninstall({ plugin }, opts) {
46
46
  if (had) rmrf(dest);
47
47
 
48
48
  if (!had && result.action !== 'removed') {
49
- log.info(`Nothing to remove at ${dest}`);
49
+ log.info(`Nothing to remove at ${shortPath(dest)}`);
50
50
  return false;
51
51
  }
52
- log.ok(`Removed -> ${dest}`);
53
- if (result.action === 'removed') log.info(`Unregistered from chat.pluginLocations (${settings})`);
52
+ log.ok(`Removed -> ${shortPath(dest)}`);
53
+ if (result.action === 'removed') log.info(`Unregistered from chat.pluginLocations (${shortPath(settings)})`);
54
54
  if (result.backup) log.debug(`Backed up settings.json -> ${path.basename(result.backup)}`);
55
55
  log.info('Please reload VS Code: Ctrl+Shift+P (Cmd+Shift+P) -> Developer: Reload Window');
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
 
@@ -55,10 +55,9 @@ function assertNoMarketplaceConflict(manifestFile, { plugin, repo }, force) {
55
55
  .list(manifestFile)
56
56
  .find((p) => p.plugin === plugin && (p.repo || '') !== repo);
57
57
  if (clash) {
58
- throw new UserError(
59
- `'${plugin}' is already installed from a different marketplace (${clash.repo}).`,
60
- { hint: `Uninstall it first, or re-run with --force to replace it.` },
61
- );
58
+ throw new UserError(`'${plugin}' is already installed from a different marketplace.`, {
59
+ hint: 'Uninstall it first, or re-run with --force to replace it.',
60
+ });
62
61
  }
63
62
  }
64
63
 
@@ -81,29 +80,51 @@ async function installPlugin({
81
80
  ref: effectiveRef,
82
81
  plugin,
83
82
  marketplace: brand.id,
83
+ label: brand.label,
84
84
  deps,
85
85
  });
86
86
 
87
87
  const requested = resolveTargets(targets);
88
88
  assertNoMarketplaceConflict(manifestFile, { plugin, repo: brand.repo }, force);
89
89
 
90
- log.banner(`Installing '${plugin}' (marketplace: ${resolved.marketplace}, repo: ${brand.repo}@${effectiveRef})`);
90
+ const from = effectiveRef === 'main' ? brand.label : `${brand.label} (${effectiveRef})`;
91
+ log.banner(`Installing '${plugin}' from ${from}`);
92
+ log.debug(`source: ${brand.repo}@${effectiveRef}, marketplace: ${resolved.marketplace}`);
91
93
  if (resolved.description) log.info(resolved.description);
92
94
  log.rule();
93
95
 
94
96
  log.step('[Harnesses]');
97
+ const explicit = Array.isArray(targets) && targets.length > 0;
95
98
  const available = requested.filter((name) => byName(name).detect(pathOpts));
96
- for (const name of requested) {
97
- 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)}).`);
98
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.
99
109
  if (!available.length) {
100
- log.plain('');
101
- log.warn('No supported harness found. Install Claude Code, Cursor, or VS Code first.');
102
- 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(', ')}.`);
103
124
  }
104
125
 
105
126
  const want = await chooseHarnesses(available, {
106
- explicit: Array.isArray(targets) && targets.length > 0,
127
+ explicit,
107
128
  assumeYes,
108
129
  confirm: deps.confirm,
109
130
  });
@@ -180,7 +201,7 @@ async function uninstallPlugin({ brand, plugin, targets, deps = {}, pathOpts } =
180
201
  ).marketplace;
181
202
  }
182
203
 
183
- log.banner(`Uninstalling '${plugin}' (repo: ${brand.repo})`);
204
+ log.banner(`Uninstalling '${plugin}' from ${brand.label}`);
184
205
  log.info(`Removing from: ${want.map((n) => byName(n).title).join(', ')}`);
185
206
  log.rule();
186
207
 
@@ -209,13 +230,19 @@ async function updateAll({ brand, force = false, deps = {}, pathOpts } = {}) {
209
230
  const manifestFile = paths.manifestPath(pathOpts);
210
231
  const entries = manifest.list(manifestFile);
211
232
  if (!entries.length) {
212
- log.warn(`Nothing recorded in ${manifestFile} - install a plugin first.`);
233
+ log.warn('No plugins installed yet - nothing to update.');
213
234
  return { updated: [], failed: [] };
214
235
  }
215
236
 
216
- log.banner(`Updating ${entries.length} plugin(s) from ${manifestFile}`);
237
+ log.banner(`Updating ${log.plural(entries.length, 'plugin')}`);
238
+ log.plain('');
217
239
  const updated = [];
218
240
  const failed = [];
241
+ // Each plugin would otherwise print a full install report; at five plugins
242
+ // that is sixty lines of scrollback to find one failure in. Collapse to a
243
+ // line each unless --verbose asked for the detail.
244
+ const collapse = !log.isVerbose && !log.isQuiet;
245
+ const idWidth = Math.min(Math.max(...entries.map((e) => e.plugin.length), 4), 42);
219
246
 
220
247
  for (const entry of entries) {
221
248
  const entryBrand = Object.freeze({
@@ -224,8 +251,9 @@ async function updateAll({ brand, force = false, deps = {}, pathOpts } = {}) {
224
251
  ref: entry.ref || brand.ref,
225
252
  id: entry.marketplace || brand.id,
226
253
  });
254
+ if (collapse) log.setQuiet(true);
227
255
  try {
228
- await installPlugin({
256
+ const result = await installPlugin({
229
257
  brand: entryBrand,
230
258
  plugin: entry.plugin,
231
259
  ref: entry.ref,
@@ -235,23 +263,32 @@ async function updateAll({ brand, force = false, deps = {}, pathOpts } = {}) {
235
263
  deps,
236
264
  pathOpts,
237
265
  });
266
+ if (collapse) log.setQuiet(false);
238
267
  updated.push(entry.plugin);
268
+ const where = (result.targets || []).map((n) => byName(n).title).join(', ');
269
+ if (collapse) log.ok(`${entry.plugin.padEnd(idWidth)} ${log.dim(where)}`);
239
270
  } catch (err) {
271
+ if (collapse) log.setQuiet(false);
240
272
  failed.push({ plugin: entry.plugin, error: err.message });
241
- log.error(`${entry.plugin}: ${err.message}`);
273
+ log.error(`${entry.plugin.padEnd(idWidth)} ${err.message}`);
242
274
  }
243
275
  }
244
276
 
277
+ log.plain('');
245
278
  log.rule();
246
- log.ok(`Updated ${updated.length}/${entries.length}`);
247
- if (failed.length) log.warn(`Failed: ${failed.map((f) => f.plugin).join(', ')}`);
279
+ if (failed.length) {
280
+ log.warn(`Updated ${updated.length} of ${entries.length}; failed: ${failed.map((f) => f.plugin).join(', ')}`);
281
+ } else {
282
+ log.ok(`Updated ${log.plural(updated.length, 'plugin')}`);
283
+ }
284
+ log.plain('');
248
285
  return { updated, failed };
249
286
  }
250
287
 
251
288
  async function listPlugins({ brand, deps = {}, pathOpts } = {}) {
252
289
  const catalog = await loadCatalog({ repo: brand.repo, ref: brand.ref, deps });
253
290
  if (!catalog) {
254
- throw new UserError(`No marketplace registry found in ${brand.repo}@${brand.ref}.`, {
291
+ throw new UserError(`Could not read ${brand.label}.`, {
255
292
  hint: 'Check --repo, or the branch you pointed at with --ref.',
256
293
  });
257
294
  }
@@ -262,6 +299,7 @@ async function listPlugins({ brand, deps = {}, pathOpts } = {}) {
262
299
  .map((p) => p.plugin),
263
300
  );
264
301
  return {
302
+ label: brand.label,
265
303
  marketplace: catalog.marketplace,
266
304
  repo: brand.repo,
267
305
  plugins: catalog.plugins.map((p) => {
package/src/log.js CHANGED
@@ -20,9 +20,72 @@ function unicodeSupported() {
20
20
  }
21
21
 
22
22
  // Every prefix is padded to the same width so continuation lines line up.
23
+ const INDENT = 6;
23
24
  const TICK = unicodeSupported() ? ` ${String.fromCharCode(0x2713)} ` : ' OK ';
24
25
  const BANG = ' !! ';
25
26
  const CROSS = ' XX ';
27
+ const MARK = unicodeSupported() ? String.fromCharCode(0x2713) : '*';
28
+
29
+ /**
30
+ * Terminal width, clamped: prose past ~78 columns is harder to read, not easier.
31
+ * COLUMNS is honoured so redirected output and tests can pin a width.
32
+ */
33
+ function width(max = 78) {
34
+ const cols = process.stdout.columns || Number(process.env.COLUMNS) || 80;
35
+ return Math.max(40, Math.min(cols - 1, max));
36
+ }
37
+
38
+ const ASCII_MAP = {
39
+ '—': '-', // em dash
40
+ '–': '-', // en dash
41
+ '‘': "'",
42
+ '’': "'",
43
+ '“': '"',
44
+ '”': '"',
45
+ '…': '...',
46
+ '•': '*',
47
+ '→': '->',
48
+ ' ': ' ',
49
+ };
50
+
51
+ /**
52
+ * Marketplace descriptions are third-party text and routinely contain em dashes
53
+ * and smart quotes. On a console that cannot render them they arrive as
54
+ * mojibake, so downgrade to ASCII rather than print garbage.
55
+ */
56
+ function toAscii(text) {
57
+ let out = String(text).replace(/[—–‘’“”…•→ ]/g, (c) => ASCII_MAP[c]);
58
+ out = out.normalize('NFD').replace(/[̀-ͯ]/g, ''); // strip diacritics
59
+ // ESC is kept: colour codes must survive, they are not text.
60
+ return out.replace(/[^\x1b\x20-\x7e\t\r\n]/g, '?');
61
+ }
62
+
63
+ const ascii = (text) => (unicodeSupported() ? text : toAscii(text));
64
+
65
+ /** Wrap to the terminal, keeping long unbreakable tokens (paths, URLs) intact. */
66
+ function wrap(text, indent = INDENT, max = width()) {
67
+ const room = Math.max(20, max - indent);
68
+ const lines = [];
69
+ let line = '';
70
+ for (const word of String(text).split(/\s+/).filter(Boolean)) {
71
+ if (!line) line = word;
72
+ else if (line.length + 1 + word.length <= room) line += ` ${word}`;
73
+ else {
74
+ lines.push(line);
75
+ line = word;
76
+ }
77
+ }
78
+ if (line) lines.push(line);
79
+ return lines.length ? lines : [''];
80
+ }
81
+
82
+ function truncate(text, max) {
83
+ const clean = String(text).trim();
84
+ if (clean.length <= max) return clean;
85
+ return `${clean.slice(0, Math.max(1, max - 3)).trimEnd()}...`;
86
+ }
87
+
88
+ const plural = (n, word, suffix = 's') => `${n} ${word}${n === 1 ? '' : suffix}`;
26
89
 
27
90
  function colorEnabled() {
28
91
  if (process.env.NO_COLOR !== undefined) return false;
@@ -44,33 +107,57 @@ const log = {
44
107
  get isVerbose() {
45
108
  return state.verbose;
46
109
  },
110
+ get isQuiet() {
111
+ return state.quiet;
112
+ },
47
113
  banner(msg) {
48
- if (!state.quiet) console.log(`\n${paint('36', msg)}`);
114
+ if (!state.quiet) console.log(`\n${paint('36', ascii(msg))}`);
49
115
  },
50
116
  rule() {
51
- if (!state.quiet) console.log(paint('90', '-'.repeat(64)));
117
+ if (!state.quiet) console.log(paint('90', '-'.repeat(width())));
52
118
  },
53
119
  step(msg) {
54
- if (!state.quiet) console.log(`\n${paint('1', msg)}`);
120
+ if (!state.quiet) console.log(`\n${paint('1', ascii(msg))}`);
55
121
  },
56
122
  ok(msg) {
57
- if (!state.quiet) console.log(`${paint('32', TICK)}${msg}`);
123
+ if (!state.quiet) console.log(`${paint('32', TICK)}${ascii(msg)}`);
58
124
  },
125
+ /** Wrapped and indented, so long descriptions stay readable at any width. */
59
126
  info(msg) {
60
- if (!state.quiet) console.log(paint('90', ` ${msg}`));
127
+ if (state.quiet) return;
128
+ for (const line of wrap(ascii(msg))) console.log(paint('90', ` ${line}`));
61
129
  },
62
130
  warn(msg) {
63
- if (!state.quiet) console.log(`${paint('33', BANG)}${msg}`);
131
+ if (state.quiet) return;
132
+ const [first, ...rest] = wrap(ascii(msg));
133
+ console.log(`${paint('33', BANG)}${first}`);
134
+ for (const line of rest) console.log(paint('33', ` ${line}`));
64
135
  },
65
136
  error(msg) {
66
- console.error(`${paint('31', CROSS)}${msg}`);
137
+ const [first, ...rest] = wrap(ascii(msg));
138
+ console.error(`${paint('31', CROSS)}${first}`);
139
+ for (const line of rest) console.error(paint('31', ` ${line}`));
67
140
  },
68
141
  debug(msg) {
69
- if (state.verbose && !state.quiet) console.log(paint('90', ` .. ${msg}`));
142
+ if (state.verbose && !state.quiet) console.log(paint('90', ` .. ${ascii(msg)}`));
70
143
  },
71
144
  plain(msg = '') {
72
- if (!state.quiet) console.log(msg);
145
+ if (!state.quiet) console.log(ascii(msg));
146
+ },
147
+ dim(msg) {
148
+ return paint('90', msg);
149
+ },
150
+ bold(msg) {
151
+ return paint('1', msg);
73
152
  },
153
+ MARK,
154
+ width,
155
+ wrap,
156
+ truncate,
157
+ plural,
158
+ ascii,
159
+ toAscii,
160
+ unicodeSupported,
74
161
  };
75
162
 
76
163
  module.exports = log;
@@ -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
+ };
package/src/util.js CHANGED
@@ -36,7 +36,7 @@ function assertPlugin(id) {
36
36
  function assertRepo(repo) {
37
37
  if (typeof repo !== 'string' || !REPO_RE.test(repo)) {
38
38
  throw new UserError(`Invalid repo: ${JSON.stringify(repo)}`, {
39
- hint: 'Expected owner/repo, e.g. context-plugins/plugin-marketplace',
39
+ hint: 'Expected owner/repo, e.g. acme/plugin-marketplace',
40
40
  });
41
41
  }
42
42
  return repo;
@@ -208,6 +208,63 @@ function timestamp(date = new Date()) {
208
208
 
209
209
  const stripBom = (s) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s);
210
210
 
211
+ /** Display form of a path: `~` beats repeating the user's home directory back at them. */
212
+ function shortPath(target, home = require('os').homedir()) {
213
+ if (!target || !home) return target;
214
+ const normalized = String(target);
215
+ if (normalized.toLowerCase().startsWith(home.toLowerCase())) {
216
+ const rest = normalized.slice(home.length);
217
+ return `~${rest.startsWith(path.sep) || rest.startsWith('/') ? '' : path.sep}${rest}`;
218
+ }
219
+ return normalized;
220
+ }
221
+
222
+ /** Edit distance, capped work for the short strings we compare (plugin ids). */
223
+ function editDistance(a, b) {
224
+ const rows = a.length + 1;
225
+ const cols = b.length + 1;
226
+ let prev = Array.from({ length: cols }, (_v, i) => i);
227
+ for (let i = 1; i < rows; i += 1) {
228
+ const curr = [i];
229
+ for (let j = 1; j < cols; j += 1) {
230
+ curr[j] = Math.min(
231
+ prev[j] + 1,
232
+ curr[j - 1] + 1,
233
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
234
+ );
235
+ }
236
+ prev = curr;
237
+ }
238
+ return prev[cols - 1];
239
+ }
240
+
241
+ const sharedPrefix = (a, b) => {
242
+ let i = 0;
243
+ while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
244
+ return i;
245
+ };
246
+
247
+ /**
248
+ * Closest names to `query`. Substring hits rank first, then names sharing a
249
+ * substantial prefix - plugin ids in a marketplace tend to share a suffix, so
250
+ * plain edit distance alone rates `azure-cognitve` as too far from
251
+ * `azure-cognitive-sdk` to be worth offering.
252
+ */
253
+ function suggest(query, candidates, limit = 3) {
254
+ const q = String(query).toLowerCase();
255
+ const threshold = Math.max(3, Math.ceil(q.length * 0.4));
256
+ const scored = candidates
257
+ .map((name) => {
258
+ const n = String(name).toLowerCase();
259
+ if (n.includes(q) || q.includes(n)) return { name, score: 0 };
260
+ if (sharedPrefix(q, n) >= Math.max(4, Math.ceil(q.length * 0.6))) return { name, score: 1 };
261
+ return { name, score: editDistance(q, n) };
262
+ })
263
+ .filter((c) => c.score <= threshold)
264
+ .sort((a, b) => a.score - b.score || a.name.length - b.name.length);
265
+ return scored.slice(0, limit).map((c) => c.name);
266
+ }
267
+
211
268
  module.exports = {
212
269
  UserError,
213
270
  assertPlugin,
@@ -226,4 +283,7 @@ module.exports = {
226
283
  pool,
227
284
  timestamp,
228
285
  stripBom,
286
+ shortPath,
287
+ editDistance,
288
+ suggest,
229
289
  };