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
107 changes: 107 additions & 0 deletions docs/zh/guide/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# 插件

OpenCLI 支持社区贡献的 plugins。你可以从 GitHub 安装第三方 adapters,它们会和内置 commands 一起在启动时自动发现。

## 安装插件

```bash
# 安装插件
opencli plugin install github:ByteYue/opencli-plugin-github-trending

# 列出已安装插件
opencli plugin list

# 使用插件(本质上就是普通 command)
opencli github-trending today

# 卸载插件
opencli plugin uninstall github-trending
```

## 插件目录结构

Plugins 存放在 `~/.opencli/plugins/<name>/`。每个子目录都会在启动时扫描 `.yaml`、`.ts`、`.js` 命令文件,格式与内置 adapters 相同。

## 安装来源

```bash
opencli plugin install github:user/repo
opencli plugin install https://github.com/user/repo
```

如果仓库名带 `opencli-plugin-` 前缀,本地目录会自动去掉这个前缀。例如 `opencli-plugin-hot-digest` 会变成 `hot-digest`。

## YAML plugin 示例

```text
my-plugin/
hot.yaml
```

```yaml
site: my-plugin
name: hot
description: Example plugin command
strategy: public
browser: false

pipeline:
- evaluate: |
() => [{ title: 'hello', url: 'https://example.com' }]

columns: [title, url]
```

## TypeScript plugin 示例

```text
my-plugin/
index.ts
package.json
```

```json
{
"name": "opencli-plugin-my-plugin",
"type": "module"
}
```

```ts
import { cli, Strategy } from '@jackwener/opencli/registry';

cli({
site: 'my-plugin',
name: 'hot',
description: 'Example TS plugin command',
strategy: Strategy.PUBLIC,
browser: false,
columns: ['title', 'url'],
func: async () => [{ title: 'hello', url: 'https://example.com' }],
});
```

运行 `opencli plugin install` 时,TS plugins 会自动完成基础设置:

1. 安装 plugin 自身依赖
2. 补齐 TypeScript 运行环境
3. 将宿主 `@jackwener/opencli` 链接到 plugin 的 `node_modules/`,保证 `@jackwener/opencli/registry` 指向当前宿主版本

## 示例 plugins

- `opencli-plugin-github-trending`:GitHub Trending 仓库
- `opencli-plugin-hot-digest`:多平台热点聚合(zhihu、weibo、bilibili、v2ex、stackoverflow、reddit、linux-do)
- `opencli-plugin-juejin`:稀土掘金热榜、分类和文章流

## 排查问题

### TS plugin import 报错

如果看到 `Cannot find module '@jackwener/opencli/registry'`,通常是宿主 symlink 失效。重新安装 plugin 即可:

```bash
opencli plugin uninstall my-plugin
opencli plugin install github:user/opencli-plugin-my-plugin
```

安装或卸载 plugin 后,建议重新打开一个终端,确保启动时重新发现命令。
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "1.1.1",
"version": "1.2.0",
"publishConfig": {
"access": "public"
},
Expand Down
7 changes: 4 additions & 3 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
import { log } from './logger.js';
Expand Down Expand Up @@ -156,7 +157,7 @@ async function discoverClisFromFs(dir: string): Promise<void> {
) {
if (!(await isCliModule(filePath))) continue;
promises.push(
import(`file://${filePath}`).catch((err) => {
import(pathToFileURL(filePath).href).catch((err) => {
log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
})
);
Expand Down Expand Up @@ -244,7 +245,7 @@ async function discoverPluginDir(dir: string, site: string): Promise<void> {
} else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
if (!(await isCliModule(filePath))) continue;
promises.push(
import(`file://${filePath}`).catch((err) => {
import(pathToFileURL(filePath).href).catch((err) => {
log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
})
);
Expand All @@ -256,7 +257,7 @@ async function discoverPluginDir(dir: string, site: string): Promise<void> {
if (fileSet.has(jsFile)) continue;
if (!(await isCliModule(filePath))) continue;
promises.push(
import(`file://${filePath}`).catch((err) => {
import(pathToFileURL(filePath).href).catch((err) => {
log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
})
);
Expand Down
3 changes: 2 additions & 1 deletion src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { type CliCommand, type InternalCliCommand, type Arg, Strategy, getRegistry, fullName } from './registry.js';
import type { IPage } from './types.js';
import { pathToFileURL } from 'node:url';
import { executePipeline } from './pipeline/index.js';
import { AdapterLoadError } from './errors.js';
import { shouldUseBrowserSession } from './capabilityRouting.js';
Expand Down Expand Up @@ -86,7 +87,7 @@ async function runCommand(
const modulePath = internal._modulePath;
if (!_loadedModules.has(modulePath)) {
try {
await import(`file://${modulePath}`);
await import(pathToFileURL(modulePath).href);
_loadedModules.add(modulePath);
} catch (err) {
throw new AdapterLoadError(
Expand Down
Loading