best-geo-test 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capabilities/capabilities.json +362 -0
- package/dist/api.js +111 -0
- package/dist/args.js +24 -0
- package/dist/build-env.js +7 -0
- package/dist/commands/auth.js +246 -0
- package/dist/commands/call.js +50 -0
- package/dist/commands/plan.js +68 -0
- package/dist/commands/session.js +25 -0
- package/dist/commands/simple.js +145 -0
- package/dist/config.js +60 -0
- package/dist/credentials.js +54 -0
- package/dist/device.js +43 -0
- package/dist/envelope.js +26 -0
- package/dist/host-error.js +48 -0
- package/dist/index.js +95 -0
- package/dist/local-server/index.js +196 -0
- package/dist/local-server/page.js +95 -0
- package/dist/store.js +141 -0
- package/guides/company.json +66 -0
- package/guides/product.json +64 -0
- package/guides/question.json +68 -0
- package/package.json +38 -0
- package/skills/best-geo/SKILL.md +107 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { callApi, isServerVerdict } from "../api.js";
|
|
3
|
+
import { COMMAND_NAME, CLI_VERSION } from "../config.js";
|
|
4
|
+
import { clearCredentials, clearPending, loadActive, loadApiKey, loadPending, promotePending, savePending, } from "../credentials.js";
|
|
5
|
+
import { deviceInfo } from "../device.js";
|
|
6
|
+
import { emit, fail, ok } from "../envelope.js";
|
|
7
|
+
import { startKeyIntake } from "../local-server/index.js";
|
|
8
|
+
import { configPath } from "../store.js";
|
|
9
|
+
/**
|
|
10
|
+
* 收敛可能存在的 pending 凭据(设计文档 §6.2 第 11 步)。
|
|
11
|
+
*
|
|
12
|
+
* 进程如果在「claim 已经成功、但还没提升为 active」之间被中断,本机就留下一个 pending:
|
|
13
|
+
* 服务端那边新密钥其实已经生效并顶替了旧密钥,本机却还在用旧的。
|
|
14
|
+
* 所以**任何用到凭据的命令都要先跑这里**,用同一个设备 ID 幂等重试 claim:
|
|
15
|
+
* 成功就提升为 active;确认失败(过期、换绑到别的设备)就清掉 pending,旧 active 继续可用
|
|
16
|
+
* —— claim 失败意味着服务端那次顶替没有发生。
|
|
17
|
+
*/
|
|
18
|
+
export async function settlePendingCredential() {
|
|
19
|
+
const pending = loadPending();
|
|
20
|
+
if (!pending) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const result = await claim(pending.apiKey);
|
|
24
|
+
if (result.ok) {
|
|
25
|
+
promotePending({
|
|
26
|
+
userId: result.data.user.id,
|
|
27
|
+
agentId: result.data.agentId,
|
|
28
|
+
companyIds: result.data.companyIds,
|
|
29
|
+
});
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!isServerVerdict(result)) {
|
|
33
|
+
// 没拿到服务端的终局裁决就**不能**动 pending:服务端那边可能已经绑好并撤销了旧密钥,
|
|
34
|
+
// 删掉 pending 等于把唯一的恢复依据丢了。这次命令直接失败,让调用方稍后重试。
|
|
35
|
+
// 判据是「是不是裁决」而不是「是不是网络错误」—— 服务端 500 同样什么都没告诉我们。
|
|
36
|
+
return fail("SERVER_ERROR", `本机有一份待确认的授权,暂时无法与服务端确认:${result.message}`, {
|
|
37
|
+
requestId: result.requestId,
|
|
38
|
+
nextAction: {
|
|
39
|
+
type: "ask_user",
|
|
40
|
+
message: "网络恢复后重试同一条命令即可自动收敛;在此之前不要重新登录,也不要删除本机配置文件。",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// 服务端明确拒绝(过期、已绑到别的设备等):这把 pending 永远不会生效,清掉。
|
|
45
|
+
// 旧 active 仍然有效 —— claim 失败意味着服务端那次顶替根本没发生
|
|
46
|
+
clearPending();
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 打开本地页面录入密钥并完成设备绑定。
|
|
51
|
+
*
|
|
52
|
+
* 顺序上有两条不能变:
|
|
53
|
+
* 1. **先做本机预检,再让用户录入**。配置目录读写不通就直接返回权限申请,
|
|
54
|
+
* 否则用户白粘贴一次密钥,最后还是存不下来。
|
|
55
|
+
* 2. **先 claim 成功,再把 pending 提升为 active**。反过来的话,
|
|
56
|
+
* 一把不可用的密钥会把原来能用的凭据覆盖掉,用户两头落空。
|
|
57
|
+
*/
|
|
58
|
+
export async function authLogin() {
|
|
59
|
+
// 预检:这行会读配置、必要时生成并原子写入设备 ID。权限有问题会在这里抛出,
|
|
60
|
+
// 由 index.ts 统一翻译成 HOST_PERMISSION_REQUIRED —— 此时页面还没开,用户也还没录入
|
|
61
|
+
const info = deviceInfo();
|
|
62
|
+
const unsettled = await settlePendingCredential();
|
|
63
|
+
if (unsettled) {
|
|
64
|
+
emit(unsettled);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const existing = loadApiKey();
|
|
68
|
+
let session;
|
|
69
|
+
try {
|
|
70
|
+
session = await startKeyIntake();
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// 起不来本地服务最常见的原因就是沙箱不让绑回环端口。
|
|
74
|
+
// 这是宿主能授权解决的事,所以给 request_host_approval 而不是笼统的 SERVER_ERROR
|
|
75
|
+
emit(fail("HOST_PERMISSION_REQUIRED", `无法在本机启动录入服务:${errorMessage(error)}`, {
|
|
76
|
+
nextAction: {
|
|
77
|
+
type: "request_host_approval",
|
|
78
|
+
message: "Best GEO 需要在 127.0.0.1 上监听一个随机端口来接收用户粘贴的密钥(不对外暴露)。" +
|
|
79
|
+
"请用当前工具的权限确认机制授权本机回环端口监听后重试同一条命令。",
|
|
80
|
+
capability: "loopback_port",
|
|
81
|
+
command: `${COMMAND_NAME} auth login`,
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
openBrowser(session.url);
|
|
87
|
+
// stderr 的第一段是写给调用方(大模型)的运行说明,不是给人看的提示。
|
|
88
|
+
// 不写清楚的话,模型看到命令迟迟不返回,会以为卡住而中断进程,或者干脆把命令甩给用户自己去终端执行 ——
|
|
89
|
+
// 后者等于把本机绑定这一步丢了,登录永远完不成。
|
|
90
|
+
const tag = `[${COMMAND_NAME}]`;
|
|
91
|
+
process.stderr.write(`${tag} auth login 已启动本机录入服务,会阻塞等待用户粘贴密钥,最长 5 分钟。\n` +
|
|
92
|
+
`${tag} 调用方请保持本进程运行直到它自行退出,不要中断、不要转交给用户在别处执行;\n` +
|
|
93
|
+
`${tag} 进程退出时 stdout 会给出唯一一个 JSON 结果,那时再向用户汇报成败。\n` +
|
|
94
|
+
`${tag} 现在请把下面这个地址转达给用户,让他在浏览器里打开并粘贴密钥(密钥不要经过你):\n`);
|
|
95
|
+
process.stderr.write(`${tag} 本地页面:${session.url}\n`);
|
|
96
|
+
// 本机已有凭据时先说清楚会发生什么。真正的撤销由服务端在 claim 事务里做,
|
|
97
|
+
// 而且只在新密钥验完之后
|
|
98
|
+
if (existing) {
|
|
99
|
+
process.stderr.write(`${tag} 本机已有一份授权,录入新密钥会替换它。\n`);
|
|
100
|
+
}
|
|
101
|
+
let key;
|
|
102
|
+
try {
|
|
103
|
+
key = await session.waitForKey;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
session.close();
|
|
107
|
+
emit(fail("AUTH_REQUIRED", "本地录入超时或被取消", {
|
|
108
|
+
nextAction: {
|
|
109
|
+
type: "auth_login",
|
|
110
|
+
message: "用户在 5 分钟内没有完成粘贴。先问清他是否已经拿到密钥,再由你重新执行 command," +
|
|
111
|
+
"并把新的本地页面地址转达给他。",
|
|
112
|
+
command: `${COMMAND_NAME} auth login`,
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// 密钥已到手,本机录入服务在这一刻自行关闭(见 local-server/index.ts)。
|
|
118
|
+
// 这条日志的作用是让模型知道「等待阶段结束了、进程正在收尾」,而不是以为它又挂住了。
|
|
119
|
+
process.stderr.write(`${tag} 已收到密钥,本机录入服务已关闭,正在向服务端校验并绑定本机,请继续等待本进程退出。\n`);
|
|
120
|
+
// 先落地为 pending 再 claim:claim 成功后进程若被中断,下一条命令还能靠 pending 幂等收敛。
|
|
121
|
+
// 写失败就别调 claim 了 —— 服务端把密钥绑到本设备、本机却存不下来,是最难解释的一种状态
|
|
122
|
+
savePending(key);
|
|
123
|
+
const result = await claim(key);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
// 和 settlePendingCredential 同一把尺子:只有服务端明确裁定这把密钥不可用才清掉它。
|
|
126
|
+
// 无条件清的话,服务端「已绑定但响应丢了」的情况下,用户刚粘贴的密钥会彻底消失,
|
|
127
|
+
// 而旧密钥可能已经被那次绑定撤销了 —— 两头落空,只能回网页重建
|
|
128
|
+
if (isServerVerdict(result)) {
|
|
129
|
+
clearPending();
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
process.stderr.write(`${tag} 未能确认绑定结果,已保留这把待确认的密钥。网络恢复后执行任意 ${COMMAND_NAME} 命令即可自动收敛。\n`);
|
|
133
|
+
}
|
|
134
|
+
emit(result);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const active = promotePending({
|
|
138
|
+
userId: result.data.user.id,
|
|
139
|
+
agentId: result.data.agentId,
|
|
140
|
+
companyIds: result.data.companyIds,
|
|
141
|
+
});
|
|
142
|
+
emit(ok({
|
|
143
|
+
user: result.data.user,
|
|
144
|
+
device: result.data.device,
|
|
145
|
+
companyIds: active?.companyIds ?? result.data.companyIds,
|
|
146
|
+
replacedPreviousAuthorization: result.data.replacedPreviousAuthorization,
|
|
147
|
+
configPath: configPath(),
|
|
148
|
+
}, result.requestId));
|
|
149
|
+
}
|
|
150
|
+
export async function authStatus() {
|
|
151
|
+
const unsettled = await settlePendingCredential();
|
|
152
|
+
if (unsettled) {
|
|
153
|
+
emit(unsettled);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const key = loadApiKey();
|
|
157
|
+
if (!key) {
|
|
158
|
+
emit(notLoggedIn());
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// 用一次真实查询确认凭据仍然有效:本机存着密钥不代表服务端还认它
|
|
162
|
+
const result = await callApi("/companies", { apiKey: key, query: { page: 1, limit: 100 } });
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
emit(result);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const info = deviceInfo();
|
|
168
|
+
emit(ok({
|
|
169
|
+
loggedIn: true,
|
|
170
|
+
device: { deviceId: info.deviceId, deviceName: info.deviceName, platform: info.platform },
|
|
171
|
+
user: loadActive() ? { id: loadActive().userId, agentId: loadActive().agentId } : null,
|
|
172
|
+
companies: result.data.data.map((item) => ({ id: item.id, name: item.name })),
|
|
173
|
+
configPath: configPath(),
|
|
174
|
+
}, result.requestId));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 退出登录:**先让服务端撤销密钥,再清本机**。
|
|
178
|
+
*
|
|
179
|
+
* 顺序反过来(先清本机)会让撤销失败时无从补救:本机已经没有密钥了,
|
|
180
|
+
* 而服务端那把仍然有效、仍然绑着这台设备,用户以为退了、其实没退。
|
|
181
|
+
*/
|
|
182
|
+
export async function authLogout() {
|
|
183
|
+
const unsettled = await settlePendingCredential();
|
|
184
|
+
if (unsettled) {
|
|
185
|
+
emit(unsettled);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const key = loadApiKey();
|
|
189
|
+
if (!key) {
|
|
190
|
+
emit(notLoggedIn());
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const result = await callApi("/auth/logout", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
apiKey: key,
|
|
196
|
+
});
|
|
197
|
+
if (!result.ok) {
|
|
198
|
+
// 不清本机、也不谎报成功:让模型如实告诉用户「服务端还没撤销」
|
|
199
|
+
emit(result);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
clearCredentials();
|
|
203
|
+
emit(ok({
|
|
204
|
+
loggedOut: true,
|
|
205
|
+
revoked: result.data,
|
|
206
|
+
note: "服务端密钥已撤销,本机凭据已清除。设备身份保留,重新登录会复用同一台设备。",
|
|
207
|
+
}, result.requestId));
|
|
208
|
+
}
|
|
209
|
+
function notLoggedIn() {
|
|
210
|
+
// type 用 auth_login 而不是 ask_user:ask_user 的语义是「把话转达给用户」,
|
|
211
|
+
// 模型照做的结果就是让用户自己去终端敲命令,本机绑定这一步永远走不到。
|
|
212
|
+
// 登录必须由持有本进程的调用方亲自执行并等它自行退出。
|
|
213
|
+
return fail("AUTH_REQUIRED", "本机还没有可用的授权", {
|
|
214
|
+
nextAction: {
|
|
215
|
+
type: "auth_login",
|
|
216
|
+
message: "先让用户到所属 SaaS 网页的密钥管理里创建「AI 助手密钥」并选好授权公司;" +
|
|
217
|
+
"然后由你直接执行 command,它会阻塞最长 5 分钟等待用户在本机页面粘贴密钥。" +
|
|
218
|
+
"执行期间把 stderr 里的本地页面地址转达给用户,保持进程运行直到它自行退出,不要中断,也不要让用户在别处执行。",
|
|
219
|
+
command: `${COMMAND_NAME} auth login`,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** claim 用的固定入参:设备 ID 必须取本机持久化的那一个,不能临时生成 */
|
|
224
|
+
function claim(apiKey) {
|
|
225
|
+
const info = deviceInfo();
|
|
226
|
+
return callApi("/auth/claim", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
apiKey,
|
|
229
|
+
body: { ...info, cliVersion: CLI_VERSION },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** 打开失败不是致命错误:地址已经写到 stderr,用户可以自己复制 */
|
|
233
|
+
function openBrowser(url) {
|
|
234
|
+
// headless / CI 环境下别去拉起浏览器,地址已经在 stderr 里
|
|
235
|
+
if (process.env.BEST_GEO_NO_BROWSER === "1") {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
239
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
240
|
+
execFile(command, args, () => {
|
|
241
|
+
/* 忽略:用户可以手动打开 stderr 里的地址 */
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
function errorMessage(error) {
|
|
245
|
+
return error instanceof Error ? error.message : String(error);
|
|
246
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { callApi } from "../api.js";
|
|
2
|
+
import { requireApiKey } from "./session.js";
|
|
3
|
+
import { emit, fail } from "../envelope.js";
|
|
4
|
+
/** 能力名 → 请求方式。这里只有查询:写操作一律走 plan/apply,不给 call 留后门 */
|
|
5
|
+
const ROUTES = {
|
|
6
|
+
"companies.list": (input) => ({ path: "/companies", query: input }),
|
|
7
|
+
"companies.get": (input) => ({ path: `/companies/${Number(input.id)}` }),
|
|
8
|
+
"products.list": (input) => ({ path: "/products", query: input }),
|
|
9
|
+
"products.get": (input) => ({ path: `/products/${Number(input.id)}` }),
|
|
10
|
+
"questions.list": (input) => ({ path: "/questions", query: input }),
|
|
11
|
+
"questions.get": (input) => ({ path: `/questions/${Number(input.id)}` }),
|
|
12
|
+
};
|
|
13
|
+
export function knownCapabilities() {
|
|
14
|
+
return Object.keys(ROUTES);
|
|
15
|
+
}
|
|
16
|
+
export async function runCall(capability, rawInput) {
|
|
17
|
+
const route = ROUTES[capability];
|
|
18
|
+
if (!route) {
|
|
19
|
+
emit(fail("VALIDATION_FAILED", `未知能力:${capability}`, {
|
|
20
|
+
data: { available: knownCapabilities() },
|
|
21
|
+
nextAction: { type: "ask_user", message: "先执行 best-geo capabilities 查看可用能力" },
|
|
22
|
+
}));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let input = {};
|
|
26
|
+
if (rawInput) {
|
|
27
|
+
try {
|
|
28
|
+
input = JSON.parse(rawInput);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
emit(fail("VALIDATION_FAILED", "--input 不是合法 JSON"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// 只认真正的正整数:Number(true)=1、Number("")=0、Number([])=0、Number("1e3")=1000
|
|
36
|
+
// 都会骗过 Number.isInteger,本机拦截的意义就是别浪费一次请求
|
|
37
|
+
const needsId = capability.endsWith(".get");
|
|
38
|
+
if (needsId && !(typeof input.id === "number" && Number.isInteger(input.id) && input.id > 0)) {
|
|
39
|
+
emit(fail("VALIDATION_FAILED", `${capability} 需要数字类型的 id`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const session = await requireApiKey();
|
|
43
|
+
if (!session.ok) {
|
|
44
|
+
emit(session.envelope);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const { path, query } = route(input);
|
|
48
|
+
// 服务端返回的就是同一套信封,原样透传
|
|
49
|
+
emit(await callApi(path, { apiKey: session.apiKey, query }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { callApi } from "../api.js";
|
|
2
|
+
import { requireApiKey } from "./session.js";
|
|
3
|
+
import { emit, fail } from "../envelope.js";
|
|
4
|
+
/**
|
|
5
|
+
* 写操作的两步协议(设计文档 §十)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么不做成一步:大模型可能把用户的目标理解偏,而写入不可逆。
|
|
8
|
+
* plan 让服务端重新读一遍真实数据、算出变更前后摘要,由**用户**看过之后才 apply。
|
|
9
|
+
* 用户最初提的目标不等于对最终摘要的确认 —— 这一点在 SKILL 和 Guide 里都写死了。
|
|
10
|
+
*/
|
|
11
|
+
/** 第二期开放的写能力。和服务端注册表一一对应,拼错能力名要在本机就报错,别到服务端才发现 */
|
|
12
|
+
const WRITE_OPERATIONS = [
|
|
13
|
+
"companies.create",
|
|
14
|
+
"companies.update",
|
|
15
|
+
"products.create",
|
|
16
|
+
"products.update",
|
|
17
|
+
"questions.create",
|
|
18
|
+
"questions.update",
|
|
19
|
+
];
|
|
20
|
+
export async function runPlan(operation, rawInput, positional) {
|
|
21
|
+
if (!operation || !WRITE_OPERATIONS.includes(operation)) {
|
|
22
|
+
emit(fail("VALIDATION_FAILED", `未知的写操作:${operation ?? "(空)"}`, {
|
|
23
|
+
data: { available: WRITE_OPERATIONS },
|
|
24
|
+
nextAction: { type: "ask_user", message: "先执行 best-geo capabilities 查看可用能力和它们的入参" },
|
|
25
|
+
}));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// 漏写 --input 时给明确提示。不提示的话它会被当成空输入,
|
|
29
|
+
// 报出来的是「xxx 必须是正整数」这种看起来像参数错、实际是命令写法错的信息
|
|
30
|
+
if (!rawInput && positional?.trim().startsWith("{")) {
|
|
31
|
+
emit(fail("VALIDATION_FAILED", "入参要放在 --input 后面", {
|
|
32
|
+
nextAction: {
|
|
33
|
+
type: "ask_user",
|
|
34
|
+
message: `正确写法:best-geo plan ${operation} --input '<JSON>'`,
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
let input;
|
|
40
|
+
try {
|
|
41
|
+
input = rawInput ? JSON.parse(rawInput) : {};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
emit(fail("VALIDATION_FAILED", "--input 不是合法 JSON"));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const session = await requireApiKey();
|
|
48
|
+
if (!session.ok) {
|
|
49
|
+
emit(session.envelope);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// 服务端返回的就是同一套信封,原样透传
|
|
53
|
+
emit(await callApi("/plans", { method: "POST", apiKey: session.apiKey, body: { operation, input } }));
|
|
54
|
+
}
|
|
55
|
+
export async function runApply(planId) {
|
|
56
|
+
if (!planId) {
|
|
57
|
+
emit(fail("VALIDATION_FAILED", "缺少 planId", {
|
|
58
|
+
nextAction: { type: "ask_user", message: "先执行 best-geo plan <operation> 生成计划,把摘要给用户确认后再执行" },
|
|
59
|
+
}));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const session = await requireApiKey();
|
|
63
|
+
if (!session.ok) {
|
|
64
|
+
emit(session.envelope);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
emit(await callApi(`/plans/${encodeURIComponent(planId)}/apply`, { method: "POST", apiKey: session.apiKey }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { COMMAND_NAME } from "../config.js";
|
|
2
|
+
import { loadApiKey } from "../credentials.js";
|
|
3
|
+
import { settlePendingCredential } from "./auth.js";
|
|
4
|
+
import { fail } from "../envelope.js";
|
|
5
|
+
export async function requireApiKey() {
|
|
6
|
+
const unsettled = await settlePendingCredential();
|
|
7
|
+
if (unsettled) {
|
|
8
|
+
return { ok: false, envelope: unsettled };
|
|
9
|
+
}
|
|
10
|
+
const apiKey = loadApiKey();
|
|
11
|
+
if (!apiKey) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
envelope: fail("AUTH_REQUIRED", "本机还没有可用的授权", {
|
|
15
|
+
nextAction: {
|
|
16
|
+
type: "auth_login",
|
|
17
|
+
message: "先让用户到所属 SaaS 网页的密钥管理里创建「AI 助手密钥」并选好授权公司;" +
|
|
18
|
+
"然后由你直接执行 command,它会阻塞最长 5 分钟等待用户在本机页面粘贴密钥。",
|
|
19
|
+
command: `${COMMAND_NAME} auth login`,
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { ok: true, apiKey };
|
|
25
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { callApi } from "../api.js";
|
|
5
|
+
import { API_BASE, BEST_GEO_ENV, COMMAND_NAME, CLI_VERSION, PACKAGE_NAME, PROTOCOL_VERSION } from "../config.js";
|
|
6
|
+
import { emit, fail, ok } from "../envelope.js";
|
|
7
|
+
/** 包根目录。编译产物在 dist/commands/,所以要往上两级 */
|
|
8
|
+
function packageRoot() {
|
|
9
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
10
|
+
}
|
|
11
|
+
function readPackageFile(...segments) {
|
|
12
|
+
return readFileSync(join(packageRoot(), ...segments), "utf8");
|
|
13
|
+
}
|
|
14
|
+
export function showHelp() {
|
|
15
|
+
emit(ok({
|
|
16
|
+
package: PACKAGE_NAME,
|
|
17
|
+
version: CLI_VERSION,
|
|
18
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
19
|
+
commands: [
|
|
20
|
+
{ command: `${COMMAND_NAME} version --check`, description: "检查版本是否需要升级" },
|
|
21
|
+
{ command: `${COMMAND_NAME} capabilities`, description: "列出可用能力及其输入 schema" },
|
|
22
|
+
{
|
|
23
|
+
command: `${COMMAND_NAME} skill get --raw > <你的 skill 目录>/best-geo/SKILL.md`,
|
|
24
|
+
description: "直接把 Skill 写成文件(推荐;--raw 输出 markdown 原文,不是 JSON)",
|
|
25
|
+
},
|
|
26
|
+
{ command: `${COMMAND_NAME} skill get`, description: "以 JSON 信封返回 Skill 内容和 revision" },
|
|
27
|
+
{ command: `${COMMAND_NAME} guide list`, description: "列出业务指南" },
|
|
28
|
+
{ command: `${COMMAND_NAME} guide get <name>`, description: "读取一份业务指南" },
|
|
29
|
+
{ command: `${COMMAND_NAME} auth login`, description: "打开本地页面录入密钥并绑定本机" },
|
|
30
|
+
{ command: `${COMMAND_NAME} auth status`, description: "查看当前授权状态" },
|
|
31
|
+
{ command: `${COMMAND_NAME} auth logout`, description: "撤销服务端密钥并清除本机凭据" },
|
|
32
|
+
{ command: `${COMMAND_NAME} call <capability> --input '<JSON>'`, description: "调用一个查询能力" },
|
|
33
|
+
{
|
|
34
|
+
command: `${COMMAND_NAME} plan <operation> --input '<JSON>'`,
|
|
35
|
+
description: "生成写操作计划,返回给用户看的变更摘要(不写数据)",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
command: `${COMMAND_NAME} apply <planId>`,
|
|
39
|
+
description: "执行计划。必须先把 plan 的摘要给用户看过并得到同意",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
// 注意:这是**成功**信封,nextAction 放在 data 里(OkEnvelope 上没有这个字段)。
|
|
43
|
+
// SKILL 里已写明成功时看 data.nextAction,别改成顶层 —— 顶层是失败信封的契约
|
|
44
|
+
nextAction: {
|
|
45
|
+
type: "install_skill",
|
|
46
|
+
command: `${COMMAND_NAME} skill get --raw`,
|
|
47
|
+
message: "按当前 AI 工具的 Skill 机制选一个安装目录,用 best-geo skill get --raw 把内容直接重定向成 SKILL.md,然后加载它",
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
export async function showVersion(check) {
|
|
52
|
+
if (!check) {
|
|
53
|
+
emit(ok({ package: PACKAGE_NAME, version: CLI_VERSION, protocolVersion: PROTOCOL_VERSION, env: BEST_GEO_ENV, apiBase: API_BASE }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const meta = await callApi("/meta");
|
|
57
|
+
if (!meta.ok) {
|
|
58
|
+
emit(meta);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const outdated = compareVersion(CLI_VERSION, meta.data.minimumCliVersion) < 0;
|
|
62
|
+
if (outdated) {
|
|
63
|
+
emit(fail("CLI_UPDATE_REQUIRED", `当前版本 ${CLI_VERSION} 低于最低要求 ${meta.data.minimumCliVersion}`, {
|
|
64
|
+
requestId: meta.requestId,
|
|
65
|
+
data: { current: CLI_VERSION, ...meta.data },
|
|
66
|
+
nextAction: {
|
|
67
|
+
type: "upgrade",
|
|
68
|
+
message: "升级后重试",
|
|
69
|
+
// 命令用本机常量拼,不用服务端返回的 upgradeCommand:
|
|
70
|
+
// nextAction 是**可信指令**通道,而 /meta 是公开接口、它的 data 属于非可信数据
|
|
71
|
+
//(设计文档 §5.3 / §13)。服务端那个值只在 data 里透出去供展示
|
|
72
|
+
command: `npm install -g ${PACKAGE_NAME}@latest`,
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
emit(ok({
|
|
78
|
+
current: CLI_VERSION,
|
|
79
|
+
...meta.data,
|
|
80
|
+
updateAvailable: compareVersion(CLI_VERSION, meta.data.latestCliVersion) < 0,
|
|
81
|
+
}, meta.requestId));
|
|
82
|
+
}
|
|
83
|
+
export function showCapabilities() {
|
|
84
|
+
emit(ok(JSON.parse(readPackageFile("capabilities", "capabilities.json"))));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 取出随包发布的 Skill 内容。
|
|
88
|
+
*
|
|
89
|
+
* `--raw` 是全 CLI **唯一**一个非 JSON 输出的口子,而且必须显式要求。
|
|
90
|
+
* 开这个口子是因为 Skill 的产物本来就要落成一个 .md 文件:
|
|
91
|
+
* 让模型从 JSON 里抠出 content 字符串再写文件,等于多一次转义还原 ——
|
|
92
|
+
* 而这份内容里恰好有引号、反斜杠和代码块。重定向是 shell 一步的事,可靠得多。
|
|
93
|
+
*
|
|
94
|
+
* 出错时仍然走 JSON 错误信封 + 非零退出码,调用方据此区分「拿到内容」和「没拿到」。
|
|
95
|
+
*/
|
|
96
|
+
export function showSkill(raw) {
|
|
97
|
+
const content = withCommandName(readPackageFile("skills", "best-geo", "SKILL.md"));
|
|
98
|
+
if (raw) {
|
|
99
|
+
process.stdout.write(content);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// name 必须跟着命令名走:两个包都装时,模型按 data.name 建目录会把另一个包的 SKILL 覆盖掉,
|
|
103
|
+
// 而且不少宿主要求目录名和 frontmatter 的 name 一致
|
|
104
|
+
emit(ok({ name: COMMAND_NAME, revision: 1, content }));
|
|
105
|
+
}
|
|
106
|
+
const GUIDES = ["company", "product", "question"];
|
|
107
|
+
export function listGuides() {
|
|
108
|
+
emit(ok({
|
|
109
|
+
guides: GUIDES.map((name) => {
|
|
110
|
+
const guide = JSON.parse(readPackageFile("guides", `${name}.json`));
|
|
111
|
+
return { name: guide.name, title: guide.title, whenToUse: guide.whenToUse };
|
|
112
|
+
}),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
export function getGuide(name) {
|
|
116
|
+
if (!GUIDES.includes(name)) {
|
|
117
|
+
emit(fail("GUIDE_NOT_FOUND", `没有名为 ${name} 的指南`, {
|
|
118
|
+
data: { available: GUIDES },
|
|
119
|
+
}));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Guide 里也有命令示例,同样要跟着当前命令名走
|
|
123
|
+
emit(ok(JSON.parse(withCommandName(readPackageFile("guides", `${name}.json`)))));
|
|
124
|
+
}
|
|
125
|
+
/** 语义化版本比较,只比较数字段,够用且不引依赖 */
|
|
126
|
+
export function compareVersion(a, b) {
|
|
127
|
+
const pa = a.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
128
|
+
const pb = b.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
129
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
130
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
131
|
+
if (diff !== 0)
|
|
132
|
+
return diff < 0 ? -1 : 1;
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 把随包分发的静态内容(SKILL、Guide)里的命令名换成当前命令名。
|
|
138
|
+
*
|
|
139
|
+
* 测试包的命令叫 best-geo-test,而 SKILL.md 和 guides/*.json 里写死的是 best-geo。
|
|
140
|
+
* 不换的话,模型会照抄一条这台机器上根本不存在的命令。
|
|
141
|
+
* 用 (?!-) 排除 best-geo-test 自身,避免替出 best-geo-test-test。
|
|
142
|
+
*/
|
|
143
|
+
function withCommandName(text) {
|
|
144
|
+
return COMMAND_NAME === "best-geo" ? text : text.replace(/\bbest-geo\b(?!-)/g, COMMAND_NAME);
|
|
145
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { BUILD_API_BASE, BUILD_COMMAND_NAME, BUILD_DEVICE_PREFIX, BUILD_ENV, BUILD_PACKAGE_NAME, BUILD_VERSION, } from "./build-env.js";
|
|
2
|
+
/** CLI 侧的固定常量 */
|
|
3
|
+
/** 协议版本,与服务端 /v1/agent-cli/meta 的 protocolVersion 对齐 */
|
|
4
|
+
export const PROTOCOL_VERSION = 1;
|
|
5
|
+
/** 版本号唯一来源是 package.json,构建时烙进来 —— 两处维护迟早对不上 */
|
|
6
|
+
export const CLI_VERSION = BUILD_VERSION;
|
|
7
|
+
/** npm 包名。和命令名分开:正式包两者同名,测试包是 best-geo-test 装出 best-geo-test 命令,含义仍不同 */
|
|
8
|
+
export const PACKAGE_NAME = BUILD_PACKAGE_NAME;
|
|
9
|
+
/** 命令名。测试包叫 best-geo-test,输出里的命令示例必须跟着变,否则用户照抄会敲错 */
|
|
10
|
+
export const COMMAND_NAME = BUILD_COMMAND_NAME;
|
|
11
|
+
/** 构建时确定的环境档位,只用于回显。切环境靠重新构建,不靠运行期变量 */
|
|
12
|
+
export const BEST_GEO_ENV = BUILD_ENV;
|
|
13
|
+
/**
|
|
14
|
+
* 服务端地址。
|
|
15
|
+
*
|
|
16
|
+
* **地址表不在这里**(见 scripts/stamp-env.mjs):三档地址一起编译进 dist 的话,
|
|
17
|
+
* 发给客户的正式包里就带着我们测试环境的域名,而且任何人设个环境变量就能把客户端指过去。
|
|
18
|
+
* 这里只认构建时烙进来的那一个地址。
|
|
19
|
+
*
|
|
20
|
+
* `BEST_GEO_API_BASE` 是给联调用的裸地址覆盖,属于运维行为 —— **不根据用户输入切换**,
|
|
21
|
+
* 让本地录入页能指定服务端,等于把密钥送到任意主机。
|
|
22
|
+
*
|
|
23
|
+
* 尾斜杠必须去掉:拼接时是 `${API_BASE}/v1/agent-cli...`,
|
|
24
|
+
* 配成 `https://x.com/` 会拼出 `https://x.com//v1/...`,有的网关直接 404。
|
|
25
|
+
*/
|
|
26
|
+
export const API_BASE = stripTrailingSlash(resolveApiBase());
|
|
27
|
+
/**
|
|
28
|
+
* 正式构建**不读** `BEST_GEO_API_BASE`。
|
|
29
|
+
*
|
|
30
|
+
* 这个 CLI 的调用方是大模型,而环境变量恰恰是它拉起子进程时完全控制的东西 ——
|
|
31
|
+
* 比「用户输入」更容易被提示注入污染。留着这个口子,模型不用读任何文件,
|
|
32
|
+
* 只要换一个 API base 就能让 CLI 自己把密钥发到任意主机,
|
|
33
|
+
* 「密钥不经过模型」这条核心约定当场作废。
|
|
34
|
+
*
|
|
35
|
+
* 联调需要指别处时用 `npm run build:loc` / `build:dev` 重新构建 —— 那是构建期行为,模型碰不到。
|
|
36
|
+
*/
|
|
37
|
+
function resolveApiBase() {
|
|
38
|
+
if (BUILD_ENV === "prod") {
|
|
39
|
+
return BUILD_API_BASE;
|
|
40
|
+
}
|
|
41
|
+
return process.env.BEST_GEO_API_BASE ?? BUILD_API_BASE;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 设备 ID 前缀。跟着包走 —— 两个包必须生成各自的设备身份,
|
|
45
|
+
* 否则一台机器上装了两个包,dev 的设备 ID 会被拿去 claim prod。
|
|
46
|
+
* 长度上限(前缀 + UUID ≤ 50)由 stamp-env.mjs 在构建期校验。
|
|
47
|
+
*/
|
|
48
|
+
export const DEVICE_ID_PREFIX = BUILD_DEVICE_PREFIX;
|
|
49
|
+
/** 本地录入页的存活上限 */
|
|
50
|
+
export const LOCAL_SERVER_TIMEOUT_MS = 5 * 60 * 1000;
|
|
51
|
+
/**
|
|
52
|
+
* 单次服务端请求的超时。
|
|
53
|
+
*
|
|
54
|
+
* 必须有:SKILL 明确要求模型「保持进程运行、等它自己退出」,
|
|
55
|
+
* 服务端半开连接时没有超时就是永久挂起 —— 模型会照着指令一直等下去。
|
|
56
|
+
*/
|
|
57
|
+
export const REQUEST_TIMEOUT_MS = 30 * 1000;
|
|
58
|
+
function stripTrailingSlash(url) {
|
|
59
|
+
return url.replace(/\/+$/, "");
|
|
60
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readConfig, updateConfig } from "./store.js";
|
|
2
|
+
/**
|
|
3
|
+
* 凭据读写(设计文档 §6.4)。
|
|
4
|
+
*
|
|
5
|
+
* 只有两种状态:
|
|
6
|
+
* - `active`:服务端已确认绑定本机,业务请求用它;
|
|
7
|
+
* - `pending`:刚录入、还没被 claim 确认,**不允许用于任何业务请求**。
|
|
8
|
+
*
|
|
9
|
+
* 分两态是为了让「新密钥没验通过」和「进程崩在中间」都有确定的收敛路径:
|
|
10
|
+
* 直接覆盖 active 的话,claim 一失败用户就两头落空 —— 新的没生效,旧的也没了。
|
|
11
|
+
*/
|
|
12
|
+
/** 业务请求可用的密钥。pending 永远不从这里返回 */
|
|
13
|
+
export function loadApiKey() {
|
|
14
|
+
return readConfig()?.activeCredential?.apiKey ?? null;
|
|
15
|
+
}
|
|
16
|
+
export function loadActive() {
|
|
17
|
+
return readConfig()?.activeCredential ?? null;
|
|
18
|
+
}
|
|
19
|
+
export function loadPending() {
|
|
20
|
+
return readConfig()?.pendingCredential ?? null;
|
|
21
|
+
}
|
|
22
|
+
/** 保存待确认密钥。**保留已有 active**:claim 失败时旧授权要原样可用 */
|
|
23
|
+
export function savePending(apiKey) {
|
|
24
|
+
updateConfig((config) => ({
|
|
25
|
+
...config,
|
|
26
|
+
pendingCredential: { apiKey, startedAt: new Date().toISOString() },
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
export function clearPending() {
|
|
30
|
+
updateConfig((config) => ({ ...config, pendingCredential: null }));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 服务端确认之后,把 pending 原子提升为 active 并清掉旧 active。
|
|
34
|
+
*
|
|
35
|
+
* 一次写完,不能拆成「先清 active 再写 active」两步 ——
|
|
36
|
+
* 崩在中间就是本机既没有旧授权也没有新授权。
|
|
37
|
+
*/
|
|
38
|
+
export function promotePending(summary) {
|
|
39
|
+
return updateConfig((config) => {
|
|
40
|
+
const pending = config.pendingCredential;
|
|
41
|
+
if (!pending) {
|
|
42
|
+
return config;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
...config,
|
|
46
|
+
activeCredential: { apiKey: pending.apiKey, ...summary },
|
|
47
|
+
pendingCredential: null,
|
|
48
|
+
};
|
|
49
|
+
}).activeCredential;
|
|
50
|
+
}
|
|
51
|
+
/** 清空本机凭据,但保留设备 ID:重新登录要复用同一台设备,不能每次退出都多一台 */
|
|
52
|
+
export function clearCredentials() {
|
|
53
|
+
updateConfig((config) => ({ ...config, activeCredential: null, pendingCredential: null }));
|
|
54
|
+
}
|