Skip to content

Commit 966f6e5

Browse files
authored
feat(plugin): add update command, hot reload after install, README section (#307)
- Add `opencli plugin update <name>` command (git pull + post-install lifecycle) - Extract shared postInstallLifecycle() helper to deduplicate install/update code - Hot reload: call discoverPlugins() after install/update (no restart needed) - Add Plugins section to README.md and README.zh-CN.md - Add updatePlugin test coverage
1 parent ea83242 commit 966f6e5

File tree

5 files changed

+115
-28
lines changed

5 files changed

+115
-28
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,25 @@ opencli bilibili hot -f csv # CSV
262262
opencli bilibili hot -v # Verbose: show pipeline debug steps
263263
```
264264

265+
## Plugins
266+
267+
Extend OpenCLI with community-contributed adapters. Plugins use the same YAML/TS format as built-in commands and are automatically discovered at startup.
268+
269+
```bash
270+
opencli plugin install github:user/opencli-plugin-my-tool # Install
271+
opencli plugin list # List installed
272+
opencli plugin update my-tool # Update to latest
273+
opencli plugin uninstall my-tool # Remove
274+
```
275+
276+
| Plugin | Type | Description |
277+
|--------|------|-------------|
278+
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
279+
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | Multi-platform trending aggregator |
280+
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金 (Juejin) hot articles |
281+
282+
See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
283+
265284
## For AI Agents (Developer Guide)
266285

267286
If you are an AI assistant tasked with creating a new command adapter for `opencli`, please follow the AI Agent workflow below:

README.zh-CN.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,25 @@ opencli bilibili hot -f csv # CSV
264264
opencli bilibili hot -v # 详细模式:展示管线执行步骤调试信息
265265
```
266266

267+
## 插件
268+
269+
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 YAML/TS 格式,启动时自动发现。
270+
271+
```bash
272+
opencli plugin install github:user/opencli-plugin-my-tool # 安装
273+
opencli plugin list # 查看已安装
274+
opencli plugin update my-tool # 更新到最新
275+
opencli plugin uninstall my-tool # 卸载
276+
```
277+
278+
| 插件 | 类型 | 描述 |
279+
|------|------|------|
280+
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending 仓库 |
281+
| [opencli-plugin-hot-digest](https://github.com/ByteYue/opencli-plugin-hot-digest) | TS | 多平台热榜聚合 |
282+
| [opencli-plugin-juejin](https://github.com/Astro-Han/opencli-plugin-juejin) | YAML | 稀土掘金热门文章 |
283+
284+
详见 [插件指南](./docs/zh/guide/plugins.md) 了解如何创建自己的插件。
285+
267286
## 致 AI Agent(开发者指南)
268287

269288
如果你是一个被要求查阅代码并编写新 `opencli` 适配器的 AI,请遵守以下工作流。

src/cli.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,11 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
257257
.argument('<source>', 'Plugin source (e.g. github:user/repo)')
258258
.action(async (source: string) => {
259259
const { installPlugin } = await import('./plugin.js');
260+
const { discoverPlugins } = await import('./discovery.js');
260261
try {
261262
const name = installPlugin(source);
262-
console.log(chalk.green(`✅ Plugin "${name}" installed successfully.`));
263-
console.log(chalk.dim(` Restart opencli to use the new commands.`));
263+
await discoverPlugins();
264+
console.log(chalk.green(`✅ Plugin "${name}" installed successfully. Commands are ready to use.`));
264265
} catch (err: any) {
265266
console.error(chalk.red(`Error: ${err.message}`));
266267
process.exitCode = 1;
@@ -282,6 +283,24 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
282283
}
283284
});
284285

286+
pluginCmd
287+
.command('update')
288+
.description('Update a plugin to the latest version')
289+
.argument('<name>', 'Plugin name')
290+
.action(async (name: string) => {
291+
const { updatePlugin } = await import('./plugin.js');
292+
const { discoverPlugins } = await import('./discovery.js');
293+
try {
294+
updatePlugin(name);
295+
await discoverPlugins();
296+
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
297+
} catch (err: any) {
298+
console.error(chalk.red(`Error: ${err.message}`));
299+
process.exitCode = 1;
300+
}
301+
});
302+
303+
285304
pluginCmd
286305
.command('list')
287306
.description('List installed plugins')

src/plugin.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
66
import * as fs from 'node:fs';
77
import * as path from 'node:path';
88
import { PLUGINS_DIR } from './discovery.js';
9-
import { listPlugins, uninstallPlugin, _parseSource } from './plugin.js';
9+
import { listPlugins, uninstallPlugin, updatePlugin, _parseSource } from './plugin.js';
1010

1111
describe('parseSource', () => {
1212
it('parses github:user/repo format', () => {
@@ -84,3 +84,9 @@ describe('uninstallPlugin', () => {
8484
expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed');
8585
});
8686
});
87+
88+
describe('updatePlugin', () => {
89+
it('throws for non-existent plugin', () => {
90+
expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
91+
});
92+
});

src/plugin.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,32 @@ export interface PluginInfo {
1818
source?: string;
1919
}
2020

21+
/**
22+
* Shared post-install lifecycle: npm install → host symlink → TS transpile.
23+
* Called by both installPlugin() and updatePlugin().
24+
*/
25+
function postInstallLifecycle(pluginDir: string): void {
26+
const pkgJsonPath = path.join(pluginDir, 'package.json');
27+
if (!fs.existsSync(pkgJsonPath)) return;
28+
29+
try {
30+
execFileSync('npm', ['install', '--omit=dev'], {
31+
cwd: pluginDir,
32+
encoding: 'utf-8',
33+
stdio: ['pipe', 'pipe', 'pipe'],
34+
});
35+
} catch {
36+
// Non-fatal: npm install may fail if no real deps
37+
}
38+
39+
// Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
40+
// against the running host, not a stale npm-published version.
41+
linkHostOpencli(pluginDir);
42+
43+
// Transpile .ts → .js via esbuild (production node can't load .ts directly).
44+
transpilePluginTs(pluginDir);
45+
}
46+
2147
/**
2248
* Install a plugin from a source.
2349
* Currently supports "github:user/repo" format (git clone wrapper).
@@ -52,31 +78,7 @@ export function installPlugin(source: string): string {
5278
throw new Error(`Failed to clone plugin: ${err.message}`);
5379
}
5480

55-
// If the plugin has a package.json, run npm install for regular deps,
56-
// then symlink the host opencli into node_modules for peerDep resolution.
57-
const pkgJsonPath = path.join(targetDir, 'package.json');
58-
if (fs.existsSync(pkgJsonPath)) {
59-
try {
60-
execFileSync('npm', ['install', '--omit=dev'], {
61-
cwd: targetDir,
62-
encoding: 'utf-8',
63-
stdio: ['pipe', 'pipe', 'pipe'],
64-
});
65-
} catch {
66-
// Non-fatal: npm install may fail if no real deps
67-
}
68-
69-
// Symlink host opencli into plugin's node_modules so TS plugins
70-
// can resolve '@jackwener/opencli/registry' against the running host.
71-
// This is more reliable than depending on the npm-published version
72-
// which may lag behind the local installation.
73-
linkHostOpencli(targetDir);
74-
75-
// Transpile TS plugin files to JS so they work in production mode
76-
// (node cannot load .ts files directly without tsx).
77-
transpilePluginTs(targetDir);
78-
}
79-
81+
postInstallLifecycle(targetDir);
8082
return name;
8183
}
8284

@@ -91,6 +93,28 @@ export function uninstallPlugin(name: string): void {
9193
fs.rmSync(targetDir, { recursive: true, force: true });
9294
}
9395

96+
/**
97+
* Update a plugin by name (git pull + re-install lifecycle).
98+
*/
99+
export function updatePlugin(name: string): void {
100+
const targetDir = path.join(PLUGINS_DIR, name);
101+
if (!fs.existsSync(targetDir)) {
102+
throw new Error(`Plugin "${name}" is not installed.`);
103+
}
104+
105+
try {
106+
execFileSync('git', ['pull', '--ff-only'], {
107+
cwd: targetDir,
108+
encoding: 'utf-8',
109+
stdio: ['pipe', 'pipe', 'pipe'],
110+
});
111+
} catch (err: any) {
112+
throw new Error(`Failed to update plugin: ${err.message}`);
113+
}
114+
115+
postInstallLifecycle(targetDir);
116+
}
117+
94118
/**
95119
* List all installed plugins.
96120
*/

0 commit comments

Comments
 (0)