funcoding-cli 0.0.0-stage → 0.2.1

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/dist/mcp.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * MCP Server:info / add / list / remove。配置位置和格式见 agents.ts 顶部的说明。
3
+ * Claude Code 优先调用它自己的 `claude mcp add-json` / `claude mcp remove`(用户范围的配置在 ~/.claude.json,
4
+ * 正在运行的 Claude Code 也会写这个文件,不能直接改);Codex、Cursor 直接改配置文件,改之前备份。
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
8
+ import { homedir } from 'node:os';
9
+ import { basename, dirname, join, resolve } from 'node:path';
10
+ import { AGENT_IDS, AGENTS, codexHome } from './agents.js';
11
+ import { api, siteUrl } from './api.js';
12
+ import { jsonServerNames, setJsonServer, setTomlServer, tomlServerNames } from './config-files.js';
13
+ import { configDir } from './credentials.js';
14
+ import { bold, CliError, dim, done, fail, green, info, stars, warn } from './ui.js';
15
+ /** 接受 owner/repo[/sub],或者网站上的详情页地址 */
16
+ export function parseMcpSpec(input) {
17
+ const s = input.trim();
18
+ const fromUrl = /\/mcp\/([^/?#]+\/[^/?#]+(?:\/[^/?#]+)?)\/?(?:[?#].*)?$/.exec(s)?.[1];
19
+ const id = (fromUrl ?? s).replace(/^\/+|\/+$/g, '').toLowerCase();
20
+ return /^[a-z0-9][\w.-]*\/[a-z0-9][\w.-]*(\/[a-z0-9][\w.-]*)?$/.test(id) ? id : null;
21
+ }
22
+ export async function fetchMcp(spec) {
23
+ const id = parseMcpSpec(spec);
24
+ if (!id)
25
+ return fail('usage', `MCP Server 要写成 owner/repo 或 owner/repo/sub,或者网站上的详情页地址:${spec}`);
26
+ try {
27
+ return await api(`/api/cli/mcp/${id}`);
28
+ }
29
+ catch (err) {
30
+ if (err instanceof CliError && err.code === 'not_found')
31
+ fail('not_found', `没有找到 MCP Server:${id}`, '运行 funcoding mcp find <关键词> 查找');
32
+ throw err;
33
+ }
34
+ }
35
+ const describeLaunch = (l) => (l.kind === 'remote' ? `远程服务(${l.transport})${l.url}` : `${l.registry === 'npm' ? 'npm' : 'PyPI'} 包 ${l.package}${l.version ? `@${l.version}` : ''}`);
36
+ const inputsOf = (l) => (l.kind === 'remote' ? l.headers : l.env);
37
+ const flagOf = (l) => (l.kind === 'remote' ? '--header' : '--env');
38
+ // ---------------- info ----------------
39
+ export async function mcpInfo(spec) {
40
+ const s = await fetchMcp(spec);
41
+ const page = siteUrl(s.page);
42
+ done({ server: { ...s, page } }, () => {
43
+ info(`${bold(s.name)} ${dim(`${s.id} · ★ ${stars(s.stars)}${s.official ? ' · 官方' : ''}`)}`);
44
+ if (s.description)
45
+ info(s.description);
46
+ info(dim(s.repo.url));
47
+ info(dim(page));
48
+ info('');
49
+ if (s.launches.length === 0)
50
+ return info(`没有登记标准安装包,配置方法见 README:${s.repo.url}`);
51
+ info(bold(`启动方式(配置名 ${s.configName})`));
52
+ for (const l of s.launches) {
53
+ info(` ${describeLaunch(l)}`);
54
+ for (const i of inputsOf(l))
55
+ info(dim(` ${flagOf(l)} ${i.name}=…${i.required ? '(必填)' : ''}${i.description ? ` ${i.description}` : ''}`));
56
+ }
57
+ });
58
+ }
59
+ function locationOf(agent, scope, cwd = process.cwd()) {
60
+ const home = homedir();
61
+ if (agent === 'codex')
62
+ return { agent, scope, format: 'toml', file: scope === 'project' ? resolve(cwd, '.codex', 'config.toml') : join(codexHome(), 'config.toml') };
63
+ if (agent === 'cursor')
64
+ return { agent, scope, format: 'json', file: scope === 'project' ? resolve(cwd, '.cursor', 'mcp.json') : join(home, '.cursor', 'mcp.json') };
65
+ return { agent, scope, format: 'json', file: scope === 'project' ? resolve(cwd, '.mcp.json') : null };
66
+ }
67
+ const readText = (file) => readFile(file, 'utf8').catch((err) => (err.code === 'ENOENT' ? null : Promise.reject(err)));
68
+ async function configuredNames(loc) {
69
+ // Claude Code 用户范围的 Server 在 ~/.claude.json 的 mcpServers 里,只读
70
+ const file = loc.file ?? join(homedir(), '.claude.json');
71
+ const text = await readText(file);
72
+ try {
73
+ return loc.format === 'toml' ? tomlServerNames(text) : jsonServerNames(text);
74
+ }
75
+ catch {
76
+ warn(`无法解析 ${file},已跳过`);
77
+ return [];
78
+ }
79
+ }
80
+ async function backupFile(file, agent) {
81
+ if ((await readText(file)) === null)
82
+ return null;
83
+ const backup = join(configDir(), 'backups', 'mcp', `${agent}-${basename(file)}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
84
+ await mkdir(dirname(backup), { recursive: true });
85
+ await copyFile(file, backup);
86
+ return backup;
87
+ }
88
+ /** 写入或删除一项(config 为 null 时删除);返回备份路径 */
89
+ async function writeConfig(loc, name, config) {
90
+ const text = await readText(loc.file);
91
+ let next;
92
+ if (loc.format === 'toml')
93
+ next = setTomlServer(text, name, config);
94
+ else {
95
+ try {
96
+ next = setJsonServer(text, name, config ? jsonEntry(loc.agent, config) : null);
97
+ }
98
+ catch (err) {
99
+ return fail('error', `无法解析 ${loc.file}:${err.message}`, '请先修正这个文件');
100
+ }
101
+ }
102
+ const backup = await backupFile(loc.file, loc.agent);
103
+ await mkdir(dirname(loc.file), { recursive: true });
104
+ await writeFile(loc.file, next);
105
+ return backup;
106
+ }
107
+ function jsonEntry(agent, c) {
108
+ if (c.kind === 'stdio')
109
+ return { type: 'stdio', command: c.command, args: c.args, ...(Object.keys(c.env).length ? { env: c.env } : {}) };
110
+ // Cursor 的远程服务只写 url(文档示例没有 type);Claude Code 要写 type
111
+ const headers = Object.keys(c.headers).length ? { headers: c.headers } : {};
112
+ return agent === 'cursor' ? { url: c.url, ...headers } : { type: c.transport, url: c.url, ...headers };
113
+ }
114
+ /** 调用 claude 命令;没装 claude 时返回 null */
115
+ function runClaude(args) {
116
+ const r = spawnSync('claude', args, { encoding: 'utf8', shell: process.platform === 'win32' });
117
+ if (r.error && r.error.code === 'ENOENT')
118
+ return null;
119
+ return { ok: r.status === 0, output: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
120
+ }
121
+ const shellQuote = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
122
+ /** 选一种启动方式:智能体支持、符合 --via、必填项都已提供 */
123
+ export function pickLaunch(launches, agent, opts) {
124
+ const candidates = launches.filter((l) => {
125
+ if (agent === 'codex' && l.kind === 'remote' && l.transport === 'sse')
126
+ return false;
127
+ if (!opts.via)
128
+ return true;
129
+ return opts.via === 'remote' ? l.kind === 'remote' : l.kind === 'stdio' && l.registry === opts.via;
130
+ });
131
+ if (candidates.length === 0)
132
+ return null;
133
+ for (const l of candidates) {
134
+ const given = l.kind === 'remote' ? opts.headers : opts.env;
135
+ if (inputsOf(l).every((i) => !i.required || given[i.name] !== undefined))
136
+ return { launch: l };
137
+ }
138
+ const first = candidates[0];
139
+ const given = first.kind === 'remote' ? opts.headers : opts.env;
140
+ return { missing: { launch: first, inputs: inputsOf(first).filter((i) => i.required && given[i.name] === undefined).map((i) => i.name) } };
141
+ }
142
+ export function toConfig(l, opts) {
143
+ if (l.kind === 'remote')
144
+ return { kind: 'remote', transport: l.transport, url: l.url, headers: opts.headers };
145
+ return { kind: 'stdio', command: l.command, args: l.args, env: opts.env };
146
+ }
147
+ /** 配置一个 Server 到多个目标;失败抛 CliError */
148
+ export async function addServer(s, opts) {
149
+ if (s.devtool)
150
+ warn(`${s.name} 被归为 MCP 开发工具(SDK、框架等),可能不是可以直接运行的 Server。`);
151
+ if (s.launches.length === 0)
152
+ fail('not_found', `${s.name} 没有登记标准安装包,无法自动配置。`, `按 README 手动配置:${s.repo.url}`, { readme: s.repo.url });
153
+ const name = opts.name ?? s.configName;
154
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name))
155
+ fail('usage', `配置名只能包含字母、数字、下划线和连字符:${name}`);
156
+ // 先检查所有目标,确定都能写再动手
157
+ const plan = [];
158
+ for (const t of opts.targets) {
159
+ const picked = pickLaunch(s.launches, t.agent, opts);
160
+ if (!picked)
161
+ fail('not_found', `${s.name} 没有 ${AGENTS[t.agent].label} 支持的启动方式${opts.via ? `(--via ${opts.via})` : ''}。`, `可选的启动方式见 funcoding mcp info ${s.id}`);
162
+ if ('missing' in picked) {
163
+ const { launch, inputs } = picked.missing;
164
+ fail('usage', `${s.name} 需要提供:${inputs.join('、')}`, `向用户索取后运行 funcoding mcp add ${s.id} ${inputs.map((i) => `${flagOf(launch)} ${i}=<值>`).join(' ')}`, { missing: inputs, flag: flagOf(launch) });
165
+ }
166
+ const loc = locationOf(t.agent, t.scope);
167
+ if (!opts.force && (await configuredNames(loc)).includes(name))
168
+ fail('exists', `${AGENTS[t.agent].label} 里已经配置了 ${name}。`, '确认要覆盖就加 --force');
169
+ plan.push({ loc, config: toConfig(picked.launch, opts), launch: picked.launch });
170
+ }
171
+ const results = [];
172
+ for (const { loc, config, launch } of plan) {
173
+ const base = { id: s.id, name, agent: loc.agent, scope: loc.scope, launch: describeLaunch(launch) };
174
+ if (loc.agent === 'claude-code') {
175
+ const json = JSON.stringify(jsonEntry('claude-code', config));
176
+ if (opts.force)
177
+ runClaude(['mcp', 'remove', name, '--scope', loc.scope]);
178
+ const r = runClaude(['mcp', 'add-json', name, json, '--scope', loc.scope]);
179
+ if (r?.ok) {
180
+ results.push({ ...base, file: loc.file });
181
+ continue;
182
+ }
183
+ if (r)
184
+ fail('error', `claude mcp add-json 失败:${r.output}`);
185
+ if (!loc.file)
186
+ fail('error', '没有找到 claude 命令,无法写入 Claude Code 的用户范围配置。', `安装 Claude Code 后运行:claude mcp add-json ${name} ${shellQuote(json)} --scope user`);
187
+ }
188
+ const backup = await writeConfig(loc, name, config);
189
+ results.push({ ...base, file: loc.file, ...(backup ? { backup } : {}) });
190
+ }
191
+ return results;
192
+ }
193
+ export async function mcpAdd(spec, opts) {
194
+ const results = await addServer(await fetchMcp(spec), opts);
195
+ done({ added: results }, () => {
196
+ for (const r of results) {
197
+ info(green(`✓ 已在 ${AGENTS[r.agent].label}(${r.scope === 'user' ? '个人' : '项目'})配置 ${r.name}`) + dim(` · ${r.launch}`));
198
+ if (r.file)
199
+ info(dim(` ${r.file}`));
200
+ if (r.backup)
201
+ info(dim(` 原文件备份在 ${r.backup}`));
202
+ }
203
+ if (results.some((r) => r.agent === 'claude-code' && r.scope === 'project'))
204
+ info(dim('项目范围的 Server 需要在 Claude Code 里确认后才会启用。'));
205
+ info(dim('重启智能体或新开会话后生效。'));
206
+ });
207
+ }
208
+ // ---------------- list / remove ----------------
209
+ export async function mcpList(sel) {
210
+ const servers = [];
211
+ for (const agent of sel.agents)
212
+ for (const scope of sel.scopes) {
213
+ const loc = locationOf(agent, scope);
214
+ for (const name of await configuredNames(loc))
215
+ servers.push({ agent, scope, name, file: loc.file ?? join(homedir(), '.claude.json') });
216
+ }
217
+ done({ servers }, () => {
218
+ if (servers.length === 0)
219
+ return info('还没有配置 MCP Server。');
220
+ for (const agent of AGENT_IDS)
221
+ for (const scope of sel.scopes) {
222
+ const group = servers.filter((s) => s.agent === agent && s.scope === scope);
223
+ if (group.length === 0)
224
+ continue;
225
+ info(`${bold(AGENTS[agent].label)} ${dim(`${scope === 'user' ? '个人' : '项目'} · ${group[0].file}`)}`);
226
+ for (const s of group)
227
+ info(` ${s.name}`);
228
+ info('');
229
+ }
230
+ });
231
+ }
232
+ export async function mcpRemove(names, sel) {
233
+ if (names.length === 0)
234
+ fail('usage', '缺少要删除的 MCP Server 配置名。', '运行 funcoding mcp list 查看');
235
+ const removed = [];
236
+ for (const name of names) {
237
+ let found = false;
238
+ for (const agent of sel.agents)
239
+ for (const scope of sel.scopes) {
240
+ const loc = locationOf(agent, scope);
241
+ if (!(await configuredNames(loc)).includes(name))
242
+ continue;
243
+ found = true;
244
+ if (agent === 'claude-code') {
245
+ const r = runClaude(['mcp', 'remove', name, '--scope', scope]);
246
+ if (r?.ok) {
247
+ removed.push({ agent, scope, name, file: loc.file });
248
+ continue;
249
+ }
250
+ if (r)
251
+ fail('error', `claude mcp remove 失败:${r.output}`);
252
+ if (!loc.file)
253
+ fail('error', '没有找到 claude 命令,无法修改 Claude Code 的用户范围配置。', `安装 Claude Code 后运行:claude mcp remove ${name} --scope user`);
254
+ }
255
+ const backup = await writeConfig(loc, name, null);
256
+ removed.push({ agent, scope, name, file: loc.file, ...(backup ? { backup } : {}) });
257
+ }
258
+ if (!found)
259
+ fail('not_found', `没有找到配置名为 ${name} 的 MCP Server`, '运行 funcoding mcp list 查看');
260
+ }
261
+ done({ removed }, () => {
262
+ for (const r of removed)
263
+ info(`${green('✓ 已删除')} ${AGENTS[r.agent].label} ${r.name}${r.file ? dim(` · ${r.file}`) : ''}`);
264
+ });
265
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,18 @@
1
+ /** 下载的文件路径来自网络,写盘前必须确认不会跑出目标目录 */
2
+ import { isAbsolute, normalize, relative, resolve, sep } from 'node:path';
3
+ /** 合法的相对路径:不能是绝对路径、不能有 ..、不能有反斜杠和控制字符 */
4
+ export function isSafeRelativePath(p) {
5
+ if (!p || p.length > 300 || isAbsolute(p) || /[\\\0-\x1f]/.test(p) || /^[a-zA-Z]:/.test(p))
6
+ return false;
7
+ return p.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
8
+ }
9
+ /** 把相对路径落到 root 下;越界返回 null */
10
+ export function resolveInside(root, p) {
11
+ if (!isSafeRelativePath(p))
12
+ return null;
13
+ const full = resolve(root, normalize(p));
14
+ const rel = relative(root, full);
15
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..') ? full : null;
16
+ }
17
+ /** 目录名只允许小写字母、数字和连字符 */
18
+ export const isSafeFolderName = (name) => /^[a-z0-9][a-z0-9-]{0,63}$/.test(name);
package/dist/setup.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * setup:
3
+ * 1. 没有全局安装时用 npm 全局安装本 CLI,之后终端里可以直接用 funcoding 或简写 fun(--no-global 跳过)
4
+ * 2. 把随 CLI 发布的 funcoding Skill(skill/funcoding/SKILL.md)装到智能体,让它知道什么时候、怎么用这个 CLI。
5
+ * 这个 Skill 不在网站目录里,来源记录标成 builtin,update 时从当前版本的 CLI 重新复制
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { existsSync } from 'node:fs';
10
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
11
+ import { delimiter, dirname, join } from 'node:path';
12
+ import { AGENTS, skillsRoot } from './agents.js';
13
+ import { USER_AGENT, VERSION } from './api.js';
14
+ import { META_FILE, moveAway } from './install.js';
15
+ import { cyan, dim, done, fail, green, info, warn } from './ui.js';
16
+ const isWindows = process.platform === 'win32';
17
+ /** PATH 上能不能找到这个命令;npx 运行时会把临时目录加进 PATH,那些目录不算 */
18
+ export function onPath(command, env = process.env) {
19
+ const exts = isWindows ? ['.cmd', '.exe', ''] : [''];
20
+ return (env.PATH ?? '')
21
+ .split(delimiter)
22
+ .filter((dir) => dir && !/[\\/]_npx[\\/]/.test(dir))
23
+ .some((dir) => exts.some((ext) => existsSync(join(dir, command + ext))));
24
+ }
25
+ /** 全局安装当前版本;npm 的输出写到 stderr,不影响 --json 的 stdout */
26
+ function installGlobal() {
27
+ if (onPath('funcoding'))
28
+ return { status: 'present' };
29
+ const pkg = `funcoding-cli@${VERSION}`;
30
+ info(dim(`正在全局安装 ${pkg},之后可以直接使用 funcoding 或 fun 命令…`));
31
+ const r = spawnSync('npm', ['install', '--global', pkg], { stdio: ['ignore', process.stderr, process.stderr], shell: isWindows });
32
+ if (r.status === 0)
33
+ return { status: 'installed' };
34
+ return { status: 'failed', hint: `手动运行 npm install -g ${pkg}(权限不足时参考 https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally)` };
35
+ }
36
+ export const BUILTIN_ID = 'funcoding';
37
+ const SOURCE = new URL('../skill/funcoding/SKILL.md', import.meta.url);
38
+ export const builtinContent = () => readFile(SOURCE, 'utf8');
39
+ export async function installBuiltin(targets, force) {
40
+ const content = await builtinContent();
41
+ const dirs = targets.map((t) => ({ ...t, dir: join(skillsRoot(t.agent, t.scope), BUILTIN_ID) }));
42
+ const foreign = [];
43
+ for (const d of dirs) {
44
+ const meta = await readFile(join(d.dir, META_FILE), 'utf8').catch(() => null);
45
+ const present = meta !== null || (await stat(d.dir).then(() => true, () => false));
46
+ // 之前 setup 装的直接换成新版;同名的其他 Skill 要 --force,并且先备份
47
+ if (present && !(meta && JSON.parse(meta).builtin))
48
+ foreign.push(d);
49
+ }
50
+ if (foreign.length > 0 && !force)
51
+ fail('exists', `${foreign.map((d) => d.dir).join('、')} 已存在,且不是 funcoding setup 安装的。`, '确认要覆盖就加 --force(会先备份到配置目录)');
52
+ for (const d of dirs) {
53
+ const tmp = join(dirname(d.dir), `.funcoding-tmp-${randomBytes(4).toString('hex')}`);
54
+ await mkdir(tmp, { recursive: true });
55
+ await writeFile(join(tmp, 'SKILL.md'), content);
56
+ await writeFile(join(tmp, META_FILE), `${JSON.stringify({ id: BUILTIN_ID, name: BUILTIN_ID, builtin: true, installedAt: new Date().toISOString(), cli: USER_AGENT }, null, 2)}\n`);
57
+ if (foreign.includes(d))
58
+ await moveAway(d.dir, d.agent, BUILTIN_ID);
59
+ else
60
+ await rm(d.dir, { recursive: true, force: true });
61
+ await rename(tmp, d.dir);
62
+ }
63
+ return dirs;
64
+ }
65
+ export async function setup(targets, force, global) {
66
+ const cli = global ? installGlobal() : { status: 'skipped' };
67
+ const dirs = await installBuiltin(targets, force);
68
+ done({ cli, installed: dirs.map((d) => ({ id: BUILTIN_ID, agent: d.agent, scope: d.scope, dir: d.dir })) }, () => {
69
+ if (cli.status === 'installed')
70
+ info(green(`✓ 已全局安装 funcoding-cli,在终端里输入 ${cyan('funcoding')} 或 ${cyan('fun')} 即可使用`));
71
+ if (cli.status === 'failed')
72
+ warn(`全局安装失败,${cli.hint}`);
73
+ for (const d of dirs)
74
+ info(green(`✓ 已把 funcoding Skill 装到 ${d.dir}`));
75
+ info(`之后可以直接让 ${dirs.map((d) => AGENTS[d.agent].label).join('、')} 帮你查找和安装 Skill、配置 MCP,例如「找一个处理 PDF 的 Skill 装上」。`);
76
+ info(dim(`也可以输入 ${[...new Set(dirs.map((d) => cyan(AGENTS[d.agent].invoke(BUILTIN_ID))))].join(' / ')} 主动调用。新开会话后生效。`));
77
+ });
78
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,86 @@
1
+ import { siteUrl } from './api.js';
2
+ import { fetchMe, requireToken } from './auth.js';
3
+ import { fetchSkill, installSkill, scanAll } from './install.js';
4
+ import { addServer, fetchMcp } from './mcp.js';
5
+ import { bold, CliError, cyan, dim, done, green, info, stars, truncate, yellow } from './ui.js';
6
+ function printCards(title, cards, hrefOf) {
7
+ if (cards.length === 0)
8
+ return;
9
+ info(bold(title));
10
+ for (const c of cards) {
11
+ info(` ${cyan(c.id)} ${dim(`★ ${stars(c.stars)}${c.official ? ' · 官方' : ''}`)}`);
12
+ if (c.description)
13
+ info(` ${truncate(c.description, (process.stdout.columns || 100) - 6)}`);
14
+ info(dim(` ${hrefOf(c)}`));
15
+ }
16
+ info('');
17
+ }
18
+ export async function favorites() {
19
+ const me = await fetchMe(await requireToken());
20
+ const { skills, mcp } = me.favorites;
21
+ done({ skills, mcp }, () => {
22
+ if (skills.length + mcp.length === 0)
23
+ return info(`还没有收藏。可以在 ${siteUrl('/skills/')} 收藏 Skill 和 MCP Server。`);
24
+ printCards('收藏的 Skill', skills, (c) => `funcoding skills add ${c.id}`);
25
+ printCards('收藏的 MCP Server', mcp, (c) => `funcoding mcp add ${c.id}`);
26
+ info(dim(`运行 ${cyan('funcoding favorites sync')} 一次安装全部收藏。`));
27
+ });
28
+ }
29
+ export async function sync(targets, dryRun) {
30
+ const me = await fetchMe(await requireToken());
31
+ const items = [];
32
+ const agents = [...new Set(targets.map((t) => t.agent))];
33
+ const scopes = [...new Set(targets.map((t) => t.scope))];
34
+ const installed = await scanAll({ agents, scopes });
35
+ for (const card of me.favorites.skills) {
36
+ const missing = targets.filter((t) => !installed.some((s) => s.agent === t.agent && s.scope === t.scope && s.source?.id === card.id));
37
+ if (missing.length === 0) {
38
+ items.push({ kind: 'skill', id: card.id, status: 'present', agents });
39
+ continue;
40
+ }
41
+ if (dryRun) {
42
+ items.push({ kind: 'skill', id: card.id, status: 'planned', agents: missing.map((t) => t.agent) });
43
+ continue;
44
+ }
45
+ try {
46
+ // 同名目录可能是手动放的或别的来源,不覆盖
47
+ await installSkill(await fetchSkill(card.id), missing, false);
48
+ items.push({ kind: 'skill', id: card.id, status: 'installed', agents: missing.map((t) => t.agent) });
49
+ }
50
+ catch (err) {
51
+ if (!(err instanceof CliError))
52
+ throw err;
53
+ items.push({ kind: 'skill', id: card.id, status: 'skipped', agents: missing.map((t) => t.agent), reason: err.message, hint: err.hint });
54
+ }
55
+ }
56
+ for (const card of me.favorites.mcp) {
57
+ try {
58
+ const server = await fetchMcp(card.id);
59
+ if (dryRun) {
60
+ items.push({ kind: 'mcp', id: card.id, status: 'planned', agents });
61
+ continue;
62
+ }
63
+ await addServer(server, { targets, env: {}, headers: {}, force: false });
64
+ items.push({ kind: 'mcp', id: card.id, status: 'installed', agents });
65
+ }
66
+ catch (err) {
67
+ if (!(err instanceof CliError))
68
+ throw err;
69
+ // 已经配置过的算已装;缺少必填项、没有安装包等记为跳过
70
+ if (err.code === 'exists')
71
+ items.push({ kind: 'mcp', id: card.id, status: 'present', agents });
72
+ else
73
+ items.push({ kind: 'mcp', id: card.id, status: 'skipped', agents, reason: err.message, hint: err.hint ?? (err.code === 'usage' ? undefined : `funcoding mcp info ${card.id}`) });
74
+ }
75
+ }
76
+ done({ dryRun, items }, () => {
77
+ if (items.length === 0)
78
+ return info(`还没有收藏。可以在 ${siteUrl('/skills/')} 收藏 Skill 和 MCP Server。`);
79
+ const label = { installed: green('新装'), present: dim('已装'), skipped: yellow('跳过'), planned: cyan('将安装') };
80
+ for (const i of items) {
81
+ info(`${label[i.status]} ${i.kind === 'skill' ? 'Skill' : 'MCP'} ${i.id}${i.reason ? dim(`:${i.reason}`) : ''}`);
82
+ if (i.hint)
83
+ info(dim(` ${i.hint}`));
84
+ }
85
+ });
86
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 输出层。CLI 主要给智能体用,约定:
3
+ * - 加 --json 时 stdout 只输出一个 JSON 对象(成功 { ok: true, ... },失败 { ok: false, error: { code, message, hint } }),
4
+ * 过程提示一律写到 stderr;不加时是给人看的文字
5
+ * - 退出码固定(见 EXIT),智能体可以据此决定下一步
6
+ * - 颜色只在交互终端里用,NO_COLOR 或 --json 时关闭
7
+ */
8
+ export const EXIT = { ok: 0, error: 1, usage: 2, not_found: 3, exists: 4, auth: 5 };
9
+ export class CliError extends Error {
10
+ code;
11
+ /** 下一步可以运行的命令或可以做的事 */
12
+ hint;
13
+ details;
14
+ constructor(code, message, hint, details) {
15
+ super(message);
16
+ this.code = code;
17
+ this.hint = hint;
18
+ this.details = details;
19
+ }
20
+ }
21
+ let json = false;
22
+ export const setJsonMode = (v) => {
23
+ json = v;
24
+ };
25
+ export const isJsonMode = () => json;
26
+ const color = () => !json && !!process.stdout.isTTY && !process.env.NO_COLOR;
27
+ const wrap = (code) => (s) => (color() ? `\x1b[${code}m${s}\x1b[0m` : s);
28
+ export const bold = wrap('1');
29
+ export const dim = wrap('2');
30
+ export const green = wrap('32');
31
+ export const yellow = wrap('33');
32
+ export const red = wrap('31');
33
+ export const cyan = wrap('36');
34
+ /** 给人看的输出:--json 时改写到 stderr,不污染 stdout 的 JSON */
35
+ export const info = (msg) => (json ? console.error(msg) : console.log(msg));
36
+ export const warn = (msg) => console.error(yellow(`! ${msg}`));
37
+ export function fail(code, message, hint, details) {
38
+ throw new CliError(code, message, hint, details);
39
+ }
40
+ /** 命令的最终结果:--json 时输出 JSON,否则调用 human 打印文字 */
41
+ export function done(data, human) {
42
+ if (json)
43
+ console.log(JSON.stringify({ ok: true, ...data }, null, 2));
44
+ else
45
+ human();
46
+ }
47
+ export function reportError(err) {
48
+ if (json) {
49
+ console.log(JSON.stringify({ ok: false, error: { code: err.code, message: err.message, ...(err.hint ? { hint: err.hint } : {}), ...(err.details ?? {}) } }, null, 2));
50
+ }
51
+ else {
52
+ console.error(red(`✗ ${err.message}`));
53
+ if (err.hint)
54
+ console.error(dim(` ${err.hint}`));
55
+ }
56
+ process.exitCode = EXIT[err.code];
57
+ }
58
+ export function stars(n) {
59
+ return n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
60
+ }
61
+ /** 截断到终端宽度 */
62
+ export function truncate(s, max = (process.stdout.columns || 100) - 4) {
63
+ const flat = s.replace(/\s+/g, ' ').trim();
64
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
65
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "funcoding-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "funcoding.ai 的命令行:给 AI 编程智能体用,查找和安装 Agent Skills、配置 MCP Server、同步收藏、查看 AI 热点和中文文档",
5
+ "type": "module",
6
+ "bin": {
7
+ "funcoding": "dist/cli.js",
8
+ "fun": "dist/cli.js",
9
+ "funcoding-cli": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "skill"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "test": "node --experimental-strip-types --no-warnings --test 'src/**/*.test.ts'",
18
+ "prepublishOnly": "pnpm build && pnpm test"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.17"
22
+ },
23
+ "keywords": [
24
+ "agent-skills",
25
+ "mcp",
26
+ "claude-code",
27
+ "codex",
28
+ "cursor",
29
+ "skills",
30
+ "cli"
31
+ ],
32
+ "homepage": "https://funcoding.ai",
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "@types/node": "^26.6.4",
36
+ "typescript": "^7.0.2"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/funcodingdev/funcoding_ai.git",
41
+ "directory": "packages/funcoding-cli"
42
+ }
43
+ }
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: funcoding
3
+ description: 用 funcoding 命令行(funcoding-cli)从 funcoding.ai 查找并安装 Agent Skills 和 MCP Server,管理已安装的 Skill,同步用户在网站上的收藏,查看 AI 热点和日报,搜索和阅读 Claude Code、Codex、Cursor 等智能体的中文文档。当用户想找某类 Skill 或 MCP Server、安装或更新 Skill、配置 MCP、同步收藏、了解 AI 圈最近的新闻,或查阅 AI 编程工具的中文文档时使用。
4
+ ---
5
+
6
+ # funcoding
7
+
8
+ 本 Skill 说明如何使用 `funcoding` 命令行(npm 包 `funcoding-cli`,命令可简写为 `fun`)。
9
+
10
+ ## 安装
11
+
12
+ 如果 PATH 里找不到 `funcoding` 命令,运行下面的命令安装(需要 Node.js 18.17 及以上):
13
+
14
+ ```bash
15
+ npx --yes funcoding-cli@latest init --json
16
+ ```
17
+
18
+ `init` 会全局安装本工具,并装上本 Skill。返回结果里 `cli.status` 为 `failed` 时(比如没有权限),把 `cli.hint` 告诉用户;在用户处理好之前,可以用 `npx --yes funcoding-cli@latest <命令>` 临时代替 `funcoding <命令>`。
19
+
20
+ `funcoding update` 会把本工具更新到最新版本,并同时更新本 Skill。
21
+
22
+ ## 使用约定
23
+
24
+ - **始终加 `--json`**:stdout 只输出一个 JSON 对象。成功时是 `{ "ok": true, ... }`,失败时是 `{ "ok": false, "error": { "code", "message", "hint" } }`。过程提示写在 stderr,可以忽略。
25
+ - 出错时先看 `error.hint`,里面通常是下一步该运行的命令或该补的参数。
26
+ - 退出码:0 成功,1 一般错误,2 参数错误或缺少必填项,3 未找到,4 目标已存在(需要 `--force`),5 未登录或登录已过期。
27
+ - `--agent=<name>` 指定装给哪个智能体:`claude-code`、`codex`、`cursor`,多个用逗号分隔;`all` 表示本机检测到的全部智能体。不指定时默认装给 Claude Code。**如果你不是 Claude Code,请显式加上 `--agent`。**
28
+ - `--project` 装到当前项目(可以随仓库提交,团队共用);不加时装到用户目录,对所有项目生效。
29
+ - 不确定某条命令的参数时,运行 `funcoding <命令> --help`,比如 `funcoding skills add --help`。
30
+ - `funcoding info --json` 返回本机环境:已安装的智能体、各自的 Skill 目录、登录状态。
31
+
32
+ ## Skill 管理
33
+
34
+ 使用 `skills` 命令:
35
+
36
+ 1. `funcoding skills find <query>`:查找 Skill。结果里的 `skills[].id` 形如 `owner/repo/skill`。
37
+ 2. **安装前先检查**:`funcoding skills info <id>` 返回 SKILL.md 全文(`skill.content`)和文件清单(`skill.files`,其中的可执行脚本列在 `skill.scripts`)。用一两句话告诉用户这个 Skill 做什么;如果有执行任意命令、读取或外传密钥和个人数据、修改系统设置等危险操作,要明确指出。**得到用户确认后再安装。**
38
+ 3. `funcoding skills add <id>... [--agent=<name>] [--project]`:安装。目录已存在时会以退出码 4 停下;向用户确认后再加 `--force`,旧版本会备份到配置目录。
39
+ 4. 告诉用户装到了哪个目录(`installed[].dir`),以及如何调用:Claude Code 和 Cursor 输入 `/<目录名>`,Codex 输入 `$<目录名>`,也可以直接描述任务,让智能体自动选用。新装的 Skill 可能需要新开会话才会加载。
40
+
41
+ 管理已安装的 Skill:
42
+
43
+ - `funcoding skills list`:列出所有智能体在用户目录和当前项目里的 Skill。`source` 不为空的是通过 funcoding 安装的。
44
+ - `funcoding skills update --check`:检查远端 SKILL.md 是否有变化,不安装。`funcoding skills update [<name>]`:按原来源重新安装。
45
+ - `funcoding skills remove <name>...`:删除前会先备份。只能删除通过 funcoding 安装的 Skill;删除其他 Skill 需要加 `--force`,务必先征得用户同意。
46
+
47
+ ## MCP 配置
48
+
49
+ 使用 `mcp` 命令:
50
+
51
+ 1. `funcoding mcp find <query>`:查找 MCP Server。结果里的 `servers[].id` 形如 `owner/repo`。
52
+ 2. `funcoding mcp info <id>`:查看可用的启动方式(`server.launches`),每种方式需要的环境变量(`env`)或请求头(`headers`),以及是否必填。
53
+ 3. `funcoding mcp add <id> [--agent=<name>] [--project] [--env=KEY=VALUE ...] [--header=KEY=VALUE ...] [--via=remote|npm|pypi]`:
54
+ - 缺少必填项时,会以退出码 2 返回,`error.missing` 列出缺少的项。**向用户索取这些值,不要编造。** 密钥只通过参数传入,不要写进其他文件。
55
+ - 没有登记标准安装包时,会以退出码 3 返回,`error.readme` 是 README 地址。这时按 README 手动配置。
56
+ 4. 配置在重启智能体或新开会话后生效。Claude Code 项目范围的 Server 需要用户在 Claude Code 里确认后才会启用。
57
+ 5. `funcoding mcp list` 列出已配置的 Server;`funcoding mcp remove <name>...` 删除,删除前会备份原文件。
58
+
59
+ ## 收藏
60
+
61
+ 用户在 funcoding.ai 上收藏的 Skill 和 MCP Server,需要先登录:
62
+
63
+ - `funcoding auth status`:查看是否已登录。返回退出码 5 表示未登录。
64
+ - `funcoding auth login`:会显示验证码和授权地址(在 stderr 中),并尝试打开浏览器。把地址和验证码告诉用户,请用户在浏览器中确认;命令会一直等到用户确认后才返回。
65
+ - `funcoding favorites list`:列出收藏。
66
+ - `funcoding favorites sync [--agent=<name>] [--project] [--dry-run]`:安装全部收藏,已装的跳过。建议先用 `--dry-run` 给用户看将要安装什么。`items[]` 里 `status` 为 `skipped` 的条目附有原因和提示,通常是 MCP 缺少必填的环境变量。
67
+
68
+ ## AI 热点
69
+
70
+ - `funcoding news list [--limit=<n>]`:当前 AI 热榜(多家来源报道的事件,按热度排序,附中文标题和摘要)。
71
+ - `funcoding news daily [<YYYY-MM-DD>]`:某一天的 AI 日报,不写日期时为最新一期。
72
+
73
+ ## 文档
74
+
75
+ `docs` 命令用来查阅 AI 编程智能体的中文文档。这些文档按官方文档的章节结构整理,并标注了官方来源和核实日期。
76
+
77
+ - `funcoding docs search <query> [--agent=<slug>]`:搜索文档。这里的 `--agent` 可以是站内任意智能体,比如 `claude-code`、`codex`、`cursor`、`gemini-cli`。
78
+ - `funcoding docs fetch <agent>/<path>`:读取文档全文(Markdown),开头附官方来源链接和核实日期。命令、配置和价格以官方文档为准。
79
+
80
+ ## `funcoding help` 输出
81
+
82
+ ```
83
+ Usage: funcoding [-hV] [--json] <command>
84
+
85
+ Commands:
86
+ init 初始化:全局安装本工具(之后可用 funcoding 或 fun),并给智能体装上 funcoding Skill
87
+ update 把本工具更新到最新版本,并刷新已安装的 funcoding Skill
88
+ info 环境信息:版本、站点、登录状态、本机的智能体和 Skill 目录、配置目录
89
+ help 显示帮助
90
+ skills 查找、安装和管理 Agent Skills(find / info / add / list / update / remove)
91
+ mcp 查找 MCP Server,并写入各智能体的 MCP 配置(find / info / add / list / remove)
92
+ favorites 网站上收藏的 Skill 和 MCP Server(list / sync)
93
+ auth 登录 funcoding.ai(login / logout / status)
94
+ news AI 热点(list / daily)
95
+ docs AI 编程智能体的中文文档(search / fetch)
96
+ ```