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.
@@ -0,0 +1,98 @@
1
+ /** search / news / docs:只读查询网站内容 */
2
+ import { api, siteUrl } from './api.js';
3
+ import { bold, CliError, cyan, dim, done, fail, info, stars, truncate } from './ui.js';
4
+ function printCards(title, cards, hrefOf) {
5
+ if (cards.length === 0)
6
+ return;
7
+ info(bold(title));
8
+ for (const c of cards) {
9
+ info(` ${cyan(c.id)} ${dim(`★ ${stars(c.stars)}${c.official ? ' · 官方' : ''}`)}`);
10
+ if (c.description)
11
+ info(` ${truncate(c.description, (process.stdout.columns || 100) - 6)}`);
12
+ info(dim(` ${hrefOf(c)}`));
13
+ }
14
+ info('');
15
+ }
16
+ export async function search(query, limit) {
17
+ if (!query)
18
+ fail('usage', '缺少搜索词。', '例如 funcoding search pdf');
19
+ const r = await api(`/api/cli/search?q=${encodeURIComponent(query)}&limit=${limit}`);
20
+ done({ skills: r.skills, mcp: r.mcp }, () => {
21
+ if (r.skills.length + r.mcp.length === 0)
22
+ return info(`没有找到「${query}」相关的 Skill 或 MCP Server。`);
23
+ printCards('Skills', r.skills, (c) => `funcoding install ${c.id}`);
24
+ printCards('MCP Server', r.mcp, (c) => `funcoding mcp add ${c.id}`);
25
+ });
26
+ }
27
+ function printStory(s, n) {
28
+ info(`${n ? `${String(n).padStart(2)}. ` : ' · '}${bold(s.title)}${dim(` ${s.sourceCount} 家报道`)}`);
29
+ if (s.summary)
30
+ info(` ${truncate(s.summary, (process.stdout.columns || 100) - 6)}`);
31
+ info(dim(` ${s.url}`));
32
+ }
33
+ export async function news(opts) {
34
+ if (opts.daily !== undefined) {
35
+ const day = opts.daily || 'latest';
36
+ if (day !== 'latest' && !/^\d{4}-\d{2}-\d{2}$/.test(day))
37
+ fail('usage', '日期要写成 YYYY-MM-DD。');
38
+ const issue = await api(`/api/cli/news?daily=${day}`);
39
+ return done({ daily: issue }, () => {
40
+ info(bold(`AI 日报 · 第 ${issue.no} 期 · ${issue.day}`));
41
+ info(dim(issue.url));
42
+ info('');
43
+ info(bold('头条'));
44
+ printStory(issue.headline);
45
+ for (const sec of issue.sections) {
46
+ info('');
47
+ info(bold(sec.label));
48
+ for (const s of sec.stories)
49
+ printStory(s);
50
+ }
51
+ if (issue.flash.length > 0) {
52
+ info('');
53
+ info(bold('快讯'));
54
+ for (const s of issue.flash)
55
+ info(` · ${s.title} ${dim(s.url)}`);
56
+ }
57
+ });
58
+ }
59
+ const r = await api(`/api/cli/news?limit=${opts.limit}`);
60
+ done({ updatedAt: r.updatedAt, stories: r.stories }, () => {
61
+ if (r.stories.length === 0)
62
+ return info('暂无热点。');
63
+ info(bold('AI 热榜') + dim(` ${siteUrl('/news/')}`));
64
+ r.stories.forEach((s, i) => printStory(s, i + 1));
65
+ });
66
+ }
67
+ export async function docsSearch(query, agent, limit) {
68
+ if (!query)
69
+ fail('usage', '缺少搜索词。', '例如 funcoding docs search hooks --agent claude-code');
70
+ const params = new URLSearchParams({ q: query, limit: String(limit), ...(agent ? { agent } : {}) });
71
+ const r = await api(`/api/cli/docs/search?${params}`);
72
+ done({ docs: r.docs }, () => {
73
+ if (r.docs.length === 0)
74
+ return info(`没有找到「${query}」相关的文档。`);
75
+ for (const d of r.docs) {
76
+ info(`${cyan(d.id)} ${bold(d.title)} ${dim(d.agent)}`);
77
+ if (d.description)
78
+ info(` ${truncate(d.description, (process.stdout.columns || 100) - 4)}`);
79
+ }
80
+ info('');
81
+ info(dim(`运行 ${cyan('funcoding docs read <id>')} 阅读全文。`));
82
+ });
83
+ }
84
+ export async function docsRead(id) {
85
+ const path = id
86
+ .trim()
87
+ .replace(/^https?:\/\/[^/]+/, '')
88
+ .replace(/^\/?agents\//, '')
89
+ .replace(/^\/+|\/+$/g, '');
90
+ if (!/^[a-z0-9][\w-]*(\/[\w.-]+)*$/i.test(path))
91
+ fail('usage', '文档要写成 <智能体>/<路径>,例如 claude-code/hooks。', '运行 funcoding docs search <关键词> 查找');
92
+ const markdown = await api(`/api/cli/docs/${path}`, { headers: { Accept: 'text/markdown' } }).catch((err) => {
93
+ if (err instanceof CliError && err.code === 'not_found')
94
+ fail('not_found', `没有找到文档:${path}`, '运行 funcoding docs search <关键词> 查找');
95
+ throw err;
96
+ });
97
+ done({ id: path, url: siteUrl(`/agents/${path}/`), markdown }, () => info(markdown));
98
+ }
@@ -0,0 +1,30 @@
1
+ /** 登录凭证存在 ~/.config/funcoding/credentials.json(Windows 是 %APPDATA%\funcoding),只有自己可读 */
2
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ /** CLI 自己的配置目录:凭证、覆盖安装时的备份 */
6
+ export function configDir() {
7
+ if (process.env.FUNCODING_CONFIG_DIR)
8
+ return process.env.FUNCODING_CONFIG_DIR;
9
+ if (process.platform === 'win32' && process.env.APPDATA)
10
+ return join(process.env.APPDATA, 'funcoding');
11
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'funcoding');
12
+ }
13
+ export const credentialsPath = () => join(configDir(), 'credentials.json');
14
+ export async function loadCredentials(site) {
15
+ try {
16
+ const c = JSON.parse(await readFile(credentialsPath(), 'utf8'));
17
+ // 换了站点地址(比如本地开发)就不用之前的 token
18
+ return c.token && c.site === site ? c : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export async function saveCredentials(c) {
25
+ const file = credentialsPath();
26
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
27
+ await writeFile(file, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
28
+ await chmod(file, 0o600).catch(() => { });
29
+ }
30
+ export const clearCredentials = () => rm(credentialsPath(), { force: true });
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Skill 管理:info / install / list / update / remove。
3
+ * 安装时先下载到目标目录旁边的临时目录,全部成功后再整体移动过去,失败不会留下装了一半的目录;
4
+ * 覆盖和删除前把旧目录备份到 CLI 的配置目录(留在 skills 目录里会被智能体当成同名 Skill 加载)。
5
+ */
6
+ import { createHash, randomBytes } from 'node:crypto';
7
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+ import { AGENT_IDS, AGENTS, skillsRoot } from './agents.js';
10
+ import { api, SITE, siteUrl, USER_AGENT } from './api.js';
11
+ import { configDir } from './credentials.js';
12
+ import { isSafeFolderName, resolveInside } from './paths.js';
13
+ import { builtinContent, installBuiltin } from './setup.js';
14
+ import { bold, CliError, cyan, dim, done, fail, green, info, stars, warn } from './ui.js';
15
+ /** 和服务端一致的上限 */
16
+ const MAX_FILES = 200;
17
+ const MAX_BYTES = 10 * 1024 * 1024;
18
+ export const META_FILE = '.funcoding.json';
19
+ /** 接受 owner/repo/skill,或者网站上的详情页地址 */
20
+ export function parseSkillSpec(input) {
21
+ const s = input.trim();
22
+ const fromUrl = /\/skills\/([^/?#]+\/[^/?#]+\/[^/?#]+)\/?(?:install\.md)?(?:[?#].*)?$/.exec(s)?.[1];
23
+ const id = (fromUrl ?? s).replace(/^\/+|\/+$/g, '').toLowerCase();
24
+ // 每一段都以字母或数字开头:排除 . 和 .. 这类路径
25
+ return /^[a-z0-9][\w.-]*\/[a-z0-9][\w.-]*\/[a-z0-9][\w.-]*$/.test(id) ? id : null;
26
+ }
27
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
28
+ const exists = (p) => stat(p).then(() => true, () => false);
29
+ async function download(url, maxBytes) {
30
+ const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(60_000) });
31
+ if (!res.ok) {
32
+ await res.body?.cancel();
33
+ throw new CliError('error', `下载失败(HTTP ${res.status}):${url}`);
34
+ }
35
+ const buf = new Uint8Array(await res.arrayBuffer());
36
+ if (buf.byteLength > maxBytes)
37
+ throw new CliError('error', `文件太大:${url}`);
38
+ return buf;
39
+ }
40
+ async function mapLimit(items, limit, fn) {
41
+ let next = 0;
42
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
43
+ while (next < items.length)
44
+ await fn(items[next++]);
45
+ }));
46
+ }
47
+ /** 把目录挪到备份位置;跨磁盘(比如 --project 装在另一块盘上)不能 rename,就先复制再删 */
48
+ export async function moveAway(dir, agent, folder) {
49
+ const backup = join(configDir(), 'backups', agent, `${folder}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
50
+ await mkdir(dirname(backup), { recursive: true });
51
+ await rename(dir, backup).catch(async (err) => {
52
+ if (err.code !== 'EXDEV')
53
+ throw err;
54
+ await cp(dir, backup, { recursive: true });
55
+ await rm(dir, { recursive: true, force: true });
56
+ });
57
+ return backup;
58
+ }
59
+ /** 取 Skill 的安装信息(含 SKILL.md 全文和文件清单) */
60
+ export async function fetchSkill(spec) {
61
+ const id = parseSkillSpec(spec);
62
+ if (!id)
63
+ return fail('usage', `Skill 要写成 owner/repo/skill,或者 ${SITE}/skills/ 下的详情页地址:${spec}`);
64
+ try {
65
+ return await api(`/api/cli/skills/${id}`);
66
+ }
67
+ catch (err) {
68
+ if (err instanceof CliError && err.code === 'not_found')
69
+ fail('not_found', `没有找到 Skill:${id}`, `运行 funcoding search <关键词> 查找`);
70
+ throw err;
71
+ }
72
+ }
73
+ // ---------------- info ----------------
74
+ export async function skillInfo(spec) {
75
+ const s = await fetchSkill(spec);
76
+ const page = siteUrl(`/skills/${s.id}/`);
77
+ const scripts = s.files.filter((f) => /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|ps1|bat|cmd)$/i.test(f.path)).map((f) => f.path);
78
+ done({ skill: { id: s.id, name: s.name, description: s.description, folder: s.folder, repo: s.repo.url, path: s.path, stars: s.stars, official: s.official, page, files: s.files, truncated: s.truncated, scripts, content: s.content } }, () => {
79
+ info(`${bold(s.name)} ${dim(`${s.id} · ★ ${stars(s.stars)}${s.official ? ' · 官方' : ''}`)}`);
80
+ if (s.description)
81
+ info(s.description);
82
+ info(dim(`${s.repo.url}${s.path ? `/tree/${s.repo.branch}/${s.path}` : ''}`));
83
+ info(dim(page));
84
+ info('');
85
+ info(bold(`文件(${s.files.length}${s.truncated ? ',未列全' : ''})`));
86
+ for (const f of s.files)
87
+ info(` ${f.path}${scripts.includes(f.path) ? dim(' 可执行脚本') : ''}`);
88
+ info('');
89
+ info(bold('SKILL.md'));
90
+ info(s.content);
91
+ });
92
+ }
93
+ /** 下载 Skill 的全部文件到 dir(dir 不能已存在) */
94
+ async function downloadTo(skill, dir) {
95
+ let total = 0;
96
+ await mapLimit(skill.files, 4, async (f) => {
97
+ const target = resolveInside(dir, f.path);
98
+ if (!target)
99
+ throw new CliError('error', `拒绝写入可疑路径:${f.path}`);
100
+ const url = skill.rawBase + f.path.split('/').map(encodeURIComponent).join('/');
101
+ const data = await download(url, MAX_BYTES);
102
+ total += data.byteLength;
103
+ if (total > MAX_BYTES)
104
+ throw new CliError('error', 'Skill 总大小超过 10 MB,请手动安装。', `仓库地址:${skill.repo.url}`);
105
+ await mkdir(dirname(target), { recursive: true });
106
+ await writeFile(target, data);
107
+ });
108
+ const md = await readFile(join(dir, 'SKILL.md'));
109
+ const meta = { id: skill.id, name: skill.name, repo: skill.repo.url, path: skill.path, branch: skill.repo.branch, installedAt: new Date().toISOString(), cli: USER_AGENT, skillHash: sha256(md) };
110
+ await writeFile(join(dir, META_FILE), `${JSON.stringify(meta, null, 2)}\n`);
111
+ }
112
+ /** 装到多个目标:只下载一次,其余目标复制 */
113
+ export async function installSkill(skill, targets, force) {
114
+ if (!isSafeFolderName(skill.folder))
115
+ fail('error', `服务端返回的目录名不合法:${skill.folder}`);
116
+ if (skill.files.length === 0 || !skill.files.some((f) => f.path === 'SKILL.md'))
117
+ fail('not_found', `${skill.id} 没有 SKILL.md,可能已被仓库删除。`);
118
+ if (skill.files.length > MAX_FILES)
119
+ fail('error', `文件太多(${skill.files.length} 个),请手动安装。`, `仓库地址:${skill.repo.url}`);
120
+ if (skill.truncated)
121
+ warn(`${skill.id} 的文件太多,只会安装前一部分,缺少的请从仓库补齐。`);
122
+ const plan = targets.map((t) => ({ ...t, root: skillsRoot(t.agent, t.scope), dest: join(skillsRoot(t.agent, t.scope), skill.folder) }));
123
+ if (!force) {
124
+ const taken = [];
125
+ for (const p of plan)
126
+ if (await exists(p.dest))
127
+ taken.push(p.dest);
128
+ if (taken.length > 0)
129
+ fail('exists', `已经存在:${taken.join('、')}`, '确认要覆盖就加 --force(旧版本会备份到配置目录)', { paths: taken });
130
+ }
131
+ info(`${bold(skill.name)} ${dim(`${skill.repo.owner}/${skill.repo.name}${skill.path ? `/${skill.path}` : ''} · ${skill.files.length} 个文件`)}`);
132
+ const results = [];
133
+ let source = null;
134
+ for (const p of plan) {
135
+ await mkdir(p.root, { recursive: true });
136
+ const tmp = join(p.root, `.funcoding-tmp-${randomBytes(4).toString('hex')}`);
137
+ try {
138
+ if (source)
139
+ await cp(source, tmp, { recursive: true });
140
+ else
141
+ await downloadTo(skill, tmp);
142
+ const backup = (await exists(p.dest)) ? await moveAway(p.dest, p.agent, skill.folder) : undefined;
143
+ await rename(tmp, p.dest);
144
+ source = p.dest;
145
+ results.push({ id: skill.id, name: skill.name, agent: p.agent, scope: p.scope, dir: p.dest, files: skill.files.length, ...(backup ? { backup } : {}) });
146
+ }
147
+ catch (err) {
148
+ await rm(tmp, { recursive: true, force: true });
149
+ throw err;
150
+ }
151
+ }
152
+ return results;
153
+ }
154
+ function printInstalled(r) {
155
+ const a = AGENTS[r.agent];
156
+ info(green(`✓ 已安装到 ${r.dir}`));
157
+ if (r.backup)
158
+ info(dim(` 旧版本备份在 ${r.backup}`));
159
+ info(` 在 ${a.label} 里输入 ${cyan(a.invoke(r.dir.split(/[\\/]/).pop()))} 调用,或直接描述任务让它自动选用。`);
160
+ }
161
+ export async function install(specs, targets, force) {
162
+ if (specs.length === 0)
163
+ fail('usage', '缺少要安装的 Skill。', '例如 funcoding install anthropics/skills/pdf');
164
+ const installed = [];
165
+ for (const spec of specs)
166
+ installed.push(...(await installSkill(await fetchSkill(spec), targets, force)));
167
+ done({ installed }, () => {
168
+ for (const r of installed)
169
+ printInstalled(r);
170
+ info(dim('Skill 会按 SKILL.md 的指示行事,可能运行其中的脚本,使用前请先阅读 SKILL.md。'));
171
+ });
172
+ }
173
+ const frontmatter = (text, key) => new RegExp(`^${key}:\\s*["']?(.+?)["']?\\s*$`, 'm').exec(/^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? '')?.[1];
174
+ async function scan(agent, scope) {
175
+ const root = skillsRoot(agent, scope);
176
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
177
+ const out = [];
178
+ for (const e of entries) {
179
+ if (!e.isDirectory() || e.name.startsWith('.'))
180
+ continue;
181
+ const dir = join(root, e.name);
182
+ const md = await readFile(join(dir, 'SKILL.md'), 'utf8').catch(() => null);
183
+ if (md === null)
184
+ continue;
185
+ const source = await readFile(join(dir, META_FILE), 'utf8')
186
+ .then((t) => JSON.parse(t))
187
+ .catch(() => null);
188
+ out.push({ agent, scope, folder: e.name, dir, name: frontmatter(md, 'name') ?? e.name, description: frontmatter(md, 'description') ?? '', source });
189
+ }
190
+ return out.sort((a, b) => a.folder.localeCompare(b.folder));
191
+ }
192
+ export async function scanAll(sel) {
193
+ const out = [];
194
+ for (const agent of sel.agents)
195
+ for (const scope of sel.scopes)
196
+ out.push(...(await scan(agent, scope)));
197
+ return out;
198
+ }
199
+ export async function list(sel) {
200
+ const items = await scanAll(sel);
201
+ done({ skills: items }, () => {
202
+ if (items.length === 0)
203
+ return info(`还没有安装 Skill。可以运行 ${cyan('funcoding search <关键词>')} 查找。`);
204
+ for (const agent of AGENT_IDS) {
205
+ for (const scope of sel.scopes) {
206
+ const group = items.filter((s) => s.agent === agent && s.scope === scope);
207
+ if (group.length === 0)
208
+ continue;
209
+ info(`${bold(AGENTS[agent].label)} ${dim(`${scope === 'user' ? '个人' : '项目'} · ${skillsRoot(agent, scope)}`)}`);
210
+ for (const s of group)
211
+ info(` ${s.folder}${s.source ? dim(` ← ${s.source.id}`) : ''}`);
212
+ info('');
213
+ }
214
+ }
215
+ });
216
+ }
217
+ // ---------------- update ----------------
218
+ const matches = (s, name) => !name || s.folder === name || s.source?.id === name.toLowerCase();
219
+ /** 按 .funcoding.json 记录的来源重新安装;check 时只比较远端 SKILL.md 有没有变化 */
220
+ export async function update(name, sel, check) {
221
+ const items = (await scanAll(sel)).filter((s) => s.source && matches(s, name));
222
+ if (items.length === 0)
223
+ fail('not_found', name ? `没有找到由 funcoding 安装的 ${name}` : '没有由 funcoding 安装的 Skill', '运行 funcoding list 查看已安装的 Skill');
224
+ const results = [];
225
+ const cache = new Map();
226
+ for (const s of items) {
227
+ const id = s.source.id;
228
+ if (s.source.builtin) {
229
+ const local = await readFile(join(s.dir, 'SKILL.md'), 'utf8').catch(() => null);
230
+ if (check)
231
+ results.push({ id, agent: s.agent, scope: s.scope, dir: s.dir, status: local === (await builtinContent()) ? 'up_to_date' : 'outdated' });
232
+ else {
233
+ await installBuiltin([{ agent: s.agent, scope: s.scope }], true);
234
+ results.push({ id, agent: s.agent, scope: s.scope, dir: s.dir, status: 'updated' });
235
+ }
236
+ continue;
237
+ }
238
+ const skill = cache.get(id) ?? (await fetchSkill(id));
239
+ cache.set(id, skill);
240
+ if (check) {
241
+ const local = await readFile(join(s.dir, 'SKILL.md')).catch(() => null);
242
+ const remote = await download(`${skill.rawBase}SKILL.md`, MAX_BYTES).catch(() => null);
243
+ const status = !local || !remote ? 'unknown' : sha256(local) === sha256(remote) ? 'up_to_date' : 'outdated';
244
+ results.push({ id, agent: s.agent, scope: s.scope, dir: s.dir, status });
245
+ continue;
246
+ }
247
+ await installSkill(skill, [{ agent: s.agent, scope: s.scope }], true);
248
+ results.push({ id, agent: s.agent, scope: s.scope, dir: s.dir, status: 'updated' });
249
+ }
250
+ done({ skills: results }, () => {
251
+ const label = { updated: green('已更新'), up_to_date: dim('已是最新'), outdated: cyan('有更新'), unknown: dim('无法比较') };
252
+ for (const r of results)
253
+ info(`${label[r.status]} ${r.id} ${dim(r.dir)}`);
254
+ if (check && results.some((r) => r.status === 'outdated'))
255
+ info(dim(`运行 ${cyan('funcoding update')} 更新。`));
256
+ });
257
+ }
258
+ // ---------------- remove ----------------
259
+ export async function remove(names, sel, force) {
260
+ if (names.length === 0)
261
+ fail('usage', '缺少要删除的 Skill。', '运行 funcoding list 查看已安装的 Skill');
262
+ const all = await scanAll(sel);
263
+ const removed = [];
264
+ for (const name of names) {
265
+ const found = all.filter((s) => s.folder === name || s.source?.id === name.toLowerCase());
266
+ if (found.length === 0)
267
+ fail('not_found', `没有找到已安装的 ${name}`, '运行 funcoding list 查看已安装的 Skill');
268
+ const manual = found.filter((s) => !s.source);
269
+ if (manual.length > 0 && !force)
270
+ fail('usage', `${manual.map((s) => s.dir).join('、')} 不是由 funcoding 安装的。`, '确认要删除就加 --force(会先备份到配置目录)', { paths: manual.map((s) => s.dir) });
271
+ for (const s of found)
272
+ removed.push({ id: s.source?.id ?? null, agent: s.agent, scope: s.scope, dir: s.dir, backup: await moveAway(s.dir, s.agent, s.folder) });
273
+ }
274
+ done({ removed }, () => {
275
+ for (const r of removed)
276
+ info(`${green('✓ 已删除')} ${r.dir} ${dim(`(备份在 ${r.backup})`)}`);
277
+ });
278
+ }
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 search <关键词> 查找');
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
+ }