@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
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The single authorization entry point for every skill invocation.
3
+ *
4
+ * Before this module the CLI resolved PrivTokens in three unrelated places:
5
+ * `resolveHttpContext` (4 TS handlers, with credential store + auto device
6
+ * login), `registry_loader.py` (1 Python skill, with its own credential-file
7
+ * reader), and plain `os.environ["PRIV_TOKEN"]` (the other 7 Python skills,
8
+ * which therefore ignored `remixmate login` entirely). Auto browser auth only
9
+ * ever covered the first group.
10
+ *
11
+ * Now `runner.ts` calls `ensureAuth()` once, before dispatch, for every skill —
12
+ * so the credential store, the OS keychain and the device flow exist in exactly
13
+ * one place (Node), and Python scripts only ever read an injected `PRIV_TOKEN`.
14
+ */
15
+ import { type CredentialSource } from './resolve.js';
16
+ /**
17
+ * Per-skill authorization requirement, declared as `auth` in skill.json.
18
+ *
19
+ * - `required`: no token → run the device flow; still no token → abort (exit 4).
20
+ * - `optional`: inject a token when one is already available, otherwise carry
21
+ * on silently. Never opens a browser — these skills degrade to a
22
+ * reduced mode (e.g. web-record keeps the file locally instead of
23
+ * uploading), and popping an auth window for that would be rude.
24
+ * - `none`: purely local, never touches ab-api (e.g. web-screenshot).
25
+ */
26
+ export type AuthMode = 'required' | 'optional' | 'none';
27
+ export declare const AUTH_MODE_VALUES: readonly ["required", "optional", "none"];
28
+ export interface AuthPreflight {
29
+ /** Backend the invocation is bound to; injected so Python derives the same one. */
30
+ apiBaseUrl: string;
31
+ /** Absent only for `none`, or for `optional` with no credential available. */
32
+ token?: string;
33
+ source?: CredentialSource;
34
+ userLabel?: string;
35
+ }
36
+ /**
37
+ * Resolve (and if necessary acquire) a PrivToken for one skill invocation.
38
+ * Throws SkillError(exit 4) when `mode === 'required'` and no token could be
39
+ * obtained; the message is the shared NOT_AUTHENTICATED_HINT.
40
+ */
41
+ export declare function ensureAuth(opts: {
42
+ mode: AuthMode;
43
+ apiBaseUrl?: string;
44
+ flagToken?: string;
45
+ }): Promise<AuthPreflight>;
46
+ /**
47
+ * Environment overrides handed to a spawned Python skill.
48
+ *
49
+ * `MM_API_BASE_URL` is always set so the child resolves the same backend the
50
+ * preflight authenticated against — otherwise a `--api-base-url` flag or a
51
+ * credential bound to a staging backend would silently disagree with the URL
52
+ * the Python side derives. `PRIV_TOKEN` is only set when non-empty, so an
53
+ * `optional` skill without a credential keeps inheriting whatever the host set.
54
+ */
55
+ export declare function authChildEnv(pre: AuthPreflight): Record<string, string>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The single authorization entry point for every skill invocation.
3
+ *
4
+ * Before this module the CLI resolved PrivTokens in three unrelated places:
5
+ * `resolveHttpContext` (4 TS handlers, with credential store + auto device
6
+ * login), `registry_loader.py` (1 Python skill, with its own credential-file
7
+ * reader), and plain `os.environ["PRIV_TOKEN"]` (the other 7 Python skills,
8
+ * which therefore ignored `remixmate login` entirely). Auto browser auth only
9
+ * ever covered the first group.
10
+ *
11
+ * Now `runner.ts` calls `ensureAuth()` once, before dispatch, for every skill —
12
+ * so the credential store, the OS keychain and the device flow exist in exactly
13
+ * one place (Node), and Python scripts only ever read an injected `PRIV_TOKEN`.
14
+ */
15
+ import { resolveApiBaseUrl } from '../http.js';
16
+ import { EXIT, SkillError } from '../errors.js';
17
+ import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './resolve.js';
18
+ import { attemptAutoLogin } from './auto-login.js';
19
+ export const AUTH_MODE_VALUES = ['required', 'optional', 'none'];
20
+ /**
21
+ * Resolve (and if necessary acquire) a PrivToken for one skill invocation.
22
+ * Throws SkillError(exit 4) when `mode === 'required'` and no token could be
23
+ * obtained; the message is the shared NOT_AUTHENTICATED_HINT.
24
+ */
25
+ export async function ensureAuth(opts) {
26
+ const apiBaseUrl = resolveApiBaseUrl(opts.apiBaseUrl);
27
+ if (opts.mode === 'none')
28
+ return { apiBaseUrl };
29
+ let resolved = await resolvePrivToken({ flag: opts.flagToken, apiBaseUrl });
30
+ // Browser authorization is only ever attempted for `required`. attemptAutoLogin
31
+ // self-gates on CI / headless / kill-switch, so the env-injected cloud path
32
+ // returns here untouched.
33
+ if (!resolved && opts.mode === 'required') {
34
+ resolved = await attemptAutoLogin(apiBaseUrl);
35
+ }
36
+ if (!resolved) {
37
+ if (opts.mode === 'optional')
38
+ return { apiBaseUrl };
39
+ throw new SkillError(NOT_AUTHENTICATED_HINT, EXIT.NOT_AUTHENTICATED);
40
+ }
41
+ return {
42
+ apiBaseUrl,
43
+ token: resolved.privToken,
44
+ source: resolved.source,
45
+ userLabel: resolved.userLabel,
46
+ };
47
+ }
48
+ /**
49
+ * Environment overrides handed to a spawned Python skill.
50
+ *
51
+ * `MM_API_BASE_URL` is always set so the child resolves the same backend the
52
+ * preflight authenticated against — otherwise a `--api-base-url` flag or a
53
+ * credential bound to a staging backend would silently disagree with the URL
54
+ * the Python side derives. `PRIV_TOKEN` is only set when non-empty, so an
55
+ * `optional` skill without a credential keeps inheriting whatever the host set.
56
+ */
57
+ export function authChildEnv(pre) {
58
+ const env = { MM_API_BASE_URL: pre.apiBaseUrl };
59
+ if (pre.token)
60
+ env.PRIV_TOKEN = pre.token;
61
+ return env;
62
+ }
@@ -23,3 +23,16 @@ export declare function isHeadless(): boolean;
23
23
  * AND the environment can plausibly open a browser for the user.
