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/README.md +107 -2
- package/dist/agents.js +55 -0
- package/dist/api.js +51 -0
- package/dist/auth.js +99 -0
- package/dist/cli.js +317 -0
- package/dist/config-files.js +84 -0
- package/dist/content.js +100 -0
- package/dist/credentials.js +30 -0
- package/dist/info.js +40 -0
- package/dist/init.js +90 -0
- package/dist/install.js +278 -0
- package/dist/mcp.js +265 -0
- package/dist/paths.js +18 -0
- package/dist/setup.js +78 -0
- package/dist/sync.js +86 -0
- package/dist/ui.js +65 -0
- package/package.json +41 -4
- package/skill/funcoding/SKILL.md +96 -0
|
@@ -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
|
+
}
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** skills find / mcp find / 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
|
+
/** skills find / mcp find */
|
|
17
|
+
export async function find(kind, query, limit) {
|
|
18
|
+
const cmd = kind === 'skill' ? 'skills' : 'mcp';
|
|
19
|
+
if (!query)
|
|
20
|
+
fail('usage', '缺少关键词。', `例如 funcoding ${cmd} find pdf`);
|
|
21
|
+
const r = await api(`/api/cli/search?q=${encodeURIComponent(query)}&limit=${limit}`);
|
|
22
|
+
const items = kind === 'skill' ? r.skills : r.mcp;
|
|
23
|
+
done({ [kind === 'skill' ? 'skills' : 'servers']: items }, () => {
|
|
24
|
+
if (items.length === 0)
|
|
25
|
+
return info(`没有找到「${query}」相关的${kind === 'skill' ? ' Skill' : ' MCP Server'}。`);
|
|
26
|
+
printCards(kind === 'skill' ? 'Skills' : 'MCP Server', items, (c) => `funcoding ${cmd} add ${c.id}`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function printStory(s, n) {
|
|
30
|
+
info(`${n ? `${String(n).padStart(2)}. ` : ' · '}${bold(s.title)}${dim(` ${s.sourceCount} 家报道`)}`);
|
|
31
|
+
if (s.summary)
|
|
32
|
+
info(` ${truncate(s.summary, (process.stdout.columns || 100) - 6)}`);
|
|
33
|
+
info(dim(` ${s.url}`));
|
|
34
|
+
}
|
|
35
|
+
export async function newsDaily(date) {
|
|
36
|
+
const day = date || 'latest';
|
|
37
|
+
if (day !== 'latest' && !/^\d{4}-\d{2}-\d{2}$/.test(day))
|
|
38
|
+
fail('usage', `日期要写成 YYYY-MM-DD:${day}`);
|
|
39
|
+
const issue = await api(`/api/cli/news?daily=${day}`);
|
|
40
|
+
done({ daily: issue }, () => {
|
|
41
|
+
info(bold(`AI 日报 · 第 ${issue.no} 期 · ${issue.day}`));
|
|
42
|
+
info(dim(issue.url));
|
|
43
|
+
info('');
|
|
44
|
+
info(bold('头条'));
|
|
45
|
+
printStory(issue.headline);
|
|
46
|
+
for (const sec of issue.sections) {
|
|
47
|
+
info('');
|
|
48
|
+
info(bold(sec.label));
|
|
49
|
+
for (const s of sec.stories)
|
|
50
|
+
printStory(s);
|
|
51
|
+
}
|
|
52
|
+
if (issue.flash.length > 0) {
|
|
53
|
+
info('');
|
|
54
|
+
info(bold('快讯'));
|
|
55
|
+
for (const s of issue.flash)
|
|
56
|
+
info(` · ${s.title} ${dim(s.url)}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function newsList(limit) {
|
|
61
|
+
const r = await api(`/api/cli/news?limit=${limit}`);
|
|
62
|
+
done({ updatedAt: r.updatedAt, stories: r.stories }, () => {
|
|
63
|
+
if (r.stories.length === 0)
|
|
64
|
+
return info('暂无热点。');
|
|
65
|
+
info(bold('AI 热榜') + dim(` ${siteUrl('/news/')}`));
|
|
66
|
+
r.stories.forEach((s, i) => printStory(s, i + 1));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function docsSearch(query, agent, limit) {
|
|
70
|
+
if (!query)
|
|
71
|
+
fail('usage', '缺少搜索词。', '例如 funcoding docs search hooks --agent=claude-code');
|
|
72
|
+
const params = new URLSearchParams({ q: query, limit: String(limit), ...(agent ? { agent } : {}) });
|
|
73
|
+
const r = await api(`/api/cli/docs/search?${params}`);
|
|
74
|
+
done({ docs: r.docs }, () => {
|
|
75
|
+
if (r.docs.length === 0)
|
|
76
|
+
return info(`没有找到「${query}」相关的文档。`);
|
|
77
|
+
for (const d of r.docs) {
|
|
78
|
+
info(`${cyan(d.id)} ${bold(d.title)} ${dim(d.agent)}`);
|
|
79
|
+
if (d.description)
|
|
80
|
+
info(` ${truncate(d.description, (process.stdout.columns || 100) - 4)}`);
|
|
81
|
+
}
|
|
82
|
+
info('');
|
|
83
|
+
info(dim(`运行 ${cyan('funcoding docs fetch <id>')} 阅读全文。`));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export async function docsFetch(id) {
|
|
87
|
+
const path = id
|
|
88
|
+
.trim()
|
|
89
|
+
.replace(/^https?:\/\/[^/]+/, '')
|
|
90
|
+
.replace(/^\/?agents\//, '')
|
|
91
|
+
.replace(/^\/+|\/+$/g, '');
|
|
92
|
+
if (!/^[a-z0-9][\w-]*(\/[\w.-]+)*$/i.test(path))
|
|
93
|
+
fail('usage', '文档要写成 <智能体>/<路径>,例如 claude-code/hooks。', '运行 funcoding docs search <关键词> 查找');
|
|
94
|
+
const markdown = await api(`/api/cli/docs/${path}`, { headers: { Accept: 'text/markdown' } }).catch((err) => {
|
|
95
|
+
if (err instanceof CliError && err.code === 'not_found')
|
|
96
|
+
fail('not_found', `没有找到文档:${path}`, '运行 funcoding docs search <关键词> 查找');
|
|
97
|
+
throw err;
|
|
98
|
+
});
|
|
99
|
+
done({ id: path, url: siteUrl(`/agents/${path}/`), markdown }, () => info(markdown));
|
|
100
|
+
}
|
|
@@ -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 });
|
package/dist/info.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** info:环境信息,只读本机状态,不访问网络 */
|
|
2
|
+
import { AGENT_IDS, AGENTS, currentAgent, installedAgents, skillsRoot } from './agents.js';
|
|
3
|
+
import { SITE, VERSION } from './api.js';
|
|
4
|
+
import { configDir, credentialsPath, loadCredentials } from './credentials.js';
|
|
5
|
+
import { onPath } from './init.js';
|
|
6
|
+
import { bold, dim, done, fail, info } from './ui.js';
|
|
7
|
+
export async function envInfo(field) {
|
|
8
|
+
const creds = await loadCredentials(SITE);
|
|
9
|
+
const found = installedAgents();
|
|
10
|
+
const data = {
|
|
11
|
+
version: VERSION,
|
|
12
|
+
site: SITE,
|
|
13
|
+
node: process.versions.node,
|
|
14
|
+
/** 是否已全局安装(终端里能直接用 funcoding / fun) */
|
|
15
|
+
global: onPath('funcoding'),
|
|
16
|
+
user: creds?.user ?? null,
|
|
17
|
+
currentAgent: currentAgent(),
|
|
18
|
+
agents: AGENT_IDS.map((id) => ({ id, installed: found.includes(id), skills: { user: skillsRoot(id, 'user'), project: skillsRoot(id, 'project') } })),
|
|
19
|
+
configDir: configDir(),
|
|
20
|
+
credentials: credentialsPath(),
|
|
21
|
+
};
|
|
22
|
+
if (field) {
|
|
23
|
+
if (!(field in data))
|
|
24
|
+
fail('usage', `没有这一项:${field}`, `可选:${Object.keys(data).join('、')}`);
|
|
25
|
+
const value = data[field];
|
|
26
|
+
return done({ [field]: value }, () => info(typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : String(value)));
|
|
27
|
+
}
|
|
28
|
+
done(data, () => {
|
|
29
|
+
const row = (k, v) => info(`${bold(k.padEnd(14))}${v}`);
|
|
30
|
+
row('version', VERSION);
|
|
31
|
+
row('site', SITE);
|
|
32
|
+
row('node', process.versions.node);
|
|
33
|
+
row('global', data.global ? '已全局安装(funcoding / fun)' : '未全局安装,运行 npx --yes funcoding-cli@latest init');
|
|
34
|
+
row('user', creds?.user ? `${creds.user.name}${creds.user.githubLogin ? ` (@${creds.user.githubLogin})` : ''}` : '未登录');
|
|
35
|
+
row('currentAgent', data.currentAgent ? AGENTS[data.currentAgent].label : '无');
|
|
36
|
+
for (const id of AGENT_IDS)
|
|
37
|
+
row(id, `${found.includes(id) ? '已安装' : dim('未检测到')} ${dim(skillsRoot(id, 'user'))}`);
|
|
38
|
+
row('configDir', configDir());
|
|
39
|
+
});
|
|
40
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init:
|
|
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
|
+
const EACCES_DOC = '权限不足时参考 https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally';
|
|
26
|
+
/** 全局安装当前版本;npm 的输出写到 stderr,不影响 --json 的 stdout */
|
|
27
|
+
function installGlobal() {
|
|
28
|
+
if (onPath('funcoding'))
|
|
29
|
+
return { status: 'present' };
|
|
30
|
+
const pkg = `funcoding-cli@${VERSION}`;
|
|
31
|
+
info(dim(`正在全局安装 ${pkg},之后可以直接使用 funcoding 或 fun 命令…`));
|
|
32
|
+
const r = spawnSync('npm', ['install', '--global', pkg], { stdio: ['ignore', process.stderr, process.stderr], shell: isWindows });
|
|
33
|
+
if (r.status === 0)
|
|
34
|
+
return { status: 'installed' };
|
|
35
|
+
return { status: 'failed', hint: `手动运行 npm install -g ${pkg}(${EACCES_DOC})` };
|
|
36
|
+
}
|
|
37
|
+
/** update:全局安装最新版,再用新版刷新已安装的 funcoding Skill */
|
|
38
|
+
export function selfUpdate() {
|
|
39
|
+
info(dim('正在用 npm 全局安装 funcoding-cli@latest…'));
|
|
40
|
+
const r = spawnSync('npm', ['install', '--global', 'funcoding-cli@latest'], { stdio: ['ignore', process.stderr, process.stderr], shell: isWindows });
|
|
41
|
+
if (r.status !== 0)
|
|
42
|
+
fail('error', '更新失败。', `手动运行 npm install -g funcoding-cli@latest(${EACCES_DOC})`);
|
|
43
|
+
const version = spawnSync('funcoding', ['--version'], { encoding: 'utf8', shell: isWindows }).stdout?.trim() || null;
|
|
44
|
+
// 让新版本把随包的 funcoding Skill 复制过去;没装过这个 Skill 时会报未找到,忽略
|
|
45
|
+
spawnSync('funcoding', ['skills', 'update', BUILTIN_ID, '--json'], { stdio: 'ignore', shell: isWindows });
|
|
46
|
+
done({ from: VERSION, to: version }, () => info(green(version && version !== VERSION ? `✓ 已从 ${VERSION} 更新到 ${version}` : `✓ 已是最新版本 ${version ?? VERSION}`)));
|
|
47
|
+
}
|
|
48
|
+
export const BUILTIN_ID = 'funcoding';
|
|
49
|
+
const SOURCE = new URL('../skill/funcoding/SKILL.md', import.meta.url);
|
|
50
|
+
export const builtinContent = () => readFile(SOURCE, 'utf8');
|
|
51
|
+
export async function installBuiltin(targets, force) {
|
|
52
|
+
const content = await builtinContent();
|
|
53
|
+
const dirs = targets.map((t) => ({ ...t, dir: join(skillsRoot(t.agent, t.scope), BUILTIN_ID) }));
|
|
54
|
+
const foreign = [];
|
|
55
|
+
for (const d of dirs) {
|
|
56
|
+
const meta = await readFile(join(d.dir, META_FILE), 'utf8').catch(() => null);
|
|
57
|
+
const present = meta !== null || (await stat(d.dir).then(() => true, () => false));
|
|
58
|
+
// 之前 init 装的直接换成新版;同名的其他 Skill 要 --force,并且先备份
|
|
59
|
+
if (present && !(meta && JSON.parse(meta).builtin))
|
|
60
|
+
foreign.push(d);
|
|
61
|
+
}
|
|
62
|
+
if (foreign.length > 0 && !force)
|
|
63
|
+
fail('exists', `${foreign.map((d) => d.dir).join('、')} 已存在,且不是 funcoding init 安装的。`, '确认要覆盖就加 --force(会先备份到配置目录)');
|
|
64
|
+
for (const d of dirs) {
|
|
65
|
+
const tmp = join(dirname(d.dir), `.funcoding-tmp-${randomBytes(4).toString('hex')}`);
|
|
66
|
+
await mkdir(tmp, { recursive: true });
|
|
67
|
+
await writeFile(join(tmp, 'SKILL.md'), content);
|
|
68
|
+
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`);
|
|
69
|
+
if (foreign.includes(d))
|
|
70
|
+
await moveAway(d.dir, d.agent, BUILTIN_ID);
|
|
71
|
+
else
|
|
72
|
+
await rm(d.dir, { recursive: true, force: true });
|
|
73
|
+
await rename(tmp, d.dir);
|
|
74
|
+
}
|
|
75
|
+
return dirs;
|
|
76
|
+
}
|
|
77
|
+
export async function init(targets, force, global) {
|
|
78
|
+
const cli = global ? installGlobal() : { status: 'skipped' };
|
|
79
|
+
const dirs = await installBuiltin(targets, force);
|
|
80
|
+
done({ cli, installed: dirs.map((d) => ({ id: BUILTIN_ID, agent: d.agent, scope: d.scope, dir: d.dir })) }, () => {
|
|
81
|
+
if (cli.status === 'installed')
|
|
82
|
+
info(green(`✓ 已全局安装 funcoding-cli,在终端里输入 ${cyan('funcoding')} 或 ${cyan('fun')} 即可使用`));
|
|
83
|
+
if (cli.status === 'failed')
|
|
84
|
+
warn(`全局安装失败,${cli.hint}`);
|
|
85
|
+
for (const d of dirs)
|
|
86
|
+
info(green(`✓ 已把 funcoding Skill 装到 ${d.dir}`));
|
|
87
|
+
info(`之后可以直接让 ${dirs.map((d) => AGENTS[d.agent].label).join('、')} 帮你查找和安装 Skill、配置 MCP,例如「找一个处理 PDF 的 Skill 装上」。`);
|
|
88
|
+
info(dim(`也可以输入 ${[...new Set(dirs.map((d) => cyan(AGENTS[d.agent].invoke(BUILTIN_ID))))].join(' / ')} 主动调用。新开会话后生效。`));
|
|
89
|
+
});
|
|
90
|
+
}
|
package/dist/install.js
ADDED
|
@@ -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 './init.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 skills find <关键词> 查找');
|
|
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 skills add 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 skills find <关键词>')} 查找。`);
|
|
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 skills 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 skills update')} 更新。`));
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// ---------------- remove ----------------
|
|
259
|
+
export async function remove(names, sel, force) {
|
|
260
|
+
if (names.length === 0)
|
|
261
|
+
fail('usage', '缺少要删除的 Skill。', '运行 funcoding skills 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 skills 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
|
+
}
|