@remixmate/cli 0.9.13 → 0.9.15

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.
Files changed (63) hide show
  1. package/README.md +54 -1
  2. package/README.zh-CN.md +37 -1
  3. package/dist/auth/auto-login.js +10 -1
  4. package/dist/auth/commands.js +181 -21
  5. package/dist/auth/credential-store.d.ts +16 -0
  6. package/dist/auth/credential-store.js +17 -0
  7. package/dist/auth/device-flow-runner.d.ts +15 -0
  8. package/dist/auth/device-flow-runner.js +60 -12
  9. package/dist/auth/device-flow.js +24 -7
  10. package/dist/auth/ensure.d.ts +55 -0
  11. package/dist/auth/ensure.js +62 -0
  12. package/dist/auth/environment.d.ts +13 -0
  13. package/dist/auth/environment.js +21 -0
  14. package/dist/auth/pending-store.d.ts +33 -0
  15. package/dist/auth/pending-store.js +45 -0
  16. package/dist/billing.d.ts +44 -0
  17. package/dist/billing.js +76 -0
  18. package/dist/cli.js +75 -8
  19. package/dist/doctor.d.ts +22 -0
  20. package/dist/doctor.js +142 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +33 -0
  23. package/dist/exec.d.ts +18 -0
  24. package/dist/exec.js +47 -0
  25. package/dist/handlers/gen-digital-human.js +1 -0
  26. package/dist/handlers/gen-image.js +1 -0
  27. package/dist/handlers/gen-video.js +1 -0
  28. package/dist/handlers/gen-voice.js +2 -0
  29. package/dist/handlers/index.d.ts +7 -0
  30. package/dist/http.d.ts +20 -6
  31. package/dist/http.js +43 -15
  32. package/dist/manifest.json +2 -2
  33. package/dist/registry.d.ts +4 -2
  34. package/dist/registry.js +2 -1
  35. package/dist/runner.d.ts +4 -0
  36. package/dist/runner.js +36 -12
  37. package/dist/skill-schema.d.ts +19 -0
  38. package/dist/skill-schema.js +18 -0
  39. package/dist/text.d.ts +8 -0
  40. package/dist/text.js +15 -0
  41. package/package.json +2 -2
  42. package/skills/export-jianying/skill.json +1 -0
  43. package/skills/gen-digital-human/SKILL.md +12 -0
  44. package/skills/gen-digital-human/skill.json +1 -0
  45. package/skills/gen-image/SKILL.md +12 -0
  46. package/skills/gen-image/skill.json +1 -0
  47. package/skills/gen-script/skill.json +1 -0
  48. package/skills/gen-video/SKILL.md +12 -0
  49. package/skills/gen-video/skill.json +1 -0
  50. package/skills/gen-voice/SKILL.md +12 -0
  51. package/skills/gen-voice/skill.json +1 -0
  52. package/skills/prepare-video-assets/SKILL.md +12 -0
  53. package/skills/prepare-video-assets/skill.json +1 -0
  54. package/skills/render-video/SKILL.md +12 -0
  55. package/skills/render-video/scripts/render_video.py +63 -0
  56. package/skills/render-video/skill.json +1 -0
  57. package/skills/template-registry/scripts/list_templates.py +16 -1
  58. package/skills/template-registry/scripts/registry_loader.py +63 -63
  59. package/skills/template-registry/scripts/render_job_client.py +27 -0
  60. package/skills/template-registry/skill.json +1 -0
  61. package/skills/video-parser/skill.json +1 -0
  62. package/skills/web-record/skill.json +1 -0
  63. package/skills/web-screenshot/skill.json +1 -0
package/README.md CHANGED
@@ -52,7 +52,13 @@ remixmate logout # remove stored credentials from this machine
52
52
  `login` uses the OAuth 2.0 Device Authorization Grant (RFC 8628): the CLI shows a
53
53
  device code, you approve it in a browser that is already signed in to the web app, and
54
54
  the CLI receives and stores your credential in the OS keychain (or a `0600` file at
55
- `~/.config/remixmate/credentials.json`). It only runs in an interactive terminal.
55
+ `~/.config/remixmate/credentials.json`).
56
+
57
+ No TTY is required — the device flow reads no keyboard input, so an agent host
58
+ (Claude Code / Codex) can run it and relay the authorization link into the chat.
59
+ The CLI prints a link with the device code pre-filled, so approving is one click.
60
+ It is refused only where a browser could never be reached: CI, a headless Linux
61
+ session, or `REMIXMATE_NO_BROWSER_AUTH=1`; use `PRIV_TOKEN` there instead.
56
62
 
57
63
  For CI and agent-embedded hosts (ab-agent / Claude Code), keep using the `PRIV_TOKEN`
58
64
  environment variable. Credential resolution precedence is:
@@ -63,6 +69,53 @@ environment variable. Credential resolution precedence is:
63
69
 
64
70
  When `PRIV_TOKEN` is set, `login` is skipped and the device flow is never auto-triggered.
