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 +32 -1
- package/bin/cli.js +5 -0
- package/package.json +1 -1
- package/run.js +1 -0
- package/src/brand.js +15 -5
- package/src/catalog.js +42 -6
- package/src/cli.js +87 -13
- package/src/doctor.js +171 -0
- package/src/fetch.js +2 -1
- package/src/harness/claude.js +33 -6
- package/src/harness/cursor.js +7 -5
- package/src/harness/vscode.js +11 -9
- package/src/install.js +58 -20
- package/src/log.js +96 -9
- package/src/require-node.js +23 -0
- package/src/util.js +61 -1
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>` |
|
|
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
package/run.js
CHANGED
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(`
|
|
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
|
-
|
|
95
|
-
|
|
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 ${
|
|
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>
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
221
|
-
log.
|
|
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(
|
|
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
|
|
package/src/harness/claude.js
CHANGED
|
@@ -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.
|
|
47
|
+
log.debug(`marketplace add returned ${added.code} (likely already added) - continuing.`);
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
const
|
|
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
|
|
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
|
-
|
|
82
|
+
const location = () => 'claude on PATH';
|
|
83
|
+
|
|
84
|
+
module.exports = { name, title, detect, location, install, uninstall, needsSource: false };
|
package/src/harness/cursor.js
CHANGED
|
@@ -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
|
-
|
|
47
|
+
const location = (opts) => shortPath(paths.cursorRoot(opts));
|
|
48
|
+
|
|
49
|
+
module.exports = { name, title, detect, location, install, uninstall, needsSource: true, destFor };
|
package/src/harness/vscode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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}'
|
|
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(
|
|
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
|
|
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}
|
|
273
|
+
log.error(`${entry.plugin.padEnd(idWidth)} ${err.message}`);
|
|
242
274
|
}
|
|
243
275
|
}
|
|
244
276
|
|
|
277
|
+
log.plain('');
|
|
245
278
|
log.rule();
|
|
246
|
-
|
|
247
|
-
|
|
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(`
|
|
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(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
};
|