tripo-cli 0.1.0 → 0.2.0

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 (64) hide show
  1. package/README.md +19 -3
  2. package/dist/commands/completion.js +1 -1
  3. package/dist/commands/config-cmd.js +32 -17
  4. package/dist/commands/doctor.js +6 -5
  5. package/dist/commands/home.js +4 -2
  6. package/dist/commands/login.js +129 -32
  7. package/dist/config/config.js +103 -14
  8. package/dist/core/client.js +16 -2
  9. package/dist/core/device-auth.js +41 -9
  10. package/dist/program.js +21 -1
  11. package/package.json +2 -2
  12. package/skill/commands/account.md +15 -4
  13. package/dist/src/ai/experts.js +0 -115
  14. package/dist/src/ai/llm.js +0 -88
  15. package/dist/src/ai/plan-card.js +0 -79
  16. package/dist/src/ai/wizard.js +0 -104
  17. package/dist/src/cli.js +0 -7
  18. package/dist/src/commands/account.js +0 -27
  19. package/dist/src/commands/ai.js +0 -113
  20. package/dist/src/commands/batch.js +0 -148
  21. package/dist/src/commands/completion.js +0 -84
  22. package/dist/src/commands/config-cmd.js +0 -84
  23. package/dist/src/commands/docs.js +0 -30
  24. package/dist/src/commands/doctor.js +0 -94
  25. package/dist/src/commands/generate.js +0 -81
  26. package/dist/src/commands/home.js +0 -68
  27. package/dist/src/commands/login.js +0 -225
  28. package/dist/src/commands/make.js +0 -142
  29. package/dist/src/commands/mcp.js +0 -194
  30. package/dist/src/commands/process-groups.js +0 -164
  31. package/dist/src/commands/shared.js +0 -75
  32. package/dist/src/commands/task.js +0 -127
  33. package/dist/src/commands/topup.js +0 -61
  34. package/dist/src/commands/view.js +0 -97
  35. package/dist/src/config/config.js +0 -79
  36. package/dist/src/config/context.js +0 -44
  37. package/dist/src/config/history.js +0 -104
  38. package/dist/src/core/client.js +0 -120
  39. package/dist/src/core/download.js +0 -107
  40. package/dist/src/core/errors.js +0 -64
  41. package/dist/src/core/output.js +0 -72
  42. package/dist/src/core/requests.js +0 -169
  43. package/dist/src/core/task-service.js +0 -161
  44. package/dist/src/core/upload.js +0 -36
  45. package/dist/src/core/viewer-assets.js +0 -24
  46. package/dist/src/core/viewer.js +0 -59
  47. package/dist/src/core/watch.js +0 -41
  48. package/dist/src/i18n.js +0 -19
  49. package/dist/src/knowledge/chains.js +0 -152
  50. package/dist/src/knowledge/error-catalog.js +0 -91
  51. package/dist/src/knowledge/models.js +0 -110
  52. package/dist/src/knowledge/params.js +0 -174
  53. package/dist/src/knowledge/scenarios.js +0 -112
  54. package/dist/src/pipeline/make.js +0 -233
  55. package/dist/src/pipeline/runner.js +0 -73
  56. package/dist/src/pipeline/stdin.js +0 -40
  57. package/dist/src/program.js +0 -147
  58. package/dist/src/types.js +0 -2
  59. package/dist/src/util/input-detect.js +0 -92
  60. package/dist/src/util/notify.js +0 -17
  61. package/dist/src/util/open.js +0 -18
  62. package/dist/src/util/spinner.js +0 -62
  63. package/dist/src/util/term-image.js +0 -28
  64. package/dist/src/version.js +0 -10
