Files
ringo 1d1000ddf5
CI / contract (push) Has been cancelled
feat(hosts): WorkBuddy 接入(install-workbuddy:本机 stdio + 技能安装 + 握手自证)
WorkBuddy 自定义 MCP 支持 command/args/env(取证:其二进制 calculateConfigHash 先判 entry.command),因此走与 Codex 同款的本机 stdio full 工具面,不退远程引擎面。

落地:① 启动器多维护一个 MCP 稳定入口 ~/.deta/bin/deta-mcp-<档位>(指向当前插件副本,升级自动刷新;WB 信任哈希按命令行算,路径稳定=升级不重弹信任);② deta-cli 新增 install-workbuddy [--http]——备份并合并 ~/.workbuddy/mcp.json(同名旧条目与旧远程条目替换掉,防双工具面)、把插件内全部技能装进 ~/.workbuddy/skills/(单一事实源,带 icon.svg)、用宿主将执行的同一条命令做真实握手自证;③ aml-design 技能两处 Codex 特有措辞改宿主中立;④ 契约校验加 E7(装配源齐备 + README 指引);⑤ 探针 scripts/verify-workbuddy.sh(配置形态/稳定入口/技能/握手/WB 日志,5 项断言)。

实测:studio 0.3.16 已装副本重跑幂等通过(握手 39 工具);writer 0.1.7 在沙箱 HOME 验证(deta_writer + report-authoring 技能 + 握手 39 工具)。WB 侧需重启并点一次信任确认。
2026-09-20 17:13:15 +08:00

