Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const { installPlugin, uninstallPlugin, updateAll, listPlugins } = require('./in
const { diagnose } = require('./doctor');
const pkg = require('../package.json');

const VALUE_FLAGS = new Set(['repo', 'ref', 'marketplace', 'targets']);
const VALUE_FLAGS = new Set(['repo', 'ref', 'marketplace', 'targets', 'target']);
const BOOL_FLAGS = new Set(['force', 'yes', 'long', 'verbose', 'quiet', 'json', 'help', 'version']);

// A plugin id longer than this is treated as an outlier when sizing the list grid.
Expand Down Expand Up @@ -97,6 +97,7 @@ Options
-y, --yes Accept every detected harness without asking
--force Replace a plugin installed from another marketplace
--long Show plugin descriptions (list)
--target <name> Show install status for one editor only (list): ${NAMES.join(', ')}
--json Machine-readable output (list, installed)
--verbose Show underlying git / CLI detail
--quiet Suppress progress output
Expand All @@ -113,6 +114,7 @@ Examples
${bin} install acme-payments --repo acme/plugin-marketplace
${bin} install paypal --targets cursor,vscode --ref v1.2.0
${bin} uninstall paypal
${bin} list --target cursor
`.trimStart();
}

Expand Down Expand Up @@ -197,21 +199,28 @@ async function run(argv = process.argv.slice(2), profile = {}) {
return result.failed.length ? 1 : 0;
}
case 'list': {
const result = await listPlugins({ brand });
const target = flags.target || null;
const result = await listPlugins({ brand, target });
if (flags.json) {
log.plain(JSON.stringify(result, null, 2));
return 0;
}
const plugins = [...result.plugins].sort((a, b) => a.name.localeCompare(b.name));
log.banner(`${log.plural(plugins.length, 'plugin')} in ${result.label}`);
const scope = target ? ` installed in ${byName(target).title}` : '';
log.banner(`${log.plural(plugins.length, 'plugin')} in ${result.label}${scope}`);
log.plain('');

if (flags.long) {
// Full detail, one plugin per block.
// Full detail, one plugin per block. The mark answers "installed anywhere?"
// (or, with --target, "installed there?"); the line under it always names
// the actual editors on record, so it never reads as installed everywhere.
for (const p of plugins) {
const mark = p.installed ? log.MARK : ' ';
log.plain(` ${mark} ${log.bold(p.name)}`);
if (p.description) log.info(p.description);
if (p.targets.length) {
log.info(`Installed into: ${p.targets.map((n) => byName(n).title).join(', ')}`);
}
}
} else {
// A grid of names. Descriptions in a marketplace are long and largely
Expand All @@ -238,7 +247,9 @@ async function run(argv = process.argv.slice(2), profile = {}) {

log.plain('');
const count = plugins.filter((p) => p.installed).length;
if (count) log.info(`${log.MARK} installed on this machine (${count})`);
if (count) {
log.info(`${log.MARK} installed${target ? ` in ${byName(target).title}` : ' on this machine'} (${count})`);
}
if (!flags.long) log.info(`Run \`${bin} list --long\` for descriptions.`);
log.info(`Install one with \`${bin} install <plugin>\`.`);
return 0;
Expand Down
71 changes: 59 additions & 12 deletions src/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ async function askEach(names, ask) {
* --targets is a decision, --yes opts out, and a non-interactive shell has
* nobody to ask (so it uses every detected assistant rather than hanging).
*/
async function chooseHarnesses(available, { explicit = false, assumeYes = false, confirm } = {}) {
async function chooseHarnesses(
available,
{ explicit = false, assumeYes = false, confirm, onPrompted } = {},
) {
if (!available.length || explicit || assumeYes) return available;

if (confirm) return askEach(available, confirm);
Expand All @@ -36,6 +39,9 @@ async function chooseHarnesses(available, { explicit = false, assumeYes = false,
return available;
}

// Only this branch draws the prompt flow, so only this branch leaves a connector
// for the caller to close with `log.groupEnd`.
if (onPrompted) onPrompted();
const prompter = createPrompter();
try {
return await askEach(available, (question, def) => prompter.confirm(question, def));
Expand Down Expand Up @@ -114,6 +120,7 @@ async function runInstall({ brand, plugin, ref, targets, force, assumeYes, deps,

const requested = resolveTargets(targets);
assertNoMarketplaceConflict(manifestFile, { plugin, repo: brand.repo }, force);
const recorded = manifest.find(manifestFile, { plugin, repo: brand.repo });

const from = effectiveRef === 'main' ? brand.label : `${brand.label} (${effectiveRef})`;
log.banner(`Installing '${plugin}' from ${from}`);
Expand Down Expand Up @@ -151,17 +158,33 @@ async function runInstall({ brand, plugin, ref, targets, force, assumeYes, deps,
log.info(`Continuing with ${available.map((n) => byName(n).title).join(', ')}.`);
}

let prompted = false;
const want = await chooseHarnesses(available, {
explicit,
assumeYes,
confirm: deps.confirm,
onPrompted: () => {
prompted = true;
},
});
// When the questions were drawn, this line closes their flow; otherwise nothing was
// drawn to close and it stays the plain info line it has always been.
const closeGroup = (msg) => (prompted ? log.groupEnd(msg) : log.info(msg));
if (!want.length) {
log.plain('');
log.warn('No harness selected - nothing was installed.');
if (prompted) log.groupEnd('No harness selected - nothing was installed.');
else {
log.plain('');
log.warn('No harness selected - nothing was installed.');
}
return { plugin, targets: [], marketplace: resolved.marketplace, ref: effectiveRef };
}
log.info(`Installing into: ${want.map((n) => byName(n).title).join(', ')}`);
closeGroup(`Installing into: ${want.map((n) => byName(n).title).join(', ')}`);

// Editors this run skipped that an earlier one installed into. Install only ever
// adds, so their copies are still on disk untouched - they stay on the record
// below (or `update` would never refresh them again) and get a line in the
// closing summary, which is where the user looks to see where the plugin lives.
const untouched = (recorded ? recorded.targets || [] : []).filter((n) => !want.includes(n));

// Only pay for the fetch if a chosen harness needs the files. The session
// owns the clone, so a second plugin from the same repo checks out locally.
Expand Down Expand Up @@ -196,19 +219,30 @@ async function runInstall({ brand, plugin, ref, targets, force, assumeYes, deps,
}

if (installed.length) {
// The record is the union of what is on disk: what this run installed, plus the
// editors an earlier run installed into that this one left alone. Writing only
// `installed` would drop those, and `update` reads this list to decide what to
// refresh - so a dropped editor becomes a copy that is never updated again.
const keep = new Set([...untouched, ...installed]);
manifest.upsert(manifestFile, {
plugin,
repo: brand.repo,
marketplace: resolved.marketplace,
ref: effectiveRef,
targets: installed,
targets: NAMES.filter((n) => keep.has(n)), // canonical order, not call order
installedAt: nowIso(),
});
}

summarize(installed, 'Installed into');
summarize(installed, 'Installed into', untouched);

return { plugin, targets: installed, marketplace: resolved.marketplace, ref: effectiveRef };
return {
plugin,
targets: installed,
untouched,
marketplace: resolved.marketplace,
ref: effectiveRef,
};
}

async function uninstallPlugin({ brand, plugin, targets, deps = {}, pathOpts } = {}) {
Expand Down Expand Up @@ -318,42 +352,55 @@ async function updateAll({ brand, force = false, deps = {}, pathOpts } = {}) {
return { updated, failed };
}

async function listPlugins({ brand, deps = {}, pathOpts } = {}) {
async function listPlugins({ brand, deps = {}, pathOpts, target } = {}) {
if (target && !NAMES.includes(target)) {
throw new UserError(`Unknown target: ${target}`, { hint: `Valid targets: ${NAMES.join(', ')}` });
}
const catalog = await loadCatalog({ repo: brand.repo, ref: brand.ref, deps });
if (!catalog) {
throw new UserError(`Could not read ${brand.label}.`, {
hint: 'Check --repo, or the branch you pointed at with --ref.',
});
}
const installed = new Set(
// Per-plugin, not per-machine: a plugin recorded with targets: ['cursor'] is only
// installed in Cursor, so `installed` (and an optional --target filter) must read
// that list rather than "does this plugin appear anywhere in the manifest".
const targetsByPlugin = new Map(
manifest
.list(paths.manifestPath(pathOpts))
.filter((p) => (p.repo || '') === brand.repo)
.map((p) => p.plugin),
.map((p) => [p.plugin, p.targets || []]),
);
return {
label: brand.label,
marketplace: catalog.marketplace,
repo: brand.repo,
plugins: catalog.plugins.map((p) => {
const name = typeof p === 'string' ? p : p.name;
const targets = targetsByPlugin.get(name) || [];
return {
name,
description: (typeof p === 'object' && p.description) || '',
installed: installed.has(name),
targets,
installed: target ? targets.includes(target) : targets.length > 0,
};
}),
};
}

function summarize(done, verb) {
function summarize(done, verb, unchanged = []) {
log.plain('');
log.rule();
if (!done.length) {
log.warn('Nothing was changed. Are Claude Code / Cursor / VS Code installed?');
} else {
log.ok(`${verb}: ${done.map((n) => byName(n).title).join(', ')}`);
}
// An editor that already had the plugin and was skipped this run still has it, so
// it belongs in the report - otherwise this reads as "it is only in these two".
if (unchanged.length) {
log.info(`Already installed: ${unchanged.map((n) => byName(n).title).join(', ')}`);
}
log.plain('');
}

Expand Down
10 changes: 10 additions & 0 deletions src/log.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const TICK = unicodeSupported() ? ` ${String.fromCharCode(0x2713)} ` : ' OK
const BANG = ' !! ';
const CROSS = ' XX ';
const MARK = unicodeSupported() ? String.fromCharCode(0x2713) : '*';
// Closes the prompt flow drawn by prompt.js, so its connector has somewhere to land.
// Its 3-column gutter matches that flow's, not the 6 of the prefixes above.
const GROUP_END = unicodeSupported() ? String.fromCharCode(0x2514) : '+';

/**
* Terminal width, clamped: prose past ~78 columns is harder to read, not easier.
Expand Down Expand Up @@ -133,6 +136,13 @@ const log = {
console.log(`${paint('33', BANG)}${first}`);
for (const line of rest) console.log(paint('33', ` ${line}`));
},
/** The last line of the prompt flow: `└ <msg>`, closing the connector above it. */
groupEnd(msg) {
if (state.quiet) return;
const [first, ...rest] = wrap(ascii(msg), 3);
console.log(`${paint('90', GROUP_END)} ${first}`);
for (const line of rest) console.log(` ${line}`);
},
error(msg) {
const [first, ...rest] = wrap(ascii(msg));
console.error(`${paint('31', CROSS)}${first}`);
Expand Down
80 changes: 68 additions & 12 deletions src/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const readline = require('node:readline/promises');
const { stdin, stdout } = require('node:process');
const log = require('./log');

/**
* A prompt is only safe when a human is actually there. Piped input, CI, and
Expand All @@ -17,37 +18,92 @@ function isInteractive(env = process.env) {
const YES = new Set(['y', 'yes']);
const NO = new Set(['n', 'no']);

function createPrompter() {
const rl = readline.createInterface({ input: stdin, output: stdout });
const ESC = String.fromCharCode(27);
const UP_AND_CLEAR = `${ESC}[1A${ESC}[2K\r`;

/**
* The questions in one run are a single flow, not a handful of unrelated lines, so
* they are drawn as one: the glyph sits against its text with no floating gap, and a
* connector runs from each answer down to the next question and on to whatever the
* caller prints last (see `log.groupEnd`).
*
* Legacy Windows consoles run cp437/cp1252 and render box drawing as mojibake, so
* every glyph has an ASCII stand-in - the same rule log.js applies to its check mark.
* Built from code points rather than pasted in, so the source stays ASCII and no
* editor or console re-encoding can turn them into mojibake either.
*/
function glyphs(unicode = log.unicodeSupported()) {
return unicode
? { step: String.fromCharCode(0x25c6), bar: String.fromCharCode(0x2502) } // diamond, bar
: { step: '*', bar: '|' };
}

/**
* `true`/`false` for an answer, `null` for anything else so the caller can re-ask.
* Empty input takes the default, which is what makes a bare Enter work.
*/
function parseAnswer(input, defaultYes = true) {
if (input === undefined || input === null) return defaultYes;
const normalized = String(input).trim().toLowerCase();
if (normalized === '') return defaultYes;
if (YES.has(normalized)) return true;
if (NO.has(normalized)) return false;
return null;
}

// `input`/`out`/`unicode` default to the real terminal; they are overridable so the
// flow can be driven and inspected without one.
function createPrompter({ input = stdin, out = stdout, unicode } = {}) {
const g = glyphs(unicode);
const rl = readline.createInterface({ input, output: out });
rl.on('SIGINT', () => {
rl.close();
stdout.write('\nCancelled.\n');
out.write(`\n${g.bar} Cancelled.\n`);
process.exit(130);
});

// Redrawing means clearing the row the user just typed on. Only attempt it on a
// TTY, and only when that row cannot have wrapped - past the terminal width the
// cursor arithmetic would clear the wrong line and eat real output.
const canRedraw = (line) => Boolean(out.isTTY) && line.length < (out.columns || 80);

return {
async confirm(question, defaultYes = true) {
const suffix = defaultYes ? '[Y/n]' : '[y/N]';
const hint = defaultYes ? '(Y/n)' : '(y/N)';
const asked = `${g.step} ${question} ${hint} `;
for (let attempt = 0; attempt < 3; attempt += 1) {
let answer;
try {
answer = await rl.question(` ? ${question} ${suffix} `);
answer = await rl.question(asked);
} catch {
return defaultYes; // stdin closed mid-question
}
if (answer === undefined) return defaultYes;
const normalized = answer.trim().toLowerCase();
if (normalized === '') return defaultYes;
if (YES.has(normalized)) return true;
if (NO.has(normalized)) return false;
stdout.write(' Please answer y or n.\n');
const parsed = parseAnswer(answer, defaultYes);
if (parsed === null) {
out.write(`${log.dim(g.bar)} Please answer yes or no.\n`);
continue;
}
// The keystroke goes, the question and its hint stay: the row is redrawn as it
// was asked, and the decision lands under it as its own resolved step. Reading
// back `(Y/n) y` next to `Yes` is the same answer twice.
if (canRedraw(asked + String(answer))) {
out.write(UP_AND_CLEAR);
out.write(`${log.dim(g.step)} ${question} ${hint}\n`);
} else if (!out.isTTY) {
// A TTY echoes the user's Enter, so the cursor is already on a fresh row.
// Nothing echoes off one, so the answer would land on the asked row.
out.write('\n');
}
out.write(`${log.dim(g.bar)} ${log.dim(parsed ? 'Yes' : 'No')}\n`);
return parsed;
}
return defaultYes;
},
close() {
out.write(`${log.dim(g.bar)}\n`); // connector into the caller's closing line
rl.close();
},
};
}

module.exports = { isInteractive, createPrompter };
module.exports = { isInteractive, createPrompter, glyphs, parseAnswer };
Loading
Loading