context-plugins 0.2.0 → 0.3.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
@@ -27,7 +27,7 @@ context-plugins installed # what this machine has
27
27
 
28
28
  | Option | Default | Description |
29
29
  | --- | --- | --- |
30
- | `--repo <owner/repo>` | `context-plugins/plugin-marketplace` | Marketplace repository |
30
+ | `--repo <owner/repo>` | the bundled marketplace | Install from a different marketplace |
31
31
  | `--ref <branch\|tag\|sha>` | `main` | Version to install from |
32
32
  | `--marketplace <name>` | read from `marketplace.json` | Marketplace name |
33
33
  | `--targets <list>` | ask | `claude`, `cursor`, `vscode`, or `all` — skips the prompt |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-plugins",
3
- "version": "0.2.0",
3
+ "version": "0.3.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/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,6 +1,6 @@
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.
@@ -83,7 +83,9 @@ function sourcePathFor(entry, plugin) {
83
83
  * Resolve everything the installers need for one plugin:
84
84
  * the marketplace name (derived unless overridden) and the folder inside the repo.
85
85
  */
86
- async function resolvePlugin({ repo, ref, plugin, marketplace = null, deps = {} }) {
86
+ async function resolvePlugin({ repo, ref, plugin, marketplace = null, label, deps = {} }) {
87
+ // `label` is what the user sees; the repository stays an internal detail.
88
+ const shown = label || `${repo}@${ref}`;
87
89
  const catalog = await loadCatalog({ repo, ref, deps });
88
90
  const entry = entryFor(catalog, plugin);
89
91
 
@@ -91,14 +93,17 @@ async function resolvePlugin({ repo, ref, plugin, marketplace = null, deps = {}
91
93
  const known = catalog.plugins
92
94
  .map((p) => (typeof p === 'string' ? p : p && p.name))
93
95
  .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,
96
+ const close = suggest(plugin, known);
97
+ throw new UserError(`Plugin '${plugin}' is not listed in ${shown}.`, {
98
+ hint: close.length
99
+ ? `Did you mean: ${close.join(', ')}? Run 'list' to see all ${known.length}.`
100
+ : `Run 'list' to see the ${known.length} available plugins.`,
96
101
  });
97
102
  }
98
103
 
99
104
  const resolvedMarketplace = marketplace || (catalog && catalog.marketplace);
100
105
  if (!resolvedMarketplace) {
101
- throw new UserError(`Could not determine the marketplace name for ${repo}@${ref}.`, {
106
+ throw new UserError(`Could not determine the marketplace name for ${shown}.`, {
102
107
  hint: `No 'name' in ${REGISTRY_FILES[0]}. Pass --marketplace <name>.`,
103
108
  });
104
109
  }
package/src/cli.js CHANGED
@@ -4,13 +4,16 @@ 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
10
  const pkg = require('../package.json');
11
11
 
12
12
  const VALUE_FLAGS = new Set(['repo', 'ref', 'marketplace', 'targets']);
13
- const BOOL_FLAGS = new Set(['force', 'yes', 'verbose', 'quiet', 'json', 'help', 'version']);
13
+ const BOOL_FLAGS = new Set(['force', 'yes', 'long', 'verbose', 'quiet', 'json', 'help', 'version']);
14
+
15
+ // A plugin id longer than this is treated as an outlier when sizing the list grid.
16
+ const OUTLIER_NAME = 36;
14
17
 
15
18
  const camel = (s) => s.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
16
19
  const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
@@ -85,12 +88,13 @@ Usage
85
88
  ${bin} installed
86
89
 
87
90
  Options
88
- --repo <owner/repo> Marketplace repository (default: ${brand.repo})
91
+ --repo <owner/repo> Use a different marketplace (default: ${brand.label})
89
92
  --ref <branch|tag|sha> Version to install from (default: ${brand.ref})
90
- --marketplace <name> Marketplace name (default: read from marketplace.json)
93
+ --marketplace <name> Marketplace name (default: read from the marketplace)
91
94
  --targets <list> Comma-separated: ${NAMES.join(', ')}, all (skips the prompt)
92
95
  -y, --yes Accept every detected harness without asking
93
96
  --force Replace a plugin installed from another marketplace
97
+ --long Show plugin descriptions (list)
94
98
  --json Machine-readable output (list, installed)
95
99
  --verbose Show underlying git / CLI detail
96
100
  --quiet Suppress progress output
@@ -196,13 +200,45 @@ async function run(argv = process.argv.slice(2), profile = {}) {
196
200
  log.plain(JSON.stringify(result, null, 2));
197
201
  return 0;
198
202
  }
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);
203
+ const plugins = [...result.plugins].sort((a, b) => a.name.localeCompare(b.name));
204
+ log.banner(`${log.plural(plugins.length, 'plugin')} in ${result.label}`);
205
+ log.plain('');
206
+
207
+ if (flags.long) {
208
+ // Full detail, one plugin per block.
209
+ for (const p of plugins) {
210
+ const mark = p.installed ? log.MARK : ' ';
211
+ log.plain(` ${mark} ${log.bold(p.name)}`);
212
+ if (p.description) log.info(p.description);
213
+ }
214
+ } else {
215
+ // A grid of names. Descriptions in a marketplace are long and largely
216
+ // boilerplate, so a truncated column of them shows the same prefix on
217
+ // every row and crowds out the one thing that differs.
218
+ // Sized to the longest name that is not an outlier. Using the true
219
+ // maximum lets one very long id set the width for every column and
220
+ // collapse the grid; using a percentile makes a third of the rows
221
+ // ragged. Ignoring only the outliers keeps every ordinary row aligned.
222
+ const lengths = plugins.map((p) => p.name.length);
223
+ const cell = Math.max(16, ...lengths.filter((l) => l <= OUTLIER_NAME)) + 3;
224
+ const cols = Math.max(1, Math.floor((log.width(120) - 2) / cell));
225
+ const rows = Math.ceil(plugins.length / cols);
226
+ for (let r = 0; r < rows; r += 1) {
227
+ let line = ' ';
228
+ for (let c = 0; c < cols; c += 1) {
229
+ const p = plugins[c * rows + r]; // column-major keeps A-Z reading down
230
+ if (!p) continue;
231
+ line += `${p.installed ? log.MARK : ' '} ${p.name.padEnd(cell - 2)}`;
232
+ }
233
+ log.plain(line.trimEnd());
234
+ }
203
235
  }
236
+
204
237
  log.plain('');
205
- log.info('* = installed on this machine');
238
+ const count = plugins.filter((p) => p.installed).length;
239
+ if (count) log.info(`${log.MARK} installed on this machine (${count})`);
240
+ if (!flags.long) log.info(`Run \`${bin} list --long\` for descriptions.`);
241
+ log.info(`Install one with \`${bin} install <plugin>\`.`);
206
242
  return 0;
207
243
  }
208
244
  case 'installed': {
@@ -212,14 +248,19 @@ async function run(argv = process.argv.slice(2), profile = {}) {
212
248
  return 0;
213
249
  }
214
250
  if (!entries.length) {
215
- log.info('No plugins installed by this tool yet.');
251
+ log.info('No plugins installed yet.');
252
+ log.info(`Browse what is available with: ${bin} list`);
216
253
  return 0;
217
254
  }
218
- log.banner(`${entries.length} plugin(s) installed`);
255
+ log.banner(`${log.plural(entries.length, 'plugin')} installed`);
256
+ log.plain('');
257
+ const idWidth = Math.min(Math.max(...entries.map((e) => e.plugin.length), 4), 42);
219
258
  for (const e of entries) {
220
- log.plain(` ${e.plugin} [${(e.targets || []).join(', ')}]`);
221
- log.info(`${e.repo}@${e.ref} (marketplace: ${e.marketplace})`);
259
+ const where = (e.targets || []).map((n) => (byName(n) ? byName(n).title : n));
260
+ log.plain(` ${e.plugin.padEnd(idWidth)} ${log.dim(where.join(', '))}`);
261
+ log.debug(`${e.repo}@${e.ref} (marketplace: ${e.marketplace})`);
222
262
  }
263
+ log.plain('');
223
264
  return 0;
224
265
  }
225
266
  default:
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
 
@@ -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,11 +35,11 @@ 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
  }
@@ -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,11 +46,11 @@ 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;
package/src/install.js CHANGED
@@ -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,13 +80,16 @@ 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
 
@@ -180,7 +182,7 @@ async function uninstallPlugin({ brand, plugin, targets, deps = {}, pathOpts } =
180
182
  ).marketplace;
181
183
  }
182
184
 
183
- log.banner(`Uninstalling '${plugin}' (repo: ${brand.repo})`);
185
+ log.banner(`Uninstalling '${plugin}' from ${brand.label}`);
184
186
  log.info(`Removing from: ${want.map((n) => byName(n).title).join(', ')}`);
185
187
  log.rule();
186
188
 
@@ -209,13 +211,19 @@ async function updateAll({ brand, force = false, deps = {}, pathOpts } = {}) {
209
211
  const manifestFile = paths.manifestPath(pathOpts);
210
212
  const entries = manifest.list(manifestFile);
211
213
  if (!entries.length) {
212
- log.warn(`Nothing recorded in ${manifestFile} - install a plugin first.`);
214
+ log.warn('No plugins installed yet - nothing to update.');
213
215
  return { updated: [], failed: [] };
214
216
  }
215
217
 
216
- log.banner(`Updating ${entries.length} plugin(s) from ${manifestFile}`);
218
+ log.banner(`Updating ${log.plural(entries.length, 'plugin')}`);
219
+ log.plain('');
217
220
  const updated = [];
218
221
  const failed = [];
222
+ // Each plugin would otherwise print a full install report; at five plugins
223
+ // that is sixty lines of scrollback to find one failure in. Collapse to a
224
+ // line each unless --verbose asked for the detail.
225
+ const collapse = !log.isVerbose && !log.isQuiet;
226
+ const idWidth = Math.min(Math.max(...entries.map((e) => e.plugin.length), 4), 42);
219
227
 
220
228
  for (const entry of entries) {
221
229
  const entryBrand = Object.freeze({
@@ -224,8 +232,9 @@ async function updateAll({ brand, force = false, deps = {}, pathOpts } = {}) {
224
232
  ref: entry.ref || brand.ref,
225
233
  id: entry.marketplace || brand.id,
226
234
  });
235
+ if (collapse) log.setQuiet(true);
227
236
  try {
228
- await installPlugin({
237
+ const result = await installPlugin({
229
238
  brand: entryBrand,
230
239
  plugin: entry.plugin,
231
240
  ref: entry.ref,
@@ -235,23 +244,32 @@ async function updateAll({ brand, force = false, deps = {}, pathOpts } = {}) {
235
244
  deps,
236
245
  pathOpts,
237
246
  });
247
+ if (collapse) log.setQuiet(false);
238
248
  updated.push(entry.plugin);
249
+ const where = (result.targets || []).map((n) => byName(n).title).join(', ');
250
+ if (collapse) log.ok(`${entry.plugin.padEnd(idWidth)} ${log.dim(where)}`);
239
251
  } catch (err) {
252
+ if (collapse) log.setQuiet(false);
240
253
  failed.push({ plugin: entry.plugin, error: err.message });
241
- log.error(`${entry.plugin}: ${err.message}`);
254
+ log.error(`${entry.plugin.padEnd(idWidth)} ${err.message}`);
242
255
  }
243
256
  }
244
257
 
258
+ log.plain('');
245
259
  log.rule();
246
- log.ok(`Updated ${updated.length}/${entries.length}`);
247
- if (failed.length) log.warn(`Failed: ${failed.map((f) => f.plugin).join(', ')}`);
260
+ if (failed.length) {
261
+ log.warn(`Updated ${updated.length} of ${entries.length}; failed: ${failed.map((f) => f.plugin).join(', ')}`);
262
+ } else {
263
+ log.ok(`Updated ${log.plural(updated.length, 'plugin')}`);
264
+ }
265
+ log.plain('');
248
266
  return { updated, failed };
249
267
  }
250
268
 
251
269
  async function listPlugins({ brand, deps = {}, pathOpts } = {}) {
252
270
  const catalog = await loadCatalog({ repo: brand.repo, ref: brand.ref, deps });
253
271
  if (!catalog) {
254
- throw new UserError(`No marketplace registry found in ${brand.repo}@${brand.ref}.`, {
272
+ throw new UserError(`Could not read ${brand.label}.`, {
255
273
  hint: 'Check --repo, or the branch you pointed at with --ref.',
256
274
  });
257
275
  }
@@ -262,6 +280,7 @@ async function listPlugins({ brand, deps = {}, pathOpts } = {}) {
262
280
  .map((p) => p.plugin),
263
281
  );
264
282
  return {
283
+ label: brand.label,
265
284
  marketplace: catalog.marketplace,
266
285
  repo: brand.repo,
267
286
  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;
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
  };