package/README.md CHANGED
@@ -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
- const SETTABLE = new Set([
8
- 'region',
9
- 'api_base_url',
10
- 'platform_base_url',
11
- 'language',
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 (key masked)')
16
+ .description('show effective config (keys masked)')
22
17
  .action(() => {
23
18
  const cfg = loadConfig();
24
19
  const shown = {
25
20
  ...cfg,
26
- api_key: cfg.api_key ? maskKey(cfg.api_key) : undefined,
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 (${[...SETTABLE].join(', ')})`)
34
+ .description(`set a config value (${SETTABLE.join(', ')})`)
35
35
  .action((key, value) => {
36
- if (!SETTABLE.has(key)) {
37
- throw new UsageError(`unknown config key "${key}" (valid: ${[...SETTABLE].join(', ')})`);
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);
@@ -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
- ? process.env.TRIPO_API_KEY
32
- ? 'from TRIPO_API_KEY env'
33
- : 'from ~/.tripo/config.json'
34
- : 'missing — run "tripo login"',
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()) {
@@ -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
- log(`${balanceLine}${last ? t({ zh: ` · 上次任务 @${last.name}`, en: ` · last task @${last.name}` }) : ''}`);
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: [
@@ -1,10 +1,15 @@
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`. Key precedence: TRIPO_API_KEY env > ~/.tripo/config.json.
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 { deleteApiKey, loadConfig, maskKey, REGION_DEFAULTS, resolveApiKey, resolvePlatformBaseUrl, resolveRegion, updateConfig, } from '../config/config.js';
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
14
  import { DeviceAuthUnavailableError, requestDeviceCode, waitForDeviceApproval } from '../core/device-auth.js';
10
15
  import { AuthError, CliError, UsageError } from '../core/errors.js';
@@ -22,20 +27,28 @@ export function registerAuthCommands(program) {
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
29
  .option('--region <region>', 'ov or cn — only needed for the browser flow; auto-detected once a key exists')
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
- .action(() => {
32
- const config = loadConfig();
33
- if (!config.api_key) {
34
- log(t({ zh: '本地没有存储 API Key', en: 'no API key stored locally' }));
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
- deleteApiKey();
38
- success(t({ zh: '已删除本地 API Key', en: 'local API key removed' }));
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,36 +57,115 @@ export function registerAuthCommands(program) {
44
57
  });
45
58
  program
46
59
  .command('whoami')
47
- .description('show the active key (masked), region, and balance')
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
- if (!key)
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
- `key ${maskKey(key)} (${source})`,
66
- `region ${region}`,
67
- `api ${client.baseUrl}`,
68
- t({ zh: `余额 ${balance.balance} 积分(冻结 ${balance.frozen})`, en: `balance ${balance.balance} credits (${balance.frozen} frozen)` }),
69
- ].join('\n'));
91
+ profiles: listProfiles(config).map((x) => ({ name: x.name, region: x.profile.region, active: x.active })),
92
+ }, lines.join('\n'));
70
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);
99
+ });
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
+ }
71
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()) {
@@ -105,8 +197,8 @@ export async function runLogin(opts) {
105
197
  throw new CliError('cancelled', EXIT.FATAL);
106
198
  region = answer;
107
199
  }
108
- const key = opts.paste ? await keyFromConsole(region, false) : await deviceFlowWithFallback(region);
109
- await validateAndSave(key, region);
200
+ const key = opts.paste ? await keyFromConsole(region, false) : await deviceFlowWithFallback(region, profile);
201
+ await validateAndSave(key, region, profile);
110
202
  p.outro(t({
111
203
  zh: '登录完成!试试: tripo make "一只可爱的机器人"',
112
204
  en: 'logged in! try: tripo make "a cute robot"',
@@ -119,10 +211,12 @@ export async function runLogin(opts) {
119
211
  * the backend feature flag is off (endpoints 404) or the console API is
120
212
  * unreachable, so login never hard-fails on a backend rollout state.
121
213
  */
122
- async function deviceFlowWithFallback(region) {
214
+ async function deviceFlowWithFallback(region, profile) {
123
215
  let grant;
124
216
  try {
125
- grant = await requestDeviceCode(region);
217
+ // The backend rotates keys by name; scoping the name by profile keeps two
218
+ // profiles of the same account from revoking each other's key.
219
+ grant = await requestDeviceCode(region, profile === DEFAULT_PROFILE ? undefined : profile);
126
220
  }
127
221
  catch (err) {
128
222
  if (err instanceof DeviceAuthUnavailableError) {
@@ -258,12 +352,12 @@ async function keyFromConsole(region, openBrowser) {
258
352
  * its verdict beats whatever the user selected, so a wrong region choice
259
353
  * self-heals instead of producing a config that 401s on every call.
260
354
  */
261
- async function validateAndSave(key, selectedRegion) {
355
+ async function validateAndSave(key, selectedRegion, profile) {
262
356
  if (process.env.TRIPO_API_BASE_URL) {
263
357
  const client = new TripoClient({ apiKey: key });
264
358
  try {
265
359
  const balance = await client.getBalance();
266
- finishSave(key, selectedRegion ?? 'ov', balance.balance);
360
+ finishSave(key, selectedRegion ?? 'ov', balance.balance, profile);
267
361
  }
268
362
  catch (err) {
269
363
  throw asAuthError(err);
@@ -283,13 +377,16 @@ async function validateAndSave(key, selectedRegion) {
283
377
  en: `this key belongs to the ${detected.region} region — auto-switched (your choice "${selectedRegion}" was wrong)`,
284
378
  }));
285
379
  }
286
- finishSave(key, detected.region, detected.balance.balance);
380
+ finishSave(key, detected.region, detected.balance.balance, profile);
287
381
  }
288
- function finishSave(key, region, balance) {
289
- updateConfig({ api_key: key, region });
382
+ function finishSave(key, region, balance, profile) {
383
+ // Saving activates the profile: "the account I just logged into is the
384
+ // account I am using" — no second command needed for the common case.
385
+ updateProfile(profile, { api_key: key, region });
386
+ const where = profile === DEFAULT_PROFILE ? '~/.tripo/config.json' : `~/.tripo/config.json · profile "${profile}"`;
290
387
  success(t({
291
- zh: `Key 验证通过(region=${region}),当前余额 ${balance} 积分(已保存到 ~/.tripo/config.json,权限 0600)`,
292
- en: `key verified (region=${region}); balance ${balance} credits (saved to ~/.tripo/config.json, mode 0600)`,
388
+ zh: `Key 验证通过(region=${region}),当前余额 ${balance} 积分(已保存到 ${where},权限 0600)`,
389
+ en: `key verified (region=${region}); balance ${balance} credits (saved to ${where}, mode 0600)`,
293
390
  }));
294
391
  if (Number(balance) === 0) {
295
392
  warn(t({
@@ -1,10 +1,25 @@
1
1
  /**
2
- * Global config at ~/.tripo/config.json (mode 0600 — it holds the API key).
2
+ * Global config at ~/.tripo/config.json (mode 0600 — it holds API keys).
3
+ *
4
+ * Multi-account model: credentials live in named *profiles* (a dict plus an
5
+ * `active_profile` pointer — same shape as AWS CLI profiles / kubectl
6
+ * contexts). Everything account-bound (key, region, URL overrides) is
7
+ * per-profile; user preferences (language, llm, output dir) stay top-level.
8
+ *
9
+ * v0.1.0 stored a single key at the top level. That format is normalized
10
+ * into `profiles.default` on *every* load, so old files keep working
11
+ * untouched and the rest of the codebase only ever sees one shape. Writes
12
+ * always persist the normalized form, so any write migrates the file.
13
+ *
3
14
  * Precedence for every setting: env var > directory context > global config.
15
+ * Profile selection: TRIPO_PROFILE env (set by the global --profile flag
16
+ * too) > active_profile > "default". TRIPO_API_KEY bypasses profiles
17
+ * entirely — agent/CI setups never touch the profile machinery.
4
18
  */
5
19
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
6
20
  import { homedir } from 'node:os';
7
21
  import { join } from 'node:path';
22
+ export const DEFAULT_PROFILE = 'default';
8
23
  /**
9
24
  * Everything region-specific reduces to these three URLs: the generation API
10
25
  * (`api`), the console website (`platform`), and the console's own backend
@@ -35,9 +50,33 @@ export function tripoHome() {
35
50
  function configPath() {
36
51
  return join(tripoHome(), 'config.json');
37
52
  }
53
+ /**
54
+ * Fold v0.1.0 top-level credential fields into `profiles.default`, so no
55
+ * other code ever branches on "old format vs new format". If a default
56
+ * profile already exists its fields win (the file was already migrated;
57
+ * stray top-level leftovers must not resurrect a replaced key).
58
+ */
59
+ export function normalizeConfig(raw) {
60
+ const { api_key, region, api_base_url, platform_base_url, ...rest } = raw;
61
+ const legacy = {};
62
+ if (api_key)
63
+ legacy.api_key = api_key;
64
+ if (region)
65
+ legacy.region = region;
66
+ if (api_base_url)
67
+ legacy.api_base_url = api_base_url;
68
+ if (platform_base_url)
69
+ legacy.platform_base_url = platform_base_url;
70
+ if (Object.keys(legacy).length === 0)
71
+ return rest;
72
+ return {
73
+ ...rest,
74
+ profiles: { ...rest.profiles, [DEFAULT_PROFILE]: { ...legacy, ...rest.profiles?.[DEFAULT_PROFILE] } },
75
+ };
76
+ }
38
77
  export function loadConfig() {
39
78
  try {
40
- return JSON.parse(readFileSync(configPath(), 'utf8'));
79
+ return normalizeConfig(JSON.parse(readFileSync(configPath(), 'utf8')));
41
80
  }
42
81
  catch {
43
82
  return {};
@@ -47,7 +86,9 @@ export function saveConfig(config) {
47
86
  const dir = tripoHome();
48
87
  mkdirSync(dir, { recursive: true, mode: 0o700 });
49
88
  const file = configPath();
50
- writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
89
+ // Writes always persist the normalized shape — this is what migrates
90
+ // legacy files, and it keeps a single on-disk format going forward.
91
+ writeFileSync(file, JSON.stringify(normalizeConfig(config), null, 2) + '\n', { mode: 0o600 });
51
92
  // writeFileSync mode only applies on creation; enforce on every save.
52
93
  chmodSync(file, 0o600);
53
94
  }
@@ -56,37 +97,85 @@ export function updateConfig(patch) {
56
97
  saveConfig(next);
57
98
  return next;
58
99
  }
59
- export function deleteApiKey() {
100
+ /** Names are shell-friendly and short — they appear in key names and flags. */
101
+ export function isValidProfileName(name) {
102
+ return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name);
103
+ }
104
+ /** Selected profile name: TRIPO_PROFILE (or --profile) > config > default. */
105
+ export function activeProfileName(config = loadConfig()) {
106
+ return process.env.TRIPO_PROFILE || config.active_profile || DEFAULT_PROFILE;
107
+ }
108
+ /** The selected profile's data; {} when it does not exist (yet). */
109
+ export function activeProfile(config = loadConfig()) {
110
+ return config.profiles?.[activeProfileName(config)] ?? {};
111
+ }
112
+ export function listProfiles(config = loadConfig()) {
113
+ const active = activeProfileName(config);
114
+ return Object.entries(config.profiles ?? {}).map(([name, profile]) => ({
115
+ name,
116
+ profile,
117
+ active: name === active,
118
+ }));
119
+ }
120
+ /** Create/update one profile and (by default) make it the active one. */
121
+ export function updateProfile(name, patch, opts = {}) {
60
122
  const config = loadConfig();
61
- delete config.api_key;
62
- if (Object.keys(config).length === 0) {
123
+ const profiles = { ...config.profiles, [name]: { ...config.profiles?.[name], ...patch } };
124
+ saveConfig({ ...config, profiles, ...(opts.activate !== false ? { active_profile: name } : {}) });
125
+ }
126
+ export function setActiveProfile(name) {
127
+ updateConfig({ active_profile: name });
128
+ }
129
+ /** Remove a profile (logout). Active pointer falls back to default. */
130
+ export function deleteProfile(name) {
131
+ const config = loadConfig();
132
+ if (!config.profiles?.[name])
133
+ return false;
134
+ const profiles = { ...config.profiles };
135
+ delete profiles[name];
136
+ const next = { ...config, profiles };
137
+ if (Object.keys(profiles).length === 0)
138
+ delete next.profiles;
139
+ if (next.active_profile === name)
140
+ delete next.active_profile;
141
+ if (Object.keys(next).length === 0) {
63
142
  try {
64
143
  unlinkSync(configPath());
65
144
  }
66
145
  catch { }
146
+ return true;
67
147
  }
68
- else {
69
- saveConfig(config);
70
- }
148
+ saveConfig(next);
149
+ return true;
71
150
  }
72
- /** Resolve the API key: env wins, then global config. */
151
+ /** Resolve the API key: env wins, then the active profile. */
73
152
  export function resolveApiKey(config = loadConfig()) {
74
- return process.env.TRIPO_API_KEY || config.api_key;
153
+ return process.env.TRIPO_API_KEY || activeProfile(config).api_key;
154
+ }
155
+ /**
156
+ * Where the effective key comes from, for whoami/doctor and for the
157
+ * "no key" error message — so a mistyped --profile says "profile not
158
+ * found", never a misleading "you are not logged in".
159
+ */
160
+ export function describeKeySource(config = loadConfig()) {
161
+ if (process.env.TRIPO_API_KEY)
162
+ return 'env:TRIPO_API_KEY';
163
+ return `profile:${activeProfileName(config)}`;
75
164
  }
76
165
  export function resolveRegion(config = loadConfig()) {
77
166
  const env = process.env.TRIPO_REGION;
78
167
  if (env === 'cn' || env === 'ov')
79
168
  return env;
80
- return config.region ?? 'ov';
169
+ return activeProfile(config).region ?? 'ov';
81
170
  }
82
171
  export function resolveApiBaseUrl(config = loadConfig()) {
83
172
  return (process.env.TRIPO_API_BASE_URL ||
84
- config.api_base_url ||
173
+ activeProfile(config).api_base_url ||
85
174
  REGION_DEFAULTS[resolveRegion(config)].api);
86
175
  }
87
176
  export function resolvePlatformBaseUrl(config = loadConfig()) {
88
177
  return (process.env.TRIPO_PLATFORM_BASE_URL ||
89
- config.platform_base_url ||
178
+ activeProfile(config).platform_base_url ||
90
179
  REGION_DEFAULTS[resolveRegion(config)].platform);
91
180
  }
92
181
  export function configFileExists() {
@@ -3,7 +3,8 @@
3
3
  * Handles the response envelope, error translation, timeouts, and
4
4
  * exponential-backoff retries for transient failures (429/5xx/network).
5
5
  */
6
- import { loadConfig, resolveApiBaseUrl, resolveApiKey } from '../config/config.js';
6
+ import { activeProfileName, DEFAULT_PROFILE, listProfiles, loadConfig, resolveApiBaseUrl, resolveApiKey } from '../config/config.js';
7
+ import { t } from '../i18n.js';
7
8
  import { ApiError, AuthError, NetworkError } from './errors.js';
8
9
  export class TripoClient {
9
10
  baseUrl;
@@ -16,7 +17,20 @@ export class TripoClient {
16
17
  this.baseUrl = (options.baseUrl ?? resolveApiBaseUrl(config)).replace(/\/$/, '');
17
18
  const key = options.apiKey ?? resolveApiKey(config);
18
19
  if (!key) {
19
- throw new AuthError('no API key configured');
20
+ // A mistyped --profile must say so — "not logged in" would send the
21
+ // user to re-login while their key sits in another profile.
22
+ const wanted = activeProfileName(config);
23
+ const known = listProfiles(config).map((p) => p.name);
24
+ if (wanted !== DEFAULT_PROFILE && !known.includes(wanted)) {
25
+ throw new AuthError(t({
26
+ zh: `没有叫「${wanted}」的 profile${known.length ? `(现有: ${known.join(', ')})` : ''}`,
27
+ en: `profile "${wanted}" not found${known.length ? ` (have: ${known.join(', ')})` : ''}`,
28
+ }), t({
29
+ zh: `用 tripo login --profile ${wanted} 登录,或 tripo use 切换`,
30
+ en: `run "tripo login --profile ${wanted}" to create it, or "tripo use" to switch`,
31
+ }));
32
+ }
33
+ throw new AuthError(t({ zh: '未配置 API Key', en: 'no API key configured' }));
20
34
  }
21
35
  this.apiKey = key;
22
36
  this.timeoutMs = options.timeoutMs ?? 60_000;