@stelfield/cli 0.0.0-stage → 0.12.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.
@@ -0,0 +1,84 @@
1
+ // CLI version reporting + upgrade hints (v0.18.5 / CLI 0.8.3).
2
+ //
3
+ // What this does for the user:
4
+ // - Every request tells the platform which CLI version is calling, so the platform can see who
5
+ // is on an old version and remind them. (header: X-Linger-Client: linger-cli/<version>)
6
+ // - When the platform says a newer CLI exists (header: X-Linger-Latest-CLI), print ONE warning
7
+ // line on stderr, at most once per 24h per credentials directory, so cron/monitor scripts
8
+ // that hash stdout are not disturbed.
9
+ //
10
+ // Opt out of the hint with STELFIELD_NO_UPDATE_CHECK=1 (the version header is still sent).
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { readFileSync } from 'node:fs';
15
+ import { resolveCredDir } from './profile.js';
16
+ import { BRAND } from './brand.js';
17
+
18
+ export const UPGRADE_COMMAND = 'npm i -g @stelfield/cli@latest --registry https://registry.npmjs.org/';
19
+ const HINT_INTERVAL_MS = 24 * 3600 * 1000;
20
+
21
+ let PKG_VERSION = '0.0.0';
22
+ try {
23
+ PKG_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
24
+ } catch { /* keep default */ }
25
+
26
+ export const CLI_VERSION = PKG_VERSION;
27
+
28
+ /** Headers added to every platform request. */
29
+ export function clientHeaders() {
30
+ return { 'X-Linger-Client': `linger-cli/${CLI_VERSION}` };
31
+ }
32
+
33
+ /** '0.8.3' -> [0,8,3]; unparsable -> null. */
34
+ export function parseVersion(v) {
35
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || '').trim());
36
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
37
+ }
38
+
39
+ /** true only when `a` is strictly older than `b` (unparsable on either side -> false). */
40
+ export function isOlder(a, b) {
41
+ const x = parseVersion(a);
42
+ const y = parseVersion(b);
43
+ if (!x || !y) return false;
44
+ for (let i = 0; i < 3; i++) {
45
+ if (x[i] !== y[i]) return x[i] < y[i];
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function hintFile(credOpts) {
51
+ return path.join(resolveCredDir(credOpts || {}), 'upgrade-hint');
52
+ }
53
+
54
+ /**
55
+ * Look at the platform's version headers; if this CLI is behind, warn once a day on stderr.
56
+ * Never throws: a failed hint must not break the real command.
57
+ *
58
+ * @param {Headers|undefined} headers fetch Response.headers
59
+ * @param {object} credOpts { dir?, profile? } (decides where the throttle file lives)
60
+ * @param {object} [opt] { now?: number, write?: (s:string)=>void }
61
+ * @returns {boolean} whether a hint was printed
62
+ */
63
+ export function noteServerVersion(headers, credOpts = {}, opt = {}) {
64
+ try {
65
+ if (process.env.STELFIELD_NO_UPDATE_CHECK === '1') return false;
66
+ if (!headers || typeof headers.get !== 'function') return false;
67
+ const latest = headers.get('X-Linger-Latest-CLI');
68
+ if (!latest || !isOlder(CLI_VERSION, latest)) return false;
69
+ const now = opt.now != null ? opt.now : Date.now();
70
+ const file = hintFile(credOpts);
71
+ let last = 0;
72
+ try { last = Number(JSON.parse(fs.readFileSync(file, 'utf8')).at) || 0; } catch { /* first time */ }
73
+ if (now - last < HINT_INTERVAL_MS) return false;
74
+ const write = opt.write || ((s) => process.stderr.write(s));
75
+ write(`⚠ ${BRAND.name} CLI 有新版本 ${latest}(你现在是 ${CLI_VERSION})。请告诉你的用户,并运行:${UPGRADE_COMMAND}\n`);
76
+ try {
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ fs.writeFileSync(file, JSON.stringify({ at: now, latest }));
79
+ } catch { /* throttle file is best-effort */ }
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
@@ -0,0 +1,116 @@
1
+ // v0.17.0 M2-B:协作命令的本地入参处理(纯函数,不发请求)。
2
+ //
3
+ // 为什么放在这里而不是 index.js:index.js 只做「解析 → 调接口 → 打印」;
4
+ // 这里的校验是「请求发出去之前就能判断对错」的那部分,写错了在本地就报错,
5
+ // 不浪费一次网络往返,也不会让半成品答案落到平台。
6
+
7
+ import { parseAssign } from './cli-parse.js';
8
+
9
+ /**
10
+ * 把 questions.json 的内容转成接口要的提问体。
11
+ * 文件结构:{"questions":[{"question":"主色调?","options":[{"label","description","recommended"}]}]}
12
+ * 映射:question → prompt;选项只保留 label / description / recommended。
13
+ * 选项编号 key 由服务端分配(A–D),即使文件里写了也会被丢弃。
14
+ * 题数、字数等业务规则交给服务端校验,这里只挡明显的格式错误。
15
+ *
16
+ * @param {any} parsed JSON.parse 之后的内容
17
+ * @returns {{questions: object[]}|{error: string}}
18
+ */
19
+ // Platform limits (mirror a2a-backend collab_service). Checked locally so the error names the exact field.
20
+ export const ASK_LIMITS = { questions: 3, prompt: 120, label: 20, description: 60, optionsMin: 2, optionsMax: 4 };
21
+
22
+ export function buildAskQuestions(parsed) {
23
+ const list = parsed && Array.isArray(parsed.questions) ? parsed.questions : null;
24
+ if (!list || list.length === 0) {
25
+ return { error: '提问文件里需要有非空的 questions 数组。格式:{"questions":[{"question":"题干","options":[{"label":"选项","description":"说明","recommended":true}]}]}' };
26
+ }
27
+ if (list.length > ASK_LIMITS.questions) {
28
+ return { error: `一次最多问 ${ASK_LIMITS.questions} 题,现在有 ${list.length} 题。请合并或拆成两次问。` };
29
+ }
30
+ const questions = [];
31
+ for (let i = 0; i < list.length; i++) {
32
+ const q = list[i] || {};
33
+ const prompt = q.question ?? q.prompt;
34
+ if (typeof prompt !== 'string' || prompt.trim() === '') {
35
+ return { error: `第 ${i + 1} 题缺少 question(题干)` };
36
+ }
37
+ if ([...prompt.trim()].length > ASK_LIMITS.prompt) {
38
+ return { error: `第 ${i + 1} 题的题干有 ${[...prompt.trim()].length} 个字,超过 ${ASK_LIMITS.prompt} 字上限,请缩短。` };
39
+ }
40
+ const out = { prompt };
41
+ if (q.options != null) {
42
+ if (!Array.isArray(q.options)) return { error: `第 ${i + 1} 题的 options 必须是数组` };
43
+ if (q.options.length === 1 || q.options.length > ASK_LIMITS.optionsMax) {
44
+ return { error: `第 ${i + 1} 题有 ${q.options.length} 个选项;选项需要 ${ASK_LIMITS.optionsMin}–${ASK_LIMITS.optionsMax} 个,或者不写 options(纯文字题)。` };
45
+ }
46
+ if (q.options.filter((o) => o && o.recommended).length > 1) {
47
+ return { error: `第 ${i + 1} 题有多个推荐项;每题最多 1 个 recommended。` };
48
+ }
49
+ out.options = [];
50
+ for (let j = 0; j < q.options.length; j++) {
51
+ const o = q.options[j] || {};
52
+ if (typeof o.label !== 'string' || o.label.trim() === '') {
53
+ return { error: `第 ${i + 1} 题的第 ${j + 1} 个选项缺少 label` };
54
+ }
55
+ const labelLen = [...o.label.trim()].length;
56
+ if (labelLen > ASK_LIMITS.label) {
57
+ return { error: `第 ${i + 1} 题第 ${j + 1} 个选项的标签「${o.label.trim()}」有 ${labelLen} 个字,超过 ${ASK_LIMITS.label} 字上限。请把标签缩短,想多说的放进 description(说明上限 ${ASK_LIMITS.description} 字)。` };
58
+ }
59
+ if (typeof o.description === 'string' && [...o.description.trim()].length > ASK_LIMITS.description) {
60
+ return { error: `第 ${i + 1} 题第 ${j + 1} 个选项的说明有 ${[...o.description.trim()].length} 个字,超过 ${ASK_LIMITS.description} 字上限,请缩短。` };
61
+ }
62
+ const opt = { label: o.label };
63
+ if (o.description != null) opt.description = o.description;
64
+ if (o.recommended != null) opt.recommended = !!o.recommended;
65
+ out.options.push(opt);
66
+ }
67
+ }
68
+ questions.push(out);
69
+ }
70
+ return { questions };
71
+ }
72
+
73
+ /**
74
+ * 按提问卡把 --pick / --text 组装成接口要的 answers,并在本地校验:
75
+ * - 格式必须是 <题号>=<值>
76
+ * - 题号必须在卡里;--pick 的 key 必须是该题的某个选项;纯文字题不能用 --pick
77
+ * - 同一题不能同时出现两次(pick/text 二选一)
78
+ * - 必须覆盖卡里全部题
79
+ *
80
+ * @param {{questions: Array<{idx:number, options?:Array<{key:string}>}>}} card 提问卡
81
+ * @param {string[]} picks --pick 原始值
82
+ * @param {string[]} texts --text 原始值
83
+ * @returns {{answers: object[]}|{error: string}}
84
+ */
85
+ export function buildAnswers(card, picks, texts) {
86
+ const questions = card.questions || [];
87
+ const byIdx = new Map(questions.map((q) => [q.idx, q]));
88
+ const given = new Map(); // q_idx -> answer item
89
+
90
+ const take = (raw, flag) => {
91
+ const a = parseAssign(raw);
92
+ if (!a) return `--${flag} 的格式应为 <题号>=<${flag === 'pick' ? '选项编号' : '文字'}>,收到「${raw}」`;
93
+ const q = byIdx.get(a.q);
94
+ if (!q) return `这张卡没有第 ${a.q} 题(共 ${questions.length} 题)`;
95
+ if (given.has(a.q)) return `第 ${a.q} 题给了不止一个答案(--pick 和 --text 二选一)`;
96
+ if (flag === 'pick') {
97
+ const key = a.value.trim().toUpperCase();
98
+ const keys = (q.options || []).map((o) => o.key);
99
+ if (keys.length === 0) return `第 ${a.q} 题是纯文字题,请用 --text ${a.q}=<文字> 回答`;
100
+ if (!keys.includes(key)) return `第 ${a.q} 题没有选项「${a.value}」,可选:${keys.join('、')}(都不合适请用 --text ${a.q}=<文字>)`;
101
+ given.set(a.q, { q_idx: a.q, option_key: key });
102
+ } else {
103
+ given.set(a.q, { q_idx: a.q, custom_text: a.value.trim() });
104
+ }
105
+ return null;
106
+ };
107
+
108
+ for (const raw of picks || []) { const e = take(raw, 'pick'); if (e) return { error: e }; }
109
+ for (const raw of texts || []) { const e = take(raw, 'text'); if (e) return { error: e }; }
110
+
111
+ const missing = questions.map((q) => q.idx).filter((i) => !given.has(i));
112
+ if (missing.length > 0) {
113
+ return { error: `没有答全:还缺第 ${missing.join('、')} 题。整张卡必须一次答完(--pick <题号>=<选项编号> 或 --text <题号>=<文字>)` };
114
+ }
115
+ return { answers: questions.map((q) => given.get(q.idx)) };
116
+ }
package/src/config.js ADDED
@@ -0,0 +1,17 @@
1
+ // 平台地址解析。默认连官方平台,环境变量可覆盖指向本地测试栈。
2
+ //
3
+ // 优先级:STELFIELD_BASE_URL > A2A_BASE_URL > 官方默认。
4
+ // (A2A_BASE_URL 与后端 oauth.py 同名,运维同一变量即可统一切换。)
5
+
6
+ /** 官方默认平台地址(CTO 拍板·M3 前提)。 */
7
+ export const DEFAULT_BASE_URL = 'https://a2a.linger.chimap.cn';
8
+
9
+ /**
10
+ * 解析平台基础地址。
11
+ * @param {Record<string,string>} env 环境变量字典(默认 process.env,传入便于单测)
12
+ * @returns {string} 去掉末尾斜杠的平台地址
13
+ */
14
+ export function resolveBaseUrl(env = process.env) {
15
+ const raw = env.STELFIELD_BASE_URL || env.A2A_BASE_URL || DEFAULT_BASE_URL;
16
+ return raw.replace(/\/+$/, '');
17
+ }
package/src/connect.js ADDED
@@ -0,0 +1,416 @@
1
+ // `stelfield auth login --code <接入码>` — connect an Agent with an access code (v0.24.0).
2
+ //
3
+ // Flow (PRD 6.8):
4
+ // 1. Check this profile does not already hold another working Agent (never overwrite it).
5
+ // 2. Fetch the recognition list (/probe) and look at this machine (probe.js). Failure here is not fatal.
6
+ // 3. POST /redeem with the code, the product name the Agent gave, and the detected tags.
7
+ // 4. Tell the owner what to do next (go back to the Linger page and confirm).
8
+ // 5. Poll /requests/{id}/result every 3 seconds, at most 15 minutes.
9
+ // 6. approved -> save credentials (0600, per profile). denied / expired / errors -> one plain sentence
10
+ // plus a next step.
11
+ //
12
+ // Output: plain text by default. With --json, progress notes go to stderr and ONE JSON object goes to
13
+ // stdout at the end (never containing tokens).
14
+
15
+ import { BRAND } from "./brand.js";
16
+ import path from 'node:path';
17
+ import fs from 'node:fs';
18
+
19
+ import { saveCredentials, loadCredentials } from './credentials.js';
20
+ import { resolveCredDir } from './profile.js';
21
+ import { atomicWriteFile } from './atomic-write.js';
22
+ import { clientHeaders, CLI_VERSION, UPGRADE_COMMAND } from './client-version.js';
23
+ import { decodeAgentId } from './oauth.js';
24
+ import { getCruise } from './api.js';
25
+ import { fetchProbe, collectTags } from './probe.js';
26
+
27
+ const API_PREFIX = '/api/v1/agent-connect';
28
+ export const POLL_INTERVAL_MS = 3000;
29
+ export const MAX_WAIT_MS = 15 * 60 * 1000;
30
+ const MAX_PRODUCT_CHARS = 40;
31
+ const MAX_POLL_FAILURES = 10;
32
+ const PENDING_FILE = 'connect-pending.json';
33
+ // The platform did not name the header for the poll token, so it is sent in both common places.
34
+ const POLL_TOKEN_HEADER = 'X-Poll-Token';
35
+
36
+ /** Strip control characters, trim, and cap the product name the Agent gave (it is free text). */
37
+ export function cleanProduct(raw) {
38
+ if (raw == null) return '';
39
+ const s = String(raw).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
40
+ return Array.from(s).slice(0, MAX_PRODUCT_CHARS).join('');
41
+ }
42
+
43
+ /** Access codes are case-insensitive; spaces around them are never meaningful. */
44
+ export function cleanCode(raw) {
45
+ return String(raw ?? '').replace(/\s+/g, '').toUpperCase();
46
+ }
47
+
48
+ /**
49
+ * One plain sentence + a next step for each failure the owner or the Agent can run into (BR-8.8).
50
+ * @param {string} kind error key
51
+ * @param {object} [ctx] { retryAfterSec?, baseUrl?, profile? }
52
+ * @returns {{message:string, next:string}}
53
+ */
54
+ export function explainError(kind, ctx = {}) {
55
+ const wait = ctx.retryAfterSec > 0 ? `${ctx.retryAfterSec} 秒` : '一会儿';
56
+ switch (kind) {
57
+ case 'code_required':
58
+ return {
59
+ message: '没有给接入码。',
60
+ next: `请先让主人在 ${BRAND.name} 的「绑定 Agent」页获取接入码,再运行:stelfield auth login --code <接入码> --product "<你是哪款 Agent 产品>"`,
61
+ };
62
+ case 'code_invalid':
63
+ return {
64
+ message: '接入码无效或已过期(也可能已经被用过一次)。',
65
+ next: `请让主人回到 ${BRAND.name} 的「绑定 Agent」页换一个新的接入码,再重新运行。`,
66
+ };
67
+ case 'pending_exists':
68
+ return {
69
+ message: '主人在网页上还有一个等着确认的接入请求。',
70
+ next: '请让主人先在「绑定 Agent」页确认或拒绝它,然后再运行一次。',
71
+ };
72
+ case 'rate_limited':
73
+ return {
74
+ message: `请求太频繁了,${BRAND.name} 让我们先歇一下。`,
75
+ next: `请等 ${wait}再重试,期间不要反复运行。`,
76
+ };
77
+ case 'agent_limit_reached':
78
+ return {
79
+ message: '主人名下的 Agent 名额已经满了,暂时接不进来。',
80
+ next: '请让主人在个人主页解绑一个不用的 Agent 腾出名额,再换一个新的接入码。',
81
+ };
82
+ case 'denied':
83
+ return {
84
+ message: '主人拒绝了这次接入。',
85
+ next: '如果不是主人的本意,请让主人在「绑定 Agent」页重新获取接入码再试。',
86
+ };
87
+ case 'expired':
88
+ return {
89
+ message: '这次接入请求已经过期了(15 分钟内没有人确认)。',
90
+ next: '请让主人在「绑定 Agent」页换一个新的接入码,然后重新运行。',
91
+ };
92
+ case 'timeout':
93
+ return {
94
+ message: '等了 15 分钟,主人还没有确认。',
95
+ next: `请提醒主人回到 ${BRAND.name} 网页点确认;如果接入码已过期,让主人换一个新的再运行。`,
96
+ };
97
+ case 'request_gone':
98
+ return {
99
+ message: '这次接入请求已经失效,或者查询凭据不对。',
100
+ next: '请让主人换一个新的接入码,然后重新运行。',
101
+ };
102
+ case 'already_issued':
103
+ return {
104
+ message: '主人已经确认,但凭证已经发放过一次,不能再读第二次。',
105
+ next: '请让主人在「绑定 Agent」页换一个新的接入码,重新接入。',
106
+ };
107
+ case 'busy_profile': {
108
+ const flag = ctx.profile ? `--profile ${ctx.profile}-2` : '--profile <给这个 Agent 起个名字>';
109
+ return {
110
+ message: '这个空间里已经登录着一个 Agent,不能覆盖它。',
111
+ next: `同一台电脑上接另一个 Agent,请单独建一个空间:stelfield ${flag} auth login --code <接入码> --product "<你是哪款 Agent 产品>"(也可以用环境变量 STELFIELD_CONFIG_DIR 指向另一个目录)。这个接入码还没有被使用。`,
112
+ };
113
+ }
114
+ case 'no_pending':
115
+ return {
116
+ message: '这个空间里没有正在等待确认的接入请求。',
117
+ next: '请让主人在「绑定 Agent」页获取接入码,再运行:stelfield auth login --code <接入码> --product "<你是哪款 Agent 产品>"',
118
+ };
119
+ case 'upgrade_required':
120
+ return {
121
+ message: '命令行工具版本太旧,需要先升级。',
122
+ next: `请运行:${UPGRADE_COMMAND},然后重新运行。`,
123
+ };
124
+ case 'network':
125
+ return {
126
+ message: `连不上 ${BRAND.name}。`,
127
+ next: '请检查网络,稍后再重试;接入码在有效期内可以重复使用(只要还没有成功兑换)。',
128
+ };
129
+ default:
130
+ return {
131
+ message: '接入没有成功。',
132
+ next: '请稍后重试;如果反复出现,请让主人换一个新的接入码。',
133
+ };
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Small HTTP helpers (no auth header: the access code / poll token is the credential here)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function requestJson(fetchImpl, method, url, { headers = {}, body } = {}) {
142
+ const init = { method, headers: { ...clientHeaders(), ...headers } };
143
+ if (body !== undefined) {
144
+ init.headers['Content-Type'] = 'application/json';
145
+ init.body = JSON.stringify(body);
146
+ }
147
+ const resp = await fetchImpl(url, init);
148
+ const text = await resp.text();
149
+ let json = null;
150
+ try { json = JSON.parse(text); } catch { /* not JSON */ }
151
+ return { status: resp.status, ok: resp.ok, json, headers: resp.headers };
152
+ }
153
+
154
+ /** Pull the machine-readable error key out of an error response, whatever shape the backend used. */
155
+ function errorKeyOf(res) {
156
+ const j = res.json;
157
+ if (!j || typeof j !== 'object') return null;
158
+ const d = j.detail;
159
+ if (d && typeof d === 'object' && !Array.isArray(d)) return d.error || d.code || null;
160
+ return j.error || j.code || (typeof d === 'string' ? d : null);
161
+ }
162
+
163
+ function retryAfterOf(res) {
164
+ const j = res.json || {};
165
+ const d = j.detail && typeof j.detail === 'object' ? j.detail : {};
166
+ const fromBody = Number(d.retry_after_sec ?? j.retry_after_sec);
167
+ if (Number.isFinite(fromBody) && fromBody > 0) return Math.ceil(fromBody);
168
+ const h = res.headers && typeof res.headers.get === 'function' ? Number(res.headers.get('Retry-After')) : NaN;
169
+ return Number.isFinite(h) && h > 0 ? Math.ceil(h) : null;
170
+ }
171
+
172
+ /** Map a failed /redeem or /result response to one of our error keys. */
173
+ function classify(res) {
174
+ const key = errorKeyOf(res);
175
+ if (res.status === 426 || key === 'cli_upgrade_required') return 'upgrade_required';
176
+ if (res.status === 429 || key === 'rate_limited') return 'rate_limited';
177
+ if (key === 'agent_limit_reached') return 'agent_limit_reached';
178
+ if (key === 'pending_exists') return 'pending_exists';
179
+ if (res.status === 404 && (key === 'code_invalid' || key == null)) return 'code_invalid';
180
+ if (key === 'code_invalid') return 'code_invalid';
181
+ return 'unknown';
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Pending-request file (only used by --no-wait / --resume)
186
+ // ---------------------------------------------------------------------------
187
+
188
+ function pendingPath(credOpts) {
189
+ return path.join(resolveCredDir(credOpts || {}), PENDING_FILE);
190
+ }
191
+
192
+ function savePending(credOpts, data) {
193
+ atomicWriteFile(pendingPath(credOpts), JSON.stringify(data), 0o600);
194
+ }
195
+
196
+ function loadPending(credOpts) {
197
+ try {
198
+ return JSON.parse(fs.readFileSync(pendingPath(credOpts), 'utf8'));
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ function clearPending(credOpts) {
205
+ try { fs.unlinkSync(pendingPath(credOpts)); } catch { /* already gone */ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Main flow
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Run `auth login --code` (or `auth login --resume`). Returns the process exit code; never throws for
214
+ * expected failures.
215
+ *
216
+ * Exit codes: 0 connected (or request started with --no-wait), 1 the connection did not happen,
217
+ * 2 wrong usage (no code).
218
+ *
219
+ * @param {object} opts
220
+ * baseUrl, code, product, noWait, resume, json, profile, credDir, env,
221
+ * log (stdout), err (stderr), fetchImpl, sleep, now, lookup (process lookup for tests),
222
+ * pollIntervalMs, maxWaitMs
223
+ * @returns {Promise<number>}
224
+ */
225
+ export async function runConnect(opts = {}) {
226
+ const {
227
+ baseUrl, code, product, noWait = false, resume = false, json = false, profile = null, credDir,
228
+ env = process.env, log = console.log, err = console.error,
229
+ fetchImpl = fetch,
230
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
231
+ now = () => Date.now(),
232
+ lookup,
233
+ pollIntervalMs = POLL_INTERVAL_MS,
234
+ maxWaitMs = MAX_WAIT_MS,
235
+ } = opts;
236
+
237
+ const credOpts = credDir ? { dir: credDir } : { profile };
238
+ // Notes for a human or an Agent reading the output. In --json mode they move to stderr so stdout stays one JSON object.
239
+ const say = (line) => (json ? err(line) : log(line));
240
+ const docsUrl = `${baseUrl}/docs/guide/auto-accept`;
241
+
242
+ /** Print a failure (text or JSON) and return exit code 1/2. */
243
+ const fail = (kind, ctx = {}, exitCode = 1, extra = {}) => {
244
+ const { message, next } = explainError(kind, { baseUrl, profile, ...ctx });
245
+ if (json) {
246
+ log(JSON.stringify({ ok: false, status: ctx.status || kind, error: kind, message, next_step: next, ...extra }));
247
+ } else {
248
+ err(`${message}\n下一步:${next}`);
249
+ }
250
+ return exitCode;
251
+ };
252
+
253
+ // Success output shared by the normal and the resume path.
254
+ const succeed = (creds, file) => {
255
+ if (json) {
256
+ log(JSON.stringify({
257
+ ok: true, status: 'approved', agent_id: creds.agent_id || null, profile: profile || null,
258
+ credentials_path: file, next_step: `运行 stelfield ping 验证;想让 Agent 自动接单,见 ${docsUrl}`,
259
+ }));
260
+ } else {
261
+ log(`✓ 主人已确认,你的 Agent 已接入 ${BRAND.name}。`);
262
+ log(` 凭证已保存到 ${file}`);
263
+ log(' 下一步:运行 stelfield ping 验证连通。');
264
+ log(` 想让 Agent 自动接单,见:${docsUrl}`);
265
+ }
266
+ return 0;
267
+ };
268
+
269
+ let requestId;
270
+ let pollToken;
271
+ let deadline;
272
+
273
+ if (resume) {
274
+ // ── Pick up a request that --no-wait started earlier ──
275
+ const pending = loadPending(credOpts);
276
+ if (!pending || !pending.request_id || !pending.poll_token) return fail('no_pending', {}, 1);
277
+ requestId = pending.request_id;
278
+ pollToken = pending.poll_token;
279
+ const exp = Date.parse(pending.expires_at || '');
280
+ deadline = now() + Math.min(maxWaitMs, Number.isFinite(exp) ? Math.max(0, exp - now()) : maxWaitMs);
281
+ say(`继续等待主人在 ${BRAND.name} 网页上确认…`);
282
+ } else {
283
+ const cleanedCode = cleanCode(code);
284
+ if (!cleanedCode) return fail('code_required', {}, 2);
285
+
286
+ // ── 1. Never overwrite another working Agent in this profile ──
287
+ const existing = loadCredentials(credOpts);
288
+ let existingAgentId = null;
289
+ if (existing && existing.access_token) {
290
+ existingAgentId = existing.agent_id || decodeAgentId(existing.access_token) || null;
291
+ let alive = false;
292
+ try {
293
+ await getCruise(baseUrl, credOpts, { fetchImpl });
294
+ alive = true;
295
+ } catch {
296
+ alive = false; // login is dead (or unreachable): re-connecting is exactly what the owner wants
297
+ }
298
+ if (alive) return fail('busy_profile', {}, 1);
299
+ }
300
+
301
+ // ── 2. Recognition (best effort) ──
302
+ const probe = await fetchProbe(baseUrl, { fetchImpl });
303
+ const tags = await collectTags(probe, { env, lookup });
304
+
305
+ // ── 3. Redeem ──
306
+ const body = {
307
+ code: cleanedCode,
308
+ env_tags: tags.env_tags,
309
+ process_tags: tags.process_tags,
310
+ cli_version: CLI_VERSION,
311
+ os: process.platform,
312
+ };
313
+ const cleanedProduct = cleanProduct(product);
314
+ if (cleanedProduct) body.product = cleanedProduct;
315
+ if (existingAgentId) body.agent_id = existingAgentId;
316
+
317
+ let res;
318
+ try {
319
+ res = await requestJson(fetchImpl, 'POST', `${baseUrl}${API_PREFIX}/redeem`, { body });
320
+ } catch {
321
+ return fail('network');
322
+ }
323
+ if (!res.ok || !res.json || !res.json.request_id || !res.json.poll_token) {
324
+ const kind = classify(res);
325
+ return fail(kind, { retryAfterSec: retryAfterOf(res) });
326
+ }
327
+ requestId = res.json.request_id;
328
+ pollToken = res.json.poll_token;
329
+ const exp = Date.parse(res.json.expires_at || '');
330
+ deadline = now() + Math.min(maxWaitMs, Number.isFinite(exp) ? Math.max(0, exp - now()) : maxWaitMs);
331
+
332
+ // ── 4. Tell the owner what to do ──
333
+ say('✓ 接入码有效,已通知主人。');
334
+ say(`请让主人回到 ${BRAND.name} 网页,在「绑定 Agent」页点确认。`);
335
+ say('等待期间不需要主人再贴别的东西;我会每隔几秒查一次,最长等 15 分钟。');
336
+
337
+ if (noWait) {
338
+ savePending(credOpts, {
339
+ request_id: requestId, poll_token: pollToken, expires_at: res.json.expires_at || null, base_url: baseUrl,
340
+ });
341
+ if (json) {
342
+ log(JSON.stringify({
343
+ ok: true, status: 'pending', request_id: requestId, expires_at: res.json.expires_at || null,
344
+ next_step: '主人在网页确认后,运行 stelfield auth login --resume 取回凭证(也可带上同样的 --profile)。',
345
+ }));
346
+ } else {
347
+ log('(--no-wait)已发起,不再等待。主人确认后,运行:stelfield auth login --resume 取回凭证。');
348
+ }
349
+ return 0;
350
+ }
351
+ }
352
+
353
+ // ── 5. Poll for the owner's answer ──
354
+ // Pending-file bookkeeping: a normal run does not leave one behind.
355
+ let failures = 0;
356
+ const url = `${baseUrl}${API_PREFIX}/requests/${encodeURIComponent(requestId)}/result`;
357
+ const pollHeaders = { Authorization: `Bearer ${pollToken}`, [POLL_TOKEN_HEADER]: pollToken };
358
+ let first = true;
359
+ for (;;) {
360
+ if (!first) {
361
+ if (now() >= deadline) { clearPending(credOpts); return fail('timeout', { status: 'timeout' }); }
362
+ await sleep(pollIntervalMs);
363
+ }
364
+ first = false;
365
+ if (now() >= deadline) { clearPending(credOpts); return fail('timeout', { status: 'timeout' }); }
366
+
367
+ let r;
368
+ try {
369
+ r = await requestJson(fetchImpl, 'GET', url, { headers: pollHeaders });
370
+ } catch {
371
+ failures += 1;
372
+ if (failures >= MAX_POLL_FAILURES) return fail('network');
373
+ continue;
374
+ }
375
+
376
+ if (r.ok && r.json) {
377
+ failures = 0;
378
+ const status = r.json.status;
379
+ if (status === 'pending') continue;
380
+ if (status === 'denied') {
381
+ clearPending(credOpts);
382
+ // The owner did not refuse: the 5-Agent limit filled up between the knock and the confirm click.
383
+ if (r.json.reason === 'agent_limit_reached') return fail('agent_limit_reached', { status: 'denied' });
384
+ return fail('denied', { status: 'denied' });
385
+ }
386
+ if (status === 'expired') { clearPending(credOpts); return fail('expired', { status: 'expired' }); }
387
+ if (status === 'approved') {
388
+ const c = r.json.credentials;
389
+ if (!c || !c.access_token) { clearPending(credOpts); return fail('already_issued', { status: 'approved' }); }
390
+ // ── 6. Save credentials (0600, per profile) ──
391
+ const agentId = c.agent_id || decodeAgentId(c.access_token) || null;
392
+ const creds = { ...c, base_url: baseUrl };
393
+ if (agentId) creds.agent_id = agentId;
394
+ const file = saveCredentials(creds, credOpts);
395
+ clearPending(credOpts);
396
+ return succeed(creds, file);
397
+ }
398
+ continue; // unknown status: keep waiting until the deadline
399
+ }
400
+
401
+ const kind = classify(r);
402
+ if (kind === 'rate_limited') {
403
+ // Slow down as told, then keep going.
404
+ const wait = (retryAfterOf(r) || 10) * 1000;
405
+ await sleep(wait);
406
+ continue;
407
+ }
408
+ if (kind === 'upgrade_required') return fail(kind);
409
+ if (r.status === 404 || r.status === 401 || r.status === 403 || r.status === 410) {
410
+ clearPending(credOpts);
411
+ return fail('request_gone');
412
+ }
413
+ failures += 1; // 5xx and the like: retry a few times
414
+ if (failures >= MAX_POLL_FAILURES) return fail('network');
415
+ }
416
+ }