24
24
  */
25
25
  export declare function canAutoAuth(): boolean;
26
+ /**
27
+ * Whether the CLI is being driven by an agent host (Claude Code / Codex /
28
+ * ab-agent) rather than typed by a human at a terminal.
29
+ *
30
+ * Used to decide (a) whether to emit the machine-readable AUTH_REQUIRED block
31
+ * alongside the human text, and (b) whether an interactive `login` may block
32
+ * for the device code's full lifetime — an agent's tool call is time-bounded,
33
+ * so it must get a bounded, resumable wait instead.
34
+ *
35
+ * Detection is deliberately loose: a piped stdout is the reliable signal, the
36
+ * env markers just make the intent explicit for hosts that allocate a pty.
37
+ */
38
+ export declare function isAgentHost(): boolean;
@@ -46,3 +46,24 @@ export function isHeadless() {
46
46
  export function canAutoAuth() {
47
47
  return !autoAuthDisabled() && !isHeadless();
48
48
  }
49
+ /**
50
+ * Whether the CLI is being driven by an agent host (Claude Code / Codex /
51
+ * ab-agent) rather than typed by a human at a terminal.
52
+ *
53
+ * Used to decide (a) whether to emit the machine-readable AUTH_REQUIRED block
54
+ * alongside the human text, and (b) whether an interactive `login` may block
55
+ * for the device code's full lifetime — an agent's tool call is time-bounded,
56
+ * so it must get a bounded, resumable wait instead.
57
+ *
58
+ * Detection is deliberately loose: a piped stdout is the reliable signal, the
59
+ * env markers just make the intent explicit for hosts that allocate a pty.
60
+ */
61
+ export function isAgentHost() {
62
+ const e = process.env;
63
+ if ((e.CLAUDECODE ?? '').trim() || (e.CLAUDE_CODE ?? '').trim() || (e.CODEX_SANDBOX ?? '').trim()) {
64
+ return true;
65
+ }
66
+ if ((e.AGENT_NAME ?? '').trim())
67
+ return true;
68
+ return !process.stdout.isTTY;
69
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pending device-authorization state, at ~/.config/remixmate/pending.json.
3
+ *
4
+ * Exists so authorization can outlive a single process. `login --start` requests
5
+ * a device code, persists it and returns immediately; `login --wait` picks it up
6
+ * and polls with a bounded window, as many times as needed. That split is what
7
+ * makes authorization workable from an agent host, whose tool calls are
8
+ * time-bounded and cannot sit on a 10-minute blocking poll.
9
+ *
10
+ * The device_code IS a short-lived bearer secret (it can be exchanged for the
11
+ * PrivToken until it expires or is used), so the file is written 0600 like the
12
+ * credential store, and is deleted as soon as it is redeemed or found expired.
13
+ */
14
+ export declare const PENDING_FILE: string;
15
+ export interface PendingAuth {
16
+ apiBaseUrl: string;
17
+ deviceCode: string;
18
+ userCode: string;
19
+ verificationUri: string;
20
+ verificationUriComplete?: string;
21
+ /** Poll interval in seconds, as instructed by the backend. */
22
+ interval: number;
23
+ /** Absolute epoch-ms deadline after which the device code is dead. */
24
+ expiresAt: number;
25
+ }
26
+ export declare function setPending(pending: PendingAuth): Promise<void>;
27
+ /**
28
+ * Read the pending authorization for `apiBaseUrl`. Returns null when absent,
29
+ * unreadable, bound to a different backend, or already expired — callers then
30
+ * tell the user to run `login --start` again.
31
+ */
32
+ export declare function getPending(apiBaseUrl: string): Promise<PendingAuth | null>;
33
+ export declare function clearPending(): Promise<void>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pending device-authorization state, at ~/.config/remixmate/pending.json.
3
+ *
4
+ * Exists so authorization can outlive a single process. `login --start` requests
5
+ * a device code, persists it and returns immediately; `login --wait` picks it up
6
+ * and polls with a bounded window, as many times as needed. That split is what
7
+ * makes authorization workable from an agent host, whose tool calls are
8
+ * time-bounded and cannot sit on a 10-minute blocking poll.
9
+ *
10
+ * The device_code IS a short-lived bearer secret (it can be exchanged for the
11
+ * PrivToken until it expires or is used), so the file is written 0600 like the
12
+ * credential store, and is deleted as soon as it is redeemed or found expired.
13
+ */
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { promises as fs } from 'node:fs';
17
+ const PENDING_DIR = join(homedir(), '.config', 'remixmate');
18
+ export const PENDING_FILE = join(PENDING_DIR, 'pending.json');
19
+ export async function setPending(pending) {
20
+ await fs.mkdir(PENDING_DIR, { recursive: true, mode: 0o700 });
21
+ await fs.writeFile(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
22
+ await fs.chmod(PENDING_FILE, 0o600);
23
+ }
24
+ /**
25
+ * Read the pending authorization for `apiBaseUrl`. Returns null when absent,
26
+ * unreadable, bound to a different backend, or already expired — callers then
27
+ * tell the user to run `login --start` again.
28
+ */
29
+ export async function getPending(apiBaseUrl) {
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(await fs.readFile(PENDING_FILE, 'utf8'));
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ if (!parsed?.deviceCode || parsed.apiBaseUrl !== apiBaseUrl)
38
+ return null;
39
+ if (!(parsed.expiresAt > Date.now()))
40
+ return null;
41
+ return parsed;
42
+ }
43
+ export async function clearPending() {
44
+ await fs.rm(PENDING_FILE, { force: true });
45
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Billing capture — makes credit consumption visible to whoever ran the skill.
3
+ *
4
+ * Credits are deducted server-side and used to be invisible here: a run printed
5
+ * its image/video URL and nothing else, so the first time a user noticed the
6
+ * credit system at all was the `insufficient_credits` error after the balance
7
+ * had already run out. ab-api now attaches a `billing` object to the envelope of
8
+ * every response that charged something (see core.Success / credits_billing.go);
9
+ * this module accumulates those across all the calls a single skill run makes —
10
+ * a gen-image run polls, a gen-video run polls, and each may charge once — and
11
+ * renders one footer at the end.
12
+ *
13
+ * Two outputs, two audiences, both on stdout:
14
+ * - a human-readable footer, which is what the agent relays to the user.
15
+ * - one `__progress__` line (phase `billing`) — the existing machine protocol,
16
+ * already parsed by ab-agent's executor and skipped by the line-scanning
17
+ * parsers in render_video.py, so a nested pipeline caller can aggregate a
18
+ * whole run's spend without a new stdout contract.
19
+ *
20
+ * Both go to stdout rather than stderr (where the auth footer lives) because
21
+ * ab-agent hands the LLM `result.stdout || result.stderr` (mcp-tools.ts) — a
22
+ * stderr-only footer would be invisible in the hosted agent, visible only when
23
+ * a local host like codex shows both streams. Adding stdout lines is safe:
24
+ * emitProgress already writes NDJSON there, so no consumer can be treating
25
+ * stdout as a single JSON document.
26
+ */
27
+ export interface BillingItem {
28
+ credits: number;
29
+ bizType?: string;
30
+ detail?: string;
31
+ }
32
+ /** The `billing` object ab-api attaches to a charged response. */
33
+ export interface Billing {
34
+ credits: number;
35
+ balance: number;
36
+ items?: BillingItem[];
37
+ }
38
+ /** Accumulate one response's billing object; no-ops on responses that charged nothing. */
39
+ export declare function recordBilling(billing: Billing | undefined | null): void;
40
+ /** Everything charged so far in this process, or null when nothing was. */
41
+ export declare function billingSummary(): Billing | null;
42
+ export declare function emitBillingFooter(): void;
43
+ /** Test seam — resets the accumulator between runs in-process. */
44
+ export declare function resetBilling(): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Billing capture — makes credit consumption visible to whoever ran the skill.
3
+ *
4
+ * Credits are deducted server-side and used to be invisible here: a run printed
5
+ * its image/video URL and nothing else, so the first time a user noticed the
6
+ * credit system at all was the `insufficient_credits` error after the balance
7
+ * had already run out. ab-api now attaches a `billing` object to the envelope of
8
+ * every response that charged something (see core.Success / credits_billing.go);
9
+ * this module accumulates those across all the calls a single skill run makes —
10
+ * a gen-image run polls, a gen-video run polls, and each may charge once — and
11
+ * renders one footer at the end.
12
+ *
13
+ * Two outputs, two audiences, both on stdout:
14
+ * - a human-readable footer, which is what the agent relays to the user.
15
+ * - one `__progress__` line (phase `billing`) — the existing machine protocol,
16
+ * already parsed by ab-agent's executor and skipped by the line-scanning
17
+ * parsers in render_video.py, so a nested pipeline caller can aggregate a
18
+ * whole run's spend without a new stdout contract.
19
+ *
20
+ * Both go to stdout rather than stderr (where the auth footer lives) because
21
+ * ab-agent hands the LLM `result.stdout || result.stderr` (mcp-tools.ts) — a
22
+ * stderr-only footer would be invisible in the hosted agent, visible only when
23
+ * a local host like codex shows both streams. Adding stdout lines is safe:
24
+ * emitProgress already writes NDJSON there, so no consumer can be treating
25
+ * stdout as a single JSON document.
26
+ */
27
+ import { emitProgress } from './progress.js';
28
+ let creditsCharged = 0;
29
+ let latestBalance = null;
30
+ const chargedItems = [];
31
+ /** Accumulate one response's billing object; no-ops on responses that charged nothing. */
32
+ export function recordBilling(billing) {
33
+ if (!billing || typeof billing.credits !== 'number' || billing.credits <= 0)
34
+ return;
35
+ creditsCharged += billing.credits;
36
+ if (typeof billing.balance === 'number')
37
+ latestBalance = billing.balance;
38
+ for (const item of billing.items ?? []) {
39
+ if (item && typeof item.credits === 'number' && item.credits > 0)
40
+ chargedItems.push(item);
41
+ }
42
+ }
43
+ /** Everything charged so far in this process, or null when nothing was. */
44
+ export function billingSummary() {
45
+ if (creditsCharged <= 0)
46
+ return null;
47
+ return { credits: creditsCharged, balance: latestBalance ?? 0, items: [...chargedItems] };
48
+ }
49
+ /**
50
+ * Emit the footer for what this process charged. Safe to call on the error path
51
+ * too: a run can fail after a successful (already billed) generation step, and
52
+ * that spend still needs to be reported.
53
+ *
54
+ * Idempotent — only the first call prints, so a handler and the dispatcher can
55
+ * both reach for it without doubling the number the user sees.
56
+ */
57
+ let emitted = false;
58
+ export function emitBillingFooter() {
59
+ if (emitted)
60
+ return;
61
+ const summary = billingSummary();
62
+ if (!summary)
63
+ return;
64
+ emitted = true;
65
+ emitProgress({ phase: 'billing', credits: summary.credits, balance: summary.balance, items: summary.items });
66
+ const balance = summary.balance.toLocaleString('en-US');
67
+ const credits = summary.credits.toLocaleString('en-US');
68
+ process.stdout.write(`\n💳 Charged ${credits} credits · balance ${balance}\n`);
69
+ }
70
+ /** Test seam — resets the accumulator between runs in-process. */
71
+ export function resetBilling() {
72
+ creditsCharged = 0;
73
+ latestBalance = null;
74
+ chargedItems.length = 0;
75
+ emitted = false;
76
+ }
package/dist/cli.js CHANGED
@@ -15,6 +15,10 @@ import { loadSkills, SKILLS_DIR } from './registry.js';
15
15
  import { parseArgv } from './argv.js';
16
16
  import { runSkill } from './runner.js';
17
17
  import { runAuthCommand } from './auth/commands.js';
18
+ import { runExec } from './exec.js';
19
+ import { authStatusLine, runDoctor } from './doctor.js';
20
+ import { padDisplay } from './text.js';
21
+ import { EXIT, SkillError } from './errors.js';
18
22
  /** Reserved auth verbs handled before skill dispatch. */
19
23
  const AUTH_VERBS = new Set(['login', 'logout', 'whoami']);
20
24
  /**
@@ -46,9 +50,12 @@ function printUsage() {
46
50
  const skills = loadSkills(resolveBaseDir());
47
51
  process.stdout.write('Usage:\n');
48
52
  process.stdout.write(' remixmate --list List all skills\n');
53
+ process.stdout.write(' remixmate doctor Diagnose environment, credentials, backend\n');
49
54
  process.stdout.write(' remixmate login Authorize this CLI via the browser\n');
55
+ process.stdout.write(' remixmate login --start | --wait Non-blocking authorization (for agent hosts)\n');
50
56
  process.stdout.write(' remixmate logout Remove stored credentials\n');
51
57
  process.stdout.write(' remixmate whoami Show the current identity\n');
58
+ process.stdout.write(' remixmate exec -- <cmd> [args...] Run a command with credentials injected\n');
52
59
  process.stdout.write(' remixmate <name> [--flag value ...] Invoke a skill\n');
53
60
  process.stdout.write(' remixmate <name> --help Show skill-specific help\n');
54
61
  process.stdout.write('\nAvailable skills:\n');
@@ -57,30 +64,83 @@ function printUsage() {
57
64
  process.stdout.write(` ${s.name.padEnd(22)} ${s.entry.type.padEnd(8)} ${summary}\n`);
58
65
  }
59
66
  }
60
- function printList() {
61
- for (const s of loadSkills(resolveBaseDir())) {
62
- process.stdout.write(`${s.name}\t${s.toolName}\t${s.entry.type}\t${describeEntryTail(s)}\n`);
67
+ /** Human-facing note on what a skill's auth mode means in practice. */
68
+ const AUTH_NOTE = {
69
+ required: '需登录',
70
+ optional: '可选',
71
+ none: '免登录',
72
+ };
73
+ /**
74
+ * List skills.
75
+ *
76
+ * The default output is for a person: what each skill is and whether it needs a
77
+ * credential. The previous tab-separated form leaked internals (toolName, entry
78
+ * type, script path) that only tooling cares about — that shape now lives behind
79
+ * `--json`, which is a stabler contract for tooling anyway.
80
+ */
81
+ function printList(json) {
82
+ const skills = loadSkills(resolveBaseDir());
83
+ if (json) {
84
+ process.stdout.write(JSON.stringify(skills.map((s) => ({
85
+ name: s.name,
86
+ toolName: s.toolName,
87
+ runtime: s.entry.type,
88
+ auth: s.auth,
89
+ entry: describeEntryTail(s),
90
+ description: s.description.split('\n')[0],
91
+ }))) + '\n');
92
+ return;
93
+ }
94
+ for (const s of skills) {
95
+ const summary = s.description.split('\n')[0].slice(0, 58);
96
+ process.stdout.write(` ${s.name.padEnd(22)} ${padDisplay(AUTH_NOTE[s.auth], 8)}${summary}\n`);
97
+ }
98
+ }
99
+ /**
100
+ * Auth status footer for the listing commands, on stderr so stdout stays clean
101
+ * for anything piping the list. A fresh install used to say nothing at all about
102
+ * credentials until a skill failed.
103
+ */
104
+ async function printAuthFooter() {
105
+ try {
106
+ process.stderr.write(`\n${await authStatusLine()}\n`);
107
+ }
108
+ catch {
109
+ // A diagnostic footer must never be the reason a listing command fails.
63
110
  }
64
111
  }
65
112
  async function main() {
66
113
  const argv = process.argv.slice(2);
67
114
  if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
68
115
  printUsage();
69
- process.exit(0);
116
+ await printAuthFooter();
117
+ process.exit(EXIT.OK);
70
118
  }
71
119
  if (argv[0] === '--list' || argv[0] === 'list') {
72
- printList();
73
- process.exit(0);
120
+ const json = argv.includes('--json') || argv.includes('--json-output');
121
+ printList(json);
122
+ if (!json)
123
+ await printAuthFooter();
124
+ process.exit(EXIT.OK);
125
+ }
126
+ if (argv[0] === 'doctor') {
127
+ const code = await runDoctor({ skipNetwork: argv.includes('--offline') });
128
+ process.exit(code);
74
129
  }
75
130
  // Auth verbs are handled before skill dispatch so they never collide with skill names.
76
131
  if (AUTH_VERBS.has(argv[0])) {
77
132
  const code = await runAuthCommand(argv[0], argv.slice(1));
78
133
  process.exit(code);
79
134
  }
135
+ if (argv[0] === 'exec') {
136
+ // Raw argv (not parsed) — everything after `--` belongs to the child command.
137
+ const code = await runExec(argv.slice(1));
138
+ process.exit(code);
139
+ }
80
140
  if (argv[0].startsWith('-')) {
81
141
  process.stderr.write(`❌ unknown option: ${argv[0]}\n`);
82
142
  printUsage();
83
- process.exit(2);
143
+ process.exit(EXIT.USAGE);
84
144
  }
85
145
  const skillName = argv[0];
86
146
  const rest = argv.slice(1);
@@ -92,6 +152,13 @@ async function main() {
92
152
  process.exit(code);
93
153
  }
94
154
  main().catch((err) => {
155
+ // A SkillError is an expected, already-worded failure (most often "not
156
+ // authenticated", exit 4) — print the message and honor its exit code rather
157
+ // than dumping a stack trace the user can do nothing with.
158
+ if (err instanceof SkillError) {
159
+ process.stderr.write(err.message + '\n');
160
+ process.exit(err.exitCode);
161
+ }
95
162
  process.stderr.write(`❌ fatal: ${err?.stack ?? err?.message ?? String(err)}\n`);
96
- process.exit(1);
163
+ process.exit(EXIT.ERROR);
97
164
  });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `remixmate doctor` — one command that answers "why isn't this working?".
3
+ *
4
+ * The failure modes this package actually produces in the field are: no
5
+ * credential, a credential bound to a different backend, a missing python3, a
6
+ * missing Playwright, and an unreachable backend. Each of those used to surface
7
+ * only as a downstream error from whichever skill happened to be run first, in
8
+ * that skill's own wording. Checking them up front, in one place, turns a
9
+ * scavenger hunt into a list.
10
+ *
11
+ * Also exports the one-line status shown under `--list` / `--help`, so a first
12
+ * run says "you are not logged in" instead of leaving the user to find out by
13
+ * invoking a skill.
14
+ */
15
+ /**
16
+ * Short auth status suitable for a footer. Never performs network I/O — a
17
+ * listing command must stay instant and work offline.
18
+ */
19
+ export declare function authStatusLine(): Promise<string>;
20
+ export declare function runDoctor(opts?: {
21
+ skipNetwork?: boolean;
22
+ }): Promise<number>;
package/dist/doctor.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `remixmate doctor` — one command that answers "why isn't this working?".
3
+ *
4
+ * The failure modes this package actually produces in the field are: no
5
+ * credential, a credential bound to a different backend, a missing python3, a
6
+ * missing Playwright, and an unreachable backend. Each of those used to surface
7
+ * only as a downstream error from whichever skill happened to be run first, in
8
+ * that skill's own wording. Checking them up front, in one place, turns a
9
+ * scavenger hunt into a list.
10
+ *
11
+ * Also exports the one-line status shown under `--list` / `--help`, so a first
12
+ * run says "you are not logged in" instead of leaving the user to find out by
13
+ * invoking a skill.
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { resolveApiBaseUrl } from './http.js';
17
+ import { EXIT } from './errors.js';
18
+ import { resolvePrivToken } from './auth/resolve.js';
19
+ import { listCredentials } from './auth/credential-store.js';
20
+ import { canAutoAuth, isHeadless } from './auth/environment.js';
21
+ import { padDisplay } from './text.js';
22
+ /**
23
+ * Short auth status suitable for a footer. Never performs network I/O — a
24
+ * listing command must stay instant and work offline.
25
+ */
26
+ export async function authStatusLine() {
27
+ const apiBaseUrl = resolveApiBaseUrl();
28
+ const resolved = await resolvePrivToken({ apiBaseUrl });
29
+ if (resolved) {
30
+ const who = resolved.userLabel ? `${resolved.userLabel} ` : '';
31
+ return `✅ 已登录 ${who}@ ${apiBaseUrl}(来源:${resolved.source})`;
32
+ }
33
+ const others = await listCredentials();
34
+ if (others.length > 0) {
35
+ return (`⚠️ 当前后端 ${apiBaseUrl} 未登录;` +
36
+ `已有凭证的后端:${others.map((c) => c.apiBaseUrl).join(', ')}`);
37
+ }
38
+ return `⚠️ 未登录(${apiBaseUrl})→ 运行 remixmate login`;
39
+ }
40
+ const ICON = { ok: '✅', warn: '⚠️ ', fail: '❌' };
41
+ /** Probe a command's presence and version line without failing on absence. */
42
+ function probe(cmd, args) {
43
+ try {
44
+ const res = spawnSync(cmd, args, { encoding: 'utf8', timeout: 10_000 });
45
+ if (res.error || res.status !== 0)
46
+ return null;
47
+ return (res.stdout || res.stderr || '').trim().split('\n')[0] || null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ async function checkBackend(apiBaseUrl) {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), 8_000);
56
+ try {
57
+ // The device-code endpoint is public, so reachability can be probed without
58
+ // a credential — and without side effects, since we never redeem the code.
59
+ const resp = await fetch(`${apiBaseUrl}/auth/device/code`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ clientLabel: 'remixmate-doctor' }),
63
+ signal: controller.signal,
64
+ });
65
+ return {
66
+ name: '后端可达性',
67
+ status: resp.ok ? 'ok' : 'warn',
68
+ detail: `${apiBaseUrl} → HTTP ${resp.status}`,
69
+ };
70
+ }
71
+ catch (err) {
72
+ const reason = err instanceof Error && err.name === 'AbortError' ? '超时' : err.message;
73
+ return { name: '后端可达性', status: 'fail', detail: `${apiBaseUrl} 不可达(${reason})` };
74
+ }
75
+ finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+ export async function runDoctor(opts = {}) {
80
+ const apiBaseUrl = resolveApiBaseUrl();
81
+ const checks = [];
82
+ const major = Number(process.versions.node.split('.')[0]);
83
+ checks.push({
84
+ name: 'Node',
85
+ status: major >= 18 ? 'ok' : 'fail',
86
+ detail: `v${process.versions.node}${major >= 18 ? '' : '(需要 >= 18)'}`,
87
+ });
88
+ const py = probe('python3', ['--version']);
89
+ checks.push({
90
+ name: 'python3',
91
+ status: py ? 'ok' : 'fail',
92
+ detail: py ?? '未找到 —— 12 个 skill 中的 8 个需要 python3 >= 3.10',
93
+ });
94
+ const playwright = py ? probe('python3', ['-c', 'import playwright; print(playwright.__version__)']) : null;
95
+ checks.push({
96
+ name: 'Playwright',
97
+ status: playwright ? 'ok' : 'warn',
98
+ detail: playwright ?? '当前解释器下未安装 —— 仅 web-screenshot / web-record 需要,首次运行会自动装',
99
+ });
100
+ checks.push({ name: '后端地址', status: 'ok', detail: apiBaseUrl });
101
+ const resolved = await resolvePrivToken({ apiBaseUrl });
102
+ const stored = await listCredentials();
103
+ if (resolved) {
104
+ checks.push({
105
+ name: '凭证',
106
+ status: 'ok',
107
+ detail: `${resolved.userLabel ?? '(已认证)'}(来源:${resolved.source})`,
108
+ });
109
+ }
110
+ else if (stored.length > 0) {
111
+ checks.push({
112
+ name: '凭证',
113
+ status: 'fail',
114
+ detail: `当前后端无凭证;已存储:${stored.map((c) => c.apiBaseUrl).join(', ')}。` +
115
+ ' 设置 MM_API_BASE_URL 指向其中之一,或重新 remixmate login',
116
+ });
117
+ }
118
+ else {
119
+ checks.push({ name: '凭证', status: 'fail', detail: '未登录 → 运行 remixmate login' });
120
+ }
121
+ checks.push({
122
+ name: '浏览器授权',
123
+ status: canAutoAuth() ? 'ok' : 'warn',
124
+ detail: canAutoAuth()
125
+ ? '可用(缺凭证时会自动打开授权页)'
126
+ : `不可用(${isHeadless() ? 'CI / 无桌面会话' : '已被 REMIXMATE_NO_BROWSER_AUTH 关闭'})→ 请用 PRIV_TOKEN`,
127
+ });
128
+ if (!opts.skipNetwork)
129
+ checks.push(await checkBackend(apiBaseUrl));
130
+ for (const c of checks) {
131
+ process.stdout.write(`${ICON[c.status]} ${padDisplay(c.name, 14)}${c.detail}\n`);
132
+ }
133
+ const failed = checks.filter((c) => c.status === 'fail');
134
+ if (failed.length === 0) {
135
+ process.stdout.write('\n一切正常。\n');
136
+ return EXIT.OK;
137
+ }
138
+ process.stdout.write(`\n${failed.length} 项需要处理:${failed.map((c) => c.name).join('、')}\n`);
139
+ // A missing credential is the single most common cause and has a dedicated
140
+ // exit code, so a host can act on it without parsing the report.
141
+ return failed.some((c) => c.name === '凭证') ? EXIT.NOT_AUTHENTICATED : EXIT.ERROR;
142
+ }