From b1b30ea0eaad142a275320536f7533b55e4d3701 Mon Sep 17 00:00:00 2001 From: tmm Date: Sun, 26 Apr 2026 23:44:05 -0400 Subject: [PATCH] feat(cli): plugins command --- cli/src/cli.test.ts | 108 ++++++++++++++++ cli/src/cli.ts | 309 ++++++++++++++++++++++++++++++++++++++++++++ cli/src/utils.ts | 22 ++++ 3 files changed, 439 insertions(+) diff --git a/cli/src/cli.test.ts b/cli/src/cli.test.ts index 4dca8367..7831d2af 100644 --- a/cli/src/cli.test.ts +++ b/cli/src/cli.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { HttpResponse, http, passthrough } from 'msw' import pc from 'picocolors' import { afterAll, beforeEach, describe, expect, inject, onTestFinished, test, vi } from 'vitest' @@ -81,6 +84,7 @@ test('help', async () => { credits Manage prepaid credits (add, status) fetch Fetch URL as markdown org Manage organizations (create, invite, list, member, switch, view) + plugins Manage first-party plugins (doctor, install, list, update) request Manage requests (list, view) token Manage API tokens (create, list, delete) update Update curl.md CLI @@ -2869,6 +2873,110 @@ describe('token', () => { }) }) +describe('plugins', () => { + test('list supported plugins', async () => { + const { output } = await serve(['plugins']) + + expect(output).toContain('install/update') + expect(output).toContain('coming soon') + expect(output).toContain('Requires `PLUGINS=all` when starting Amp.') + expect(output).toContain('Doctor: curl.md plugins doctor') + expect(output).toContain('Install: curl.md plugins install ') + }) + + test('doctor reports host and install markers', async () => { + const commandSpy = vi + .spyOn(utils, 'commandExists') + .mockImplementation((command) => command !== 'claude') + onTestFinished(() => commandSpy.mockRestore()) + + const homeDir = os.homedir() + fs.mkdirSync(path.join(homeDir, '.config', 'amp', 'plugins'), { recursive: true }) + fs.mkdirSync(path.join(homeDir, '.config', 'opencode'), { recursive: true }) + fs.mkdirSync(path.join(homeDir, '.pi', 'agent'), { recursive: true }) + + fs.writeFileSync( + path.join(homeDir, '.config', 'amp', 'plugins', 'curlmd.ts'), + "import plugin from '@curl.md/amp'\n", + ) + fs.writeFileSync( + path.join(homeDir, '.config', 'opencode', 'opencode.json'), + '{"plugin":["@curl.md/opencode"]}\n', + ) + fs.writeFileSync( + path.join(homeDir, '.pi', 'agent', 'settings.json'), + '{"packages":["npm:@curl.md/pi"]}\n', + ) + + const { output } = await serve(['plugins', 'doctor']) + + expect(output).toContain('Plugin doctor') + expect(output).toContain('amp') + expect(output).toContain('detected') + expect(output).toContain('claude') + expect(output).toContain('missing') + expect(output).toContain('unknown') + expect(output).toContain('~/.config/opencode/opencode.json') + expect(output).toContain('~/.pi/agent/settings.json') + }) + + test('status aliases doctor', async () => { + const commandSpy = vi.spyOn(utils, 'commandExists').mockReturnValue(false) + onTestFinished(() => commandSpy.mockRestore()) + + const { output } = await serve(['plugins', 'status']) + + expect(output).toContain('Plugin doctor') + expect(output).toContain('missing') + }) + + test('install amp delegates to package runner', async () => { + const spy = vi.spyOn(utils, 'execPackageBinary').mockResolvedValue({ stderr: '', stdout: '' }) + onTestFinished(() => spy.mockRestore()) + + const { output } = await serve(['plugins', 'install', 'amp']) + + expect(spy).toHaveBeenCalledWith('@curl.md/amp@latest', ['install']) + expect(output).toContain('Installed Amp plugin') + expect(output).toContain('PLUGINS=all amp') + }) + + test('install claude delegates to marketplace flow', async () => { + const spy = vi.spyOn(utils, 'execCommand').mockResolvedValue({ stderr: '', stdout: '' }) + onTestFinished(() => spy.mockRestore()) + + const { output } = await serve(['plugins', 'install', 'claude']) + + expect(spy).toHaveBeenNthCalledWith(1, 'claude', [ + 'plugin', + 'marketplace', + 'add', + 'https://curl.md/claude.json', + ]) + expect(spy).toHaveBeenNthCalledWith(2, 'claude', ['plugin', 'install', 'curl-md@curl-md']) + expect(output).toContain('Installed Claude plugin') + expect(output).toContain('/reload-plugins') + }) + + test('update opencode delegates to forced reinstall', async () => { + const spy = vi.spyOn(utils, 'execCommand').mockResolvedValue({ stderr: '', stdout: '' }) + onTestFinished(() => spy.mockRestore()) + + const { output } = await serve(['plugins', 'update', 'opencode']) + + expect(spy).toHaveBeenCalledWith('opencode', ['plugin', '-g', '@curl.md/opencode', '--force']) + expect(output).toContain('Updated OpenCode plugin') + }) + + test('unsupported plugin returns clear error', async () => { + const { exitCode, output } = await serve(['plugins', 'install', 'codex']) + + expect(exitCode).toBe(1) + expect(output).toContain('PLUGIN_UNSUPPORTED') + expect(output).toContain('Codex plugin is not supported yet') + }) +}) + describe('update', () => { test('install fails', async () => { const standaloneSpy = vi.spyOn(utils, 'isStandalone').mockReturnValue(false) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 903ad464..fd9b8984 100755 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { Cli, type MiddlewareContext, middleware, z } from 'incur' import pc from 'picocolors' import pkg from '../package.json' with { type: 'json' } @@ -6,8 +9,11 @@ import { Auth } from './internal/auth.ts' import { Session } from './internal/session.ts' import * as UI from './ui.ts' import { + commandExists, compareVersions, estimateRequests, + execCommand, + execPackageBinary, formatCost, formatValidationError, installGlobal, @@ -20,6 +26,42 @@ import { } from './utils.ts' const aliases = ['md', 'curlmd'] +const pluginNames = ['amp', 'claude', 'codex', 'cursor', 'opencode', 'pi'] as const +type PluginName = (typeof pluginNames)[number] +type SupportedPluginName = Extract + +const pluginMetadata: Record = { + amp: { + label: 'Amp', + note: 'Requires `PLUGINS=all` when starting Amp.', + supported: true, + }, + claude: { + label: 'Claude', + note: 'Installs through the Claude marketplace.', + supported: true, + }, + codex: { + label: 'Codex', + note: 'Coming soon. Use curl.md skills for now.', + supported: false, + }, + cursor: { + label: 'Cursor', + note: 'Coming soon. Use curl.md skills for now.', + supported: false, + }, + opencode: { + label: 'OpenCode', + note: 'Uses `opencode plugin` under the hood.', + supported: true, + }, + pi: { + label: 'Pi', + note: 'Uses `pi install` and `pi update` under the hood.', + supported: true, + }, +} const env = z.object({ CURLMD_API_KEY: z.string().optional().describe('API token for authentication'), @@ -1588,6 +1630,50 @@ const update = Cli.create('update', { }, }) +const plugins = Cli.create('plugins', { + args: z.object({ + action: z + .enum(['doctor', 'install', 'list', 'ls', 'status', 'update']) + .optional() + .describe('Plugin action'), + plugin: z.enum(pluginNames).optional().describe('Plugin name'), + }), + description: 'Manage first-party plugins (doctor, install, list, update)', + output: z.string(), + format: 'md', + vars, + async run(c) { + if (!c.args.action || c.args.action === 'list' || c.args.action === 'ls') + return c.ok(renderPluginList(c.displayName)) + if (c.args.action === 'doctor' || c.args.action === 'status') return c.ok(renderPluginDoctor()) + + const plugin = c.args.plugin + if (!plugin) + return c.error({ + code: 'PLUGIN_REQUIRED', + message: `Pass a plugin name. Run ${c.displayName} plugins to list supported plugins.`, + }) + if (!pluginMetadata[plugin].supported) return unsupportedPlugin(c, plugin) + + const action = c.args.action + const spinner = UI.createSpinner( + `${action === 'install' ? 'Installing' : 'Updating'} ${pluginMetadata[plugin].label} plugin`, + ) + try { + if (action === 'install') await installPlugin(plugin) + else await updatePlugin(plugin) + spinner.stop() + return c.ok(renderPluginSuccess(plugin, action)) + } catch (error) { + spinner.stop() + return c.error({ + code: action === 'install' ? 'PLUGIN_INSTALL_FAILED' : 'PLUGIN_UPDATE_FAILED', + message: formatPluginActionError(error), + }) + } + }, +}) + const request = Cli.create('request', { description: 'Manage requests (list, view)', vars, @@ -1776,6 +1862,7 @@ cli.command( }), ) cli.command(org.command(invite).command(member)) +cli.command(plugins) cli.command(request) cli.command(token) cli.command(update) @@ -1965,3 +2052,225 @@ async function run( cta: { commands: c.var.commands }, }) } + +function renderPluginList(displayName: string) { + const rows = pluginNames.map((plugin) => [ + pc.green(plugin), + pluginMetadata[plugin].supported ? 'install/update' : pc.dim('coming soon'), + pluginMetadata[plugin].note, + ]) + + return [ + UI.table(['plugin', 'status', 'notes'], rows, { noTruncate: [0, 2] }), + `Doctor: ${displayName} plugins doctor`, + `Install: ${displayName} plugins install `, + `Update: ${displayName} plugins update `, + ].join('\n\n') +} + +function renderPluginDoctor() { + const rows = pluginNames.map((plugin) => { + const diagnostic = diagnosePlugin(plugin) + return [pc.green(plugin), diagnostic.host, diagnostic.install, diagnostic.notes] + }) + + return [ + pc.bold('Plugin doctor'), + UI.table(['plugin', 'host', 'install', 'notes'], rows, { noTruncate: [0, 3] }), + ].join('\n\n') +} + +function renderPluginSuccess(plugin: PluginName, action: 'install' | 'update') { + const lines = [ + UI.success( + `${action === 'install' ? 'Installed' : 'Updated'} ${pluginMetadata[plugin].label} plugin`, + ), + `- Docs: ${pluginDocsUrl(plugin)}`, + ] + + const next = pluginNextStep(plugin) + if (next) lines.push(`- Next: ${next}`) + + return lines.join('\n') +} + +function pluginDocsUrl(plugin: PluginName) { + return `https://curl.md/docs/plugins/${plugin}` +} + +function pluginNextStep(plugin: PluginName) { + switch (plugin) { + case 'amp': + return 'start Amp with `PLUGINS=all amp`' + case 'claude': + return 'run `/reload-plugins` if Claude is already open' + default: + return undefined + } +} + +function formatPluginActionError(error: unknown) { + if (error && typeof error === 'object') { + if ('stderr' in error && typeof error.stderr === 'string' && error.stderr.trim()) + return error.stderr.trim() + if ('message' in error && typeof error.message === 'string' && error.message.trim()) + return error.message.trim() + } + + return 'Unexpected error.' +} + +async function installPlugin(plugin: PluginName) { + switch (plugin) { + case 'amp': + await execPackageBinary('@curl.md/amp@latest', ['install']) + return + case 'claude': + await execCommand('claude', ['plugin', 'marketplace', 'add', 'https://curl.md/claude.json']) + await execCommand('claude', ['plugin', 'install', 'curl-md@curl-md']) + return + case 'opencode': + await execCommand('opencode', ['plugin', '-g', '@curl.md/opencode']) + return + case 'pi': + await execCommand('pi', ['install', 'npm:@curl.md/pi']) + return + default: + throw new Error(`${pluginMetadata[plugin].label} plugin is not supported yet.`) + } +} + +async function updatePlugin(plugin: PluginName) { + switch (plugin) { + case 'amp': + await execPackageBinary('@curl.md/amp@latest', ['install']) + return + case 'claude': + await execCommand('claude', ['plugin', 'marketplace', 'update', 'curl-md']) + await execCommand('claude', ['plugin', 'install', 'curl-md@curl-md']) + return + case 'opencode': + await execCommand('opencode', ['plugin', '-g', '@curl.md/opencode', '--force']) + return + case 'pi': + await execCommand('pi', ['update', 'npm:@curl.md/pi']) + return + default: + throw new Error(`${pluginMetadata[plugin].label} plugin is not supported yet.`) + } +} + +function unsupportedPlugin(c: Pick, plugin: PluginName) { + return c.error({ + code: 'PLUGIN_UNSUPPORTED', + message: `${pluginMetadata[plugin].label} plugin is not supported yet. See ${pluginDocsUrl(plugin)}.`, + }) +} + +function diagnosePlugin(plugin: PluginName) { + if (!pluginMetadata[plugin].supported) + return { + host: pc.dim('n/a'), + install: pc.dim('coming soon'), + notes: pluginMetadata[plugin].note, + } + + const hostBinary = pluginHostBinary(plugin as SupportedPluginName) + const host = commandExists(hostBinary) ? pc.green('ok') : pc.red('missing') + const install = pluginInstallStatus(plugin as SupportedPluginName) + return { + host, + install: install.status, + notes: `${install.path} — ${install.note}`, + } +} + +function pluginHostBinary(plugin: SupportedPluginName) { + switch (plugin) { + case 'amp': + return 'amp' + case 'claude': + return 'claude' + case 'opencode': + return 'opencode' + case 'pi': + return 'pi' + } +} + +function pluginInstallStatus(plugin: SupportedPluginName) { + switch (plugin) { + case 'amp': { + const shimPath = path.join(getAmpConfigDir(), 'plugins', 'curlmd.ts') + return { + note: 'plugin shim', + path: displayPath(shimPath), + status: fileContains(shimPath, ['@curl.md/amp']) + ? pc.green('detected') + : pc.yellow('not found'), + } + } + case 'claude': { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json') + return { + note: 'best effort from settings', + path: displayPath(settingsPath), + status: fileContains(settingsPath, ['curl-md@curl-md', 'https://curl.md/claude.json']) + ? pc.green('detected') + : pc.yellow('unknown'), + } + } + case 'opencode': { + const configPath = path.join(getConfigDir('opencode'), 'opencode.json') + return { + note: 'global config reference', + path: displayPath(configPath), + status: fileContains(configPath, ['@curl.md/opencode']) + ? pc.green('detected') + : pc.yellow('not found'), + } + } + case 'pi': { + const settingsPath = path.join(os.homedir(), '.pi', 'agent', 'settings.json') + return { + note: 'extension package reference', + path: displayPath(settingsPath), + status: fileContains(settingsPath, ['npm:@curl.md/pi', '@curl.md/pi']) + ? pc.green('detected') + : pc.yellow('not found'), + } + } + } +} + +function getAmpConfigDir() { + if (process.env.AMP_CONFIG_DIR) return process.env.AMP_CONFIG_DIR + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') + return path.join(appData, 'amp') + } + + return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'amp') +} + +function getConfigDir(name: string) { + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') + return path.join(appData, name) + } + + return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), name) +} + +function displayPath(filePath: string) { + return filePath.startsWith(os.homedir()) ? `~${filePath.slice(os.homedir().length)}` : filePath +} + +function fileContains(filePath: string, needles: string[]) { + try { + const contents = fs.readFileSync(filePath, 'utf8') + return needles.some((needle) => contents.includes(needle)) + } catch { + return false + } +} diff --git a/cli/src/utils.ts b/cli/src/utils.ts index f5461634..e33060c3 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -34,6 +34,22 @@ export function installGlobal(name: string, version?: string) { return execFileAsync('npm', ['install', '-g', spec]) } +export function execCommand(command: string, args: string[]) { + const execFileAsync = util.promisify(child_process.execFile) + return execFileAsync(resolveCommand(command), args) +} + +export function execPackageBinary(spec: string, args: string[]) { + const type = detectPackageManager() + if (type === 'pnpm') return execCommand('pnpm', ['dlx', spec, ...args]) + if (type === 'bun') return execCommand('bunx', [spec, ...args]) + return execCommand('npx', ['--yes', spec, ...args]) +} + +export function commandExists(command: string) { + return hasBinary(command) +} + export function openUrl(url: string) { const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' @@ -140,6 +156,12 @@ export declare namespace UpdateCache { } } +function resolveCommand(command: string) { + if (process.platform !== 'win32') return command + if (command.endsWith('.cmd') || command.endsWith('.exe')) return command + return `${command}.cmd` +} + export function relativeTime(date: Date) { const diff = Math.floor((Date.now() - date.getTime()) / 1000) const seconds = Math.abs(diff)