65
71
 
72
+ The credential is resolved once, on the Node side (`src/auth/ensure.ts`), and injected
73
+ into spawned Python skills through their environment — skill scripts never read the
74
+ credential store or the keychain themselves. To give any other command the same
75
+ credential, use `exec`:
76
+
77
+ ```bash
78
+ remixmate exec -- python3 scripts/test-template-pipeline.py
79
+ ```
80
+
81
+ Each skill declares its requirement as `auth` in `skill.json`: `required` (authorize
82
+ when no token is present), `optional` (use a token if there is one, otherwise carry on
83
+ in a degraded mode — `web-record` keeps the file locally), or `none` (purely local, e.g.
84
+ `web-screenshot`).
85
+
86
+ Exit codes on failure: `4` = authorization needed, `5` = backend unreachable,
87
+ `2` = usage error.
88
+
89
+ ### Non-blocking authorization (agent hosts)
90
+
91
+ A blocking `login` waits for the device code's full lifetime, which outlives an
92
+ agent's time-bounded tool call. Split it instead:
93
+
94
+ ```bash
95
+ remixmate login --start --json # returns immediately with the approval link
96
+ remixmate login --wait --timeout 60 --json # bounded poll; call again while pending
97
+ ```
98
+
99
+ `--start` persists the device code to a `0600` `~/.config/remixmate/pending.json`;
100
+ `--wait` redeems it and clears the file. While the user has not yet approved,
101
+ `--wait` reports `{"status":"pending"}` with exit code 4 and leaves the pending
102
+ state in place, so it is safe to call repeatedly.
103
+
104
+ ### Diagnosing
105
+
106
+ ```bash
107
+ remixmate doctor # node / python3 / Playwright / credential / backend
108
+ remixmate doctor --offline # skip the reachability probe
109
+ ```
110
+
111
+ `doctor` exits `4` when the only problem is a missing credential.
112
+
113
+ ### Listing skills
114
+
115
+ `remixmate --list` prints a human-readable table (name + whether it needs a login
116
+ + summary) and appends the current auth status on stderr, so piping stdout is
117
+ unaffected. For tooling, use `remixmate --list --json`.
118
+
66
119
  ### Automatic browser authorization (local hosts)
67
120
 
68
121
  When a skill that needs a credential is invoked from a **local third-party host**
package/README.zh-CN.md CHANGED
@@ -48,7 +48,9 @@ remixmate whoami # 查看当前身份(绝不打印 token)
48
48
  remixmate logout # 从本机移除已存储的凭证
