tripo-cli 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/dist/commands/completion.js +1 -1
- package/dist/commands/config-cmd.js +32 -17
- package/dist/commands/doctor.js +6 -5
- package/dist/commands/home.js +4 -2
- package/dist/commands/login.js +201 -39
- package/dist/config/config.js +103 -14
- package/dist/core/client.js +16 -2
- package/dist/core/device-auth.js +41 -9
- package/dist/program.js +21 -1
- package/package.json +2 -2
- package/skill/SKILL.md +13 -1
- package/skill/commands/account.md +19 -5
- package/dist/src/ai/experts.js +0 -115
- package/dist/src/ai/llm.js +0 -88
- package/dist/src/ai/plan-card.js +0 -79
- package/dist/src/ai/wizard.js +0 -104
- package/dist/src/cli.js +0 -7
- package/dist/src/commands/account.js +0 -27
- package/dist/src/commands/ai.js +0 -113
- package/dist/src/commands/batch.js +0 -148
- package/dist/src/commands/completion.js +0 -84
- package/dist/src/commands/config-cmd.js +0 -84
- package/dist/src/commands/docs.js +0 -30
- package/dist/src/commands/doctor.js +0 -94
- package/dist/src/commands/generate.js +0 -81
- package/dist/src/commands/home.js +0 -68
- package/dist/src/commands/login.js +0 -225
- package/dist/src/commands/make.js +0 -142
- package/dist/src/commands/mcp.js +0 -194
- package/dist/src/commands/process-groups.js +0 -164
- package/dist/src/commands/shared.js +0 -75
- package/dist/src/commands/task.js +0 -127
- package/dist/src/commands/topup.js +0 -61
- package/dist/src/commands/view.js +0 -97
- package/dist/src/config/config.js +0 -79
- package/dist/src/config/context.js +0 -44
- package/dist/src/config/history.js +0 -104
- package/dist/src/core/client.js +0 -120
- package/dist/src/core/download.js +0 -107
- package/dist/src/core/errors.js +0 -64
- package/dist/src/core/output.js +0 -72
- package/dist/src/core/requests.js +0 -169
- package/dist/src/core/task-service.js +0 -161
- package/dist/src/core/upload.js +0 -36
- package/dist/src/core/viewer-assets.js +0 -24
- package/dist/src/core/viewer.js +0 -59
- package/dist/src/core/watch.js +0 -41
- package/dist/src/i18n.js +0 -19
- package/dist/src/knowledge/chains.js +0 -152
- package/dist/src/knowledge/error-catalog.js +0 -91
- package/dist/src/knowledge/models.js +0 -110
- package/dist/src/knowledge/params.js +0 -174
- package/dist/src/knowledge/scenarios.js +0 -112
- package/dist/src/pipeline/make.js +0 -233
- package/dist/src/pipeline/runner.js +0 -73
- package/dist/src/pipeline/stdin.js +0 -40
- package/dist/src/program.js +0 -147
- package/dist/src/types.js +0 -2
- package/dist/src/util/input-detect.js +0 -92
- package/dist/src/util/notify.js +0 -17
- package/dist/src/util/open.js +0 -18
- package/dist/src/util/spinner.js +0 -62
- package/dist/src/util/term-image.js +0 -28
- package/dist/src/version.js +0 -10
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ tripo login # browser approves, key arrives automatically; or --paste /
|
|
|
15
15
|
tripo make knight.png --for game-mobile --then texture,rig,convert:fbx
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Works with both Tripo regions from the same install — international (`openapi.tripo3d.ai`, console `developers.tripo3d.ai`, email login + Stripe) and China mainland (`openapi.tripo3d.com`, console `developers.tripo3d.com`, SMS login + Alipay). `tripo login` runs an RFC 8628 device flow: the terminal shows a short code, the browser signs in (each console's own method) and approves, and the key lands in the CLI automatically — with graceful fallback to open-the-keys-page-and-paste when the device-auth backend is unavailable. You never pick a region when a key is in hand: the key is probed against both regions and whichever accepts it wins; `tripo topup` then opens the matching billing page (`/billing` on ov, `/billing/recharge` on cn). If a config was moved between machines, `tripo doctor` detects the mismatch and prints the one-line fix.
|
|
18
|
+
Works with both Tripo regions from the same install — international (`openapi.tripo3d.ai`, console `developers.tripo3d.ai`, email login + Stripe) and China mainland (`openapi.tripo3d.com`, console `developers.tripo3d.com`, SMS login + Alipay). `tripo login` runs an RFC 8628 device flow: the terminal shows a short code, the browser signs in (each console's own method) and approves, and the key lands in the CLI automatically — with graceful fallback to open-the-keys-page-and-paste when the device-auth backend is unavailable. The same flow works without a TTY: `tripo login --region ov|cn` from an agent or CI prints the verification URL + code and waits for the browser approval, so an AI agent can start the login and relay the code to its human. You never pick a region when a key is in hand: the key is probed against both regions and whichever accepts it wins; `tripo topup` then opens the matching billing page (`/billing` on ov, `/billing/recharge` on cn). If a config was moved between machines, `tripo doctor` detects the mismatch and prints the one-line fix.
|
|
19
19
|
|
|
20
20
|
## Why this CLI
|
|
21
21
|
|
|
@@ -31,7 +31,7 @@ Works with both Tripo regions from the same install — international (`openapi.
|
|
|
31
31
|
| `tripo ai [description]` | plan with a wizard (no LLM needed) or multi-expert conversation (BYO LLM), confirm a plan card, execute |
|
|
32
32
|
| `tripo view [task\|file]` | interactive 3D preview in your browser (local server, model-viewer) |
|
|
33
33
|
| `tripo redo [task]` | re-run with a fresh seed |
|
|
34
|
-
| `tripo login / logout / whoami` | device flow: browser approves, key arrives automatically (paste fallback); stored 0600 in `~/.tripo/config.json` |
|
|
34
|
+
| `tripo login / logout / whoami / use` | device flow: browser approves, key arrives automatically (paste fallback); stored 0600 in `~/.tripo/config.json`; `use` switches accounts |
|
|
35
35
|
| `tripo topup / balance / usage` | open the billing page + detect the credits arriving; check spend |
|
|
36
36
|
| `tripo generate <endpoint>` | deterministic access to all 8 generation endpoints |
|
|
37
37
|
| `tripo model / anim / mesh <step>` | refine, texture, stylize, convert, import, rig-check, rig, retarget, segment, complete, decimate, smartsegment |
|
|
@@ -43,7 +43,22 @@ Works with both Tripo regions from the same install — international (`openapi.
|
|
|
43
43
|
| `tripo mcp` | run as an MCP server (Cursor / Claude Desktop) |
|
|
44
44
|
| `tripo completion <shell>` | bash/zsh/fish completion |
|
|
45
45
|
|
|
46
|
-
Every task-producing command supports `-o/--out`, `--no-wait`, `--no-download`, `--name`, `--timeout`, `--notify`, and repeatable `--param key=value` passthrough. Global flags: `--json`, `--yes`, `--quiet`, `--no-open`.
|
|
46
|
+
Every task-producing command supports `-o/--out`, `--no-wait`, `--no-download`, `--name`, `--timeout`, `--notify`, and repeatable `--param key=value` passthrough. Global flags: `--json`, `--yes`, `--quiet`, `--no-open`, `--profile`.
|
|
47
|
+
|
|
48
|
+
## Multiple accounts
|
|
49
|
+
|
|
50
|
+
Credentials live in named profiles (one per account — e.g. a cn account and an ov account, or personal + work). Logging in never overwrites another profile's key:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
tripo login # first account → profile "default"
|
|
54
|
+
tripo login --profile work-cn # second account, kept separately, becomes active
|
|
55
|
+
tripo use # switch interactively (or: tripo use default)
|
|
56
|
+
tripo whoami # shows the active profile and lists the others
|
|
57
|
+
tripo make "a fox" --profile work-cn # one-shot override, nothing sticky
|
|
58
|
+
tripo logout # removes the *active* profile only
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each profile carries its own key + region, so switching accounts switches regions automatically. `TRIPO_PROFILE=work-cn` does the same as `--profile work-cn`; `TRIPO_API_KEY` bypasses profiles entirely (CI/agents). Configs written by older versions are read as the `default` profile — no migration step, nothing breaks.
|
|
47
62
|
|
|
48
63
|
## Scenario presets
|
|
49
64
|
|
|
@@ -81,7 +96,8 @@ tripo batch run assets.yaml --concurrency 2
|
|
|
81
96
|
|
|
82
97
|
| var | meaning |
|
|
83
98
|
| --- | --- |
|
|
84
|
-
| `TRIPO_API_KEY` | API key (`tsk_...`), highest precedence |
|
|
99
|
+
| `TRIPO_API_KEY` | API key (`tsk_...`), highest precedence (bypasses profiles) |
|
|
100
|
+
| `TRIPO_PROFILE` | account profile to use, same as the global `--profile` flag |
|
|
85
101
|
| `TRIPO_REGION` | `ov` or `cn` — normally unnecessary: `tripo login` probes both regions with your key and stores the right one automatically |
|
|
86
102
|
| `TRIPO_API_BASE_URL` / `TRIPO_PLATFORM_BASE_URL` | endpoint overrides |
|
|
87
103
|
| `TRIPO_HOME` | config/history directory (default `~/.tripo`) |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** `tripo completion <shell>` — static shell completion scripts. */
|
|
2
2
|
import { UsageError } from '../core/errors.js';
|
|
3
3
|
const COMMANDS = [
|
|
4
|
-
'login', 'logout', 'whoami', 'balance', 'usage', 'topup',
|
|
4
|
+
'login', 'logout', 'whoami', 'use', 'balance', 'usage', 'topup',
|
|
5
5
|
'make', 'view', 'redo', 'ai',
|
|
6
6
|
'generate', 'model', 'anim', 'mesh', 'task', 'files', 'batch', 'history',
|
|
7
7
|
'config', 'doctor', 'docs', 'completion', 'mcp',
|
|
@@ -1,40 +1,46 @@
|
|
|
1
1
|
/** `tripo config` — global config + directory context management. */
|
|
2
|
-
import { loadConfig, maskKey, saveConfig } from '../config/config.js';
|
|
2
|
+
import { activeProfileName, loadConfig, maskKey, saveConfig, updateProfile, } from '../config/config.js';
|
|
3
3
|
import { findContextFile, loadContext, updateContext } from '../config/context.js';
|
|
4
4
|
import { UsageError } from '../core/errors.js';
|
|
5
5
|
import { emitResult, success } from '../core/output.js';
|
|
6
6
|
import { coerceValue } from './shared.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
'default_output_dir',
|
|
13
|
-
'llm.base_url',
|
|
14
|
-
'llm.api_key',
|
|
15
|
-
'llm.model',
|
|
16
|
-
]);
|
|
7
|
+
/** Account-bound settings — these live in the *active profile*. */
|
|
8
|
+
const PROFILE_SETTABLE = new Set(['region', 'api_base_url', 'platform_base_url']);
|
|
9
|
+
/** User preferences — top-level, shared by all profiles. */
|
|
10
|
+
const GLOBAL_SETTABLE = new Set(['language', 'default_output_dir', 'llm.base_url', 'llm.api_key', 'llm.model']);
|
|
11
|
+
const SETTABLE = [...PROFILE_SETTABLE, ...GLOBAL_SETTABLE];
|
|
17
12
|
export function registerConfigCommands(program) {
|
|
18
13
|
const config = program.command('config').description('global config (~/.tripo) and directory context (.tripo)');
|
|
19
14
|
config
|
|
20
15
|
.command('list')
|
|
21
|
-
.description('show effective config (
|
|
16
|
+
.description('show effective config (keys masked)')
|
|
22
17
|
.action(() => {
|
|
23
18
|
const cfg = loadConfig();
|
|
24
19
|
const shown = {
|
|
25
20
|
...cfg,
|
|
26
|
-
|
|
21
|
+
profiles: cfg.profiles
|
|
22
|
+
? Object.fromEntries(Object.entries(cfg.profiles).map(([name, prof]) => [
|
|
23
|
+
name,
|
|
24
|
+
{ ...prof, api_key: prof.api_key ? maskKey(prof.api_key) : undefined },
|
|
25
|
+
]))
|
|
26
|
+
: undefined,
|
|
27
27
|
llm: cfg.llm ? { ...cfg.llm, api_key: cfg.llm.api_key ? maskKey(cfg.llm.api_key) : undefined } : undefined,
|
|
28
28
|
};
|
|
29
29
|
const context = loadContext();
|
|
30
|
-
emitResult({ config: shown, context, context_file: findContextFile() }, JSON.stringify({ config: shown, context }, null, 2));
|
|
30
|
+
emitResult({ config: shown, active_profile: activeProfileName(cfg), context, context_file: findContextFile() }, JSON.stringify({ config: shown, active_profile: activeProfileName(cfg), context }, null, 2));
|
|
31
31
|
});
|
|
32
32
|
config
|
|
33
33
|
.command('set <key> <value>')
|
|
34
|
-
.description(`set a config value (${
|
|
34
|
+
.description(`set a config value (${SETTABLE.join(', ')})`)
|
|
35
35
|
.action((key, value) => {
|
|
36
|
-
if (
|
|
37
|
-
|
|
36
|
+
if (PROFILE_SETTABLE.has(key)) {
|
|
37
|
+
const profile = activeProfileName();
|
|
38
|
+
updateProfile(profile, { [key]: coerceValue(value) }, { activate: false });
|
|
39
|
+
success(`${key} = ${value} (profile "${profile}")`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!GLOBAL_SETTABLE.has(key)) {
|
|
43
|
+
throw new UsageError(`unknown config key "${key}" (valid: ${SETTABLE.join(', ')})`);
|
|
38
44
|
}
|
|
39
45
|
const cfg = loadConfig();
|
|
40
46
|
applyPath(cfg, key.split('.'), coerceValue(value));
|
|
@@ -45,6 +51,15 @@ export function registerConfigCommands(program) {
|
|
|
45
51
|
.command('unset <key>')
|
|
46
52
|
.description('remove a config value')
|
|
47
53
|
.action((key) => {
|
|
54
|
+
if (PROFILE_SETTABLE.has(key)) {
|
|
55
|
+
const cfg = loadConfig();
|
|
56
|
+
const profile = activeProfileName(cfg);
|
|
57
|
+
const prof = { ...cfg.profiles?.[profile] };
|
|
58
|
+
delete prof[key];
|
|
59
|
+
saveConfig({ ...cfg, profiles: { ...cfg.profiles, [profile]: prof } });
|
|
60
|
+
success(`${key} removed (profile "${profile}")`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
48
63
|
const cfg = loadConfig();
|
|
49
64
|
const parts = key.split('.');
|
|
50
65
|
const parent = parts.slice(0, -1).reduce((acc, part) => (acc && typeof acc[part] === 'object' ? acc[part] : undefined), cfg);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
|
-
import { configFileExists, loadConfig, resolveApiBaseUrl, resolveApiKey, resolveRegion, tripoHome } from '../config/config.js';
|
|
5
|
+
import { configFileExists, describeKeySource, listProfiles, loadConfig, resolveApiBaseUrl, resolveApiKey, resolveRegion, tripoHome } from '../config/config.js';
|
|
6
6
|
import { TripoClient } from '../core/client.js';
|
|
7
7
|
import { CliError } from '../core/errors.js';
|
|
8
8
|
import { detectRegionForKey } from '../core/region.js';
|
|
@@ -24,14 +24,15 @@ export function registerDoctorCommand(program) {
|
|
|
24
24
|
critical: true,
|
|
25
25
|
});
|
|
26
26
|
const key = resolveApiKey();
|
|
27
|
+
const profiles = listProfiles();
|
|
27
28
|
checks.push({
|
|
28
29
|
name: 'api key',
|
|
29
30
|
ok: Boolean(key),
|
|
30
31
|
detail: key
|
|
31
|
-
?
|
|
32
|
-
|
|
33
|
-
: '
|
|
34
|
-
|
|
32
|
+
? `from ${describeKeySource()}${profiles.length > 1 ? ` (${profiles.length} profiles: ${profiles.map((x) => x.name).join(', ')})` : ''}`
|
|
33
|
+
: profiles.length > 0
|
|
34
|
+
? `active profile has no key — profiles: ${profiles.map((x) => x.name).join(', ')}; run "tripo use"`
|
|
35
|
+
: 'missing — run "tripo login"',
|
|
35
36
|
critical: true,
|
|
36
37
|
});
|
|
37
38
|
if (configFileExists()) {
|
package/dist/commands/home.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Bare `tripo` — interactive home: status, quick actions, continue last task. */
|
|
2
2
|
import * as p from '@clack/prompts';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
|
-
import { loadConfig, resolveApiKey } from '../config/config.js';
|
|
4
|
+
import { activeProfileName, listProfiles, loadConfig, resolveApiKey } from '../config/config.js';
|
|
5
5
|
import { readHistory } from '../config/history.js';
|
|
6
6
|
import { TripoClient } from '../core/client.js';
|
|
7
7
|
import { isInteractive, log } from '../core/output.js';
|
|
@@ -29,7 +29,9 @@ export function registerHomeAction(program) {
|
|
|
29
29
|
balanceLine = t({ zh: '余额获取失败', en: 'balance unavailable' });
|
|
30
30
|
}
|
|
31
31
|
const last = readHistory().filter((e) => e.status === 'success').pop();
|
|
32
|
-
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
const profileNote = listProfiles(config).length > 1 ? t({ zh: ` · 账号 ${activeProfileName(config)}`, en: ` · profile ${activeProfileName(config)}` }) : '';
|
|
34
|
+
log(`${balanceLine}${profileNote}${last ? t({ zh: ` · 上次任务 @${last.name}`, en: ` · last task @${last.name}` }) : ''}`);
|
|
33
35
|
const action = await p.select({
|
|
34
36
|
message: t({ zh: '想做什么?', en: 'What do you want to do?' }),
|
|
35
37
|
options: [
|
package/dist/commands/login.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Onboarding loop: `tripo login` (device flow: browser approves, key arrives
|
|
3
3
|
* automatically; falls back to open-keys-page-and-paste), `tripo logout`,
|
|
4
|
-
* `tripo whoami
|
|
4
|
+
* `tripo whoami`, `tripo use` (switch between named profiles).
|
|
5
|
+
*
|
|
6
|
+
* Multi-account: every login lands in a profile (default: the active one).
|
|
7
|
+
* `tripo login --profile work` adds a second account without touching the
|
|
8
|
+
* first; `tripo use work` switches. Key precedence stays:
|
|
9
|
+
* TRIPO_API_KEY env > active profile in ~/.tripo/config.json.
|
|
5
10
|
*/
|
|
6
11
|
import * as p from '@clack/prompts';
|
|
7
|
-
import {
|
|
12
|
+
import { activeProfileName, DEFAULT_PROFILE, deleteProfile, describeKeySource, isValidProfileName, listProfiles, loadConfig, maskKey, REGION_DEFAULTS, resolveApiKey, resolvePlatformBaseUrl, resolveRegion, setActiveProfile, updateProfile, } from '../config/config.js';
|
|
8
13
|
import { TripoClient } from '../core/client.js';
|
|
9
|
-
import { DeviceAuthUnavailableError, requestDeviceCode, waitForDeviceApproval } from '../core/device-auth.js';
|
|
14
|
+
import { DeviceAuthUnavailableError, requestDeviceCode, waitForDeviceApproval, } from '../core/device-auth.js';
|
|
10
15
|
import { AuthError, CliError, UsageError } from '../core/errors.js';
|
|
11
16
|
import { detectRegionForKey } from '../core/region.js';
|
|
12
17
|
import { emitResult, isInteractive, log, shouldOpen, success, warn } from '../core/output.js';
|
|
@@ -21,21 +26,29 @@ export function registerAuthCommands(program) {
|
|
|
21
26
|
.description('sign in: browser authorizes, the key arrives automatically')
|
|
22
27
|
.option('--paste', 'skip the browser flow; just paste an API key')
|
|
23
28
|
.option('--key <key>', 'provide the API key directly (tsk_...); region is auto-detected')
|
|
24
|
-
.option('--region <region>', 'ov or cn —
|
|
29
|
+
.option('--region <region>', 'ov or cn — needed for the browser flow; required for non-interactive login')
|
|
30
|
+
.option('--profile <name>', 'save into a named profile (for a second account); becomes the active profile')
|
|
25
31
|
.action(async (opts) => {
|
|
26
32
|
await runLogin(opts);
|
|
27
33
|
});
|
|
28
34
|
program
|
|
29
35
|
.command('logout')
|
|
30
|
-
.description('remove the locally stored API key')
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
.description('remove the locally stored API key (current profile only)')
|
|
37
|
+
.option('--profile <name>', 'log out a specific profile instead of the active one')
|
|
38
|
+
.action((opts) => {
|
|
39
|
+
const name = opts.profile ?? activeProfileName();
|
|
40
|
+
if (!deleteProfile(name)) {
|
|
41
|
+
log(t({ zh: `Profile「${name}」本地没有存储 API Key`, en: `no API key stored locally for profile "${name}"` }));
|
|
35
42
|
}
|
|
36
43
|
else {
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
success(t({ zh: `已删除 profile「${name}」的本地 API Key`, en: `local API key removed (profile "${name}")` }));
|
|
45
|
+
const rest = listProfiles();
|
|
46
|
+
if (rest.length > 0) {
|
|
47
|
+
log(t({
|
|
48
|
+
zh: `其余账号仍在: ${rest.map((x) => x.name).join(', ')}(tripo use 切换)`,
|
|
49
|
+
en: `remaining profiles: ${rest.map((x) => x.name).join(', ')} (switch with "tripo use")`,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
39
52
|
}
|
|
40
53
|
log(t({
|
|
41
54
|
zh: `如需吊销 Key 本身,请前往 ${resolvePlatformBaseUrl()} 的 API Keys 页面`,
|
|
@@ -44,43 +57,120 @@ export function registerAuthCommands(program) {
|
|
|
44
57
|
});
|
|
45
58
|
program
|
|
46
59
|
.command('whoami')
|
|
47
|
-
.description('show the active key (masked), region, and
|
|
60
|
+
.description('show the active key (masked), region, balance, and other profiles')
|
|
48
61
|
.action(async () => {
|
|
49
62
|
const config = loadConfig();
|
|
63
|
+
// TripoClient's constructor is the single authority on "no key": it
|
|
64
|
+
// distinguishes a mistyped profile from not being logged in at all.
|
|
65
|
+
const client = new TripoClient();
|
|
50
66
|
const key = resolveApiKey(config);
|
|
51
|
-
|
|
52
|
-
throw new AuthError(t({ zh: '未配置 API Key', en: 'no API key configured' }));
|
|
53
|
-
const source = process.env.TRIPO_API_KEY ? 'env:TRIPO_API_KEY' : '~/.tripo/config.json';
|
|
67
|
+
const source = describeKeySource(config);
|
|
54
68
|
const region = resolveRegion(config);
|
|
55
|
-
const client = new TripoClient();
|
|
56
69
|
const balance = await client.getBalance();
|
|
70
|
+
const others = listProfiles(config).filter((x) => !x.active);
|
|
71
|
+
const lines = [
|
|
72
|
+
`key ${maskKey(key)} (${source})`,
|
|
73
|
+
`region ${region}`,
|
|
74
|
+
`api ${client.baseUrl}`,
|
|
75
|
+
t({ zh: `余额 ${balance.balance} 积分(冻结 ${balance.frozen})`, en: `balance ${balance.balance} credits (${balance.frozen} frozen)` }),
|
|
76
|
+
];
|
|
77
|
+
if (others.length > 0) {
|
|
78
|
+
lines.push(t({
|
|
79
|
+
zh: `其它账号 ${others.map((x) => profileLabel(x.name, x.profile.region)).join(', ')}(tripo use 切换)`,
|
|
80
|
+
en: `others ${others.map((x) => profileLabel(x.name, x.profile.region)).join(', ')} (switch with "tripo use")`,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
57
83
|
emitResult({
|
|
58
84
|
key: maskKey(key),
|
|
59
85
|
source,
|
|
86
|
+
profile: activeProfileName(config),
|
|
60
87
|
region,
|
|
61
88
|
api_base_url: client.baseUrl,
|
|
62
89
|
balance: balance.balance,
|
|
63
90
|
frozen: balance.frozen,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
profiles: listProfiles(config).map((x) => ({ name: x.name, region: x.profile.region, active: x.active })),
|
|
92
|
+
}, lines.join('\n'));
|
|
93
|
+
});
|
|
94
|
+
program
|
|
95
|
+
.command('use [profile]')
|
|
96
|
+
.description('switch the active account profile (no argument: pick interactively)')
|
|
97
|
+
.action(async (name) => {
|
|
98
|
+
await runUse(name);
|
|
70
99
|
});
|
|
71
100
|
}
|
|
101
|
+
function profileLabel(name, region) {
|
|
102
|
+
return region ? `${name} (${region})` : name;
|
|
103
|
+
}
|
|
104
|
+
async function runUse(name) {
|
|
105
|
+
const profiles = listProfiles();
|
|
106
|
+
if (profiles.length === 0) {
|
|
107
|
+
throw new AuthError(t({ zh: '还没有任何账号', en: 'no profiles yet' }), t({ zh: '先运行 tripo login 登录', en: 'run "tripo login" first' }));
|
|
108
|
+
}
|
|
109
|
+
if (!name) {
|
|
110
|
+
if (!isInteractive()) {
|
|
111
|
+
throw new UsageError(t({
|
|
112
|
+
zh: `非交互环境请指定名字: tripo use <${profiles.map((x) => x.name).join('|')}>`,
|
|
113
|
+
en: `specify a profile in non-interactive mode: tripo use <${profiles.map((x) => x.name).join('|')}>`,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
const answer = await p.select({
|
|
117
|
+
message: t({ zh: '切换到哪个账号?', en: 'Switch to which account?' }),
|
|
118
|
+
initialValue: profiles.find((x) => x.active)?.name,
|
|
119
|
+
options: profiles.map((x) => ({
|
|
120
|
+
value: x.name,
|
|
121
|
+
label: profileLabel(x.name, x.profile.region) + (x.active ? t({ zh: '(当前)', en: ' (current)' }) : ''),
|
|
122
|
+
hint: x.profile.api_key ? maskKey(x.profile.api_key) : undefined,
|
|
123
|
+
})),
|
|
124
|
+
});
|
|
125
|
+
if (p.isCancel(answer))
|
|
126
|
+
throw new CliError('cancelled', EXIT.FATAL);
|
|
127
|
+
name = answer;
|
|
128
|
+
}
|
|
129
|
+
const match = profiles.find((x) => x.name === name);
|
|
130
|
+
if (!match) {
|
|
131
|
+
throw new UsageError(t({
|
|
132
|
+
zh: `没有叫「${name}」的 profile(现有: ${profiles.map((x) => x.name).join(', ')})`,
|
|
133
|
+
en: `no profile named "${name}" (have: ${profiles.map((x) => x.name).join(', ')})`,
|
|
134
|
+
}), t({ zh: `新账号用 tripo login --profile ${name} 登录`, en: `log a new account in with: tripo login --profile ${name}` }));
|
|
135
|
+
}
|
|
136
|
+
setActiveProfile(name);
|
|
137
|
+
success(t({
|
|
138
|
+
zh: `已切换到「${name}」${match.profile.region ? `(region=${match.profile.region})` : ''}`,
|
|
139
|
+
en: `switched to "${name}"${match.profile.region ? ` (region=${match.profile.region})` : ''}`,
|
|
140
|
+
}));
|
|
141
|
+
if (process.env.TRIPO_API_KEY) {
|
|
142
|
+
warn(t({
|
|
143
|
+
zh: '注意:环境变量 TRIPO_API_KEY 仍然优先于 profile,如需使用 profile 请先 unset',
|
|
144
|
+
en: 'note: TRIPO_API_KEY env var still overrides the profile — unset it to use the profile key',
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
72
148
|
export async function runLogin(opts) {
|
|
73
149
|
let region = normalizeRegion(opts.region);
|
|
150
|
+
// No --profile → the *active* profile: `tripo use work && tripo login`
|
|
151
|
+
// re-authenticates work, it does not silently overwrite default.
|
|
152
|
+
const profile = opts.profile ?? activeProfileName();
|
|
153
|
+
if (!isValidProfileName(profile)) {
|
|
154
|
+
throw new UsageError(t({
|
|
155
|
+
zh: `profile 名「${profile}」不合法(小写字母/数字开头,可含 - _,最长 32)`,
|
|
156
|
+
en: `invalid profile name "${profile}" (lowercase alphanumeric start, may contain - _, max 32 chars)`,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
const replacing = loadConfig().profiles?.[profile]?.api_key;
|
|
160
|
+
if (replacing) {
|
|
161
|
+
log(t({
|
|
162
|
+
zh: `将替换 profile「${profile}」现有的 Key ${maskKey(replacing)}(新账号请用 tripo login --profile 名字)`,
|
|
163
|
+
en: `this will replace the key ${maskKey(replacing)} in profile "${profile}" (for a second account use: tripo login --profile <name>)`,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
74
166
|
if (opts.key) {
|
|
75
167
|
// Key in hand → region is detected, not asked (a key lives in exactly one region).
|
|
76
|
-
await validateAndSave(opts.key, region);
|
|
168
|
+
await validateAndSave(opts.key, region, profile);
|
|
77
169
|
return;
|
|
78
170
|
}
|
|
79
171
|
if (!isInteractive()) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
en: 'in non-interactive environments use the TRIPO_API_KEY env var or "tripo login --key tsk_..."',
|
|
83
|
-
}));
|
|
172
|
+
await runHeadlessLogin(region, profile, opts.paste);
|
|
173
|
+
return;
|
|
84
174
|
}
|
|
85
175
|
p.intro('tripo login');
|
|
86
176
|
// The browser/paste flows need a console URL *before* any key exists — the
|
|
@@ -105,13 +195,78 @@ export async function runLogin(opts) {
|
|
|
105
195
|
throw new CliError('cancelled', EXIT.FATAL);
|
|
106
196
|
region = answer;
|
|
107
197
|
}
|
|
108
|
-
const key = opts.paste ? await keyFromConsole(region, false) : await deviceFlowWithFallback(region);
|
|
109
|
-
await validateAndSave(key, region);
|
|
198
|
+
const key = opts.paste ? await keyFromConsole(region, false) : await deviceFlowWithFallback(region, profile);
|
|
199
|
+
await validateAndSave(key, region, profile);
|
|
110
200
|
p.outro(t({
|
|
111
201
|
zh: '登录完成!试试: tripo make "一只可爱的机器人"',
|
|
112
202
|
en: 'logged in! try: tripo make "a cute robot"',
|
|
113
203
|
}));
|
|
114
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Non-interactive login (agents, CI without a provisioned key): the same
|
|
207
|
+
* device flow, but with the verification URL and one-time code printed as
|
|
208
|
+
* plain text so the caller can relay them to a human, then a blocking poll
|
|
209
|
+
* until that human approves in a browser. The prompts of the interactive
|
|
210
|
+
* path are impossible here, so --region must be explicit (there is no key
|
|
211
|
+
* yet to probe, and the two regions have different console hosts) and
|
|
212
|
+
* --paste is rejected outright.
|
|
213
|
+
*/
|
|
214
|
+
async function runHeadlessLogin(region, profile, paste) {
|
|
215
|
+
if (paste) {
|
|
216
|
+
throw new UsageError(t({
|
|
217
|
+
zh: '--paste 需要交互终端;非交互环境请用设备码登录(tripo login --region ov|cn)或 TRIPO_API_KEY / --key',
|
|
218
|
+
en: '--paste needs an interactive terminal — use device login ("tripo login --region ov|cn"), TRIPO_API_KEY, or --key instead',
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
if (!region) {
|
|
222
|
+
throw new UsageError(t({
|
|
223
|
+
zh: '非交互登录请指定 --region ov(海外站)或 cn(国内站),例如: tripo login --region ov;或改用 TRIPO_API_KEY / tripo login --key tsk_...',
|
|
224
|
+
en: 'non-interactive login needs an explicit --region ov (international) or cn (China mainland), e.g. "tripo login --region ov" — or use TRIPO_API_KEY / "tripo login --key tsk_..."',
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
let grant;
|
|
228
|
+
try {
|
|
229
|
+
grant = await requestDeviceCode(region, profile === DEFAULT_PROFILE ? undefined : profile);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
if (err instanceof DeviceAuthUnavailableError) {
|
|
233
|
+
// No paste fallback without a TTY — fail with every remaining option spelled out.
|
|
234
|
+
throw new CliError(t({
|
|
235
|
+
zh: `浏览器授权服务不可用(${err.message});请设置 TRIPO_API_KEY,运行 tripo login --key tsk_...,或在交互终端里运行 tripo login`,
|
|
236
|
+
en: `device authorization unavailable (${err.message}) — set TRIPO_API_KEY, run "tripo login --key tsk_...", or run "tripo login" in an interactive terminal`,
|
|
237
|
+
}), EXIT.AUTH);
|
|
238
|
+
}
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
const minutes = Math.max(1, Math.round(grant.expires_in / 60));
|
|
242
|
+
log(t({
|
|
243
|
+
zh: [
|
|
244
|
+
'需要在浏览器完成授权;本命令会一直等待,直到授权完成或过期。请转告用户:',
|
|
245
|
+
` 1. 打开: ${grant.verification_url_complete}`,
|
|
246
|
+
' (页面如果提示登录,先登录或注册 Tripo 账号 — 新账号有免费额度)',
|
|
247
|
+
` 2. 在「Verification code」输入框填入终端码: ${grant.user_code}`,
|
|
248
|
+
' 3. 账号已有多把 API Key 时页面会让用户选择一把;一把没有则自动创建',
|
|
249
|
+
` 4. 点击「Authorize」。此码 ${minutes} 分钟内有效`,
|
|
250
|
+
].join('\n'),
|
|
251
|
+
en: [
|
|
252
|
+
'Authorization happens in a browser; this command keeps waiting until approved or expired. Relay these steps:',
|
|
253
|
+
` 1. Open: ${grant.verification_url_complete}`,
|
|
254
|
+
' (if the page asks to sign in, sign in or sign up first — new accounts get free credits)',
|
|
255
|
+
` 2. Enter this one-time code in the "Verification code" field: ${grant.user_code}`,
|
|
256
|
+
' 3. An account with several API keys is asked to pick one; with none, a key is created automatically',
|
|
257
|
+
` 4. Click "Authorize". The code expires in ${minutes} minutes`,
|
|
258
|
+
].join('\n'),
|
|
259
|
+
}));
|
|
260
|
+
const result = await waitForDeviceApproval(region, grant);
|
|
261
|
+
if (result.status === 'denied') {
|
|
262
|
+
throw new CliError(t({ zh: '授权被拒绝;重新运行 tripo login 再试', en: 'authorization denied — run "tripo login" again to retry' }), EXIT.AUTH);
|
|
263
|
+
}
|
|
264
|
+
if (result.status !== 'approved') {
|
|
265
|
+
throw new CliError(t({ zh: '授权超时或已过期;重新运行 tripo login 获取新的终端码', en: 'authorization expired — run "tripo login" again for a fresh code' }), EXIT.AUTH);
|
|
266
|
+
}
|
|
267
|
+
const saved = await validateAndSave(result.apikey, region, profile);
|
|
268
|
+
emitResult({ status: 'logged_in', profile, region: saved.region, key: maskKey(result.apikey), balance: saved.balance }, t({ zh: `登录完成(profile=${profile}, region=${saved.region})`, en: `logged in (profile=${profile}, region=${saved.region})` }));
|
|
269
|
+
}
|
|
115
270
|
/**
|
|
116
271
|
* Preferred path: RFC 8628 device flow — the browser does the sign-in and a
|
|
117
272
|
* one-click approve, the key arrives over the CLI↔console-API channel, and
|
|
@@ -119,10 +274,12 @@ export async function runLogin(opts) {
|
|
|
119
274
|
* the backend feature flag is off (endpoints 404) or the console API is
|
|
120
275
|
* unreachable, so login never hard-fails on a backend rollout state.
|
|
121
276
|
*/
|
|
122
|
-
async function deviceFlowWithFallback(region) {
|
|
277
|
+
async function deviceFlowWithFallback(region, profile) {
|
|
123
278
|
let grant;
|
|
124
279
|
try {
|
|
125
|
-
|
|
280
|
+
// The backend rotates keys by name; scoping the name by profile keeps two
|
|
281
|
+
// profiles of the same account from revoking each other's key.
|
|
282
|
+
grant = await requestDeviceCode(region, profile === DEFAULT_PROFILE ? undefined : profile);
|
|
126
283
|
}
|
|
127
284
|
catch (err) {
|
|
128
285
|
if (err instanceof DeviceAuthUnavailableError) {
|
|
@@ -258,17 +415,18 @@ async function keyFromConsole(region, openBrowser) {
|
|
|
258
415
|
* its verdict beats whatever the user selected, so a wrong region choice
|
|
259
416
|
* self-heals instead of producing a config that 401s on every call.
|
|
260
417
|
*/
|
|
261
|
-
async function validateAndSave(key, selectedRegion) {
|
|
418
|
+
async function validateAndSave(key, selectedRegion, profile) {
|
|
262
419
|
if (process.env.TRIPO_API_BASE_URL) {
|
|
263
420
|
const client = new TripoClient({ apiKey: key });
|
|
264
421
|
try {
|
|
265
422
|
const balance = await client.getBalance();
|
|
266
|
-
|
|
423
|
+
const region = selectedRegion ?? 'ov';
|
|
424
|
+
finishSave(key, region, balance.balance, profile);
|
|
425
|
+
return { region, balance: balance.balance };
|
|
267
426
|
}
|
|
268
427
|
catch (err) {
|
|
269
428
|
throw asAuthError(err);
|
|
270
429
|
}
|
|
271
|
-
return;
|
|
272
430
|
}
|
|
273
431
|
const detected = await detectRegionForKey(key);
|
|
274
432
|
if (!detected) {
|
|
@@ -283,13 +441,17 @@ async function validateAndSave(key, selectedRegion) {
|
|
|
283
441
|
en: `this key belongs to the ${detected.region} region — auto-switched (your choice "${selectedRegion}" was wrong)`,
|
|
284
442
|
}));
|
|
285
443
|
}
|
|
286
|
-
finishSave(key, detected.region, detected.balance.balance);
|
|
444
|
+
finishSave(key, detected.region, detected.balance.balance, profile);
|
|
445
|
+
return { region: detected.region, balance: detected.balance.balance };
|
|
287
446
|
}
|
|
288
|
-
function finishSave(key, region, balance) {
|
|
289
|
-
|
|
447
|
+
function finishSave(key, region, balance, profile) {
|
|
448
|
+
// Saving activates the profile: "the account I just logged into is the
|
|
449
|
+
// account I am using" — no second command needed for the common case.
|
|
450
|
+
updateProfile(profile, { api_key: key, region });
|
|
451
|
+
const where = profile === DEFAULT_PROFILE ? '~/.tripo/config.json' : `~/.tripo/config.json · profile "${profile}"`;
|
|
290
452
|
success(t({
|
|
291
|
-
zh: `Key 验证通过(region=${region}),当前余额 ${balance} 积分(已保存到
|
|
292
|
-
en: `key verified (region=${region}); balance ${balance} credits (saved to
|
|
453
|
+
zh: `Key 验证通过(region=${region}),当前余额 ${balance} 积分(已保存到 ${where},权限 0600)`,
|
|
454
|
+
en: `key verified (region=${region}); balance ${balance} credits (saved to ${where}, mode 0600)`,
|
|
293
455
|
}));
|
|
294
456
|
if (Number(balance) === 0) {
|
|
295
457
|
warn(t({
|