Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ docs/.vitepress/cache
*.pem
*.crx
*.zip
.envrc
.windsurf
.claude
.cortex
2 changes: 2 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
],
Expand Down Expand Up @@ -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' },
],
},
],
Expand Down
1 change: 1 addition & 0 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
152 changes: 152 additions & 0 deletions docs/guide/plugins.md
Original file line number Diff line number Diff line change
@@ -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/<name>/`. 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
```
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
69 changes: 69 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<source>', '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('<name>', '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 <fmt>', '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();
Expand Down
52 changes: 52 additions & 0 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -165,3 +169,51 @@ async function registerYamlCli(filePath: string, defaultSite: string): Promise<v
log.warn(`Failed to load ${filePath}: ${err.message}`);
}
}

/**
* Discover and register plugins from ~/.opencli/plugins/.
* Each subdirectory is treated as a plugin (site = directory name).
* Files inside are scanned flat (no nested site subdirs).
*/
export async function discoverPlugins(): Promise<void> {
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<void> {
const files = await fs.promises.readdir(dir);
const fileSet = new Set(files);
const promises: Promise<any>[] = [];
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);
}
Loading
Loading