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/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index baf9c92..b6aea95 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: '/zh/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 +``` diff --git a/package.json b/package.json index 616fb9f..7321391 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "bin": { "opencli": "dist/main.js" }, + "exports": { + ".": "./dist/main.js", + "./registry": "./dist/registry-api.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/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); diff --git a/src/cli.ts b/src/cli.ts index a0429c3..e274186 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -231,6 +231,75 @@ 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 { + 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) { + 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..70615ca 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,51 @@ 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 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('.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}`); + }) + ); + } + } + 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..310fe67 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,254 @@ +/** + * 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, execFileSync } 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): string { + 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 { + execFileSync('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 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 { + execFileSync('npm', ['install', '--omit=dev'], { + cwd: targetDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } 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); + + // Transpile TS plugin files to JS so they work in production mode + // (node cannot load .ts files directly without tsx). + transpilePluginTs(targetDir); + } + + return name; +} + +/** + * 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); + 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 []; + } +} + +/** 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:([\w.-]+)\/([\w.-]+)$/); + 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\/([\w.-]+)\/([\w.-]+?)(?:\.git)?$/); + if (urlMatch) { + const [, user, repo] = urlMatch; + const name = repo.replace(/^opencli-plugin-/, ''); + return { + cloneUrl: `https://github.com/${user}/${repo}.git`, + name, + }; + } + + 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. + // 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), '..'); + + 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}`); + } +} + +/** + * 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 { + 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}`); + } + } + } catch { + // Non-fatal: skip transpilation if anything goes wrong + } +} + +export { parseSource as _parseSource }; 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);