From 4c89f466c875d7e2f3a9539f5fb8e2c1b3f76229 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Sat, 21 Mar 2026 11:58:56 +0800 Subject: [PATCH 1/7] feat: plugin system (Stage 0-2) - Stage 0: discoverPlugins() scans ~/.opencli/plugins/ at startup - Stage 1: demo plugin repos (github-trending, hot-digest) - Stage 2: opencli plugin install/uninstall/list commands - package.json exports ./registry for TS plugin peerDep support - 17 new/updated tests, tsc --noEmit clean --- package.json | 4 ++ src/cli.ts | 70 +++++++++++++++++++ src/discovery.ts | 43 ++++++++++++ src/engine.test.ts | 50 ++++++++++++-- src/main.ts | 3 +- src/plugin.test.ts | 86 ++++++++++++++++++++++++ src/plugin.ts | 163 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 src/plugin.test.ts create mode 100644 src/plugin.ts diff --git a/package.json b/package.json index 616fb9f..8df7d86 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "bin": { "opencli": "dist/main.js" }, + "exports": { + ".": "./dist/main.js", + "./registry": "./dist/registry.js" + }, "scripts": { "dev": "tsx src/main.ts", "build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest", diff --git a/src/cli.ts b/src/cli.ts index a0429c3..40aa955 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -231,6 +231,76 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { printCompletionScript(shell); }); + // ── Plugin management ────────────────────────────────────────────────────── + + const pluginCmd = program.command('plugin').description('Manage opencli plugins'); + + pluginCmd + .command('install') + .description('Install a plugin from GitHub') + .argument('', 'Plugin source (e.g. github:user/repo)') + .action(async (source: string) => { + const { installPlugin } = await import('./plugin.js'); + try { + installPlugin(source); + const name = source.split('/').pop()?.replace(/^opencli-plugin-/, '') ?? source; + console.log(chalk.green(`✅ Plugin "${name}" installed successfully.`)); + console.log(chalk.dim(` Restart opencli to use the new commands.`)); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exitCode = 1; + } + }); + + pluginCmd + .command('uninstall') + .description('Uninstall a plugin') + .argument('', 'Plugin name') + .action(async (name: string) => { + const { uninstallPlugin } = await import('./plugin.js'); + try { + uninstallPlugin(name); + console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`)); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exitCode = 1; + } + }); + + pluginCmd + .command('list') + .description('List installed plugins') + .option('-f, --format ', 'Output format: table, json', 'table') + .action(async (opts) => { + const { listPlugins } = await import('./plugin.js'); + const plugins = listPlugins(); + if (plugins.length === 0) { + console.log(chalk.dim(' No plugins installed.')); + console.log(chalk.dim(` Install one with: opencli plugin install github:user/repo`)); + return; + } + if (opts.format === 'json') { + renderOutput(plugins, { + fmt: 'json', + columns: ['name', 'commands', 'source'], + title: 'opencli/plugins', + source: 'opencli plugin list', + }); + return; + } + console.log(); + console.log(chalk.bold(' Installed plugins')); + console.log(); + for (const p of plugins) { + const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : ''; + const src = p.source ? chalk.dim(` ← ${p.source}`) : ''; + console.log(` ${chalk.cyan(p.name)}${cmds}${src}`); + } + console.log(); + console.log(chalk.dim(` ${plugins.length} plugin(s) installed`)); + console.log(); + }); + // ── External CLIs ───────────────────────────────────────────────────────── const externalClis = loadExternalClis(); diff --git a/src/discovery.ts b/src/discovery.ts index a7b15c0..cff1319 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -9,11 +9,15 @@ */ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import yaml from 'js-yaml'; import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js'; import { log } from './logger.js'; +/** Plugins directory: ~/.opencli/plugins/ */ +export const PLUGINS_DIR = path.join(os.homedir(), '.opencli', 'plugins'); + /** * Discover and register CLI commands. * Uses pre-compiled manifest when available for instant startup. @@ -165,3 +169,42 @@ async function registerYamlCli(filePath: string, defaultSite: string): Promise { + try { await fs.promises.access(PLUGINS_DIR); } catch { return; } + const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + await discoverPluginDir(path.join(PLUGINS_DIR, entry.name), entry.name); + } +} + +/** + * Flat scan: read yaml/ts files directly in a plugin directory. + * Unlike discoverClisFromFs, this does NOT expect nested site subdirectories. + */ +async function discoverPluginDir(dir: string, site: string): Promise { + const files = await fs.promises.readdir(dir); + const promises: Promise[] = []; + for (const file of files) { + const filePath = path.join(dir, file); + if (file.endsWith('.yaml') || file.endsWith('.yml')) { + promises.push(registerYamlCli(filePath, site)); + } else if ( + (file.endsWith('.js') && !file.endsWith('.d.js')) || + (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) + ) { + promises.push( + import(`file://${filePath}`).catch((err: any) => { + log.warn(`Plugin ${site}/${file}: ${err.message}`); + }) + ); + } + } + await Promise.all(promises); +} diff --git a/src/engine.test.ts b/src/engine.test.ts index 2695570..1c6c3b5 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -1,11 +1,9 @@ -/** - * Tests for discovery and execution modules. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { discoverClis } from './discovery.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { discoverClis, discoverPlugins, PLUGINS_DIR } from './discovery.js'; import { executeCommand } from './execution.js'; import { getRegistry, cli, Strategy } from './registry.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; describe('discoverClis', () => { it('handles non-existent directories gracefully', async () => { @@ -14,6 +12,46 @@ describe('discoverClis', () => { }); }); +describe('discoverPlugins', () => { + const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__'); + const yamlPath = path.join(testPluginDir, 'greeting.yaml'); + + afterEach(async () => { + try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {} + }); + + it('discovers YAML plugins from ~/.opencli/plugins/', async () => { + // Create a simple YAML adapter in the plugins directory + await fs.promises.mkdir(testPluginDir, { recursive: true }); + await fs.promises.writeFile(yamlPath, ` +site: __test-plugin__ +name: greeting +description: Test plugin greeting +strategy: public +browser: false + +pipeline: + - evaluate: "() => [{ message: 'hello from plugin' }]" + +columns: [message] +`); + + await discoverPlugins(); + + const registry = getRegistry(); + const cmd = registry.get('__test-plugin__/greeting'); + expect(cmd).toBeDefined(); + expect(cmd!.site).toBe('__test-plugin__'); + expect(cmd!.name).toBe('greeting'); + expect(cmd!.description).toBe('Test plugin greeting'); + }); + + it('handles non-existent plugins directory gracefully', async () => { + // discoverPlugins should not throw if ~/.opencli/plugins/ does not exist + await expect(discoverPlugins()).resolves.not.toThrow(); + }); +}); + describe('executeCommand', () => { it('accepts kebab-case option names after Commander camelCases them', async () => { const cmd = cli({ diff --git a/src/main.ts b/src/main.ts index 58a2b6c..3d5e242 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { discoverClis } from './discovery.js'; +import { discoverClis, discoverPlugins } from './discovery.js'; import { getCompletions } from './completion.js'; import { runCli } from './cli.js'; @@ -16,6 +16,7 @@ const BUILTIN_CLIS = path.resolve(__dirname, 'clis'); const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis'); await discoverClis(BUILTIN_CLIS, USER_CLIS); +await discoverPlugins(); // ── Fast-path: handle --get-completions before commander parses ───────── // Usage: opencli --get-completions --cursor [word1 word2 ...] diff --git a/src/plugin.test.ts b/src/plugin.test.ts new file mode 100644 index 0000000..c69ddae --- /dev/null +++ b/src/plugin.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for plugin management: install, uninstall, list. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { PLUGINS_DIR } from './discovery.js'; +import { listPlugins, uninstallPlugin, _parseSource } from './plugin.js'; + +describe('parseSource', () => { + it('parses github:user/repo format', () => { + const result = _parseSource('github:ByteYue/opencli-plugin-github-trending'); + expect(result).toEqual({ + cloneUrl: 'https://github.com/ByteYue/opencli-plugin-github-trending.git', + name: 'github-trending', + }); + }); + + it('parses https URL format', () => { + const result = _parseSource('https://github.com/ByteYue/opencli-plugin-hot-digest'); + expect(result).toEqual({ + cloneUrl: 'https://github.com/ByteYue/opencli-plugin-hot-digest.git', + name: 'hot-digest', + }); + }); + + it('strips opencli-plugin- prefix from name', () => { + const result = _parseSource('github:user/opencli-plugin-my-tool'); + expect(result!.name).toBe('my-tool'); + }); + + it('keeps name without prefix', () => { + const result = _parseSource('github:user/awesome-cli'); + expect(result!.name).toBe('awesome-cli'); + }); + + it('returns null for invalid source', () => { + expect(_parseSource('invalid')).toBeNull(); + expect(_parseSource('npm:some-package')).toBeNull(); + }); +}); + +describe('listPlugins', () => { + const testDir = path.join(PLUGINS_DIR, '__test-list-plugin__'); + + afterEach(() => { + try { fs.rmSync(testDir, { recursive: true }); } catch {} + }); + + it('lists installed plugins', () => { + fs.mkdirSync(testDir, { recursive: true }); + fs.writeFileSync(path.join(testDir, 'hello.yaml'), 'site: test\nname: hello\n'); + + const plugins = listPlugins(); + const found = plugins.find(p => p.name === '__test-list-plugin__'); + expect(found).toBeDefined(); + expect(found!.commands).toContain('hello'); + }); + + it('returns empty array when no plugins dir', () => { + // listPlugins should handle missing dir gracefully + const plugins = listPlugins(); + expect(Array.isArray(plugins)).toBe(true); + }); +}); + +describe('uninstallPlugin', () => { + const testDir = path.join(PLUGINS_DIR, '__test-uninstall__'); + + afterEach(() => { + try { fs.rmSync(testDir, { recursive: true }); } catch {} + }); + + it('removes plugin directory', () => { + fs.mkdirSync(testDir, { recursive: true }); + fs.writeFileSync(path.join(testDir, 'test.yaml'), 'site: test'); + + uninstallPlugin('__test-uninstall__'); + expect(fs.existsSync(testDir)).toBe(false); + }); + + it('throws for non-existent plugin', () => { + expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed'); + }); +}); diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..6bd0fff --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,163 @@ +/** + * Plugin management: install, uninstall, and list plugins. + * + * Plugins live in ~/.opencli/plugins//. + * Install source format: "github:user/repo" + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { PLUGINS_DIR } from './discovery.js'; +import { log } from './logger.js'; + +export interface PluginInfo { + name: string; + path: string; + commands: string[]; + source?: string; +} + +/** + * Install a plugin from a source. + * Currently supports "github:user/repo" format (git clone wrapper). + */ +export function installPlugin(source: string): void { + const parsed = parseSource(source); + if (!parsed) { + throw new Error( + `Invalid plugin source: "${source}"\n` + + `Supported formats:\n` + + ` github:user/repo\n` + + ` https://github.com/user/repo` + ); + } + + const { cloneUrl, name } = parsed; + const targetDir = path.join(PLUGINS_DIR, name); + + if (fs.existsSync(targetDir)) { + throw new Error(`Plugin "${name}" is already installed at ${targetDir}`); + } + + // Ensure plugins directory exists + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + + try { + execSync(`git clone --depth 1 ${cloneUrl} ${targetDir}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (err: any) { + throw new Error(`Failed to clone plugin: ${err.message}`); + } + + // If the plugin has a package.json, run npm install for peerDeps resolution + const pkgJsonPath = path.join(targetDir, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + try { + execSync('npm install --production', { + cwd: targetDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + // Non-fatal: npm install may fail if no real deps + } + } +} + +/** + * Uninstall a plugin by name. + */ +export function uninstallPlugin(name: string): void { + const targetDir = path.join(PLUGINS_DIR, name); + if (!fs.existsSync(targetDir)) { + throw new Error(`Plugin "${name}" is not installed.`); + } + fs.rmSync(targetDir, { recursive: true, force: true }); +} + +/** + * List all installed plugins. + */ +export function listPlugins(): PluginInfo[] { + if (!fs.existsSync(PLUGINS_DIR)) return []; + + const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }); + const plugins: PluginInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const pluginDir = path.join(PLUGINS_DIR, entry.name); + const commands = scanPluginCommands(pluginDir); + const source = getPluginSource(pluginDir); + + plugins.push({ + name: entry.name, + path: pluginDir, + commands, + source, + }); + } + + return plugins; +} + +/** Scan a plugin directory for command files */ +function scanPluginCommands(dir: string): string[] { + try { + const files = fs.readdirSync(dir); + return files + .filter(f => + f.endsWith('.yaml') || f.endsWith('.yml') || + (f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts')) || + (f.endsWith('.js') && !f.endsWith('.d.js')) + ) + .map(f => path.basename(f, path.extname(f))); + } catch { + return []; + } +} + +/** Get git remote origin URL */ +function getPluginSource(dir: string): string | undefined { + try { + return execSync('git config --get remote.origin.url', { + cwd: dir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { + return undefined; + } +} + +/** Parse a plugin source string into clone URL and name */ +function parseSource(source: string): { cloneUrl: string; name: string } | null { + // github:user/repo + const githubMatch = source.match(/^github:(.+?)\/(.+?)$/); + if (githubMatch) { + const [, user, repo] = githubMatch; + const name = repo.replace(/^opencli-plugin-/, ''); + return { + cloneUrl: `https://github.com/${user}/${repo}.git`, + name, + }; + } + + // https://github.com/user/repo (or .git) + const urlMatch = source.match(/^https?:\/\/github\.com\/(.+?)\/(.+?)(?:\.git)?$/); + if (urlMatch) { + const [, user, repo] = urlMatch; + const name = repo.replace(/^opencli-plugin-/, ''); + return { + cloneUrl: `https://github.com/${user}/${repo}.git`, + name, + }; + } + + return null; +} + +export { parseSource as _parseSource }; From 51dfa25db1ebc3301c84bc831d2e106b2f542ab1 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Sat, 21 Mar 2026 12:22:25 +0800 Subject: [PATCH 2/7] fix: CDPBridge connect timeout unit mismatch (seconds vs ms) opts.timeout is passed in seconds from runtime.ts but CDPBridge was using it as milliseconds, causing instant timeout (30ms). --- src/browser/cdp.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 9d6a9ca..e5b5205 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -55,7 +55,8 @@ export class CDPBridge { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); - const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), opts?.timeout ?? 10000); + const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds + const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs); ws.on('open', () => { clearTimeout(timeout); From 116c5655bae1ecf6d140296fb8a528a3ff1e697c Mon Sep 17 00:00:00 2001 From: ByteYue Date: Sat, 21 Mar 2026 13:53:18 +0800 Subject: [PATCH 3/7] feat: add registry-api public entry point for TS plugin peerDep support - Add src/registry-api.ts: re-exports core registration API (cli, Strategy, getRegistry) without transitive side-effects, safe for plugin imports - Update package.json exports: './registry' -> './dist/registry-api.js' - Update src/registry.ts: use globalThis shared registry to ensure single instance across npm-linked plugin modules - Update .gitignore for plugin-related artifacts --- .gitignore | 4 ++++ package.json | 2 +- src/registry-api.ts | 12 ++++++++++++ src/registry.ts | 8 +++++++- 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/registry-api.ts diff --git a/.gitignore b/.gitignore index bf6125f..3211b21 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ docs/.vitepress/cache *.pem *.crx *.zip +.envrc +.windsurf +.claude +.cortex diff --git a/package.json b/package.json index 8df7d86..7321391 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "exports": { ".": "./dist/main.js", - "./registry": "./dist/registry.js" + "./registry": "./dist/registry-api.js" }, "scripts": { "dev": "tsx src/main.ts", diff --git a/src/registry-api.ts b/src/registry-api.ts new file mode 100644 index 0000000..64da785 --- /dev/null +++ b/src/registry-api.ts @@ -0,0 +1,12 @@ +/** + * Public API for opencli plugins. + * + * TS plugins should import from '@jackwener/opencli/registry' which resolves to + * this file. It re-exports ONLY the core registration API — no serialization, + * no transitive side-effects — to avoid circular dependency deadlocks when + * plugins are dynamically imported during discoverPlugins(). + */ + +export { cli, Strategy, getRegistry, fullName, registerCommand } from './registry.js'; +export type { CliCommand, Arg, CliOptions } from './registry.js'; +export type { IPage } from './types.js'; diff --git a/src/registry.ts b/src/registry.ts index fb05990..1a9aae1 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -49,7 +49,13 @@ export interface CliOptions extends Partial(); + +// Use globalThis to ensure a single shared registry across all module instances. +// This is critical for TS plugins loaded via npm link / peerDependency — without +// this, the plugin's import creates a separate module instance with its own Map. +const REGISTRY_KEY = '__opencli_registry__'; +const _registry: Map = + (globalThis as any)[REGISTRY_KEY] ??= new Map(); export function cli(opts: CliOptions): CliCommand { const strategy = opts.strategy ?? (opts.browser === false ? Strategy.PUBLIC : Strategy.COOKIE); From ef86d9bd443ededf0bd55c5afa9935908f591a21 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Sat, 21 Mar 2026 13:59:48 +0800 Subject: [PATCH 4/7] fix: symlink host opencli into plugin node_modules on install After npm install, replace the npm-installed @jackwener/opencli with a symlink to the running host's package root. This ensures TS plugins always resolve '@jackwener/opencli/registry' against the host installation, avoiding version mismatches when the published npm package lags behind. --- src/plugin.ts | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/plugin.ts b/src/plugin.ts index 6bd0fff..9997302 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -52,7 +52,8 @@ export function installPlugin(source: string): void { throw new Error(`Failed to clone plugin: ${err.message}`); } - // If the plugin has a package.json, run npm install for peerDeps resolution + // If the plugin has a package.json, run npm install for regular deps, + // then symlink the host opencli into node_modules for peerDep resolution. const pkgJsonPath = path.join(targetDir, 'package.json'); if (fs.existsSync(pkgJsonPath)) { try { @@ -64,6 +65,12 @@ export function installPlugin(source: string): void { } catch { // Non-fatal: npm install may fail if no real deps } + + // Symlink host opencli into plugin's node_modules so TS plugins + // can resolve '@jackwener/opencli/registry' against the running host. + // This is more reliable than depending on the npm-published version + // which may lag behind the local installation. + linkHostOpencli(targetDir); } } @@ -160,4 +167,35 @@ function parseSource(source: string): { cloneUrl: string; name: string } | null return null; } +/** + * Symlink the host opencli package into a plugin's node_modules. + * This ensures TS plugins resolve '@jackwener/opencli/registry' against + * the running host installation rather than a stale npm-published version. + */ +function linkHostOpencli(pluginDir: string): void { + try { + // Determine the host opencli package root from this module's location. + // In dev (tsx): import.meta.url → file:///…/opencli/src/plugin.ts → root is ../../ + // In prod (node): import.meta.url → file:///…/opencli/dist/plugin.js → root is ../ + const thisFile = new URL(import.meta.url).pathname; + const hostRoot = path.resolve(path.dirname(thisFile), '..'); + + const targetLink = path.join(pluginDir, 'node_modules', '@jackwener', 'opencli'); + + // Remove existing (npm-installed copy or stale symlink) + if (fs.existsSync(targetLink)) { + fs.rmSync(targetLink, { recursive: true, force: true }); + } + + // Ensure parent directory exists + fs.mkdirSync(path.dirname(targetLink), { recursive: true }); + + // Create symlink + fs.symlinkSync(hostRoot, targetLink, 'dir'); + log.debug(`Linked host opencli into plugin: ${targetLink} → ${hostRoot}`); + } catch (err: any) { + log.warn(`Failed to link host opencli into plugin: ${err.message}`); + } +} + export { parseSource as _parseSource }; From 2d8f7565339015ce2574351a82f77451b122631a Mon Sep 17 00:00:00 2001 From: ByteYue Date: Sat, 21 Mar 2026 14:06:45 +0800 Subject: [PATCH 5/7] fix: transpile TS plugins to JS on install, deduplicate .ts/.js discovery - installPlugin: after symlinking host opencli, transpile any .ts files to .js using esbuild from the host's node_modules/.bin/ - discoverPluginDir: skip .ts files when a .js sibling exists (production node cannot load .ts directly) - scanPluginCommands: deduplicate basenames via Set to avoid showing 'aggregate, aggregate' when both .ts and .js exist --- src/discovery.ts | 13 +++++++-- src/plugin.ts | 68 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/discovery.ts b/src/discovery.ts index cff1319..70615ca 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -190,15 +190,24 @@ export async function discoverPlugins(): Promise { */ async function discoverPluginDir(dir: string, site: string): Promise { const files = await fs.promises.readdir(dir); + const fileSet = new Set(files); const promises: Promise[] = []; for (const file of files) { const filePath = path.join(dir, file); if (file.endsWith('.yaml') || file.endsWith('.yml')) { promises.push(registerYamlCli(filePath, site)); + } else if (file.endsWith('.js') && !file.endsWith('.d.js')) { + promises.push( + import(`file://${filePath}`).catch((err: any) => { + log.warn(`Plugin ${site}/${file}: ${err.message}`); + }) + ); } else if ( - (file.endsWith('.js') && !file.endsWith('.d.js')) || - (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) + file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') ) { + // Skip .ts if a compiled .js sibling exists (production mode can't load .ts) + const jsFile = file.replace(/\.ts$/, '.js'); + if (fileSet.has(jsFile)) continue; promises.push( import(`file://${filePath}`).catch((err: any) => { log.warn(`Plugin ${site}/${file}: ${err.message}`); diff --git a/src/plugin.ts b/src/plugin.ts index 9997302..996d63e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -71,6 +71,10 @@ export function installPlugin(source: string): void { // This is more reliable than depending on the npm-published version // which may lag behind the local installation. linkHostOpencli(targetDir); + + // Transpile TS plugin files to JS so they work in production mode + // (node cannot load .ts files directly without tsx). + transpilePluginTs(targetDir); } } @@ -115,13 +119,16 @@ export function listPlugins(): PluginInfo[] { function scanPluginCommands(dir: string): string[] { try { const files = fs.readdirSync(dir); - return files - .filter(f => - f.endsWith('.yaml') || f.endsWith('.yml') || - (f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts')) || - (f.endsWith('.js') && !f.endsWith('.d.js')) - ) - .map(f => path.basename(f, path.extname(f))); + const names = new Set( + files + .filter(f => + f.endsWith('.yaml') || f.endsWith('.yml') || + (f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts')) || + (f.endsWith('.js') && !f.endsWith('.d.js')) + ) + .map(f => path.basename(f, path.extname(f))) + ); + return [...names]; } catch { return []; } @@ -198,4 +205,51 @@ function linkHostOpencli(pluginDir: string): void { } } +/** + * Transpile TS plugin files to JS so they work in production mode. + * Uses esbuild from the host opencli's node_modules for fast single-file transpilation. + */ +function transpilePluginTs(pluginDir: string): void { + try { + // Resolve esbuild binary from the host opencli's node_modules + const thisFile = new URL(import.meta.url).pathname; + const hostRoot = path.resolve(path.dirname(thisFile), '..'); + const esbuildBin = path.join(hostRoot, 'node_modules', '.bin', 'esbuild'); + + if (!fs.existsSync(esbuildBin)) { + log.debug('esbuild not found in host node_modules, skipping TS transpilation'); + return; + } + + const files = fs.readdirSync(pluginDir); + const tsFiles = files.filter(f => + f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') + ); + + for (const tsFile of tsFiles) { + const jsFile = tsFile.replace(/\.ts$/, '.js'); + const jsPath = path.join(pluginDir, jsFile); + + // Skip if .js already exists (plugin may ship pre-compiled) + if (fs.existsSync(jsPath)) continue; + + try { + execSync( + `"${esbuildBin}" "${tsFile}" --outfile="${jsFile}" --format=esm --platform=node`, + { + cwd: pluginDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + log.debug(`Transpiled plugin file: ${tsFile} → ${jsFile}`); + } catch (err: any) { + log.warn(`Failed to transpile ${tsFile}: ${err.message}`); + } + } + } catch { + // Non-fatal: skip transpilation if anything goes wrong + } +} + export { parseSource as _parseSource }; From 230665c2809fd981f452a78a31f1f855f44c766d Mon Sep 17 00:00:00 2001 From: ByteYue Date: Sat, 21 Mar 2026 14:10:28 +0800 Subject: [PATCH 6/7] docs: add plugin system user guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New docs/guide/plugins.md covering: - Installation/uninstallation commands - Creating YAML plugins (zero-dep) - Creating TS plugins (with peerDep) - TS plugin install lifecycle (clone → deps → symlink → transpile) - Example plugins and troubleshooting - Add Plugins to VitePress sidebar (EN + ZH) - Link from getting-started.md Next Steps --- docs/.vitepress/config.mts | 2 + docs/guide/getting-started.md | 1 + docs/guide/plugins.md | 152 ++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 docs/guide/plugins.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index baf9c92..bd04bc2 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -31,6 +31,7 @@ export default defineConfig({ { text: 'Installation', link: '/guide/installation' }, { text: 'Browser Bridge', link: '/guide/browser-bridge' }, { text: 'Troubleshooting', link: '/guide/troubleshooting' }, + { text: 'Plugins', link: '/guide/plugins' }, ], }, ], @@ -150,6 +151,7 @@ export default defineConfig({ { text: '快速开始', link: '/zh/guide/getting-started' }, { text: '安装', link: '/zh/guide/installation' }, { text: 'Browser Bridge', link: '/zh/guide/browser-bridge' }, + { text: '插件', link: '/guide/plugins' }, ], }, ], diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 7c2861c..14c68ff 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -52,5 +52,6 @@ opencli bilibili hot -v # Verbose: show pipeline debug - [Installation details](/guide/installation) - [Browser Bridge setup](/guide/browser-bridge) +- [Plugins — extend with community adapters](/guide/plugins) - [All available adapters](/adapters/) - [For developers / AI agents](/developer/contributing) diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md new file mode 100644 index 0000000..120320a --- /dev/null +++ b/docs/guide/plugins.md @@ -0,0 +1,152 @@ +# Plugins + +OpenCLI supports community-contributed plugins. Install third-party adapters from GitHub, and they're automatically discovered alongside built-in commands. + +## Quick Start + +```bash +# Install a plugin +opencli plugin install github:ByteYue/opencli-plugin-github-trending + +# List installed plugins +opencli plugin list + +# Use the plugin (it's just a regular command) +opencli github-trending repos --limit 10 + +# Remove a plugin +opencli plugin uninstall github-trending +``` + +## How Plugins Work + +Plugins live in `~/.opencli/plugins//`. Each subdirectory is scanned at startup for `.yaml`, `.ts`, or `.js` command files — the same formats used by built-in adapters. + +### Supported Source Formats + +```bash +opencli plugin install github:user/repo +opencli plugin install https://github.com/user/repo +``` + +The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`. + +## Creating a Plugin + +### Option 1: YAML Plugin (Simplest) + +Zero dependencies, no build step. Just create a `.yaml` file: + +``` +my-plugin/ +├── my-command.yaml +└── README.md +``` + +Example `my-command.yaml`: + +```yaml +site: my-plugin +name: my-command +description: My custom command +strategy: public +browser: false + +args: + limit: + type: int + default: 10 + +pipeline: + - fetch: + url: https://api.example.com/data + - map: + title: ${{ item.title }} + score: ${{ item.score }} + - limit: ${{ args.limit }} + +columns: [title, score] +``` + +### Option 2: TypeScript Plugin + +For richer logic (multi-source aggregation, custom transformations, etc.): + +``` +my-plugin/ +├── package.json +├── my-command.ts +└── README.md +``` + +`package.json`: + +```json +{ + "name": "opencli-plugin-my-plugin", + "version": "0.1.0", + "type": "module", + "peerDependencies": { + "@jackwener/opencli": ">=1.0.0" + } +} +``` + +`my-command.ts`: + +```typescript +import { cli, Strategy } from '@jackwener/opencli/registry'; + +cli({ + site: 'my-plugin', + name: 'my-command', + description: 'My custom command', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'limit', type: 'int', default: 10, help: 'Number of items' }, + ], + columns: ['title', 'score'], + func: async (_page, kwargs) => { + const res = await fetch('https://api.example.com/data'); + const data = await res.json(); + return data.items.slice(0, kwargs.limit).map((item: any, i: number) => ({ + title: item.title, + score: item.score, + })); + }, +}); +``` + +### TS Plugin Install Lifecycle + +When you run `opencli plugin install`, TS plugins are automatically set up: + +1. **Clone** — `git clone --depth 1` from GitHub +2. **npm install** — Resolves regular dependencies +3. **Host symlink** — Links the running `@jackwener/opencli` into the plugin's `node_modules/` so `import from '@jackwener/opencli/registry'` always resolves against the host +4. **Transpile** — Compiles `.ts` → `.js` via `esbuild` (production `node` cannot load `.ts` directly) + +On startup, if both `my-command.ts` and `my-command.js` exist, the `.js` version is loaded to avoid duplicate registration. + +## Example Plugins + +| Repo | Type | Description | +|------|------|-------------| +| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories | +| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator (zhihu, weibo, bilibili, v2ex, stackoverflow, reddit, linux-do) | + +## Troubleshooting + +### Command not found after install + +Restart opencli (or open a new terminal) — plugins are discovered at startup. + +### TS plugin import errors + +If you see `Cannot find module '@jackwener/opencli/registry'`, the host symlink may be broken. Reinstall the plugin: + +```bash +opencli plugin uninstall my-plugin +opencli plugin install github:user/opencli-plugin-my-plugin +``` From 73d7fea1da2d9f30def0be44eba28ed25f18e638 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 21 Mar 2026 19:36:56 +0800 Subject: [PATCH 7/7] fix: address review issues in plugin system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Security: replace execSync with execFileSync to prevent shell injection - Replace deprecated npm --production with --omit=dev - Tighten parseSource regex to [\w.-]+ to reject special chars - Fix ZH sidebar plugin link (/guide/plugins → /zh/guide/plugins) - Return plugin name from installPlugin() to avoid duplicated logic - Use execFileSync for esbuild transpilation - Fix misleading comment in linkHostOpencli --- docs/.vitepress/config.mts | 2 +- src/cli.ts | 3 +-- src/plugin.ts | 31 +++++++++++++++---------------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index bd04bc2..b6aea95 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -151,7 +151,7 @@ export default defineConfig({ { text: '快速开始', link: '/zh/guide/getting-started' }, { text: '安装', link: '/zh/guide/installation' }, { text: 'Browser Bridge', link: '/zh/guide/browser-bridge' }, - { text: '插件', link: '/guide/plugins' }, + { text: '插件', link: '/zh/guide/plugins' }, ], }, ], diff --git a/src/cli.ts b/src/cli.ts index 40aa955..e274186 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -242,8 +242,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .action(async (source: string) => { const { installPlugin } = await import('./plugin.js'); try { - installPlugin(source); - const name = source.split('/').pop()?.replace(/^opencli-plugin-/, '') ?? source; + const name = installPlugin(source); console.log(chalk.green(`✅ Plugin "${name}" installed successfully.`)); console.log(chalk.dim(` Restart opencli to use the new commands.`)); } catch (err: any) { diff --git a/src/plugin.ts b/src/plugin.ts index 996d63e..310fe67 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { execSync } from 'node:child_process'; +import { execSync, execFileSync } from 'node:child_process'; import { PLUGINS_DIR } from './discovery.js'; import { log } from './logger.js'; @@ -22,7 +22,7 @@ export interface PluginInfo { * Install a plugin from a source. * Currently supports "github:user/repo" format (git clone wrapper). */ -export function installPlugin(source: string): void { +export function installPlugin(source: string): string { const parsed = parseSource(source); if (!parsed) { throw new Error( @@ -44,7 +44,7 @@ export function installPlugin(source: string): void { fs.mkdirSync(PLUGINS_DIR, { recursive: true }); try { - execSync(`git clone --depth 1 ${cloneUrl} ${targetDir}`, { + execFileSync('git', ['clone', '--depth', '1', cloneUrl, targetDir], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); @@ -57,7 +57,7 @@ export function installPlugin(source: string): void { const pkgJsonPath = path.join(targetDir, 'package.json'); if (fs.existsSync(pkgJsonPath)) { try { - execSync('npm install --production', { + execFileSync('npm', ['install', '--omit=dev'], { cwd: targetDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], @@ -76,6 +76,8 @@ export function installPlugin(source: string): void { // (node cannot load .ts files directly without tsx). transpilePluginTs(targetDir); } + + return name; } /** @@ -150,7 +152,7 @@ function getPluginSource(dir: string): string | undefined { /** Parse a plugin source string into clone URL and name */ function parseSource(source: string): { cloneUrl: string; name: string } | null { // github:user/repo - const githubMatch = source.match(/^github:(.+?)\/(.+?)$/); + const githubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)$/); if (githubMatch) { const [, user, repo] = githubMatch; const name = repo.replace(/^opencli-plugin-/, ''); @@ -161,7 +163,7 @@ function parseSource(source: string): { cloneUrl: string; name: string } | null } // https://github.com/user/repo (or .git) - const urlMatch = source.match(/^https?:\/\/github\.com\/(.+?)\/(.+?)(?:\.git)?$/); + const urlMatch = source.match(/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?$/); if (urlMatch) { const [, user, repo] = urlMatch; const name = repo.replace(/^opencli-plugin-/, ''); @@ -182,8 +184,8 @@ function parseSource(source: string): { cloneUrl: string; name: string } | null function linkHostOpencli(pluginDir: string): void { try { // Determine the host opencli package root from this module's location. - // In dev (tsx): import.meta.url → file:///…/opencli/src/plugin.ts → root is ../../ - // In prod (node): import.meta.url → file:///…/opencli/dist/plugin.js → root is ../ + // Both dev (tsx src/plugin.ts) and prod (node dist/plugin.js) are one level + // deep, so path.dirname + '..' always gives us the package root. const thisFile = new URL(import.meta.url).pathname; const hostRoot = path.resolve(path.dirname(thisFile), '..'); @@ -234,14 +236,11 @@ function transpilePluginTs(pluginDir: string): void { if (fs.existsSync(jsPath)) continue; try { - execSync( - `"${esbuildBin}" "${tsFile}" --outfile="${jsFile}" --format=esm --platform=node`, - { - cwd: pluginDir, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - } - ); + execFileSync(esbuildBin, [tsFile, `--outfile=${jsFile}`, '--format=esm', '--platform=node'], { + cwd: pluginDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); log.debug(`Transpiled plugin file: ${tsFile} → ${jsFile}`); } catch (err: any) { log.warn(`Failed to transpile ${tsFile}: ${err.message}`);