411 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* bin/deta-cli.mjs — DETA 命令行门面(登录 / 自检)
*
* 为什么需要:登录助手原先只在插件目录里(`<插件缓存>/bin/deta-login.mjs`),
* 普通用户既不知道插件装在哪,也没有稳定入口;工具回执里让用户「跑登录助手」时,
* 给不出一个可复制的命令。启动器会把本文件注册为 `~/.deta/bin/deta`(幂等维护),
* 于是各处统一口径就变成:
*
* ~/.deta/bin/deta login # 登录 deta.city(写 ~/.deta/credentials.json 与 env)
* ~/.deta/bin/deta status # 看凭据来源 / 运行时 / 工作区
* ~/.deta/bin/deta doctor # 端到端自检(运行时、凭据、编译服务、MCP 握手)
*
* 纪律:本文件**永不打印令牌**,只报告来源与长度;密码/验证码一律由 deta-login.mjs
* 在用户自己的终端里交互读取(不回显)。
*/
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, join, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const HOME = homedir();
const DETA_DIR = join(HOME, '.deta');
/** 产品档位:优先读插件清单的 name——开发态是 `plugins/studio`,而宿主缓存布局是
* `<市场>/<插件>/<版本>/`,从路径取目录名会把版本号当成产品名(实测踩过)。 */
function detectProfile() {
if (process.env.DETA_PROFILE) return process.env.DETA_PROFILE;
try {
const manifest = JSON.parse(readFileSync(join(HERE, '..', '.codex-plugin', 'plugin.json'), 'utf8'));
if (typeof manifest?.name === 'string' && manifest.name) return manifest.name;
} catch { /* 清单不可读时回落目录名(仅开发态正确) */ }
return basename(join(HERE, '..'));
}
const PROFILE = detectProfile();
function readToken() {
if (process.env.DETA_TOKEN) return { source: '环境变量 DETA_TOKEN', token: process.env.DETA_TOKEN };
const envPath = join(DETA_DIR, 'env');
if (existsSync(envPath)) {
const m = /DETA_TOKEN=('([^']*)'|"([^"]*)"|(\S+))/.exec(readFileSync(envPath, 'utf8'));
const token = (m?.[2] ?? m?.[3] ?? m?.[4] ?? '').trim();
if (token) return { source: envPath, token };
}
const credPath = join(DETA_DIR, 'credentials.json');
if (existsSync(credPath)) {
try {
const c = JSON.parse(readFileSync(credPath, 'utf8'));
if (typeof c.token === 'string' && c.token) return { source: credPath, token: c.token, meta: c };
} catch { /* 忽略损坏的凭据文件,按未登录处理 */ }
}
return { source: '', token: '' };
}
function cmdLogin(argv) {
const loginScript = join(HERE, 'deta-login.mjs');
if (!existsSync(loginScript)) {
console.error(`✗ 找不到登录助手:${loginScript}`);
process.exit(1);
}
// 直接交给登录助手,stdio 全继承:密码由用户在终端输入(本进程不经手)
const child = spawn(process.execPath, [loginScript, ...argv], { stdio: 'inherit' });
child.on('exit', (code) => process.exit(code ?? 1));
}
/**
* 运行时供给:插件包自带打包产物 → 安装到 `~/.deta/runtime`。
*
* 为什么随插件分发而不是 npm 安装:`@ringoarch/ant-copilot` 在私有 registry 上,匿名读 401,
* 走 npm 等于要求每台同事机器都配 token。打包产物是自包含单文件,插件包带上即可「装插件就能用」。
* 启动器每次拉起都会做同样的幂等检查(缺失/不一致就补)——本命令是它的**显式版 + 自证版**,
* 用于首次核对、补装可选依赖、以及排障(装完立刻做一次真实 MCP 握手)。
*/
/** 插件包内的运行时位置 */
function bundledRuntime() {
const dir = join(HERE, '..', 'runtime');
return { dir, entry: join(dir, 'dist', 'deta-mcp.mjs'), manifestPath: join(dir, 'manifest.json') };
}
function sha256File(p) {
try {
return createHash('sha256').update(readFileSync(p)).digest('hex');
} catch {
return '';
}
}
function readJson(p) {
try {
return JSON.parse(readFileSync(p, 'utf8'));
} catch {
return null;
}
}
/** 运行时溯源描述(给 status / doctor 用;永不打印凭据) */
function runtimeProvenance(root) {
const m = readJson(join(root, 'manifest.json'));
if (!m) return '';
const sha = sha256File(join(root, 'dist', 'deta-mcp.mjs'));
const verdict = sha && sha === m.sha256 ? 'sha 一致' : 'sha 不一致(运行时被改动或拷贝不完整,跑 deta install-runtime --force 重装)';
const size = typeof m.bytes === 'number' ? ` · ${(m.bytes / 1048576).toFixed(2)}MB` : '';
return `${m.source ?? '?'}@${m.version ?? '?'} (${m.gitSha ?? '?'})${size} · ${verdict}`;
}
/** 候选预览图的栅格化依赖(可选):缺失只影响候选预览图 */
function resvgReady(root) {
return existsSync(join(root, 'node_modules', '@resvg', 'resvg-js', 'package.json'));
}
async function cmdInstallRuntime(argv) {
const force = argv.includes('--force');
const noResvg = argv.includes('--no-resvg');
const b = bundledRuntime();
if (!existsSync(b.entry)) {
console.error(`✗ 插件包内没有运行时:${b.entry}`);
console.error(' 这是打包缺陷(插件应由 scripts/sync-runtime.mjs 带上运行时)。');
console.error(' 处置:重装插件(codex plugin remove <插件>@deta && codex plugin add <插件>@deta);');
console.error(' 开发态则在插件仓执行 node scripts/sync-runtime.mjs。');
process.exit(1);
}
const manifest = readJson(b.manifestPath) ?? {};
const srcSha = sha256File(b.entry);
if (manifest.sha256 && srcSha !== manifest.sha256) {
console.error(`✗ 插件包内运行时与 manifest 不一致(包内 sha ${String(srcSha).slice(0, 12)}… ≠ manifest ${String(manifest.sha256).slice(0, 12)}…)`);
console.error(' 处置:在插件仓重跑 node scripts/sync-runtime.mjs 并重装插件。');
process.exit(1);
}
const target = join(DETA_DIR, 'runtime');
const targetEntry = join(target, 'dist', 'deta-mcp.mjs');
const installedSha = sha256File(targetEntry);
const same = installedSha && installedSha === srcSha;
const sizeMb = (statSync(b.entry).size / 1048576).toFixed(2);
if (same && !force) {
console.log(`✓ 运行时已是最新:${target}(${sizeMb}MB · ${b.manifestPath ? `来自 ${manifest.source ?? '?'}@${manifest.version ?? '?'} (${manifest.gitSha ?? '?'})` : ''})`);
} else {
mkdirSync(join(target, 'dist'), { recursive: true });
copyFileSync(b.entry, targetEntry);
if (existsSync(b.manifestPath)) copyFileSync(b.manifestPath, join(target, 'manifest.json'));
const verify = sha256File(targetEntry);
if (verify !== srcSha) {
console.error(`✗ 安装后校验失败:${targetEntry} 与源不一致`);
process.exit(1);
}
console.log(`✓ 已安装运行时到 ${target}(${sizeMb}MB · sha ${srcSha.slice(0, 12)}… · 来自 ${manifest.source ?? '?'}@${manifest.version ?? '?'} (${manifest.gitSha ?? '?'}))`);
}
// 可选依赖:候选预览图的 SVG 栅格化(缺了只是没有预览图,不影响其他能力)
if (noResvg) {
console.log('· 已按 --no-resvg 跳过 @resvg/resvg-js(候选预览图不可用)');
} else if (resvgReady(target) && !force) {
console.log('✓ 候选预览图依赖 @resvg/resvg-js 已就绪');
} else {
console.log('· 正在安装候选预览图依赖 @resvg/resvg-js(可选,需要联网访问公共 npm)…');
const r = spawnSync('npm', ['i', '--prefix', target, '--no-save', '--no-audit', '--no-fund', '--loglevel=error', '@resvg/resvg-js@^2'], { stdio: 'inherit' });
if (r.error || r.status !== 0) {
console.log('! @resvg/resvg-js 未装上(自动安装失败)——其余能力不受影响,仅候选预览图不可用。');
console.log(` 手动补装:npm i --prefix "${target}" @resvg/resvg-js@^2`);
if (r.error) console.log(` 原因:${r.error.message}`);
} else {
console.log('✓ 候选预览图依赖 @resvg/resvg-js 已就绪');
}
}
// 装完立刻做一次真实 MCP 握手——「装上了」不等于「起得来」
const launcher = join(HERE, 'deta-mcp.sh');
const r = await smokeMcp('bash', [launcher, '--profile', PROFILE]);
if (r.ok) {
console.log(`✓ MCP 握手通过(--profile ${PROFILE}):${r.tools} 个工具${r.tokenLine ? ` · ${r.tokenLine.trim()}` : ''}`);
console.log('\n宿主里的下一步:退出并重启宿主应用(或开新会话)即可看到工具。');
} else {
console.error(`✗ MCP 握手失败:${r.detail}`);
console.error(' 排障:~/.deta/bin/deta doctor;仍不行时看启动器输出的运行时路径是否正确。');
process.exit(1);
}
}
function cmdStatus() {
const { source, token, meta } = readToken();
console.log(`凭据:${token ? `已存在(来源 ${source},长度 ${token.length})` : '未登录'}`);
if (meta?.username) console.log(`账号:${meta.username}(登录于 ${meta.loginAt ?? '未知'})`);
console.log(`工作区:${process.env.DETA_WORKSPACE || join(DETA_DIR, PROFILE, 'workspace')}`);
const runtime = resolveRuntime();
console.log(`运行时:${runtime || '未找到(插件包自带运行时,正常应由启动器自动安装到 ~/.deta/runtime)'}`);
if (runtime) {
const prov = runtimeProvenance(runtime);
console.log(` · 来源:${prov || '开发检出(非打包运行时,仅开发机可见)'}`);
console.log(` · 候选预览图依赖 @resvg/resvg-js:${resvgReady(runtime) ? '已就绪' : '缺失(跑 deta install-runtime 补装;不影响其他能力)'}`);
}
if (!token) console.log('\n未登录时云类工具(项目列表 / 同步 / 打开 Studio)会返回 auth_required;跑 `deta login` 即可。');
}
/** 运行时解析,与 bin/deta-mcp.sh 保持同一顺序 */
function resolveRuntime() {
const candidates = [
process.env.DETA_SERVER_ROOT || '',
join(DETA_DIR, 'runtime'),
join(HOME, 'Dev/ant/ant-copilot'),
].filter(Boolean);
for (const c of candidates) {
if (existsSync(join(c, 'dist/deta-mcp.mjs')) || existsSync(join(c, 'dist/src/headless/mcp-server.js'))) return c;
}
return '';
}
function smokeMcp(cmd, args) {
return new Promise((resolveP) => {
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
let err = '';
let done = false;
const finish = (verdict) => { if (!done) { done = true; child.kill(); resolveP(verdict); } };
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('error', (e) => finish({ ok: false, detail: `无法启动:${e.message}` }));
const send = (o) => { try { child.stdin.write(JSON.stringify(o) + '\n'); } catch { /* 进程已退出 */ } };
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'deta-doctor', version: '1' } } });
setTimeout(() => send({ jsonrpc: '2.0', method: 'notifications/initialized' }), 150);
setTimeout(() => send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), 400);
setTimeout(() => {
const lines = out.split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
const init = lines.find((x) => x.id === 1)?.result;
const list = lines.find((x) => x.id === 2)?.result;
const tokenLine = err.split('\n').find((l) => l.includes('云令牌')) || '';
if (!init) return finish({ ok: false, detail: 'MCP 未应答 initialize' });
if (!list) return finish({ ok: false, detail: 'MCP 未应答 tools/list' });
finish({ ok: true, server: init.serverInfo, tools: (list.tools ?? []).length, tokenLine });
}, 5000);
});
}
async function cmdDoctor() {
let failed = 0;
const say = (ok, label, detail) => { console.log(`${ok ? '✓' : '✗'} ${label}${detail ? ` —— ${detail}` : ''}`); if (!ok) failed += 1; };
const runtime = resolveRuntime();
say(Boolean(runtime), '领域服务运行时', runtime || '未找到:插件包自带时应由启动器自动装到 ~/.deta/runtime;否则设 DETA_SERVER_ROOT');
if (runtime) {
console.log(` · 来源:${runtimeProvenance(runtime) || '开发检出(非打包运行时)'}`);
const hasResvg = resvgReady(runtime);
console.log(` · 候选预览图依赖 @resvg/resvg-js:${hasResvg ? '已就绪' : '缺失 —— 跑 deta install-runtime 补装(只影响候选预览图)'}`);
}
const { source, token } = readToken();
say(Boolean(token), '云凭据', token ? `已存在(来源 ${source},长度 ${token.length})` : '未登录:跑 `~/.deta/bin/deta login`');
const gsa = process.env.GSA_BASE_URL || 'http://localhost:8000';
let gsaOk = false;
try {
const res = await fetch(`${gsa}/health`, { signal: AbortSignal.timeout(4000) });
gsaOk = res.ok;
} catch { gsaOk = false; }
say(gsaOk, '编译服务 GSA', gsaOk ? `${gsa}/health 通` : `${gsa} 不可达(编译/统计类工具会失败)`);
if (runtime) {
const launcher = join(HERE, 'deta-mcp.sh');
const profile = PROFILE;
const r = await smokeMcp('bash', [launcher, '--profile', profile]);
say(r.ok, `MCP 握手(--profile ${profile})`, r.ok ? `${r.tools} 个工具 · ${r.server?.name ?? '?'}${r.tokenLine ? ` · ${r.tokenLine.trim()}` : ''}` : r.detail);
}
console.log(failed ? `\n自检完成:${failed} 项需要处理。` : '\n自检完成:全部通过。');
process.exit(failed ? 1 : 0);
}
/**
* 接入 WorkBuddy:把本档位的 MCP 以 stdio 写进 ~/.workbuddy/mcp.json,并把领域技能装进
* ~/.workbuddy/skills/。WorkBuddy 的自定义 MCP 支持 command/args/env 形态
* (取证:其配置哈希 calculateConfigHash 先判 entry.command),本机 stdio = 与 Codex 同款
* full 工具面。--http 回落为远程引擎面(适合没装本机运行时的机器)。
*/
async function cmdInstallWorkbuddy(argv) {
const useHttp = argv.includes('--http');
const wbDir = join(HOME, '.workbuddy');
if (!existsSync(wbDir)) {
console.error('✗ 未检测到 WorkBuddy(没有 ~/.workbuddy 目录)。');
console.error(' 请先安装并启动一次 WorkBuddy,再重跑本命令。');
process.exit(1);
}
// 1) 稳定入口(与启动器维护的那份内容一致;启动器未跑过时这里先写出来)
const binDir = join(DETA_DIR, 'bin');
const shim = join(binDir, `deta-mcp-${PROFILE}`);
const launcher = join(HERE, 'deta-mcp.sh');
const shimBody = '#!/bin/bash\n# DETA MCP 稳定入口(由插件自动维护,勿手工编辑)\n'
+ 'export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"\n'
+ `exec bash "${launcher}" --profile ${PROFILE} "$@"\n`;
mkdirSync(binDir, { recursive: true });
writeFileSync(shim, shimBody);
chmodSync(shim, 0o755);
// 2) 合并 ~/.workbuddy/mcp.json(备份 → 合并 → 写完再校验)
const mcpPath = join(wbDir, 'mcp.json');
const serverName = `deta_${PROFILE}`;
const entry = useHttp
? { type: 'streamable-http', url: 'https://deta.city/mcp-api/mcp', disabled: false }
: { type: 'stdio', command: shim, args: [], disabled: false };
let cfg = { mcpServers: {} };
if (existsSync(mcpPath)) {
const raw = readFileSync(mcpPath, 'utf8');
try {
cfg = JSON.parse(raw);
} catch (e) {
console.error(`✗ ${mcpPath} 不是合法 JSON:${e.message}`);
console.error(' 未做任何改动;请先手工修复或备份后删除该文件。');
process.exit(1);
}
copyFileSync(mcpPath, `${mcpPath}.bak-${Date.now()}`);
}
if (!cfg.mcpServers || typeof cfg.mcpServers !== 'object') cfg.mcpServers = {};
// 旧的远程条目与新的本机条目指向同一套工具 → 并存会出双工具面,替换之
const replaced = [];
if (!useHttp && cfg.mcpServers.deta_mcp) {
delete cfg.mcpServers.deta_mcp;
replaced.push('deta_mcp(远程引擎面)');
}
if (cfg.mcpServers[serverName]) replaced.push(`${serverName}(旧配置)`);
cfg.mcpServers[serverName] = entry;
writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + '\n');
console.log(`✓ MCP 已写入 ${mcpPath}:${serverName}(${useHttp ? '远程 streamable-http' : `本机 stdio → ${shim}`})`);
if (replaced.length) console.log(` · 已替换旧条目:${replaced.join('、')}(防同服务双工具面)`);
// 3) 技能:把插件内的全部技能原样装进 WB 技能目录(单一事实源,不另写一份;
// 各档位的技能不同——studio 是 aml-design,writer 是 report-authoring)
const skillsSrc = join(HERE, '..', 'skills');
const skillsDst = join(wbDir, 'skills');
const logo = join(HERE, '..', 'assets', 'logo.svg');
let installedSkills = 0;
if (existsSync(skillsSrc)) {
for (const name of readdirSync(skillsSrc)) {
const src = join(skillsSrc, name);
if (!existsSync(join(src, 'SKILL.md'))) continue;
const dst = join(skillsDst, name);
cpSync(src, dst, { recursive: true });
if (existsSync(logo)) copyFileSync(logo, join(dst, 'icon.svg'));
installedSkills += 1;
console.log(`✓ 技能已安装到 ${dst}`);
}
}
if (!installedSkills) {
console.log(`! 插件包内未找到技能(${skillsSrc} 为空,打包缺陷);已跳过技能安装`);
}
// 4) 自证:用 WorkBuddy 将要执行的同一条命令做一次真实握手
if (!useHttp) {
const r = await smokeMcp('bash', [shim]);
if (r.ok) {
console.log(`✓ 握手自证通过:${r.tools} 个工具(${r.server?.name ?? '?'})`);
} else {
console.error(`✗ 握手自证失败:${r.detail}`);
console.error(' 排障:~/.deta/bin/deta doctor');
process.exit(1);
}
} else {
try {
const res = await fetch('https://deta.city/mcp-api/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
});
const challenge = res.headers.get('www-authenticate') ?? '';
if (res.status === 401 && challenge.includes('resource_metadata')) {
console.log('✓ 远程端点在线且 OAuth 通道在位(401 + resource_metadata)');
} else {
console.log(`! 远程端点响应异常(HTTP ${res.status})——请检查网络后重试`);
}
} catch (e) {
console.log(`! 远程端点不可达:${e.message}`);
}
}
console.log('\nWorkBuddy 里的下一步:');
console.log(' 1) 退出并重启 WorkBuddy(或在设置里刷新 MCP 列表)');
console.log(` 2) 首次会弹「是否信任 MCP 服务器 ${serverName}」→ 点允许(路径稳定,插件升级后不再询问)`);
console.log(' 3) 开新对话,说「列出 deta 的能力」核对工具面');
console.log(' 4) 首次使用云能力时调 login 工具走浏览器授权(与 Codex 同一套,不需要终端)');
}
const [cmd = 'help', ...rest] = process.argv.slice(2);
switch (cmd) {
case 'login': cmdLogin(rest); break;
case 'status': cmdStatus(); break;
case 'doctor': await cmdDoctor(); break;
case 'install-runtime': await cmdInstallRuntime(rest); break;
case 'install-workbuddy': await cmdInstallWorkbuddy(rest); break;
case 'help':
case '--help':
case '-h':
console.log(`DETA 命令行(插件自动注册为 ~/.deta/bin/deta)
deta login [--identifier <账号>] [--email-code <邮箱>] 登录 deta.city(凭据写入 ~/.deta/)
deta install-runtime [--force] [--no-resvg] 安装/更新领域服务运行时 + 可选预览图依赖
deta install-workbuddy [--http] 接入 WorkBuddy(写 mcp.json + 装技能 + 握手自证)
deta status 查看凭据来源 / 运行时 / 工作区
deta doctor 端到端自检(运行时、凭据、GSA、MCP 握手)
运行时通常无需手动安装:启动器会在插件升级后自动同步到 ~/.deta/runtime。
登录后开新会话即可让宿主读到凭据(无需 source / launchctl / 重启应用)。`);
break;
default:
console.error(`未知命令:${cmd}(可用:login / install-runtime / install-workbuddy / status / doctor)`);
process.exit(2);
}