Skip to content

Commit 1d39295

Browse files
ByteYuejackwener
andauthored
feat: plugin system (Stage 0-2)
* 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 * 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). * 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 * 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. * 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 * docs: add plugin system user guide - 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 * fix: address review issues in plugin system - 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 --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 50ec7c6 commit 1d39295

File tree

14 files changed

+691
-9
lines changed

14 files changed

+691
-9
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ docs/.vitepress/cache
1515
*.pem
1616
*.crx
1717
*.zip
18+
.envrc
19+
.windsurf
20+
.claude
21+
.cortex

docs/.vitepress/config.mts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default defineConfig({
3131
{ text: 'Installation', link: '/guide/installation' },
3232
{ text: 'Browser Bridge', link: '/guide/browser-bridge' },
3333
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
34+
{ text: 'Plugins', link: '/guide/plugins' },
3435
],
3536
},
3637
],
@@ -149,6 +150,7 @@ export default defineConfig({
149150
{ text: '快速开始', link: '/zh/guide/getting-started' },
150151
{ text: '安装', link: '/zh/guide/installation' },
151152
{ text: 'Browser Bridge', link: '/zh/guide/browser-bridge' },
153+
{ text: '插件', link: '/zh/guide/plugins' },
152154
],
153155
},
154156
],

docs/guide/getting-started.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,6 @@ opencli bilibili hot -v # Verbose: show pipeline debug
5252

5353
- [Installation details](/guide/installation)
5454
- [Browser Bridge setup](/guide/browser-bridge)
55+
- [Plugins — extend with community adapters](/guide/plugins)
5556
- [All available adapters](/adapters/)
5657
- [For developers / AI agents](/developer/contributing)

