/** * `assistant plugins` — manage external plugins installed under * `/plugins/`. * * Subcommands delegate the heavy lifting to dedicated modules under * {@link ../lib}. */ import { createRequire } from "node:module"; import { join } from "node:path"; import type { Command } from "commander"; import { cliIpcCall } from "../../ipc/cli-client.js"; import { stripAnsiAndControlChars } from "../../util/ansi.js"; import { getWorkspacePluginsDir } from "../../util/platform.js"; import { truncate } from "../../util/truncate.js"; import { resolveBundledCliModule } from "../bundled-modules.js"; import { yellow } from "../lib/cli-colors.js"; import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js"; import { confirmPrompt } from "../lib/confirm-prompt.js"; import type { PluginDiffResult } from "../lib/diff-plugin.js"; import type { PluginInspection, PluginRemoteInfo, } from "../lib/inspect-plugin.js"; import type { ConfirmStagedInstall, InstallPluginOptions, PluginFetchSource, } from "../lib/install-from-github.js"; import type { AllPluginInfo } from "../lib/list-installed-plugins.js"; import { DEFAULT_DIRECT_REF, InvalidGitHubPluginSpecError, looksLikeGitHubSpec, parseGitHubPluginSpec, } from "../lib/parse-github-plugin-spec.js"; import { DEFAULT_PLUGIN_REF, PLUGIN_UPGRADE_STRATEGIES, type PluginUpgradeStrategy, } from "../lib/plugin-constants.js"; import type { FingerprintComparison } from "../lib/plugin-fingerprint.js"; import type { PluginPinHistoryEntry } from "../lib/plugin-pin-history.js"; import type { PluginScheduleSurface } from "../lib/plugin-surfaces.js"; import { runPublish } from "../lib/publish-plugin.js"; import { registerCommand } from "../lib/register-command.js"; import { disablePlugin, enablePlugin, InvalidPluginNameError as ToggleInvalidPluginNameError, PluginAlreadyInStateException, PluginDirectoryNotFoundError, } from "../lib/toggle-plugin.js"; import type { PluginUpgradeResult } from "../lib/upgrade-plugin.js"; import { getCliLogger } from "../logger.js"; import { pluginsHelp } from "./plugins.help.js"; const loadModule = createRequire(import.meta.url); /** Lazy accessors: each getter loads its implementation module on first use. */ const libs = { get catalogCache() { return resolveBundledCliModule( "pluginCatalogCache", () => loadModule( "../lib/plugin-catalog-cache.js", ) as typeof import("../lib/plugin-catalog-cache.js"), ); }, get catalogLocal() { return resolveBundledCliModule( "pluginCatalogLocal", () => loadModule( "../lib/plugin-catalog-local.js", ) as typeof import("../lib/plugin-catalog-local.js"), ); }, get diff() { return resolveBundledCliModule( "pluginDiff", () => loadModule( "../lib/diff-plugin.js", ) as typeof import("../lib/diff-plugin.js"), ); }, get inspect() { return resolveBundledCliModule( "pluginInspect", () => loadModule( "../lib/inspect-plugin.js", ) as typeof import("../lib/inspect-plugin.js"), ); }, get installGitHub() { return resolveBundledCliModule( "pluginInstallGitHub", () => loadModule( "../lib/install-from-github.js", ) as typeof import("../lib/install-from-github.js"), ); }, get installPlatform() { return resolveBundledCliModule( "pluginInstallPlatform", () => loadModule( "../lib/install-from-platform.js", ) as typeof import("../lib/install-from-platform.js"), ); }, get installed() { return resolveBundledCliModule( "pluginInstalled", () => loadModule( "../lib/list-installed-plugins.js", ) as typeof import("../lib/list-installed-plugins.js"), ); }, get pinHistory() { return resolveBundledCliModule( "pluginPinHistory", () => loadModule( "../lib/plugin-pin-history.js", ) as typeof import("../lib/plugin-pin-history.js"), ); }, get search() { return resolveBundledCliModule( "pluginSearch", () => loadModule( "../lib/search-plugins.js", ) as typeof import("../lib/search-plugins.js"), ); }, get surfaces() { return resolveBundledCliModule( "pluginSurfaces", () => loadModule( "../lib/plugin-surfaces.js", ) as typeof import("../lib/plugin-surfaces.js"), ); }, get uninstall() { return resolveBundledCliModule( "pluginUninstall", () => loadModule( "../lib/uninstall-plugin.js", ) as typeof import("../lib/uninstall-plugin.js"), ); }, get upgrade() { return resolveBundledCliModule( "pluginUpgrade", () => loadModule( "../lib/upgrade-plugin.js", ) as typeof import("../lib/upgrade-plugin.js"), ); }, }; const log = getCliLogger("plugins"); export function registerPluginsCommand(program: Command): void { registerCommand(program, { name: pluginsHelp.name, transport: "local", description: pluginsHelp.description, build: (plugins) => { // Materialize the `@vellumai/plugin-api` shim once for the whole command // group, before any subcommand action runs. A subcommand that resolves a // plugin hook in this fresh CLI process — `uninstall` running a plugin's // `shutdown` — needs the package importable; the daemon materializes the // same shim at boot, this covers the standalone CLI. Dynamically imported // so the daemon-internal module stays out of the CLI's static graph and // does not weigh on every invocation. Best-effort: a failed shim just // means such a hook's import may not resolve. plugins.hook("preAction", async () => { const { ensurePluginApiShim } = await import("../../plugins/ensure-plugin-api-shim.js"); await ensurePluginApiShim().catch(() => {}); }); applyCommandHelp(plugins, pluginsHelp); subcommand(plugins, "install").action( async ( nameOrUrl: string, opts: { force?: boolean; ref?: string; pin?: string; allowUnreviewed?: boolean; name?: string; }, ) => { try { const direct = looksLikeGitHubSpec(nameOrUrl); // A plain marketplace install by name is platform-managed: the // content flows through the platform install endpoint (which // serves a verified `.tgz`), not a GitHub clone. The advanced // pin/ref/allow-unreviewed selectors still resolve a specific // reviewed revision through the marketplace-git path, and a // GitHub URL installs directly (untrusted). const usesMarketplaceGit = !direct && Boolean(opts.pin || opts.ref || opts.allowUnreviewed); // Consent gate the installers run between staging and finalize: // a plugin that declares schedules gets them listed and, unless // --force, must be confirmed before anything lands on disk that // the daemon's schedule reconciler could arm. const confirmStaged: ConfirmStagedInstall = (staged) => confirmDeclaredSchedules(staged, "install", { force: Boolean(opts.force), json: false, }); let result; let untrusted = false; if (!direct && !usesMarketplaceGit) { // Platform-managed install serves a verified tarball from the // pinned commit. With platform features disabled (air-gapped / // self-hosted) there is no platform to call, so resolve the pin // from the bundled catalog and install through the GitHub path. if (libs.catalogLocal.arePlatformFeaturesEnabled()) { result = await libs.installPlatform.installPluginViaPlatform( { name: nameOrUrl, force: opts.force }, { fetch: globalThis.fetch.bind(globalThis), confirmStaged }, ); } else { const source = libs.catalogLocal.resolveBundledPluginSource(nameOrUrl); if (!source) { console.error( `Plugin "${nameOrUrl}" is not in the bundled marketplace catalog.`, ); process.exitCode = 1; return; } result = await libs.installGitHub.installPlugin( { name: nameOrUrl, force: opts.force, trustedSource: { owner: source.owner, repo: source.repo, rootPath: source.path, ref: source.ref, }, }, { fetch: globalThis.fetch.bind(globalThis), confirmStaged }, ); } } else { const installOpts = direct ? await resolveDirectInstallOptions(nameOrUrl, opts) : await resolveInstallOptions(nameOrUrl, opts); if (installOpts === null) { process.exitCode = 1; return; } if (installOpts.directSource) { printUntrustedPluginWarning( installOpts.name, installOpts.directSource, ); untrusted = true; } result = await libs.installGitHub.installPlugin(installOpts, { fetch: globalThis.fetch.bind(globalThis), confirmStaged, }); } log.info( { name: result.name, target: result.target, fileCount: result.fileCount, ref: result.ref, commit: result.commit, untrusted, }, "external plugin installed", ); const pinned = result.commit ? ` at ${result.commit.slice(0, 7)}` : ""; const label = untrusted ? "untrusted plugin" : "plugin"; console.log( `Installed ${label} "${result.name}" (${result.fileCount} file${result.fileCount === 1 ? "" : "s"})${pinned} → ${result.target}`, ); const { skills } = libs.surfaces.detectPluginSurfaces( result.target, ); const setupSkill = libs.surfaces.findPluginSetupSkill( result.name, skills, ); if (setupSkill !== undefined) { console.log(libs.surfaces.formatPluginSetupHint(setupSkill)); } } catch (err) { if (err instanceof libs.installGitHub.PluginInstallDeclinedError) { // The consent gate already printed the outcome and set the exit // code; the install was aborted with nothing left on disk. return; } if (err instanceof libs.installGitHub.PluginAlreadyInstalledError) { console.error(`${err.message}\nPass --force to overwrite.`); process.exitCode = 1; return; } if (err instanceof libs.installGitHub.PluginNotFoundError) { console.error(err.message); process.exitCode = 1; return; } if ( err instanceof libs.installGitHub.InvalidPluginNameError || err instanceof libs.pinHistory.PluginPinHistoryError ) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin install failed: ${message}`); process.exitCode = 1; } }, ); subcommand(plugins, "versions").action( async (name: string, opts: { json?: boolean; limit?: string }) => { let limit: number | undefined; if (opts.limit !== undefined) { limit = Number.parseInt(opts.limit, 10); if (!Number.isInteger(limit) || limit < 1) { console.error("--limit must be a positive integer."); process.exitCode = 1; return; } } try { const history = await libs.pinHistory.listPinHistory( name, { fetch: globalThis.fetch.bind(globalThis) }, limit !== undefined ? { limit } : {}, ); if (opts.json) { process.stdout.write(JSON.stringify(history, null, 2) + "\n"); return; } // Logged after the JSON early-return so the logger's stdout // writes never corrupt --json output. log.info({ name, count: history.length }, "plugin versions"); for (const line of formatVersions(name, history)) { console.log(line); } } catch (err) { if ( err instanceof libs.installGitHub.InvalidPluginNameError || err instanceof libs.pinHistory.PluginPinHistoryError ) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin versions failed: ${message}`); process.exitCode = 1; } }, ); subcommand(plugins, "list").action( (opts: { json?: boolean; all?: boolean }) => { if (opts.all) { const all = libs.installed.listAllPlugins(); if (opts.json) { process.stdout.write(JSON.stringify(all, null, 2) + "\n"); return; } if (all.length === 0) { console.log("No plugins found."); return; } const rows = all.map((p) => ({ name: p.name, version: p.packageJson?.version ?? "—", source: p.source, status: formatAllPluginStatus(p), })); const nameW = Math.max(4, ...rows.map((r) => r.name.length)); const versionW = Math.max(7, ...rows.map((r) => r.version.length)); const sourceW = Math.max(6, ...rows.map((r) => r.source.length)); const pad = (s: string, w: number) => s + " ".repeat(w - s.length); console.log( `${pad("NAME", nameW)} ${pad("VERSION", versionW)} ${pad("SOURCE", sourceW)} STATUS`, ); for (const r of rows) { console.log( `${pad(r.name, nameW)} ${pad(r.version, versionW)} ${pad(r.source, sourceW)} ${r.status}`, ); } const userCount = all.filter((p) => p.source === "user").length; const defaultCount = all.length - userCount; const disabledCount = all.filter((p) => p.disabled).length; console.log(""); console.log( `${all.length} plugin${all.length === 1 ? "" : "s"} ` + `(${userCount} user, ${defaultCount} default` + (disabledCount > 0 ? `, ${disabledCount} disabled` : "") + `).`, ); return; } const installed = libs.installed.listInstalledPlugins(); if (opts.json) { process.stdout.write(JSON.stringify(installed, null, 2) + "\n"); return; } if (installed.length === 0) { console.log("No plugins installed."); return; } const rows = installed.map((p) => ({ name: p.name, version: p.packageJson?.version ?? "—", status: p.issues.length === 0 ? "ok" : p.issues.join("; "), })); const nameW = Math.max(4, ...rows.map((r) => r.name.length)); const versionW = Math.max(7, ...rows.map((r) => r.version.length)); const pad = (s: string, w: number) => s + " ".repeat(w - s.length); console.log( `${pad("NAME", nameW)} ${pad("VERSION", versionW)} STATUS`, ); for (const r of rows) { console.log( `${pad(r.name, nameW)} ${pad(r.version, versionW)} ${r.status}`, ); } console.log(""); console.log( `${installed.length} plugin${installed.length === 1 ? "" : "s"} installed.`, ); }, ); subcommand(plugins, "inspect").action( async (name: string, opts: { json?: boolean }) => { try { const inspection = await libs.inspect.inspectPlugin( { name }, { fetch: globalThis.fetch.bind(globalThis) }, ); if (opts.json) { process.stdout.write(JSON.stringify(inspection, null, 2) + "\n"); return; } // Logged after the JSON early-return: the CLI logger writes // info to stdout, which would otherwise corrupt --json output. log.info( { name: inspection.name, installed: inspection.installed, status: inspection.status, }, "plugin inspect", ); const describeCron = inspection.surfaces?.schedules.length ? await loadCronDescriber() : null; for (const line of formatInspection(inspection, describeCron)) { console.log(line); } } catch (err) { if (err instanceof libs.inspect.PluginInspectNotFoundError) { console.error(err.message); process.exitCode = 1; return; } if (err instanceof libs.installGitHub.InvalidPluginNameError) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin inspect failed: ${message}`); process.exitCode = 1; } }, ); subcommand(plugins, "diff").action( async (name: string, opts: { json?: boolean }) => { try { const result = await libs.diff.diffPlugin( { name }, { fetch: globalThis.fetch.bind(globalThis) }, ); if (opts.json) { process.stdout.write(JSON.stringify(result, null, 2) + "\n"); return; } // Logged after the JSON early-return: the CLI logger writes // info to stdout, which would otherwise corrupt --json output. log.info( { name: result.name, clean: result.clean, files: result.files.length, }, "plugin diff", ); for (const line of formatDiff(result)) { console.log(line); } } catch (err) { if ( err instanceof libs.uninstall.PluginNotInstalledError || err instanceof libs.diff.PluginDiffUnavailableError || err instanceof libs.installGitHub.InvalidPluginNameError ) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin diff failed: ${message}`); process.exitCode = 1; } }, ); subcommand(plugins, "search").action( async (query: string, opts: { json?: boolean }) => { try { libs.search.assertValidSearchPattern(query); const catalog = await libs.catalogCache.getPluginCatalog( DEFAULT_PLUGIN_REF, { fetch: globalThis.fetch.bind(globalThis) }, ); const matches = libs.search.filterPluginCatalog(catalog, query); const result = { query, ref: catalog.ref, matches }; if (opts.json) { process.stdout.write(JSON.stringify(result, null, 2) + "\n"); return; } // Logged after the JSON early-return: the CLI logger writes info // to stdout, which would otherwise corrupt --json output. log.info( { query: result.query, ref: result.ref, matchCount: result.matches.length, }, "external plugin search", ); if (result.matches.length === 0) { console.log(`No plugins matched "${result.query}".`); return; } const nameW = Math.max( 4, ...result.matches.map((m) => m.name.length), ); const pad = (s: string, w: number) => s + " ".repeat(w - s.length); console.log(`${pad("NAME", nameW)} PATH`); for (const m of result.matches) { console.log(`${pad(m.name, nameW)} ${m.path}`); } console.log(""); console.log( `${result.matches.length} match${result.matches.length === 1 ? "" : "es"} for "${result.query}".`, ); } catch (err) { if (err instanceof libs.search.InvalidSearchPatternError) { console.error(err.message); process.exitCode = 1; return; } if (err instanceof libs.search.PluginCatalogUnavailableError) { console.error( "The plugin catalog is temporarily unavailable. Try again shortly.", ); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin search failed: ${message}`); process.exitCode = 1; } }, ); subcommand(plugins, "publish").action( async (opts: { print?: boolean; path?: string; force?: boolean; json?: boolean; category?: string; }) => { const ok = await runPublish(opts, { confirmPrompt }); if (!ok) { process.exitCode = 1; } }, ); subcommand(plugins, "uninstall").action( async (name: string, opts: { force?: boolean }) => { try { if (!opts.force) { const result = await confirmPrompt({ question: `Uninstall plugin "${name}"? [y/N] `, isTTY: Boolean(process.stdin.isTTY), refuseNonInteractiveMessage: `Refusing to uninstall "${name}" non-interactively. Pass --force to confirm.`, }); if (result === "non-interactive") { process.exitCode = 1; return; } if (result === "denied") { console.log("Uninstall cancelled."); return; } } // Prefer the daemon: it runs the plugin's `shutdown` hook and drops // the plugin's tools/hooks in the main process — symmetric to how // install runs `init` there. Fall back to a local uninstall only // when the daemon is unreachable (a transport error carries no // `statusCode`); an operator can still uninstall while it's stopped, // and `shutdown` then runs in this process, the only one available. const daemon = await cliIpcCall<{ name: string; target: string }>( "plugins_uninstall", { pathParams: { name } }, ); let result: { name: string; target: string }; if (daemon.ok && daemon.result) { result = daemon.result; } else if (daemon.statusCode === undefined) { log.debug( { name, error: daemon.error }, "uninstall could not reach the daemon; running shutdown + removal locally", ); result = await libs.uninstall.uninstallPlugin({ name }); } else { // The daemon reached a decision (not installed, bad name, …); // surface it rather than silently retrying locally. console.error(daemon.error ?? "Plugin uninstall failed."); process.exitCode = 1; return; } log.info( { name: result.name, target: result.target }, "external plugin uninstalled", ); console.log( `Uninstalled plugin "${result.name}" from ${result.target}`, ); } catch (err) { if (err instanceof libs.installGitHub.InvalidPluginNameError) { console.error(err.message); process.exitCode = 1; return; } if (err instanceof libs.uninstall.PluginNotInstalledError) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin uninstall failed: ${message}`); process.exitCode = 1; } }, ); subcommand(plugins, "disable").action((name: string) => { try { const result = disablePlugin(name); log.info({ name: result.name }, "plugin disabled"); console.log(`Disabled plugin "${result.name}".`); } catch (err) { if ( err instanceof PluginAlreadyInStateException || err instanceof ToggleInvalidPluginNameError || err instanceof PluginDirectoryNotFoundError ) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin disable failed: ${message}`); process.exitCode = 1; } }); subcommand(plugins, "enable").action((name: string) => { try { const result = enablePlugin(name); log.info({ name: result.name }, "plugin enabled"); console.log(`Enabled plugin "${result.name}".`); } catch (err) { if ( err instanceof PluginAlreadyInStateException || err instanceof ToggleInvalidPluginNameError ) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin enable failed: ${message}`); process.exitCode = 1; } }); subcommand(plugins, "upgrade").action( async ( name: string, opts: { dryRun?: boolean; strategy?: string; json?: boolean; force?: boolean; }, ) => { const strategy = opts.strategy; if ( strategy !== undefined && !(PLUGIN_UPGRADE_STRATEGIES as readonly string[]).includes(strategy) ) { console.error( `Invalid --strategy "${strategy}". Expected one of: ${PLUGIN_UPGRADE_STRATEGIES.join(", ")}.`, ); process.exitCode = 1; return; } try { // Prefer the daemon: when the upgrade re-materializes files it runs // the plugin's `shutdown` + `init` hooks in the main process — // symmetric to how install/uninstall run their lifecycle there. // The daemon route is unattended (no staging prompt); a schedule // the upgraded revision adds is surfaced by the reconciler's // `schedule.declared` notification, and mirrored at the terminal // by the declared-schedules listing printed after the upgrade. // Fall back to a local upgrade only when the daemon is unreachable // (a transport error carries no `statusCode`); an operator can still // upgrade while it's stopped, and the new code is picked up on the // daemon's next start. A daemon-side decision (not installed, not // upgradable, …) is surfaced rather than silently retried locally. const preUpgradeScheduleNames = listInstalledScheduleNames(name); const daemon = await cliIpcCall( "plugins_upgrade", { pathParams: { name }, body: { dryRun: opts.dryRun, strategy }, }, ); let result: PluginUpgradeResult; let upgradedViaDaemon = false; if (daemon.ok && daemon.result) { result = daemon.result; upgradedViaDaemon = true; } else if (daemon.statusCode === undefined) { // The CLI logger writes debug to stdout, which would corrupt // --json output, so under --json the same note goes to stderr. // The CLI logger drops structured fields either way. const fallbackNote = "upgrade could not reach the daemon; upgrading locally"; if (opts.json) { console.error(fallbackNote); } else { log.debug({ name, error: daemon.error }, fallbackNote); } // The local path stages in this process, so it gets the same // consent gate as install: declared schedules are listed and // confirmed (or --force) before the staged tree goes live. const confirmStaged: ConfirmStagedInstall = (staged) => confirmDeclaredSchedules(staged, "upgrade", { force: Boolean(opts.force), json: Boolean(opts.json), }); result = await libs.upgrade.upgradePlugin( { name, dryRun: opts.dryRun, strategy: strategy as PluginUpgradeStrategy | undefined, }, { fetch: globalThis.fetch.bind(globalThis), confirmStaged }, ); } else { console.error(daemon.error ?? "Plugin upgrade failed."); process.exitCode = 1; return; } if (opts.json) { process.stdout.write(JSON.stringify(result, null, 2) + "\n"); return; } // Logged after the JSON early-return: the CLI logger writes // info to stdout, which would otherwise corrupt --json output. log.info( { name: result.name, outcome: result.outcome, from: result.fromCommit, to: result.toCommit, }, "plugin upgrade", ); for (const line of formatUpgrade(result)) { console.log(line); } if (upgradedViaDaemon && result.outcome === "upgraded") { await printDeclaredSchedulesAfterUpgrade( result.name, result.target, preUpgradeScheduleNames, ); } } catch (err) { if (err instanceof libs.installGitHub.PluginInstallDeclinedError) { // The consent gate already printed the outcome and set the exit // code; the upgrade was aborted with the live install untouched. return; } if ( err instanceof libs.uninstall.PluginNotInstalledError || err instanceof libs.upgrade.PluginNotUpgradableError || err instanceof libs.upgrade.PluginMergeBaselineError || err instanceof libs.installGitHub.InvalidPluginNameError ) { console.error(err.message); process.exitCode = 1; return; } const message = err instanceof Error ? err.message : String(err); console.error(`Plugin upgrade failed: ${message}`); process.exitCode = 1; } }, ); }, }); } /** Abbreviate a commit SHA for display; passes through non-SHA / null values. */ /** Full Git commit SHA — 40 hex (SHA-1) or 64 (SHA-256). */ const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; /** * Resolve `plugins install` flags into {@link InstallPluginOptions}, or `null` * when the flag combination is invalid (a message is printed in that case, and * the caller exits non-zero). Network / marketplace failures raised while * resolving a `--pin` propagate to the caller's catch. * * `--pin ` installs a specific reviewed marketplace pin: the SHA is looked * up in the plugin's pin history and installed from the marketplace commit that * introduced it, so the curated adapter stub of that era comes along. With * `--allow-unreviewed` the SHA is materialized directly (adapter from `main`), * bypassing the history check — the advanced escape hatch. */ async function resolveInstallOptions( name: string, opts: { force?: boolean; ref?: string; pin?: string; allowUnreviewed?: boolean; }, ): Promise { const force = opts.force ?? false; if (!opts.pin) { if (opts.allowUnreviewed) { console.error("--allow-unreviewed only applies together with --pin."); return null; } return { name, force, ref: opts.ref ?? DEFAULT_PLUGIN_REF }; } const pin = opts.pin.trim(); if (!FULL_SHA_RE.test(pin)) { console.error( `--pin must be a full commit SHA (40 or 64 hex chars); got ${JSON.stringify(opts.pin)}.`, ); return null; } if (opts.ref) { console.error( "--ref and --pin cannot be combined; --pin already selects the revision.", ); return null; } if (opts.allowUnreviewed) { // Materialize the exact SHA from the plugin's current-manifest repo, // bypassing the reviewed-history check. The adapter stub still comes from // the default ref, so an adapted plugin may not reproduce faithfully. return { name, force, ref: DEFAULT_PLUGIN_REF, commitOverride: pin }; } const entry = await libs.pinHistory.resolvePinToMarketplaceCommit(name, pin, { fetch: globalThis.fetch.bind(globalThis), }); if (!entry) { console.error( `"${pin}" is not a reviewed marketplace pin for "${name}".\n` + `Run \`assistant plugins versions ${name}\` to see available pins, ` + "or pass --allow-unreviewed to install it anyway.", ); return null; } return { name, force, ref: entry.marketplaceCommit }; } /** * Resolve a GitHub-URL argument into {@link InstallPluginOptions} for an * untrusted direct install, or `null` when the URL or flag combination is * invalid (a message is printed in that case, and the caller exits non-zero). * * A `/tree//` URL cannot mark where a slash-containing branch name * (`feature/x`) ends and the sub-path begins, so when the ref is left implicit * the real split is resolved against the repository's refs — exactly as * github.com does — before building the install coordinates. `--ref` states the * ref explicitly and skips that lookup (useful offline, or to force a specific * ref). `--pin` and `--allow-unreviewed` are marketplace-only and rejected here. * The install name defaults to the resolved sub-path leaf (or the repo) and can * be overridden with `--name`. */ async function resolveDirectInstallOptions( spec: string, opts: { force?: boolean; ref?: string; pin?: string; allowUnreviewed?: boolean; name?: string; }, ): Promise { if (opts.pin || opts.allowUnreviewed) { console.error( "--pin and --allow-unreviewed only apply to marketplace installs by name, not a GitHub URL.", ); return null; } let parsed; try { parsed = parseGitHubPluginSpec(spec, opts.ref); } catch (err) { if (err instanceof InvalidGitHubPluginSpecError) { console.error(err.message); return null; } throw err; } // The offline parse guesses the first `/tree/` segment is the whole ref; when // the tail could hide a slash-containing ref, ask the remote which leading // prefix is a real branch/tag and re-derive the sub-path (and default name) // from the answer. Falls back to the guess when the remote can't be listed. let { path, ref, defaultName } = parsed; if (parsed.ambiguousTreeSegments) { const resolved = await libs.installGitHub.resolveTreeRefPath( parsed.owner, parsed.repo, parsed.ambiguousTreeSegments, ); ref = resolved.ref; path = resolved.path; const leaf = path.split("/").filter(Boolean).at(-1) ?? parsed.repo; defaultName = leaf.toLowerCase(); } const requested = opts.name ?? defaultName; let name: string; try { name = libs.installGitHub.sanitizePluginName(requested); } catch (err) { if (err instanceof libs.installGitHub.InvalidPluginNameError) { console.error( opts.name ? err.message : `Could not derive a valid plugin name from "${defaultName}". ` + "Pass --name to choose one (lowercase letters, digits, '-', '_').", ); return null; } throw err; } const directSource: PluginFetchSource = { owner: parsed.owner, repo: parsed.repo, rootPath: path, ref, }; return { name, force: opts.force ?? false, directSource }; } /** * Print a prominent yellow warning before an untrusted direct install. Such a * plugin is not in the curated marketplace, has not been reviewed, and its * hooks/tools run inside the assistant with full access — so the user must * decide whether they trust the source. Goes to stderr so it stays visible * alongside (not interleaved with) the stdout result line. */ function printUntrustedPluginWarning( name: string, source: PluginFetchSource, ): void { const location = source.rootPath ? `${source.owner}/${source.repo}/${source.rootPath}` : `${source.owner}/${source.repo}`; const ref = source.ref === DEFAULT_DIRECT_REF ? "default branch" : source.ref; const lines = [ `⚠ Installing "${name}" from an unreviewed GitHub source: ${location} @ ${ref}.`, " This plugin is NOT in the Vellum marketplace and has not been reviewed.", " Its hooks and tools run inside the assistant with full access — install it only if you trust the source.", ]; console.error(yellow(lines.join("\n"))); } /** * The declared-schedules block for a plugin tree: a heading plus one aligned * row per schedule, or null when the plugin declares none. Callers own the * output stream and any per-row annotation. */ async function buildDeclaredScheduleListing( pluginName: string, pluginDir: string, ): Promise<{ heading: string; rows: string[]; schedules: readonly PluginScheduleSurface[]; } | null> { const { schedules } = libs.surfaces.detectPluginSurfaces(pluginDir); if (schedules.length === 0) { return null; } const describeCron = await loadCronDescriber(); return { heading: `Plugin "${pluginName}" declares ${schedules.length} schedule${schedules.length === 1 ? "" : "s"}:`, rows: formatScheduleRows(schedules, describeCron), schedules, }; } /** * List a staged plugin's declared schedules and ask for consent. Schedules run * automatically once armed, so neither an install nor an upgrade may add any * silently: the user either confirms the listing or passes --force. Returns * whether the operation may proceed; a refusal's user-facing outcome (message * + exit code) is fully handled here, so the caller aborts without further * output. `action` selects the install vs upgrade wording. */ async function confirmDeclaredSchedules( staged: { name: string; stagingDir: string }, action: "install" | "upgrade", opts: { force: boolean; json: boolean }, ): Promise { const listing = await buildDeclaredScheduleListing( staged.name, staged.stagingDir, ); if (!listing) { return true; } // Everything this gate prints is human output: the listing, the prompt // itself, and the cancellation line. Under --json all of it goes to stderr // so it cannot corrupt the JSON document the caller parses off stdout. const write = (line: string): void => { if (opts.json) { console.error(line); } else { console.log(line); } }; write(listing.heading); for (const row of listing.rows) { write(` ${row}`); } write( "Schedules run automatically in the background once the plugin is installed.", ); if (opts.force) { return true; } const verb = action === "install" ? "Install" : "Upgrade"; const result = await confirmPrompt({ question: `${verb} "${staged.name}" and allow these schedules? [y/N] `, isTTY: Boolean(process.stdin.isTTY), refuseNonInteractiveMessage: `Refusing to ${action} "${staged.name}" non-interactively: it declares schedules. Pass --force to confirm.`, stdout: opts.json ? process.stderr : undefined, }); if (result === "non-interactive") { process.exitCode = 1; return false; } if (result === "denied") { write(`${verb} cancelled.`); return false; } return true; } /** * Maximum rendered width of a declared-schedule listing cell. Declaration * strings come from the plugin being installed, so an overlong expression * must not be able to push the consent prompt off-screen. */ const MAX_SCHEDULE_CELL_WIDTH = 60; /** * Make a plugin-controlled declaration string safe to print next to the * install consent prompt: drop escape sequences and control characters * (which could rewrite or hide the surrounding untrusted-source warning) * and cap the cell width. */ function sanitizeScheduleCell(value: string): string { return truncate(stripAnsiAndControlChars(value), MAX_SCHEDULE_CELL_WIDTH); } /** Humanizes a cron expression for display; returns the input when unrecognized. */ type CronDescriber = (expression: string) => string; /** * Load the schedule store's cron humanizer for cadence display. Loaded * lazily inside actions because it lives in the daemon's module graph * (see the `cli/no-daemon-internals` hoisting rule); when it cannot be * loaded, listings fall back to raw expressions. */ async function loadCronDescriber(): Promise { try { const { describeCronExpression } = await import("../../schedule/schedule-store.js"); return describeCronExpression; } catch { return null; } } /** * Cadence cell for a declared-schedules listing: the humanized cron cadence * with the raw expression kept as ground truth ("Every day at 9:00 AM * (0 9 * * *)"). RRULEs and anything the describer does not recognize stay * as the raw expression alone. */ function formatCadenceCell( cadence: string, describeCron: CronDescriber | null, ): string { const described = describeCron?.(cadence); return described && described !== cadence ? `${described} (${cadence})` : cadence; } /** Aligned `name cadence (mode)` rows for a declared-schedules listing. */ function formatScheduleRows( schedules: readonly PluginScheduleSurface[], describeCron: CronDescriber | null, ): string[] { if (schedules.length === 0) { return []; } const rows = schedules.map((s) => ({ name: sanitizeScheduleCell(s.name), cadence: sanitizeScheduleCell(formatCadenceCell(s.cadence, describeCron)), mode: s.mode, })); const nameW = Math.max(...rows.map((r) => r.name.length)); const cadenceW = Math.max(...rows.map((r) => r.cadence.length)); return rows.map( (r) => `${r.name.padEnd(nameW)} ${r.cadence.padEnd(cadenceW)} (${r.mode})`, ); } /** * Names of the schedules the installed copy of a plugin currently declares. * Empty when the plugin (or its schedules/ dir) is absent, or when the * install path cannot be probed at all (for example an unsanitized name); * the upgrade itself surfaces such errors, this snapshot never does. */ function listInstalledScheduleNames(name: string): ReadonlySet { try { const surfaces = libs.surfaces.detectPluginSurfaces( join(getWorkspacePluginsDir(), name), ); return new Set(surfaces.schedules.map((s) => s.name)); } catch { return new Set(); } } /** * After a daemon-applied upgrade, list the revision's declared schedules with * a `[new]` marker on the ones the previous revision did not declare. The * daemon path has no staging prompt, so this display is how the operator sees * at the terminal what the reconciler's notification says. Display only: * arming decisions stay with the reconciler. */ async function printDeclaredSchedulesAfterUpgrade( name: string, target: string, previousNames: ReadonlySet, ): Promise { const listing = await buildDeclaredScheduleListing(name, target); if (!listing) { return; } const { heading, rows, schedules } = listing; console.log(""); console.log(heading); schedules.forEach((schedule, index) => { const marker = previousNames.has(schedule.name) ? "" : " [new]"; console.log(` ${rows[index]}${marker}`); }); if (schedules.some((schedule) => !previousNames.has(schedule.name))) { console.log( "New schedules run automatically in the background. Run 'assistant schedules list' to review them, or 'assistant schedules disable ' to turn one off.", ); } } /** * Render a plugin's marketplace-pin history as a table: the pinned commit (short * SHA), when it was promoted, and a marker for the pin currently active. An * empty history reports that none was found. */ function formatVersions( name: string, history: readonly PluginPinHistoryEntry[], ): string[] { if (history.length === 0) { return [`No marketplace pin history found for "${name}".`]; } const rows = history.map((entry) => ({ pin: shortSha(entry.pin), promoted: formatTimestamp(entry.promotedAt), marker: entry.current ? "(current)" : "", })); const pinW = Math.max(3, ...rows.map((r) => r.pin.length)); const promotedW = Math.max(8, ...rows.map((r) => r.promoted.length)); const pad = (s: string, w: number) => s + " ".repeat(w - s.length); const lines = [ `${pad("PIN", pinW)} ${pad("PROMOTED", promotedW)} `.trimEnd(), ]; for (const r of rows) { lines.push( `${pad(r.pin, pinW)} ${pad(r.promoted, promotedW)} ${r.marker}`.trimEnd(), ); } lines.push(""); lines.push("Install an older pin with:"); lines.push(` assistant plugins install ${name} --pin --force`); return lines; } function shortSha(sha: string | null): string { return sha ? sha.slice(0, 7) : "—"; } /** * Render a commit timestamp as the human-facing version: a UTC `YYYY-MM-DDThh:mm:ss` * string (seconds precision, no fractional or zone suffix). Older installs with * no recorded commit date — and remote pins whose date could not be fetched — * fall back to `unknown`, with the SHA still shown alongside as the precise id. */ function formatTimestamp(iso: string | null): string { if (!iso) { return "unknown"; } const ms = Date.parse(iso); if (!Number.isFinite(ms)) { return "unknown"; } return new Date(ms).toISOString().slice(0, 19); } /** * Build a human-readable status string for a plugin in the `--all` listing. * Combines disabled state with any structural issues. */ function formatAllPluginStatus(p: AllPluginInfo): string { const parts: string[] = []; if (p.disabled) { parts.push("disabled"); } if (p.issues.length > 0) { parts.push(p.issues.join("; ")); } if (parts.length === 0) { parts.push("enabled"); } return parts.join(", "); } /** Human-readable status line for an inspection result. The from/to revisions * now live in the installed/remote blocks, so the status itself is just words. */ function statusLine(status: PluginInspection["status"]): string { switch (status) { case "up-to-date": return "up to date"; case "update-available": return "update available"; case "not-installed": return "not installed"; case "not-in-marketplace": return "installed (not in marketplace)"; case "unknown-provenance": return "unknown (reinstall to record provenance)"; case "remote-unavailable": return "installed (marketplace unavailable)"; } } /** * Summarize drift relative to the install-time fingerprint. Reports "unknown" * when no baseline was recorded (an older or manually-copied install), "none" * when the on-disk tree matches, or a per-category count. */ function driftLine(changes: FingerprintComparison | null): string { if (!changes) { return "unknown (no recorded baseline; reinstall to record one)"; } if (changes.clean) { return "none"; } const parts = [ `${changes.modified.length} modified`, `${changes.added.length} added`, `${changes.removed.length} removed`, ]; return parts.join(", "); } /** Build the GitHub web URL for a remote pin's location (repo, or repo subtree). */ function remoteLocation(remote: PluginRemoteInfo): string { const base = `https://github.com/${remote.repo}`; return remote.path ? `${base}/tree/${remote.commit}/${remote.path}` : base; } /** * Render an inspection with timestamps as the headline version. The layout is a * name heading + rule, a top-level `status`, then `installed`/`remote` blocks * that each carry `timestamp` (the human version), `hash` (the precise id), and * `location`, with a `drift` line under the installed copy. */ function formatInspection( inspection: PluginInspection, describeCron: CronDescriber | null, ): string[] { const { name, status, local, remote, remoteError, surfaces } = inspection; const lines: string[] = [name, "─".repeat(44)]; const topRow = (label: string, value: string) => lines.push(`${label.padEnd(11)} ${value}`); const blockRow = (label: string, value: string) => lines.push(` ${label.padEnd(9)} ${value}`); // A surface block: the surface type as a heading, then its items indented // under it. Omitted entirely when the plugin contributes none of that type, // so the listing only ever shows what the plugin actually contributes. const surfaceBlock = (label: string, items: readonly string[]) => { if (items.length === 0) { return; } lines.push(label); for (const item of items) { lines.push(` ${item}`); } }; topRow("status", statusLine(status)); if (local) { lines.push("installed"); blockRow("timestamp", formatTimestamp(local.committedAt)); blockRow("hash", shortSha(local.commit)); blockRow("location", local.target); // `installedAt` is rewritten on every install/upgrade, so it reads as the // last time this copy was materialized rather than a first-install date. blockRow("updated", formatTimestamp(local.installedAt)); topRow("drift", driftLine(local.localChanges)); } if (remote) { lines.push("remote"); blockRow("timestamp", formatTimestamp(remote.committedAt)); blockRow("hash", shortSha(remote.commit)); blockRow("location", remoteLocation(remote)); } else if (remoteError) { topRow("remote", `unavailable: ${remoteError}`); } const pkgVersion = local?.version ?? null; if (pkgVersion) { topRow("pkg version", pkgVersion); } const license = remote?.license ?? null; const homepage = remote?.homepage ?? null; if (license) { topRow("license", license); } if (homepage) { topRow("homepage", homepage); } const description = remote?.description ?? local?.description ?? null; if (description) { topRow("description", description); } if (surfaces) { surfaceBlock("skills", surfaces.skills); surfaceBlock("hooks", surfaces.hooks); surfaceBlock("tools", surfaces.tools); surfaceBlock( "schedules", formatScheduleRows(surfaces.schedules, describeCron), ); } for (const issue of local?.issues ?? []) { topRow("issue", issue); } return lines; } /** * Render an upgrade result with the `timestamp (hash) → timestamp (hash)` move * as the headline, mirroring the inspect layout so the same version identity is * used everywhere. */ function formatUpgrade(result: PluginUpgradeResult): string[] { const { name, fromCommit, fromTimestamp, toCommit, toTimestamp } = result; const move = `${formatTimestamp(fromTimestamp)} (${shortSha(fromCommit)})` + ` → ${formatTimestamp(toTimestamp)} (${shortSha(toCommit)})`; const provenanceNote = result.provenanceWasUnknown ? "Previous install had no recorded provenance; it has been re-pinned." : null; switch (result.outcome) { case "already-up-to-date": return [ `"${name}" is already up to date at ${formatTimestamp(toTimestamp)} (${shortSha(toCommit)}).`, ]; case "would-upgrade": { const lines = [ `"${name}" would upgrade ${move}`, "", "dry run; no changes made.", ]; if (provenanceNote) { lines.push(provenanceNote); } return lines; } case "upgraded": { const count = result.fileCount === null ? "" : `(${result.fileCount} file${result.fileCount === 1 ? "" : "s"}) `; const hasConflicts = result.conflicts.length > 0 || result.binaryConflicts.length > 0; // Under `assistant`, an unresolved merge leaves conflicts in the tree — // the plugin would fail to load while any remain, so the usual "restart // now" guidance is replaced with resolution instructions. Most conflicts // carry git markers, but a modify/delete divergence (a file edited on one // side and deleted on the other) keeps the surviving content with no // markers, so the guidance covers both rather than assuming markers. if (result.strategy === "assistant" && hasConflicts) { const lines = [ `Merged "${name}" ${move} with conflicts`, "", `${count}→ ${result.target}`, ]; if (result.conflicts.length > 0) { lines.push( "", `Resolve ${result.conflicts.length} conflicted file${result.conflicts.length === 1 ? "" : "s"} (open each: resolve its git conflict markers, or — if a modify/delete divergence kept the file with none — decide whether to keep or remove it):`, ...result.conflicts.map((p) => ` ${p}`), ); } if (result.binaryConflicts.length > 0) { lines.push( "", `Binary conflict${result.binaryConflicts.length === 1 ? "" : "s"} (kept the local copy — choose a version manually):`, ...result.binaryConflicts.map((p) => ` ${p}`), ); } lines.push( "", "Resolve the conflicts, then the upgrade will be picked up live on the next read (no restart required).", ); if (provenanceNote) { lines.push(provenanceNote); } return lines; } const mergeNote = result.strategy === "ours" || result.strategy === "theirs" ? `Local edits were merged (--strategy ${result.strategy}).` : result.strategy === "assistant" ? "Local edits were merged with no conflicts (--strategy assistant)." : null; const lines = [ `Upgraded "${name}" ${move}`, "", `${count}→ ${result.target}`, "The upgrade is picked up live (no restart required).", ]; if (mergeNote) { lines.push(mergeNote); } if (provenanceNote) { lines.push(provenanceNote); } return lines; } } } /** * Render a diff result: a one-line "no local changes" when clean, otherwise a * header naming the install-commit baseline followed by each file's unified * diff (blank-line separated, mirroring how `git diff` stacks file patches). */ function formatDiff(result: PluginDiffResult): string[] { const baseline = `${formatTimestamp(result.committedAt)} (${shortSha(result.commit)})`; if (result.clean) { return [ `"${result.name}" has no local changes (matches install commit ${baseline}).`, ]; } const count = result.files.length; const lines = [ `"${result.name}" — ${count} file${count === 1 ? "" : "s"} changed vs install commit ${baseline}`, "", ]; for (const file of result.files) { // A non-reconstructed baseline carries an explanatory marker, not a patch // with `a/`–`b/` headers, so name the file it refers to. if (!file.reconstructed) { lines.push(`${file.path}: ${file.diff.trimEnd()}`, ""); continue; } lines.push(file.diff.trimEnd(), ""); } return lines; }