@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.
- package/README.md +54 -1
- package/README.zh-CN.md +37 -1
- package/dist/auth/auto-login.js +10 -1
- package/dist/auth/commands.js +181 -21
- package/dist/auth/credential-store.d.ts +16 -0
- package/dist/auth/credential-store.js +17 -0
- package/dist/auth/device-flow-runner.d.ts +15 -0
- package/dist/auth/device-flow-runner.js +60 -12
- package/dist/auth/device-flow.js +24 -7
- package/dist/auth/ensure.d.ts +55 -0
- package/dist/auth/ensure.js +62 -0
- package/dist/auth/environment.d.ts +13 -0
- package/dist/auth/environment.js +21 -0
- package/dist/auth/pending-store.d.ts +33 -0
- package/dist/auth/pending-store.js +45 -0
- package/dist/billing.d.ts +44 -0
- package/dist/billing.js +76 -0
- package/dist/cli.js +75 -8
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +142 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +33 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +47 -0
- package/dist/handlers/gen-digital-human.js +1 -0
- package/dist/handlers/gen-image.js +1 -0
- package/dist/handlers/gen-video.js +1 -0
- package/dist/handlers/gen-voice.js +2 -0
- package/dist/handlers/index.d.ts +7 -0
- package/dist/http.d.ts +20 -6
- package/dist/http.js +43 -15
- package/dist/manifest.json +2 -2
- package/dist/registry.d.ts +4 -2
- package/dist/registry.js +2 -1
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +36 -12
- package/dist/skill-schema.d.ts +19 -0
- package/dist/skill-schema.js +18 -0
- package/dist/text.d.ts +8 -0
- package/dist/text.js +15 -0
- package/package.json +2 -2
- package/skills/export-jianying/skill.json +1 -0
- package/skills/gen-digital-human/SKILL.md +12 -0
- package/skills/gen-digital-human/skill.json +1 -0
- package/skills/gen-image/SKILL.md +12 -0
- package/skills/gen-image/skill.json +1 -0
- package/skills/gen-script/skill.json +1 -0
- package/skills/gen-video/SKILL.md +12 -0
- package/skills/gen-video/skill.json +1 -0
- package/skills/gen-voice/SKILL.md +12 -0
- package/skills/gen-voice/skill.json +1 -0
- package/skills/prepare-video-assets/SKILL.md +12 -0
- package/skills/prepare-video-assets/skill.json +1 -0
- package/skills/render-video/SKILL.md +12 -0
- package/skills/render-video/scripts/render_video.py +63 -0
- package/skills/render-video/skill.json +1 -0
- package/skills/template-registry/scripts/list_templates.py +16 -1
- package/skills/template-registry/scripts/registry_loader.py +63 -63
- package/skills/template-registry/scripts/render_job_client.py +27 -0
- package/skills/template-registry/skill.json +1 -0
- package/skills/video-parser/skill.json +1 -0
- package/skills/web-record/skill.json +1 -0
- 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;
|
package/dist/auth/environment.js
CHANGED
|
@@ -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;
|
package/dist/billing.js
ADDED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
116
|
+
await printAuthFooter();
|
|
117
|
+
process.exit(EXIT.OK);
|
|
70
118
|
}
|
|
71
119
|
if (argv[0] === '--list' || argv[0] === 'list') {
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
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(
|
|
163
|
+
process.exit(EXIT.ERROR);
|
|
97
164
|
});
|
package/dist/doctor.d.ts
ADDED
|
@@ -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
|
+
}
|