49
49
  ```
50
50
 
51
- `login` 使用 OAuth 2.0 设备授权流程(RFC 8628):CLI 显示设备码,你在已登录 Web 的浏览器中确认,CLI 随后取回凭证并保存到系统钥匙串(或 `~/.config/remixmate/credentials.json`,权限 `0600`)。仅在交互式终端中可用。
51
+ `login` 使用 OAuth 2.0 设备授权流程(RFC 8628):CLI 给出一条已预填设备码的授权链接,你在已登录 Web 的浏览器中点一下确认,CLI 随后取回凭证并保存到系统钥匙串(或 `~/.config/remixmate/credentials.json`,权限 `0600`)。
52
+
53
+ 不需要交互式终端 —— 设备流不读键盘输入,agent 宿主(Claude Code / Codex)可以直接执行并把链接转述到聊天里。只有在浏览器根本不可达时才会拒绝:CI、无桌面会话的 Linux、或设置了 `REMIXMATE_NO_BROWSER_AUTH=1`;这些场景请改用 `PRIV_TOKEN`。
52
54
 
53
55
  CI 与 agent 宿主(ab-agent / Claude Code)继续使用 `PRIV_TOKEN` 环境变量。凭证解析优先级:
54
56
 
@@ -58,6 +60,40 @@ CI 与 agent 宿主(ab-agent / Claude Code)继续使用 `PRIV_TOKEN` 环境
58
60
 
59
61
  设置了 `PRIV_TOKEN` 时,`login` 会跳过,且任何命令都不会自动触发设备登录流程。
60
62
 
63
+ 凭证只在 CLI 的 Node 侧解析一次(`src/auth/ensure.ts`),再通过环境变量注入给被调起的 Python 技能 —— 技能脚本自己不读凭证库、不读钥匙串。需要让其它命令(如维护脚本)拿到同一份凭证时,用 `exec`:
64
+
65
+ ```bash
66
+ remixmate exec -- python3 scripts/test-template-pipeline.py
67
+ ```
68
+
69
+ 每个技能在 `skill.json` 里用 `auth` 声明鉴权需求:`required`(缺 token 就发起授权)、`optional`(有就用,没有则降级继续,如 `web-record` 只保留本地文件)、`none`(纯本地,如 `web-screenshot`)。
70
+
71
+ 失败时的退出码:`4` = 需要授权,`5` = 后端不可达,`2` = 用法错误。
72
+
73
+ ### 非阻塞授权(agent 宿主)
74
+
75
+ 阻塞式 `login` 会一直等到设备码过期(10 分钟),超过 agent 单次工具调用的时长。改用两段式:
76
+
77
+ ```bash
78
+ remixmate login --start --json # 立刻返回授权链接,不等待
79
+ remixmate login --wait --timeout 60 --json # 有界轮询;还没授权就再调一次
80
+ ```
81
+
82
+ `--start` 把设备码写入 `0600` 的 `~/.config/remixmate/pending.json`;`--wait` 兑换后清除它。用户尚未确认时 `--wait` 返回 `{"status":"pending"}` 与退出码 4,并保留 pending 状态,可以反复调用。
83
+
84
+ ### 排障
85
+
86
+ ```bash
87
+ remixmate doctor # 检查 node / python3 / Playwright / 凭证 / 后端
88
+ remixmate doctor --offline # 跳过联网探测
89
+ ```
90
+
91
+ 只有凭证一项有问题时,`doctor` 退出码为 `4`。
92
+
93
+ ### 列出技能
94
+
95
+ `remixmate --list` 默认输出人类可读表格(技能名 + 是否需登录 + 摘要),末尾附一行当前登录状态(写到 stderr,不干扰管道)。给工具消费请用 `remixmate --list --json`。
96
+
61
97
  > 安全提示:若你此前曾把 `PRIV_TOKEN` 粘贴到聊天框或写进 shell 历史,切换到 `remixmate login` 后建议轮换该 token。
62
98
 
63
99
  ## 环境
@@ -15,7 +15,16 @@ import { resolvePrivToken } from './resolve.js';
15
15
  import { canAutoAuth } from './environment.js';
16
16
  import { acquireLock, waitForCredential } from './auth-lock.js';
17
17
  import { runDeviceFlow } from './device-flow-runner.js';
18
- const DEFAULT_WAIT_MS = 25_000;
18
+ /**
19
+ * How long a skill call waits for the user to approve in the browser.
20
+ *
21
+ * 90s, not 25s: when an agent host relays the link into a chat, the clock
22
+ * includes the user reading the message, switching to a browser and clicking —
23
+ * 25s reliably expired before a human could react. The ceiling is set by the
24
+ * host's own tool-call timeout (Claude Code's Bash tool defaults to 120s), so
25
+ * this must stay comfortably under that or the wait gets truncated anyway.
26
+ */
27
+ const DEFAULT_WAIT_MS = 90_000;
19
28
  /** Extra slack on top of the wait window before a held lock is considered stale. */
20
29
  const STALE_SLACK_MS = 10_000;
21
30
  /** Resolve the short-wait window from env, falling back to the default. */
@@ -6,50 +6,198 @@
6
6
  */
7
7
  import { parseArgv } from '../argv.js';
8
8
  import { resolveApiBaseUrl } from '../http.js';
9
- import { getCredential, removeCredential } from './credential-store.js';
9
+ import { EXIT } from '../errors.js';
10
+ import { getCredential, listCredentials, removeCredential } from './credential-store.js';
10
11
  import { resolvePrivToken } from './resolve.js';
11
- import { runDeviceFlow } from './device-flow-runner.js';
12
+ import { persistApproval, runDeviceFlow, writeAuthPrompt } from './device-flow-runner.js';
13
+ import { openBrowser, pollForToken, requestDeviceCode, DeviceFlowError } from './device-flow.js';
14
+ import { clearPending, getPending, setPending } from './pending-store.js';
15
+ import { canAutoAuth, isAgentHost } from './environment.js';
16
+ /**
17
+ * Bounded wait used when an agent host runs `login` on the user's behalf. The
18
+ * device code lives ~10 minutes, but an agent's tool call does not — blocking
19
+ * that long guarantees the call is killed with nothing persisted. Stop early
20
+ * and tell the caller it can simply re-run.
21
+ */
22
+ const AGENT_LOGIN_WAIT_MS = 90_000;
23
+ /**
24
+ * Explain, when the active backend has no credential, which backends DO have
25
+ * one. Returns '' when there is nothing useful to add.
26
+ *
27
+ * Credentials are keyed by API base URL, so being "logged in" to
28
+ * http://localhost:2999/api tells you nothing about production — but the bare
29
+ * message ("not logged in") reads as if `login` had never been run.
30
+ */
31
+ async function describeOtherBackends(activeApiBaseUrl) {
32
+ const others = (await listCredentials()).filter((c) => c.apiBaseUrl !== activeApiBaseUrl);
33
+ if (others.length === 0)
34
+ return '';
35
+ const lines = others.map((c) => ` - ${c.apiBaseUrl}${c.userLabel ? `(${c.userLabel})` : ''}`);
36
+ return (`ℹ️ 当前后端:${activeApiBaseUrl}(无凭证)\n` +
37
+ ` 已存储凭证的后端:\n${lines.join('\n')}\n` +
38
+ ` 要使用其中之一,设置 MM_API_BASE_URL=<该地址>,或加 --api-base-url <该地址>。\n`);
39
+ }
12
40
  async function login(apiBaseUrl) {
13
41
  // Env token short-circuits the device flow (CI / agent-embedded hosts).
14
42
  if ((process.env.PRIV_TOKEN ?? '').trim()) {
15
43
  process.stdout.write('ℹ️ PRIV_TOKEN 环境变量已设置,CLI 将直接使用它,无需登录。\n');
16
- return 0;
44
+ return EXIT.OK;
17
45
  }
18
- // Device flow requires an interactive terminal.
19
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
20
- process.stderr.write('❌ remixmate login 需要交互式终端;在 CI/无终端环境请改用 PRIV_TOKEN 环境变量。\n');
21
- return 1;
46
+ // The device flow reads no keyboard input, so a TTY is not required — an
47
+ // agent host can run it and relay the link into chat. What IS required is a
48
+ // way to reach the user: canAutoAuth() rejects CI / headless, where a browser
49
+ // could never be opened and blocking would just hang the job.
50
+ if (!canAutoAuth()) {
51
+ process.stderr.write('❌ 当前环境无法完成浏览器授权(CI / 无桌面会话 / 已通过 REMIXMATE_NO_BROWSER_AUTH 关闭)。\n' +
52
+ ' 请改用 PRIV_TOKEN 环境变量。\n');
53
+ return EXIT.NOT_AUTHENTICATED;
22
54
  }
23
55
  // Already authenticated for this backend?
24
56
  const existing = await getCredential(apiBaseUrl);
25
57
  if (existing) {
26
58
  process.stdout.write(`✅ 已登录(${existing.userLabel ?? 'unknown'})。如需切换账号请先运行 remixmate logout。\n`);
27
- return 0;
59
+ return EXIT.OK;
28
60
  }
29
- // Interactive login uses the device code's full lifetime (no short-wait cap)
30
- // and routes progress to stdout. Shared orchestration handles persistence.
61
+ // A human at a terminal can wait out the device code's full lifetime; an
62
+ // agent tool call cannot (see AGENT_LOGIN_WAIT_MS). Progress goes to stdout.
31
63
  const outcome = await runDeviceFlow(apiBaseUrl, {
32
64
  write: (msg) => process.stdout.write(msg),
65
+ maxWaitMs: isAgentHost() ? AGENT_LOGIN_WAIT_MS : undefined,
33
66
  });
34
67
  switch (outcome.status) {
35
68
  case 'approved':
36
69
  process.stdout.write(`\n✅ 登录成功:${outcome.userLabel ?? ''}\n`);
37
70
  process.stdout.write('🔒 安全提示:若你此前曾把 PrivToken 粘贴到聊天框或写进 shell 历史,建议在控制台轮换该 token。\n');
38
- return 0;
71
+ return EXIT.OK;
39
72
  case 'denied':
40
73
  process.stderr.write('❌ 授权被拒绝。\n');
41
- return 1;
74
+ return EXIT.NOT_AUTHENTICATED;
42
75
  case 'error':
43
76
  process.stderr.write(`❌ 登录失败:${outcome.message}\n`);
44
- return 1;
77
+ return EXIT.ERROR;
45
78
  case 'timeout':
79
+ // The wait window closed, not the device code. Authorization may still be
80
+ // pending in the browser — re-running picks up the approval immediately.
81
+ process.stderr.write('⏳ 已打开授权页但未在等待窗口内完成。在浏览器点击确认后,重跑 remixmate login(或直接重跑原命令)即可。\n');
82
+ return EXIT.NOT_AUTHENTICATED;
46
83
  case 'expired':
47
84
  default:
48
85
  process.stderr.write('❌ 设备码已过期或无效,请重新运行 remixmate login。\n');
49
- return 1;
86
+ return EXIT.NOT_AUTHENTICATED;
50
87
  }
51
88
  }
89
+ /** Default bound for one `login --wait` call, overridable with --timeout <seconds>. */
90
+ const DEFAULT_WAIT_SECONDS = 60;
91
+ /**
92
+ * `login --start` — request a device code, persist it, print the link, return.
93
+ *
94
+ * Deliberately does NOT poll: the caller (an agent, or a script) gets the link
95
+ * immediately and can surface it while the user takes as long as they need.
96
+ * Approval is collected later by `login --wait`.
97
+ */
98
+ async function loginStart(apiBaseUrl, jsonOutput) {
99
+ const clientLabel = `remixmate-cli (${process.platform} ${process.arch})`;
100
+ let code;
101
+ try {
102
+ code = await requestDeviceCode(apiBaseUrl, clientLabel);
103
+ }
104
+ catch (err) {
105
+ const msg = err instanceof DeviceFlowError ? err.message : err.message;
106
+ process.stderr.write(`❌ 无法获取设备码:${msg}\n`);
107
+ return EXIT.BACKEND_UNREACHABLE;
108
+ }
109
+ await setPending({
110
+ apiBaseUrl,
111
+ deviceCode: code.deviceCode,
112
+ userCode: code.userCode,
113
+ verificationUri: code.verificationUri,
114
+ verificationUriComplete: code.verificationUriComplete,
115
+ interval: code.interval,
116
+ expiresAt: Date.now() + code.expiresIn * 1000,
117
+ });
118
+ if (jsonOutput) {
119
+ process.stdout.write(JSON.stringify({
120
+ status: 'pending',
121
+ apiBaseUrl,
122
+ verificationUri: code.verificationUri,
123
+ verificationUriComplete: code.verificationUriComplete ?? code.verificationUri,
124
+ userCode: code.userCode,
125
+ expiresIn: code.expiresIn,
126
+ // The device_code itself is intentionally omitted — `login --wait` reads
127
+ // it from the pending file, so it never has to travel through a log or
128
+ // an agent's context.
129
+ }) + '\n');
130
+ }
131
+ else {
132
+ writeAuthPrompt((m) => process.stdout.write(m), apiBaseUrl, code);
133
+ openBrowser(code.verificationUriComplete ?? code.verificationUri);
134
+ process.stdout.write('完成授权后运行 remixmate login --wait 收取凭证。\n');
135
+ }
136
+ return EXIT.OK;
137
+ }
138
+ /**
139
+ * `login --wait [--timeout N]` — poll a previously started authorization for a
140
+ * bounded window. Safe to call repeatedly until it reports approved or expired.
141
+ */
142
+ async function loginWait(apiBaseUrl, timeoutSeconds, jsonOutput) {
143
+ const emit = (payload, human, code) => {
144
+ if (jsonOutput)
145
+ process.stdout.write(JSON.stringify(payload) + '\n');
146
+ else
147
+ process.stdout.write(human);
148
+ return code;
149
+ };
150
+ const pending = await getPending(apiBaseUrl);
151
+ if (!pending) {
152
+ if (jsonOutput)
153
+ process.stdout.write(JSON.stringify({ status: 'no_pending', apiBaseUrl }) + '\n');
154
+ else
155
+ process.stderr.write(`❌ 没有进行中的授权(或已过期 / 属于别的后端)。请先运行 remixmate login --start。\n`);
156
+ return EXIT.NOT_AUTHENTICATED;
157
+ }
158
+ // Rebuild the shape pollForToken expects; expiresIn is the REMAINING lifetime
159
+ // so a resumed wait can never poll past the device code's real expiry.
160
+ const remainingSec = Math.max(1, Math.ceil((pending.expiresAt - Date.now()) / 1000));
161
+ let result;
162
+ try {
163
+ result = await pollForToken(apiBaseUrl, {
164
+ deviceCode: pending.deviceCode,
165
+ userCode: pending.userCode,
166
+ verificationUri: pending.verificationUri,
167
+ verificationUriComplete: pending.verificationUriComplete,
168
+ interval: pending.interval,
169
+ expiresIn: remainingSec,
170
+ }, { maxWaitMs: timeoutSeconds * 1000 });
171
+ }
172
+ catch (err) {
173
+ const msg = err instanceof DeviceFlowError ? err.message : err.message;
174
+ if (jsonOutput)
175
+ process.stdout.write(JSON.stringify({ status: 'error', message: msg }) + '\n');
176
+ else
177
+ process.stderr.write(`❌ 轮询失败:${msg}\n`);
178
+ return EXIT.BACKEND_UNREACHABLE;
179
+ }
180
+ if (result.status === 'approved' && result.privToken) {
181
+ await persistApproval(apiBaseUrl, result.privToken, result.userLabel, (m) => process.stdout.write(m));
182
+ await clearPending();
183
+ return emit({ status: 'approved', userLabel: result.userLabel, apiBaseUrl }, `✅ 登录成功:${result.userLabel ?? ''}\n`, EXIT.OK);
184
+ }
185
+ if (result.status === 'access_denied') {
186
+ await clearPending();
187
+ return emit({ status: 'denied' }, '❌ 授权被拒绝。\n', EXIT.NOT_AUTHENTICATED);
188
+ }
189
+ if (result.timedOut) {
190
+ // The wait window closed, not the device code — the pending state stays put
191
+ // so the caller can simply poll again.
192
+ return emit({ status: 'pending', apiBaseUrl, userCode: pending.userCode }, '⏳ 尚未完成授权。在浏览器确认后再次运行 remixmate login --wait。\n', EXIT.NOT_AUTHENTICATED);
193
+ }
194
+ await clearPending();
195
+ return emit({ status: 'expired' }, '❌ 设备码已过期,请重新运行 remixmate login --start。\n', EXIT.NOT_AUTHENTICATED);
196
+ }
52
197
  async function logout(apiBaseUrl) {
198
+ // Drop any half-finished authorization too, so a later `--wait` can't redeem
199
+ // a device code the user just logged out of.
200
+ await clearPending();
53
201
  const res = await removeCredential(apiBaseUrl);
54
202
  const localNote = 'ℹ️ logout 仅清除本地凭证,不会在服务端吊销 PrivToken;如需服务端失效请轮换 token。\n';
55
203
  if (!res.removedFile && !res.removedKeychain && !res.fileError && !res.keychainError) {
@@ -96,20 +244,23 @@ async function whoami(apiBaseUrl, flagToken) {
96
244
  const resolved = await resolvePrivToken({ flag: flagToken, apiBaseUrl });
97
245
  if (!resolved) {
98
246
  process.stderr.write('❌ 未登录。运行 remixmate login,或设置 PRIV_TOKEN 环境变量。\n');
99
- return 1;
247
+ const hint = await describeOtherBackends(apiBaseUrl);
248
+ if (hint)
249
+ process.stderr.write(hint);
250
+ return EXIT.NOT_AUTHENTICATED;
100
251
  }
101
252
  const verdict = await verifyToken(apiBaseUrl, resolved.privToken);
102
253
  if (verdict === 'unreachable') {
103
254
  process.stderr.write('❌ 无法验证身份:后端不可达或超时(本地凭证未改动)。\n');
104
- return 1;
255
+ return EXIT.BACKEND_UNREACHABLE;
105
256
  }
106
257
  if (verdict === 'invalid') {
107
258
  process.stderr.write(`❌ 凭证无效或已被吊销。来源:${resolved.source}\n`);
108
- return 1;
259
+ return EXIT.NOT_AUTHENTICATED;
109
260
  }
110
261
  const label = resolved.userLabel ?? '(已认证)';
111
- process.stdout.write(`已认证:${label}\n来源:${resolved.source}\n`);
112
- return 0;
262
+ process.stdout.write(`已认证:${label}\n来源:${resolved.source}\n后端:${apiBaseUrl}\n`);
263
+ return EXIT.OK;
113
264
  }
114
265
  /** Dispatch an auth verb. Returns a process exit code. */
115
266
  export async function runAuthCommand(verb, rest) {
@@ -117,14 +268,23 @@ export async function runAuthCommand(verb, rest) {
117
268
  const apiBaseUrl = resolveApiBaseUrl(args.api_base_url);
118
269
  const flagToken = args.token;
119
270
  switch (verb) {
120
- case 'login':
271
+ case 'login': {
272
+ const jsonOutput = args.json_output === true || args.json === true;
273
+ if (args.start === true)
274
+ return loginStart(apiBaseUrl, jsonOutput);
275
+ if (args.wait === true || typeof args.wait === 'string') {
276
+ const raw = Number(args.timeout);
277
+ const timeout = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_WAIT_SECONDS;
278
+ return loginWait(apiBaseUrl, timeout, jsonOutput);
279
+ }
121
280
  return login(apiBaseUrl);
281
+ }
122
282
  case 'logout':
123
283
  return logout(apiBaseUrl);
124
284
  case 'whoami':
125
285
  return whoami(apiBaseUrl, flagToken);
126
286
  default:
127
287
  process.stderr.write(`❌ unknown auth command: ${verb}\n`);
128
- return 2;
288
+ return EXIT.USAGE;
129
289
  }
130
290
  }
@@ -40,5 +40,21 @@ export declare function setCredential(apiBaseUrl: string, cred: StoredCredential
40
40
  }>;
41
41
  /** Read a credential for `apiBaseUrl`, or null when none is stored. */
42
42
  export declare function getCredential(apiBaseUrl: string): Promise<StoredCredential | null>;
43
+ /** Non-secret summary of one stored entry, for diagnostics (`whoami`, `doctor`). */
44
+ export interface CredentialSummary {
45
+ apiBaseUrl: string;
46
+ userLabel?: string;
47
+ createdAt: number;
48
+ secret: 'file' | 'keychain';
49
+ }
50
+ /**
51
+ * List which backends have a stored credential, WITHOUT reading any secret.
52
+ *
53
+ * Credentials are keyed by API base URL, so "logged in" is per-backend. Without
54
+ * this, a credential stored for a local dev backend while the CLI defaults to
55
+ * production produced a bare "not logged in" — technically true, actively
56
+ * misleading, and the exact confusion that motivated this work.
57
+ */
58
+ export declare function listCredentials(): Promise<CredentialSummary[]>;
43
59
  /** Remove a credential from both stores; reports per-store outcome. */
44
60
  export declare function removeCredential(apiBaseUrl: string): Promise<RemoveResult>;
@@ -99,6 +99,23 @@ export async function getCredential(apiBaseUrl) {
99
99
  return null;
100
100
  return { privToken, userLabel: entry.userLabel, createdAt: entry.createdAt };
101
101
  }
102
+ /**
103
+ * List which backends have a stored credential, WITHOUT reading any secret.
104
+ *
105
+ * Credentials are keyed by API base URL, so "logged in" is per-backend. Without
106
+ * this, a credential stored for a local dev backend while the CLI defaults to
107
+ * production produced a bare "not logged in" — technically true, actively
108
+ * misleading, and the exact confusion that motivated this work.
109
+ */
110
+ export async function listCredentials() {
111
+ const store = await readFileStore();
112
+ return Object.entries(store.credentials).map(([apiBaseUrl, entry]) => ({
113
+ apiBaseUrl,
114
+ userLabel: entry.userLabel,
115
+ createdAt: entry.createdAt,
116
+ secret: entry.secret,
117
+ }));
118
+ }
102
119
  /** Remove a credential from both stores; reports per-store outcome. */
103
120
  export async function removeCredential(apiBaseUrl) {
104
121
  const result = { removedFile: false, removedKeychain: false };
@@ -9,6 +9,15 @@
9
9
  *
10
10
  * Security: the PrivToken value is NEVER written to the provided sink.
11
11
  */
12
+ import { type DeviceCodeResponse } from './device-flow.js';
13
+ export declare function renderAuthBlock(apiBaseUrl: string, code: DeviceCodeResponse): string;
14
+ /**
15
+ * Emit the authorization prompt: a one-click link (device code pre-filled),
16
+ * the manual fallback, and — for agent hosts — the machine-readable block.
17
+ * Shared by `runDeviceFlow` and `login --start` so both entry points present
18
+ * authorization identically.
19
+ */
20
+ export declare function writeAuthPrompt(write: (msg: string) => void, apiBaseUrl: string, code: DeviceCodeResponse): void;
12
21
  export interface DeviceFlowOptions {
13
22
  /**
14
23
  * Upper bound on how long to poll for approval. When omitted the device
@@ -35,6 +44,12 @@ export type DeviceFlowOutcome = {
35
44
  status: 'error';
36
45
  message: string;
37
46
  };
47
+ /**
48
+ * Persist an approved PrivToken. Shared by `runDeviceFlow` and `login --wait`
49
+ * so a credential written by either is indistinguishable. The token value is
50
+ * never written to `write`.
51
+ */
52
+ export declare function persistApproval(apiBaseUrl: string, privToken: string, userLabel: string | undefined, write: (msg: string) => void): Promise<void>;
38
53
  /**
39
54
  * Run one full Device Flow against `apiBaseUrl`. On approval the PrivToken is
40
55
  * persisted via setCredential (keychain or file, same as `login`) and only the
@@ -11,9 +11,59 @@
11
11
  */
12
12
  import { DeviceFlowError, openBrowser, pollForToken, requestDeviceCode, } from './device-flow.js';
13
13
  import { setCredential } from './credential-store.js';
14
+ import { isAgentHost } from './environment.js';
15
+ /**
16
+ * Machine-readable authorization event, emitted alongside the human text when
17
+ * an agent host is driving the CLI. Delimited by markers (rather than a bare
18
+ * JSON line) because a skill's own stdout/stderr is interleaved on the same
19
+ * stream — the host greps for the block, never for prose that may be reworded.
20
+ */
21
+ const AUTH_BLOCK_OPEN = '<<REMIXMATE_AUTH_REQUIRED';
22
+ const AUTH_BLOCK_CLOSE = 'REMIXMATE_AUTH_REQUIRED>>';
23
+ export function renderAuthBlock(apiBaseUrl, code) {
24
+ const payload = {
25
+ apiBaseUrl,
26
+ verificationUri: code.verificationUri,
27
+ verificationUriComplete: code.verificationUriComplete ?? code.verificationUri,
28
+ userCode: code.userCode,
29
+ expiresIn: code.expiresIn,
30
+ };
31
+ return `${AUTH_BLOCK_OPEN}\n${JSON.stringify(payload)}\n${AUTH_BLOCK_CLOSE}\n`;
32
+ }
33
+ /**
34
+ * Emit the authorization prompt: a one-click link (device code pre-filled),
35
+ * the manual fallback, and — for agent hosts — the machine-readable block.
36
+ * Shared by `runDeviceFlow` and `login --start` so both entry points present
37
+ * authorization identically.
38
+ */
39
+ export function writeAuthPrompt(write, apiBaseUrl, code) {
40
+ const openUrl = code.verificationUriComplete ?? code.verificationUri;
41
+ write(`\n🔐 需要授权 remixmate CLI,请在浏览器中打开:\n ${openUrl}\n`);
42
+ if (code.verificationUriComplete) {
43
+ write(`(若链接无法打开:访问 ${code.verificationUri} 并输入设备码 ${code.userCode})\n`);
44
+ }
45
+ write('\n');
46
+ if (isAgentHost())
47
+ write(renderAuthBlock(apiBaseUrl, code));
48
+ }
14
49
  function nowSec() {
15
50
  return Math.floor(Date.now() / 1000);
16
51
  }
52
+ /**
53
+ * Persist an approved PrivToken. Shared by `runDeviceFlow` and `login --wait`
54
+ * so a credential written by either is indistinguishable. The token value is
55
+ * never written to `write`.
56
+ */
57
+ export async function persistApproval(apiBaseUrl, privToken, userLabel, write) {
58
+ const { usedKeychain, keychainError } = await setCredential(apiBaseUrl, {
59
+ privToken,
60
+ userLabel,
61
+ createdAt: nowSec(),
62
+ });
63
+ if (!usedKeychain && keychainError) {
64
+ write('(系统钥匙串不可用,凭证已写入受保护的本地文件 ~/.config/remixmate/credentials.json)\n');
65
+ }
66
+ }
17
67
  /**
18
68
  * Run one full Device Flow against `apiBaseUrl`. On approval the PrivToken is
19
69
  * persisted via setCredential (keychain or file, same as `login`) and only the
@@ -24,27 +74,25 @@ export async function runDeviceFlow(apiBaseUrl, opts = {}) {
24
74
  const clientLabel = opts.clientLabel ?? `remixmate-cli (${process.platform} ${process.arch})`;
25
75
  try {
26
76
  const code = await requestDeviceCode(apiBaseUrl, clientLabel);
77
+ // The backend pre-fills the user code into verificationUriComplete, so the
78
+ // user only has to click and confirm. Lead with that link — asking someone
79
+ // to open a bare URL and transcribe a device code is a different (much
80
+ // higher) difficulty tier, and the whole point is that a non-technical user
81
+ // never has to touch a terminal.
27
82
  const openUrl = code.verificationUriComplete ?? code.verificationUri;
28
- // Always print the URL + user code, even when the browser auto-opens, so a
29
- // host (Claude Code / Codex) can relay it to the user.
30
- write(`\n请在浏览器中完成授权:\n ${code.verificationUri}\n设备码:${code.userCode}\n\n`);
83
+ // Always print the link, even when the browser auto-opens, so a host
84
+ // (Claude Code / Codex) can relay it into the chat.
85
+ writeAuthPrompt(write, apiBaseUrl, code);
31
86
  if (openBrowser(openUrl)) {
32
87
  write('已尝试自动打开浏览器…\n');
33
88
  }
34
89
  else {
35
- write('无法自动打开浏览器,请手动复制上面的地址。\n');
90
+ write('无法自动打开浏览器,请手动复制上面的链接。\n');
36
91
  }
37
92
  write('等待授权中(可在浏览器确认)…\n');
38
93
  const result = await pollForToken(apiBaseUrl, code, { maxWaitMs: opts.maxWaitMs });
39
94
  if (result.status === 'approved' && result.privToken) {
40
- const { usedKeychain, keychainError } = await setCredential(apiBaseUrl, {
41
- privToken: result.privToken,
42
- userLabel: result.userLabel,
43
- createdAt: nowSec(),
44
- });
45
- if (!usedKeychain && keychainError) {
46
- write('(系统钥匙串不可用,凭证已写入受保护的本地文件 ~/.config/remixmate/credentials.json)\n');
47
- }
95
+ await persistApproval(apiBaseUrl, result.privToken, result.userLabel, write);
48
96
  return { status: 'approved', userLabel: result.userLabel };
49
97
  }
50
98
  if (result.status === 'access_denied')
@@ -12,17 +12,34 @@ async function postJson(url, body, timeoutMs) {
12
12
  const controller = new AbortController();
13
13
  const timer = setTimeout(() => controller.abort(), timeoutMs);
14
14
  try {
15
- const resp = await fetch(url, {
16
- method: 'POST',
17
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
18
- body: JSON.stringify(body),
19
- signal: controller.signal,
20
- });
15
+ let resp;
16
+ try {
17
+ resp = await fetch(url, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
20
+ body: JSON.stringify(body),
21
+ signal: controller.signal,
22
+ });
23
+ }
24
+ catch (err) {
25
+ // A DNS/TCP/TLS failure or an abort rejects with a plain Error. Left
26
+ // unwrapped it escapes runDeviceFlow's DeviceFlowError handler and
27
+ // surfaces to the user as a raw stack trace.
28
+ const reason = err instanceof Error && err.name === 'AbortError'
29
+ ? `请求超时(${Math.round(timeoutMs / 1000)}s)`
30
+ : err.message;
31
+ throw new DeviceFlowError(`无法连接授权服务 ${url}: ${reason}`);
32
+ }
21
33
  const text = await resp.text();
22
34
  if (!resp.ok) {
23
35
  throw new DeviceFlowError(`HTTP ${resp.status}: ${text.slice(0, 200)}`);
24
36
  }
25
- return JSON.parse(text);
37
+ try {
38
+ return JSON.parse(text);
39
+ }
40
+ catch {
41
+ throw new DeviceFlowError(`授权服务返回了非 JSON 响应: ${text.slice(0, 200)}`);
42
+ }
26
43
  }
27
44
  finally {
28
45
  clearTimeout(timer);