docs/guide/plugins.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Plugins
2+
3+
OpenCLI supports community-contributed plugins. Install third-party adapters from GitHub, and they're automatically discovered alongside built-in commands.
4+
5+
## Quick Start
6+
7+
```bash
8+
# Install a plugin
9+
opencli plugin install github:ByteYue/opencli-plugin-github-trending
10+
11+
# List installed plugins
12+
opencli plugin list
13+
14+
# Use the plugin (it's just a regular command)
15+
opencli github-trending repos --limit 10
16+
17+
# Remove a plugin
18+
opencli plugin uninstall github-trending
19+
```
20+
21+
## How Plugins Work
22+
23+
Plugins live in `~/.opencli/plugins/<name>/`. Each subdirectory is scanned at startup for `.yaml`, `.ts`, or `.js` command files — the same formats used by built-in adapters.
24+
25+
### Supported Source Formats
26+
27+
```bash
28+
opencli plugin install github:user/repo
29+
opencli plugin install https://github.com/user/repo
30+
```
31+
32+
The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
33+
34+
## Creating a Plugin
35+
36+
### Option 1: YAML Plugin (Simplest)
37+
38+
Zero dependencies, no build step. Just create a `.yaml` file:
39+
40+
```
41+
my-plugin/
42+
├── my-command.yaml
43+
└── README.md
44+
```
45+
46+
Example `my-command.yaml`:
47+
48+
```yaml
49+
site: my-plugin
50+
name: my-command
51+
description: My custom command
52+
strategy: public
53+
browser: false
54+
55+
args:
56+
limit:
57+
type: int
58+
default: 10
59+
60+
pipeline:
61+
- fetch:
62+
url: https://api.example.com/data
63+
- map:
64+
title: ${{ item.title }}
65+
score: ${{ item.score }}
66+
- limit: ${{ args.limit }}
67+
68+
columns: [title, score]
69+
```
70+
71+
### Option 2: TypeScript Plugin
72+
73+
For richer logic (multi-source aggregation, custom transformations, etc.):
74+
75+
```
76+
my-plugin/
77+
├── package.json
78+
├── my-command.ts
79+
└── README.md
80+
```
81+
82+
`package.json`:
83+
84+
```json
85+
{
86+
"name": "opencli-plugin-my-plugin",
87+
"version": "0.1.0",
88+
"type": "module",
89+
"peerDependencies": {
90+
"@jackwener/opencli": ">=1.0.0"
91+
}
92+
}
93+
```
94+
95+
`my-command.ts`:
96+
97+
```typescript
98+
import { cli, Strategy } from '@jackwener/opencli/registry';
99+
100+
cli({
101+
site: 'my-plugin',
102+
name: 'my-command',
103+
description: 'My custom command',
104+
strategy: Strategy.PUBLIC,
105+
browser: false,
106+
args: [
107+
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
108+
],
109+
columns: ['title', 'score'],
110+
func: async (_page, kwargs) => {
111+
const res = await fetch('https://api.example.com/data');
112+
const data = await res.json();
113+
return data.items.slice(0, kwargs.limit).map((item: any, i: number) => ({
114+
title: item.title,
115+
score: item.score,
116+
}));
117+
},
118+
});
119+
```
120+
121+
### TS Plugin Install Lifecycle
122+
123+
When you run `opencli plugin install`, TS plugins are automatically set up:
124+
125+
1. **Clone**`git clone --depth 1` from GitHub
126+
2. **npm install** — Resolves regular dependencies
127+
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
128+
4. **Transpile** — Compiles `.ts``.js` via `esbuild` (production `node` cannot load `.ts` directly)
129+
130+
On startup, if both `my-command.ts` and `my-command.js` exist, the `.js` version is loaded to avoid duplicate registration.
131+
132+
## Example Plugins
133+
134+
| Repo | Type | Description |
135+
|------|------|-------------|
136+
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
137+
| [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) |
138+
139+
## Troubleshooting
140+
141+
### Command not found after install
142+
143+
Restart opencli (or open a new terminal) — plugins are discovered at startup.
144+
145+
### TS plugin import errors
146+
147+
If you see `Cannot find module '@jackwener/opencli/registry'`, the host symlink may be broken. Reinstall the plugin:
148+
149+
```bash
150+
opencli plugin uninstall my-plugin
151+
opencli plugin install github:user/opencli-plugin-my-plugin
152+
```

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"bin": {
1414
"opencli": "dist/main.js"
1515
},
16+
"exports": {
17+
".": "./dist/main.js",
18+
"./registry": "./dist/registry-api.js"
19+
},
1620
"scripts": {
1721
"dev": "tsx src/main.ts",
1822
"build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",

src/browser/cdp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export class CDPBridge {
5555

5656
return new Promise((resolve, reject) => {
5757
const ws = new WebSocket(wsUrl);
58-
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), opts?.timeout ?? 10000);
58+
const timeoutMs = (opts?.timeout ?? 10) * 1000; // opts.timeout is in seconds
59+
const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
5960

6061
ws.on('open', () => {
6162
clearTimeout(timeout);

src/cli.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,75 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
231231
printCompletionScript(shell);
232232
});
233233

234+
// ── Plugin management ──────────────────────────────────────────────────────
235+
236+
const pluginCmd = program.command('plugin').description('Manage opencli plugins');
237+
238+
pluginCmd
239+
.command('install')
240+
.description('Install a plugin from GitHub')
241+
.argument('<source>', 'Plugin source (e.g. github:user/repo)')
242+
.action(async (source: string) => {
243+
const { installPlugin } = await import('./plugin.js');
244+
try {
245+
const name = installPlugin(source);
246+
console.log(chalk.green(`✅ Plugin "${name}" installed successfully.`));
247+
console.log(chalk.dim(` Restart opencli to use the new commands.`));
248+
} catch (err: any) {
249+
console.error(chalk.red(`Error: ${err.message}`));
250+
process.exitCode = 1;
251+
}
252+
});
253+
254+
pluginCmd
255+
.command('uninstall')
256+
.description('Uninstall a plugin')
257+
.argument('<name>', 'Plugin name')
258+
.action(async (name: string) => {
259+
const { uninstallPlugin } = await import('./plugin.js');
260+
try {
261+
uninstallPlugin(name);
262+
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
263+
} catch (err: any) {
264+
console.error(chalk.red(`Error: ${err.message}`));
265+
process.exitCode = 1;
266+
}
267+
});
268+
269+
pluginCmd
270+
.command('list')
271+
.description('List installed plugins')
272+
.option('-f, --format <fmt>', 'Output format: table, json', 'table')
273+
.action(async (opts) => {
274+
const { listPlugins } = await import('./plugin.js');
275+
const plugins = listPlugins();
276+
if (plugins.length === 0) {
277+
console.log(chalk.dim(' No plugins installed.'));
278+
console.log(chalk.dim(` Install one with: opencli plugin install github:user/repo`));
279+
return;
280+
}
281+
if (opts.format === 'json') {
282+
renderOutput(plugins, {
283+
fmt: 'json',
284+
columns: ['name', 'commands', 'source'],
285+
title: 'opencli/plugins',
286+
source: 'opencli plugin list',
287+
});
288+
return;
289+
}
290+
console.log();
291+
console.log(chalk.bold(' Installed plugins'));
292+
console.log();
293+
for (const p of plugins) {
294+
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
295+
const src = p.source ? chalk.dim(` ← ${p.source}`) : '';
296+
console.log(` ${chalk.cyan(p.name)}${cmds}${src}`);
297+
}
298+
console.log();
299+
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
300+
console.log();
301+
});
302+
234303
// ── External CLIs ─────────────────────────────────────────────────────────
235304

236305
const externalClis = loadExternalClis();

src/discovery.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
*/
1010

1111
import * as fs from 'node:fs';
12+
import * as os from 'node:os';
1213
import * as path from 'node:path';
1314
import yaml from 'js-yaml';
1415
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
1516
import { log } from './logger.js';
1617

18+
/** Plugins directory: ~/.opencli/plugins/ */
19+
export const PLUGINS_DIR = path.join(os.homedir(), '.opencli', 'plugins');
20+
1721
/**
1822
* Discover and register CLI commands.
1923
* Uses pre-compiled manifest when available for instant startup.
@@ -165,3 +169,51 @@ async function registerYamlCli(filePath: string, defaultSite: string): Promise<v
165169
log.warn(`Failed to load ${filePath}: ${err.message}`);
166170
}
167171
}
172+
173+
/**
174+
* Discover and register plugins from ~/.opencli/plugins/.
175+
* Each subdirectory is treated as a plugin (site = directory name).
176+
* Files inside are scanned flat (no nested site subdirs).
177+
*/
178+
export async function discoverPlugins(): Promise<void> {
179+
try { await fs.promises.access(PLUGINS_DIR); } catch { return; }
180+
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
181+
for (const entry of entries) {
182+
if (!entry.isDirectory()) continue;
183+
await discoverPluginDir(path.join(PLUGINS_DIR, entry.name), entry.name);
184+
}
185+
}
186+
187+
/**
188+
* Flat scan: read yaml/ts files directly in a plugin directory.
189+
* Unlike discoverClisFromFs, this does NOT expect nested site subdirectories.
190+
*/
191+
async function discoverPluginDir(dir: string, site: string): Promise<void> {
192+
const files = await fs.promises.readdir(dir);
193+
const fileSet = new Set(files);
194+
const promises: Promise<any>[] = [];
195+
for (const file of files) {
196+
const filePath = path.join(dir, file);
197+
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
198+
promises.push(registerYamlCli(filePath, site));
199+
} else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
200+
promises.push(
201+
import(`file://${filePath}`).catch((err: any) => {
202+
log.warn(`Plugin ${site}/${file}: ${err.message}`);
203+
})
204+
);
205+
} else if (
206+
file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')
207+
) {
208+
// Skip .ts if a compiled .js sibling exists (production mode can't load .ts)
209+
const jsFile = file.replace(/\.ts$/, '.js');
210+
if (fileSet.has(jsFile)) continue;
211+
promises.push(
212+
import(`file://${filePath}`).catch((err: any) => {
213+
log.warn(`Plugin ${site}/${file}: ${err.message}`);
214+
})
215+
);
216+
}
217+
}
218+
await Promise.all(promises);
219+
}

0 commit comments

Comments
 (0)