funcoding-cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -2
- package/dist/agents.js +55 -0
- package/dist/api.js +51 -0
- package/dist/auth.js +99 -0
- package/dist/cli.js +220 -0
- package/dist/config-files.js +84 -0
- package/dist/content.js +98 -0
- package/dist/credentials.js +30 -0
- package/dist/install.js +278 -0
- package/dist/mcp.js +265 -0
- package/dist/paths.js +18 -0
- package/dist/setup.js +49 -0
- package/dist/sync.js +86 -0
- package/dist/ui.js +65 -0
- package/package.json +40 -4
- package/skill/funcoding/SKILL.md +61 -0
package/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# funcoding-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[funcoding.ai](https://funcoding.ai) 的命令行工具,主要给 Claude Code、Codex、Cursor 等 AI 编程智能体使用。智能体可以用它:
|
|
4
|
+
|
|
5
|
+
- 查找和安装 Agent Skills
|
|
6
|
+
- 配置 MCP Server
|
|
7
|
+
- 同步用户在网站上的收藏
|
|
8
|
+
- 查看 AI 热点,搜索和阅读中文文档
|
|
9
|
+
|
|
10
|
+
人也可以直接使用。需要 Node.js 18.17 及以上。
|
|
11
|
+
|
|
12
|
+
## 让智能体用起来
|
|
13
|
+
|
|
14
|
+
把这句话发给你的智能体:
|
|
15
|
+
|
|
16
|
+
> 运行 `npx --yes funcoding-cli@latest setup`,然后帮我找一个处理 PDF 的 Skill 并安装。
|
|
17
|
+
|
|
18
|
+
`setup` 会给当前智能体装上 funcoding Skill([skill/funcoding/SKILL.md](skill/funcoding/SKILL.md))。Skill 里写明了:
|
|
19
|
+
|
|
20
|
+
- 什么时候用哪条命令
|
|
21
|
+
- 安装前先用 `info` 检查内容,并征得用户同意
|
|
22
|
+
- 缺少 MCP 密钥时向用户索取,不要编造
|
|
23
|
+
|
|
24
|
+
也可以全局安装,之后的命令是 `funcoding`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm i -g funcoding-cli
|
|
28
|
+
funcoding --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 命令
|
|
32
|
+
|
|
33
|
+
| 命令 | 说明 |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `search <关键词>` | 搜索 Skill 和 MCP Server |
|
|
36
|
+
| `info <owner/repo/skill>` | 查看 Skill 的描述、文件清单和 SKILL.md 全文 |
|
|
37
|
+
| `install <id…>` | 安装一个或多个 Skill,也可以粘贴网站上的详情页地址 |
|
|
38
|
+
| `list` | 列出已安装的 Skill:默认列出全部智能体,包括用户目录和当前项目 |
|
|
39
|
+
| `update [名称] [--check]` | 按原来源重新安装;`--check` 只比较远端的 SKILL.md,不安装 |
|
|
40
|
+
| `remove <名称…>` | 删除通过 funcoding 安装的 Skill,删除前先备份;删除其他来源的 Skill 需要 `--force` |
|
|
41
|
+
| `mcp info <owner/repo>` | 查看启动方式(远程地址、npm 或 PyPI 包),以及需要的环境变量或请求头 |
|
|
42
|
+
| `mcp add <owner/repo>` | 写入智能体的 MCP 配置;必填项用 `--env KEY=VALUE` 或 `--header KEY=VALUE` 提供,`--via remote\|npm\|pypi` 指定启动方式 |
|
|
43
|
+
| `mcp list` / `mcp remove <配置名…>` | 列出或删除 MCP 配置,修改前备份原文件 |
|
|
44
|
+
| `login` / `logout` / `whoami` | 在浏览器中授权登录(设备码)、退出登录并使登录凭证失效、查看当前账号 |
|
|
45
|
+
| `favorites` | 列出网站上收藏的 Skill 和 MCP Server |
|
|
46
|
+
| `sync [--dry-run]` | 安装全部收藏,已安装的跳过;需要密钥的 MCP 会列出缺少哪些值 |
|
|
47
|
+
| `news [--daily [YYYY-MM-DD]]` | 查看 AI 热榜,或某一天的 AI 日报 |
|
|
48
|
+
| `docs search <关键词> [--agent <智能体>]` | 搜索中文文档 |
|
|
49
|
+
| `docs read <智能体/路径>` | 读取文档的 Markdown 原文,开头附官方来源和核实日期 |
|
|
50
|
+
| `setup` | 给智能体装上 funcoding Skill;`update` 时会一起更新到当前 CLI 版本 |
|
|
51
|
+
|
|
52
|
+
## 给智能体的约定
|
|
53
|
+
|
|
54
|
+
- **`--json`**:stdout 只输出一个 JSON 对象。
|
|
55
|
+
- 成功时为 `{ "ok": true, ... }`
|
|
56
|
+
- 失败时为 `{ "ok": false, "error": { "code", "message", "hint", ... } }`
|
|
57
|
+
- 过程提示一律写到 stderr。
|
|
58
|
+
- **不交互**:需要确认的操作通过参数表达,比如覆盖时加 `--force`,`sync` 可以先加 `--dry-run` 预览。
|
|
59
|
+
- **`--agent`**:可选 `claude-code`、`codex`、`cursor`,多个用逗号分隔。`all` 表示本机检测到的全部智能体,检测方法是看用户目录下有没有对应的配置目录。不指定时,在 Claude Code 中运行(`CLAUDECODE=1`)默认装给 Claude Code,其他情况也默认 Claude Code。Codex 和 Cursor 没有文档化的环境变量,CLI 不去猜,需要显式指定。
|
|
60
|
+
- **范围**:`--project` 装到当前项目,不加时装到用户目录。`list`、`update`、`remove`、`mcp list`、`mcp remove` 默认同时查看两处,可以用 `--project` 或 `--user` 缩小范围。
|
|
61
|
+
|
|
62
|
+
| 退出码 | 含义 |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| 0 | 成功 |
|
|
65
|
+
| 1 | 一般错误 |
|
|
66
|
+
| 2 | 参数错误,或缺少必填项(`error.missing`) |
|
|
67
|
+
| 3 | 未找到(MCP 没有标准安装包时,`error.readme` 给出 README 地址) |
|
|
68
|
+
| 4 | 目标已存在,需要 `--force` |
|
|
69
|
+
| 5 | 未登录或登录已过期 |
|
|
70
|
+
|
|
71
|
+
## 安装位置
|
|
72
|
+
|
|
73
|
+
| `--agent` | Skill(用户目录 / 项目) | MCP(用户 / 项目) |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `claude-code` | `~/.claude/skills/<name>/` / `.claude/skills/<name>/` | 调用 `claude mcp add-json --scope user\|project`;本机没有 `claude` 命令时,项目范围直接写 `.mcp.json` |
|
|
76
|
+
| `codex` | `~/.agents/skills/<name>/` / `.agents/skills/<name>/` | `$CODEX_HOME/config.toml`(默认 `~/.codex/config.toml`)/ `.codex/config.toml`;不支持 SSE |
|
|
77
|
+
| `cursor` | `~/.cursor/skills/<name>/` / `.cursor/skills/<name>/` | `~/.cursor/mcp.json` / `.cursor/mcp.json` |
|
|
78
|
+
|
|
79
|
+
以上位置取自各智能体的官方文档(2026-10-07 核实),以官方为准。
|
|
80
|
+
|
|
81
|
+
## 安全
|
|
82
|
+
|
|
83
|
+
- Skill 会按 SKILL.md 的指示执行操作,可能运行其中的脚本,安装前请先阅读。
|
|
84
|
+
- CLI 只从 GitHub 下载 Skill 所在目录的文件,拒绝任何跳出目标目录的路径。单个 Skill 最多 200 个文件、10 MB。
|
|
85
|
+
- 改写 MCP 配置时只改动对应的那一项,其他内容原样保留。
|
|
86
|
+
- 覆盖、删除 Skill 和修改 MCP 配置前,都会把原内容备份到配置目录的 `backups/` 下。
|
|
87
|
+
|
|
88
|
+
登录凭证保存在 `~/.config/funcoding/credentials.json`(设置了 `XDG_CONFIG_HOME` 时放在它下面;Windows 是 `%APPDATA%\funcoding`),文件权限为 600。
|
|
89
|
+
|
|
90
|
+
环境变量:
|
|
91
|
+
|
|
92
|
+
- `FUNCODING_URL`:更换站点地址,本地开发时用 `http://localhost:5173`
|
|
93
|
+
- `FUNCODING_CONFIG_DIR`:更换配置目录
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 各智能体的 Skill 目录和 MCP 配置位置(对照官方文档核实,2026-10-07),Skill 目录和网站的 src/lib/agent-targets.ts 是同一份表:
|
|
3
|
+
* - Claude Code:Skill 在 ~/.claude/skills/<name>/、项目 .claude/skills/<name>/(code.claude.com/docs/en/skills);
|
|
4
|
+
* MCP 用 `claude mcp add-json <name> <json> --scope user|project`,项目范围写在 .mcp.json(code.claude.com/docs/en/mcp)
|
|
5
|
+
* - Codex:Skill 在 ~/.agents/skills/、项目 .agents/skills/(developers.openai.com/codex/skills);
|
|
6
|
+
* MCP 写在 $CODEX_HOME/config.toml(默认 ~/.codex/config.toml)或项目 .codex/config.toml 的 [mcp_servers.<name>],
|
|
7
|
+
* 支持 stdio 和 Streamable HTTP,不支持 SSE(developers.openai.com/codex/mcp)
|
|
8
|
+
* - Cursor:Skill 在 ~/.cursor/skills/、项目 .cursor/skills/(cursor.com/docs/skills);
|
|
9
|
+
* MCP 写在 ~/.cursor/mcp.json 或项目 .cursor/mcp.json 的 mcpServers(cursor.com/docs/mcp)
|
|
10
|
+
* 运行环境识别:只有 Claude Code 文档写明会给子进程设置 CLAUDECODE=1;Codex、Cursor 没有文档化的变量,不猜。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
15
|
+
export const AGENTS = {
|
|
16
|
+
'claude-code': { label: 'Claude Code', home: '.claude', skills: ['.claude', 'skills'], invoke: (n) => `/${n}` },
|
|
17
|
+
codex: { label: 'Codex', home: '.codex', skills: ['.agents', 'skills'], invoke: (n) => `$${n}` },
|
|
18
|
+
cursor: { label: 'Cursor', home: '.cursor', skills: ['.cursor', 'skills'], invoke: (n) => `/${n}` },
|
|
19
|
+
};
|
|
20
|
+
export const AGENT_IDS = Object.keys(AGENTS);
|
|
21
|
+
export const DEFAULT_AGENT = 'claude-code';
|
|
22
|
+
export function isAgentId(s) {
|
|
23
|
+
return s in AGENTS;
|
|
24
|
+
}
|
|
25
|
+
/** 当前运行在哪个智能体里(只认文档化的环境变量) */
|
|
26
|
+
export function currentAgent({ env = process.env } = {}) {
|
|
27
|
+
return env.CLAUDECODE === '1' ? 'claude-code' : null;
|
|
28
|
+
}
|
|
29
|
+
/** Codex 的配置目录:CODEX_HOME,默认 ~/.codex */
|
|
30
|
+
export const codexHome = ({ env = process.env, home = homedir() } = {}) => env.CODEX_HOME || join(home, '.codex');
|
|
31
|
+
/** 本机装了哪些智能体:看用户目录下有没有它们的配置目录 */
|
|
32
|
+
export function installedAgents(e = {}) {
|
|
33
|
+
const home = e.home ?? homedir();
|
|
34
|
+
return AGENT_IDS.filter((a) => existsSync(a === 'codex' ? codexHome(e) : join(home, AGENTS[a].home)));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* --agent 的取值:显式指定 > 当前运行的智能体 > Claude Code;`all` 表示本机检测到的全部(一个都没检测到时退回默认)。
|
|
38
|
+
* 返回 null 表示取值不合法
|
|
39
|
+
*/
|
|
40
|
+
export function resolveAgents(value, e = {}) {
|
|
41
|
+
if (value === 'all') {
|
|
42
|
+
const found = installedAgents(e);
|
|
43
|
+
return found.length > 0 ? found : [currentAgent(e) ?? DEFAULT_AGENT];
|
|
44
|
+
}
|
|
45
|
+
if (value) {
|
|
46
|
+
const ids = value.split(',').map((s) => s.trim());
|
|
47
|
+
return ids.every(isAgentId) ? [...new Set(ids)] : null;
|
|
48
|
+
}
|
|
49
|
+
return [currentAgent(e) ?? DEFAULT_AGENT];
|
|
50
|
+
}
|
|
51
|
+
/** Skill 根目录:project 时是当前目录下的项目目录,否则是用户主目录下的 */
|
|
52
|
+
export function skillsRoot(agent, scope, e = {}) {
|
|
53
|
+
const dirs = AGENTS[agent].skills;
|
|
54
|
+
return scope === 'project' ? resolve(e.cwd ?? process.cwd(), ...dirs) : join(e.home ?? homedir(), ...dirs);
|
|
55
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** 和 funcoding.ai 通信:地址可以用 FUNCODING_URL 改(本地开发时指向 http://localhost:5173) */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { CliError } from './ui.js';
|
|
4
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
5
|
+
export const VERSION = pkg.version;
|
|
6
|
+
export const SITE = (process.env.FUNCODING_URL ?? 'https://funcoding.ai').replace(/\/+$/, '');
|
|
7
|
+
export const USER_AGENT = `funcoding-cli/${VERSION} (node ${process.versions.node}; ${process.platform})`;
|
|
8
|
+
const codeOf = (status) => (status === 401 ? 'auth' : status === 404 ? 'not_found' : status === 400 ? 'usage' : 'error');
|
|
9
|
+
/** 接口出错:按 HTTP 状态映射到 CLI 的错误码,body 留给需要细看的调用方(如设备码登录轮询) */
|
|
10
|
+
export class ApiError extends CliError {
|
|
11
|
+
status;
|
|
12
|
+
body;
|
|
13
|
+
constructor(message, status, body) {
|
|
14
|
+
super(codeOf(status), message, status === 401 ? '运行 funcoding login 重新登录' : undefined);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.body = body;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function api(path, init = {}) {
|
|
20
|
+
const { token, headers, ...rest } = init;
|
|
21
|
+
let res;
|
|
22
|
+
try {
|
|
23
|
+
res = await fetch(`${SITE}${path}`, {
|
|
24
|
+
...rest,
|
|
25
|
+
headers: {
|
|
26
|
+
'User-Agent': USER_AGENT,
|
|
27
|
+
Accept: 'application/json',
|
|
28
|
+
...(rest.body ? { 'Content-Type': 'application/json' } : {}),
|
|
29
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
30
|
+
...headers,
|
|
31
|
+
},
|
|
32
|
+
signal: rest.signal ?? AbortSignal.timeout(30_000),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
throw new ApiError(`连不上 ${SITE}:${err.message}`, 0);
|
|
37
|
+
}
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
let body = text;
|
|
40
|
+
try {
|
|
41
|
+
body = text ? JSON.parse(text) : null;
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const b = body;
|
|
46
|
+
throw new ApiError(b?.error_description || b?.message || b?.error || `HTTP ${res.status}`, res.status, body);
|
|
47
|
+
}
|
|
48
|
+
return body;
|
|
49
|
+
}
|
|
50
|
+
/** 网站接口返回的完整网址(本地开发时跟随 FUNCODING_URL) */
|
|
51
|
+
export const siteUrl = (path) => new URL(path, `${SITE}/`).href;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备码登录(RFC 8628):CLI 申请验证码 → 用户在浏览器打开 /device/ 确认 → CLI 轮询拿到会话 token。
|
|
3
|
+
* 服务端是 better-auth 的 device-authorization 插件,client_id 固定为 funcoding-cli。
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { api, SITE } from './api.js';
|
|
7
|
+
import { clearCredentials, credentialsPath, loadCredentials, saveCredentials } from './credentials.js';
|
|
8
|
+
import { bold, cyan, dim, done, fail, green, info, warn } from './ui.js';
|
|
9
|
+
const CLIENT_ID = 'funcoding-cli';
|
|
10
|
+
const absolute = (u) => new URL(u, SITE).href;
|
|
11
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
/** 显示成 ABCD-EFGH,方便核对 */
|
|
13
|
+
const pretty = (code) => (code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code);
|
|
14
|
+
function openBrowser(url) {
|
|
15
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
16
|
+
try {
|
|
17
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
}
|
|
21
|
+
export async function login(opts) {
|
|
22
|
+
const existing = await loadCredentials(SITE);
|
|
23
|
+
if (existing) {
|
|
24
|
+
const me = await fetchMe(existing.token).catch(() => null);
|
|
25
|
+
if (me) {
|
|
26
|
+
return done({ user: me.user, alreadyLoggedIn: true }, () => info(`已经登录:${bold(me.user.name)}${me.user.githubLogin ? dim(` (@${me.user.githubLogin})`) : ''}。要换账号先运行 ${cyan('funcoding logout')}。`));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const code = await api('/api/auth/device/code', { method: 'POST', body: JSON.stringify({ client_id: CLIENT_ID }) });
|
|
30
|
+
const url = absolute(code.verification_uri_complete ?? `${code.verification_uri}?user_code=${code.user_code}`);
|
|
31
|
+
info('');
|
|
32
|
+
info(` 在浏览器里确认这个验证码:${bold(pretty(code.user_code))}`);
|
|
33
|
+
info(` ${cyan(url)}`);
|
|
34
|
+
info('');
|
|
35
|
+
if (opts.browser)
|
|
36
|
+
openBrowser(url);
|
|
37
|
+
info(dim('等待浏览器里确认…(Ctrl+C 取消)'));
|
|
38
|
+
let interval = Math.max(code.interval ?? 5, 1) * 1000;
|
|
39
|
+
const deadline = Date.now() + code.expires_in * 1000;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
await sleep(interval);
|
|
42
|
+
try {
|
|
43
|
+
const token = await api('/api/auth/device/token', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: code.device_code, client_id: CLIENT_ID }),
|
|
46
|
+
});
|
|
47
|
+
const me = await fetchMe(token.access_token);
|
|
48
|
+
await saveCredentials({ token: token.access_token, site: SITE, user: { name: me.user.name, githubLogin: me.user.githubLogin }, savedAt: new Date().toISOString() });
|
|
49
|
+
return done({ user: me.user, credentials: credentialsPath() }, () => {
|
|
50
|
+
info(green(`✓ 已登录:${me.user.name}${me.user.githubLogin ? ` (@${me.user.githubLogin})` : ''}`));
|
|
51
|
+
info(dim(`凭证保存在 ${credentialsPath()}`));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const e = err.body;
|
|
56
|
+
if (e?.error === 'authorization_pending')
|
|
57
|
+
continue;
|
|
58
|
+
if (e?.error === 'slow_down') {
|
|
59
|
+
interval += 5000;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (e?.error === 'access_denied')
|
|
63
|
+
fail('auth', '你在浏览器里拒绝了这次登录。');
|
|
64
|
+
if (e?.error === 'expired_token')
|
|
65
|
+
fail('auth', '验证码过期了。', '重新运行 funcoding login');
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
fail('auth', '验证码过期了。', '重新运行 funcoding login');
|
|
70
|
+
}
|
|
71
|
+
export async function logout() {
|
|
72
|
+
const c = await loadCredentials(SITE);
|
|
73
|
+
if (!c)
|
|
74
|
+
return done({ loggedOut: true, wasLoggedIn: false }, () => info('当前没有登录。'));
|
|
75
|
+
// 顺便让服务端的会话失效;失败也照样删除本地凭证
|
|
76
|
+
await api('/api/auth/sign-out', { method: 'POST', token: c.token, body: '{}' }).catch(() => warn('没能通知服务端退出,本地凭证已删除。'));
|
|
77
|
+
await clearCredentials();
|
|
78
|
+
done({ loggedOut: true, wasLoggedIn: true }, () => info(green('✓ 已退出登录')));
|
|
79
|
+
}
|
|
80
|
+
export async function fetchMe(token) {
|
|
81
|
+
return api('/api/cli/me', { token });
|
|
82
|
+
}
|
|
83
|
+
/** 需要登录的命令:没登录直接报错 */
|
|
84
|
+
export async function requireToken() {
|
|
85
|
+
const c = await loadCredentials(SITE);
|
|
86
|
+
if (!c)
|
|
87
|
+
return fail('auth', '还没有登录。', '运行 funcoding login');
|
|
88
|
+
return c.token;
|
|
89
|
+
}
|
|
90
|
+
export async function whoami() {
|
|
91
|
+
const c = await loadCredentials(SITE);
|
|
92
|
+
if (!c)
|
|
93
|
+
return fail('auth', '未登录。', '运行 funcoding login');
|
|
94
|
+
const me = await fetchMe(c.token);
|
|
95
|
+
done({ user: me.user, favorites: { skills: me.favorites.skills.length, mcp: me.favorites.mcp.length }, site: SITE }, () => {
|
|
96
|
+
info(`${bold(me.user.name)}${me.user.githubLogin ? dim(` (@${me.user.githubLogin})`) : ''}`);
|
|
97
|
+
info(dim(`${me.favorites.skills.length} 个收藏的 Skill,${me.favorites.mcp.length} 个收藏的 MCP · ${SITE}`));
|
|
98
|
+
});
|
|
99
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** funcoding-cli:给 AI 编程智能体用的 funcoding.ai 命令行(人也能用) */
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { AGENT_IDS, DEFAULT_AGENT, resolveAgents } from './agents.js';
|
|
5
|
+
import { SITE, VERSION } from './api.js';
|
|
6
|
+
import { login, logout, whoami } from './auth.js';
|
|
7
|
+
import { docsRead, docsSearch, news, search } from './content.js';
|
|
8
|
+
import { install, list, remove, skillInfo, update } from './install.js';
|
|
9
|
+
import { mcpAdd, mcpInfo, mcpList, mcpRemove } from './mcp.js';
|
|
10
|
+
import { setup } from './setup.js';
|
|
11
|
+
import { favorites, sync } from './sync.js';
|
|
12
|
+
import { bold, CliError, dim, fail, info, reportError, setJsonMode } from './ui.js';
|
|
13
|
+
const HELP = `${bold('funcoding')} ${dim(`v${VERSION}`)} — 从 ${SITE} 查找和安装 Agent Skills、MCP Server
|
|
14
|
+
|
|
15
|
+
${bold('Skill')}
|
|
16
|
+
search <关键词> 搜索 Skill 和 MCP Server
|
|
17
|
+
info <owner/repo/skill> 查看 Skill:描述、文件清单和 SKILL.md 全文(安装前检查用)
|
|
18
|
+
install <id…> 安装 Skill(也可以粘贴网站上的详情页地址)
|
|
19
|
+
list 列出已安装的 Skill
|
|
20
|
+
update [名称] [--check] 按原来源重新安装;--check 只检查有没有更新
|
|
21
|
+
remove <名称…> 删除由 funcoding 安装的 Skill(先备份)
|
|
22
|
+
|
|
23
|
+
${bold('MCP')}
|
|
24
|
+
mcp info <owner/repo> 查看启动方式和需要的环境变量
|
|
25
|
+
mcp add <owner/repo> 写入智能体的 MCP 配置(--env / --header 提供必填项)
|
|
26
|
+
mcp list 列出已配置的 MCP Server
|
|
27
|
+
mcp remove <配置名…> 删除配置(先备份)
|
|
28
|
+
|
|
29
|
+
${bold('收藏')}
|
|
30
|
+
login / logout / whoami 在浏览器中授权登录 / 退出 / 查看当前账号
|
|
31
|
+
favorites 列出网站上收藏的 Skill 和 MCP Server
|
|
32
|
+
sync [--dry-run] 安装全部收藏,已装的跳过
|
|
33
|
+
|
|
34
|
+
${bold('热点与文档')}
|
|
35
|
+
news [--daily [日期]] AI 热榜,或某天的 AI 日报
|
|
36
|
+
docs search <关键词> 搜索中文文档(--agent 可按智能体筛选)
|
|
37
|
+
docs read <智能体/路径> 读取文档全文(Markdown)
|
|
38
|
+
|
|
39
|
+
${bold('其他')}
|
|
40
|
+
setup 给智能体装上 funcoding Skill,让它学会使用本工具
|
|
41
|
+
|
|
42
|
+
${bold('选项')}
|
|
43
|
+
--json 只向 stdout 输出一个 JSON 对象(给智能体用)
|
|
44
|
+
-a, --agent <名称> ${AGENT_IDS.join(' | ')} | all,多个用逗号分隔(默认:当前所在的智能体,否则 ${DEFAULT_AGENT})
|
|
45
|
+
-p, --project 装到当前项目;list / update / remove / mcp list 时只看当前项目
|
|
46
|
+
-u, --user list / update / remove / mcp list 时只看用户目录(默认两处都看)
|
|
47
|
+
-f, --force 目标已存在时覆盖(旧版本先备份)
|
|
48
|
+
-e, --env KEY=VALUE mcp add:stdio Server 的环境变量,可重复
|
|
49
|
+
--header KEY=VALUE mcp add:远程 Server 的请求头,可重复
|
|
50
|
+
--via <方式> mcp add:remote | npm | pypi
|
|
51
|
+
--name <配置名> mcp add:自定义配置名
|
|
52
|
+
--dry-run sync:只列出将要执行的操作
|
|
53
|
+
--check update:只检查不安装
|
|
54
|
+
--daily [日期] news:日报,YYYY-MM-DD,不写为最新一期
|
|
55
|
+
--limit <N> search / news / docs search:返回条数
|
|
56
|
+
--no-browser login:不自动打开浏览器
|
|
57
|
+
-h, --help 显示帮助
|
|
58
|
+
-v, --version 显示版本
|
|
59
|
+
|
|
60
|
+
${bold('退出码')}
|
|
61
|
+
0 成功 1 一般错误 2 参数错误或缺少必填项 3 未找到 4 目标已存在 5 未登录或登录已过期
|
|
62
|
+
|
|
63
|
+
${bold('例子')}
|
|
64
|
+
npx funcoding-cli search pdf --json
|
|
65
|
+
npx funcoding-cli info anthropics/skills/pdf --json
|
|
66
|
+
npx funcoding-cli install anthropics/skills/pdf --agent codex --project
|
|
67
|
+
npx funcoding-cli mcp add upstash/context7 --agent all
|
|
68
|
+
`;
|
|
69
|
+
/** KEY=VALUE 列表转成对象 */
|
|
70
|
+
function pairs(list, flag) {
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const item of list ?? []) {
|
|
73
|
+
const i = item.indexOf('=');
|
|
74
|
+
if (i <= 0)
|
|
75
|
+
fail('usage', `${flag} 要写成 KEY=VALUE:${item}`);
|
|
76
|
+
out[item.slice(0, i)] = item.slice(i + 1);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
async function main() {
|
|
81
|
+
const argv = process.argv.slice(2);
|
|
82
|
+
// 参数解析出错时也要按 JSON 输出
|
|
83
|
+
setJsonMode(argv.includes('--json'));
|
|
84
|
+
// --daily 后面的日期可选:没跟日期时补一个空串,避免把后面的参数当成日期
|
|
85
|
+
const i = argv.indexOf('--daily');
|
|
86
|
+
if (i >= 0 && !/^\d{4}-\d{2}-\d{2}$|^latest$/.test(argv[i + 1] ?? ''))
|
|
87
|
+
argv.splice(i + 1, 0, '');
|
|
88
|
+
const { values, positionals } = parseArgs({
|
|
89
|
+
args: argv,
|
|
90
|
+
allowPositionals: true,
|
|
91
|
+
options: {
|
|
92
|
+
json: { type: 'boolean', default: false },
|
|
93
|
+
agent: { type: 'string', short: 'a' },
|
|
94
|
+
project: { type: 'boolean', short: 'p', default: false },
|
|
95
|
+
user: { type: 'boolean', short: 'u', default: false },
|
|
96
|
+
force: { type: 'boolean', short: 'f', default: false },
|
|
97
|
+
env: { type: 'string', short: 'e', multiple: true },
|
|
98
|
+
header: { type: 'string', multiple: true },
|
|
99
|
+
via: { type: 'string' },
|
|
100
|
+
name: { type: 'string' },
|
|
101
|
+
'dry-run': { type: 'boolean', default: false },
|
|
102
|
+
check: { type: 'boolean', default: false },
|
|
103
|
+
daily: { type: 'string' },
|
|
104
|
+
limit: { type: 'string' },
|
|
105
|
+
'no-browser': { type: 'boolean', default: false },
|
|
106
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
107
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
setJsonMode(values.json);
|
|
111
|
+
if (values.version)
|
|
112
|
+
return info(VERSION);
|
|
113
|
+
const [command, ...args] = positionals;
|
|
114
|
+
if (values.help || !command)
|
|
115
|
+
return info(HELP);
|
|
116
|
+
const limit = Math.min(30, Math.max(1, Number(values.limit) || 10));
|
|
117
|
+
/** 写入类命令的目标:智能体 × 一个范围 */
|
|
118
|
+
const targets = () => {
|
|
119
|
+
const agents = resolveAgents(values.agent);
|
|
120
|
+
if (!agents)
|
|
121
|
+
return fail('usage', `不认识的智能体:${values.agent}`, `可选:${AGENT_IDS.join('、')}、all`);
|
|
122
|
+
const scope = values.project ? 'project' : 'user';
|
|
123
|
+
return agents.map((agent) => ({ agent, scope }));
|
|
124
|
+
};
|
|
125
|
+
/** 读取和维护类命令的范围:默认全部智能体、两个范围 */
|
|
126
|
+
const selection = () => {
|
|
127
|
+
const agents = values.agent ? resolveAgents(values.agent) : AGENT_IDS;
|
|
128
|
+
if (!agents)
|
|
129
|
+
return fail('usage', `不认识的智能体:${values.agent}`, `可选:${AGENT_IDS.join('、')}、all`);
|
|
130
|
+
const scopes = values.project && !values.user ? ['project'] : values.user && !values.project ? ['user'] : ['user', 'project'];
|
|
131
|
+
return { agents, scopes };
|
|
132
|
+
};
|
|
133
|
+
switch (command) {
|
|
134
|
+
case 'search':
|
|
135
|
+
case 's':
|
|
136
|
+
return search(args.join(' '), limit);
|
|
137
|
+
case 'info':
|
|
138
|
+
if (!args[0])
|
|
139
|
+
fail('usage', '缺少 Skill。', '例如 funcoding info anthropics/skills/pdf');
|
|
140
|
+
return skillInfo(args[0]);
|
|
141
|
+
case 'install':
|
|
142
|
+
case 'add':
|
|
143
|
+
case 'i':
|
|
144
|
+
return install(args, targets(), values.force);
|
|
145
|
+
case 'list':
|
|
146
|
+
case 'ls':
|
|
147
|
+
return list(selection());
|
|
148
|
+
case 'update':
|
|
149
|
+
case 'up':
|
|
150
|
+
return update(args[0], selection(), values.check);
|
|
151
|
+
case 'remove':
|
|
152
|
+
case 'uninstall':
|
|
153
|
+
case 'rm':
|
|
154
|
+
return remove(args, selection(), values.force);
|
|
155
|
+
case 'mcp': {
|
|
156
|
+
const [sub, ...rest] = args;
|
|
157
|
+
if (sub === 'info') {
|
|
158
|
+
if (!rest[0])
|
|
159
|
+
fail('usage', '缺少 MCP Server。', '例如 funcoding mcp info upstash/context7');
|
|
160
|
+
return mcpInfo(rest[0]);
|
|
161
|
+
}
|
|
162
|
+
if (sub === 'add' || sub === 'install') {
|
|
163
|
+
if (!rest[0])
|
|
164
|
+
fail('usage', '缺少 MCP Server。', '例如 funcoding mcp add upstash/context7');
|
|
165
|
+
if (values.via && !['remote', 'npm', 'pypi'].includes(values.via))
|
|
166
|
+
fail('usage', `--via 只能是 remote、npm 或 pypi:${values.via}`);
|
|
167
|
+
return mcpAdd(rest[0], {
|
|
168
|
+
targets: targets(),
|
|
169
|
+
env: pairs(values.env, '--env'),
|
|
170
|
+
headers: pairs(values.header, '--header'),
|
|
171
|
+
via: values.via,
|
|
172
|
+
name: values.name,
|
|
173
|
+
force: values.force,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (sub === 'list' || sub === 'ls')
|
|
177
|
+
return mcpList(selection());
|
|
178
|
+
if (sub === 'remove' || sub === 'rm')
|
|
179
|
+
return mcpRemove(rest, selection());
|
|
180
|
+
return fail('usage', `不认识的命令:mcp ${sub ?? ''}`.trim(), '可用:mcp info / add / list / remove');
|
|
181
|
+
}
|
|
182
|
+
case 'login':
|
|
183
|
+
return login({ browser: !values['no-browser'] });
|
|
184
|
+
case 'logout':
|
|
185
|
+
return logout();
|
|
186
|
+
case 'whoami':
|
|
187
|
+
return whoami();
|
|
188
|
+
case 'favorites':
|
|
189
|
+
case 'fav':
|
|
190
|
+
return favorites();
|
|
191
|
+
case 'sync':
|
|
192
|
+
return sync(targets(), values['dry-run']);
|
|
193
|
+
case 'news':
|
|
194
|
+
return news({ daily: values.daily, limit });
|
|
195
|
+
case 'docs': {
|
|
196
|
+
const [sub, ...rest] = args;
|
|
197
|
+
// 文档的 --agent 是站内任意智能体的 slug,不限于能装 Skill 的三个
|
|
198
|
+
if (sub === 'search')
|
|
199
|
+
return docsSearch(rest.join(' '), values.agent?.toLowerCase(), limit);
|
|
200
|
+
if (sub === 'read') {
|
|
201
|
+
if (!rest[0])
|
|
202
|
+
fail('usage', '缺少文档。', '例如 funcoding docs read claude-code/hooks');
|
|
203
|
+
return docsRead(rest[0]);
|
|
204
|
+
}
|
|
205
|
+
return fail('usage', `不认识的命令:docs ${sub ?? ''}`.trim(), '可用:docs search / read');
|
|
206
|
+
}
|
|
207
|
+
case 'setup':
|
|
208
|
+
return setup(targets(), values.force);
|
|
209
|
+
default:
|
|
210
|
+
return fail('usage', `不认识的命令:${command}`, '运行 funcoding --help 查看用法');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
main().catch((err) => {
|
|
214
|
+
if (err instanceof CliError)
|
|
215
|
+
return reportError(err);
|
|
216
|
+
// parseArgs 的参数错误
|
|
217
|
+
if (err instanceof TypeError && err.code?.startsWith('ERR_PARSE_ARGS'))
|
|
218
|
+
return reportError(new CliError('usage', err.message, '运行 funcoding --help 查看用法'));
|
|
219
|
+
reportError(new CliError('error', err instanceof Error ? err.message : String(err)));
|
|
220
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 改写智能体的 MCP 配置文件:只动我们这一项,文件里的其他内容原样保留。
|
|
3
|
+
* - JSON(Cursor 的 mcp.json、Claude Code 的 .mcp.json):{ "mcpServers": { "<name>": {...} } }
|
|
4
|
+
* - TOML(Codex 的 config.toml):[mcp_servers.<name>] 一段(含 [mcp_servers.<name>.env] 等子表)。
|
|
5
|
+
* 不引入 TOML 解析库,只按表头定位这一段做替换或删除
|
|
6
|
+
*/
|
|
7
|
+
export function parseMcpJson(text) {
|
|
8
|
+
if (!text?.trim())
|
|
9
|
+
return {};
|
|
10
|
+
const data = JSON.parse(text);
|
|
11
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
12
|
+
throw new Error('配置文件的顶层不是 JSON 对象');
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
export const jsonServerNames = (text) => Object.keys(parseMcpJson(text).mcpServers ?? {});
|
|
16
|
+
/** 返回新的文件内容;entry 为 null 时删除这一项 */
|
|
17
|
+
export function setJsonServer(text, name, entry) {
|
|
18
|
+
const data = parseMcpJson(text);
|
|
19
|
+
const servers = { ...(data.mcpServers ?? {}) };
|
|
20
|
+
if (entry)
|
|
21
|
+
servers[name] = entry;
|
|
22
|
+
else
|
|
23
|
+
delete servers[name];
|
|
24
|
+
return `${JSON.stringify({ ...data, mcpServers: servers }, null, 2)}\n`;
|
|
25
|
+
}
|
|
26
|
+
// ---------------- TOML ----------------
|
|
27
|
+
const tomlKey = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k));
|
|
28
|
+
/** TOML 的基本字符串和 JSON 字符串的转义规则兼容 */
|
|
29
|
+
const tomlString = (s) => JSON.stringify(s);
|
|
30
|
+
const inlineTable = (o) => `{ ${Object.entries(o).map(([k, v]) => `${tomlKey(k)} = ${tomlString(v)}`).join(', ')} }`;
|
|
31
|
+
/** 表头 [mcp_servers.<name>] 或 [mcp_servers.<name>.子表],名称可以是裸键或带引号 */
|
|
32
|
+
function headerMatches(line, name) {
|
|
33
|
+
const m = /^\s*\[\s*mcp_servers\s*\.\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*(?:\.[^\]]*)?\]\s*(?:#.*)?$/.exec(line);
|
|
34
|
+
if (!m)
|
|
35
|
+
return false;
|
|
36
|
+
const raw = m[1];
|
|
37
|
+
const key = raw.startsWith('"') ? JSON.parse(raw) : raw.startsWith("'") ? raw.slice(1, -1) : raw;
|
|
38
|
+
return key === name;
|
|
39
|
+
}
|
|
40
|
+
const isHeader = (line) => /^\s*\[/.test(line);
|
|
41
|
+
/** 列出 config.toml 里已配置的 MCP Server 名称 */
|
|
42
|
+
export function tomlServerNames(text) {
|
|
43
|
+
const names = new Set();
|
|
44
|
+
for (const line of (text ?? '').split('\n')) {
|
|
45
|
+
const m = /^\s*\[\s*mcp_servers\s*\.\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)/.exec(line);
|
|
46
|
+
if (m)
|
|
47
|
+
names.add(m[1].startsWith('"') ? JSON.parse(m[1]) : m[1].replace(/^'|'$/g, ''));
|
|
48
|
+
}
|
|
49
|
+
return [...names];
|
|
50
|
+
}
|
|
51
|
+
/** 删除 [mcp_servers.<name>] 及其子表 */
|
|
52
|
+
export function removeTomlServer(text, name) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let skipping = false;
|
|
55
|
+
for (const line of (text ?? '').split('\n')) {
|
|
56
|
+
if (isHeader(line))
|
|
57
|
+
skipping = headerMatches(line, name);
|
|
58
|
+
if (!skipping)
|
|
59
|
+
out.push(line);
|
|
60
|
+
}
|
|
61
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
62
|
+
}
|
|
63
|
+
export function renderTomlServer(name, c) {
|
|
64
|
+
const key = `mcp_servers.${tomlKey(name)}`;
|
|
65
|
+
const lines = [`[${key}]`];
|
|
66
|
+
if (c.kind === 'stdio') {
|
|
67
|
+
lines.push(`command = ${tomlString(c.command)}`, `args = [${c.args.map(tomlString).join(', ')}]`);
|
|
68
|
+
if (Object.keys(c.env).length > 0)
|
|
69
|
+
lines.push('', `[${key}.env]`, ...Object.entries(c.env).map(([k, v]) => `${tomlKey(k)} = ${tomlString(v)}`));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
lines.push(`url = ${tomlString(c.url)}`);
|
|
73
|
+
if (Object.keys(c.headers).length > 0)
|
|
74
|
+
lines.push(`http_headers = ${inlineTable(c.headers)}`);
|
|
75
|
+
}
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
/** 写入(已有同名的先删掉);c 为 null 时只删除 */
|
|
79
|
+
export function setTomlServer(text, name, c) {
|
|
80
|
+
const rest = removeTomlServer(text, name).replace(/\s+$/, '');
|
|
81
|
+
if (!c)
|
|
82
|
+
return rest ? `${rest}\n` : '';
|
|
83
|
+
return `${rest ? `${rest}\n\n` : ''}${renderTomlServer(name, c)}\n`;
|
|
84
|
+
}
|