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/dist/config/config.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Global config at ~/.tripo/config.json (mode 0600 — it holds
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
148
|
+
saveConfig(next);
|
|
149
|
+
return true;
|
|
71
150
|
}
|
|
72
|
-
/** Resolve the API key: env wins, then
|
|
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() {
|
package/dist/core/client.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/device-auth.js
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* The flow is optional by design — when the backend feature flag is off the
|
|
8
8
|
* /cli/device endpoints 404 and the caller falls back to paste login.
|
|
9
9
|
*/
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
12
|
import { hostname } from 'node:os';
|
|
11
|
-
import {
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { REGION_DEFAULTS, tripoHome } from '../config/config.js';
|
|
12
15
|
import { sleep } from './client.js';
|
|
13
16
|
export const DEVICE_CODE_EXPIRED = 4290;
|
|
14
17
|
export const DEVICE_CODE_SLOW_DOWN = 4291;
|
|
@@ -32,19 +35,48 @@ export class DeviceAuthUnavailableError extends Error {
|
|
|
32
35
|
export function consoleApiBaseUrl(region) {
|
|
33
36
|
return process.env.TRIPO_CONSOLE_API_BASE_URL || REGION_DEFAULTS[region].consoleApi;
|
|
34
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* A persistent per-machine fingerprint (4 hex chars in ~/.tripo/device-id).
|
|
40
|
+
* The backend rotates keys *by name*, and the name used to be derived from
|
|
41
|
+
* the hostname alone — but default hostnames collide constantly (every
|
|
42
|
+
* other Mac is "MacBook-Pro.local"), and a collision means machine B's
|
|
43
|
+
* login silently revokes machine A's key. The fingerprint makes "same
|
|
44
|
+
* machine" mean "same fingerprint", not "same hostname by luck".
|
|
45
|
+
*/
|
|
46
|
+
export function deviceId() {
|
|
47
|
+
const file = join(tripoHome(), 'device-id');
|
|
48
|
+
try {
|
|
49
|
+
const existing = readFileSync(file, 'utf8').trim();
|
|
50
|
+
if (/^[0-9a-f]{4}$/.test(existing))
|
|
51
|
+
return existing;
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
const fresh = randomBytes(2).toString('hex');
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(tripoHome(), { recursive: true, mode: 0o700 });
|
|
57
|
+
writeFileSync(file, fresh + '\n');
|
|
58
|
+
}
|
|
59
|
+
catch { } // unwritable home: fall back to a per-login id — worst case is key buildup, never cross-machine revocation
|
|
60
|
+
return fresh;
|
|
61
|
+
}
|
|
35
62
|
/**
|
|
36
63
|
* The backend whitelists key names to [a-zA-Z0-9 _.-]{1,32} — hostnames with
|
|
37
64
|
* anything else (e.g. non-ASCII machine names) would get a hard 400, so strip
|
|
38
|
-
* offending characters up front
|
|
39
|
-
*
|
|
65
|
+
* offending characters up front. The backend also *rotates* keys by name
|
|
66
|
+
* (same name → old key revoked), so the name must be unique per (machine,
|
|
67
|
+
* profile): the device fingerprint separates same-named machines, the
|
|
68
|
+
* profile separates accounts on one machine. The hostname is only there for
|
|
69
|
+
* humans reading the /keys page; it is the part that gets truncated first.
|
|
40
70
|
*/
|
|
41
|
-
export function deviceKeyName(host = hostname()) {
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
71
|
+
export function deviceKeyName(profile, host = hostname(), id = deviceId()) {
|
|
72
|
+
const prefix = profile ? `CLI ${profile}` : 'CLI';
|
|
73
|
+
const cleanedHost = host.replace(/[^a-zA-Z0-9 _.-]/g, '').replace(/\s+/g, ' ').trim();
|
|
74
|
+
const budget = 32 - `${prefix} - ${id}`.length;
|
|
75
|
+
const shownHost = cleanedHost.slice(0, Math.max(budget, 0)).trim();
|
|
76
|
+
return `${prefix} - ${shownHost} ${id}`.replace(/\s+/g, ' ').slice(0, 32).trim();
|
|
45
77
|
}
|
|
46
|
-
export async function requestDeviceCode(region) {
|
|
47
|
-
const body = await post(`${consoleApiBaseUrl(region)}/cli/device/code`, { name: deviceKeyName() });
|
|
78
|
+
export async function requestDeviceCode(region, profile) {
|
|
79
|
+
const body = await post(`${consoleApiBaseUrl(region)}/cli/device/code`, { name: deviceKeyName(profile) });
|
|
48
80
|
const grant = body.data;
|
|
49
81
|
if (!grant ||
|
|
50
82
|
typeof grant.device_code !== 'string' ||
|
package/dist/program.js
CHANGED
|
@@ -37,6 +37,7 @@ export function buildProgram() {
|
|
|
37
37
|
.option('--yes', 'skip confirmations (auto-enabled when headless)')
|
|
38
38
|
.option('--quiet', 'suppress progress logs on stderr')
|
|
39
39
|
.option('--no-open', 'never auto-open the browser/viewer')
|
|
40
|
+
.option('--profile <name>', 'use a named account profile for this command (like TRIPO_PROFILE)')
|
|
40
41
|
.enablePositionalOptions()
|
|
41
42
|
.showSuggestionAfterError(true);
|
|
42
43
|
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
@@ -46,6 +47,11 @@ export function buildProgram() {
|
|
|
46
47
|
quiet: resolveGlobalFlag(actionCommand, 'quiet', false),
|
|
47
48
|
noOpen: !resolveGlobalFlag(actionCommand, 'open', true),
|
|
48
49
|
});
|
|
50
|
+
// --profile rides the TRIPO_PROFILE env var: config resolution already
|
|
51
|
+
// reads it, so one assignment covers every code path.
|
|
52
|
+
const profile = resolveGlobalString(actionCommand, 'profile');
|
|
53
|
+
if (profile)
|
|
54
|
+
process.env.TRIPO_PROFILE = profile;
|
|
49
55
|
const lang = loadConfig().language;
|
|
50
56
|
if (lang)
|
|
51
57
|
setLocale(lang);
|
|
@@ -70,7 +76,7 @@ export function buildProgram() {
|
|
|
70
76
|
return program;
|
|
71
77
|
}
|
|
72
78
|
/**
|
|
73
|
-
* The
|
|
79
|
+
* The global flags live on every leaf command so `tripo balance --json`
|
|
74
80
|
* works the way people type it (prefix `tripo --json balance` works too).
|
|
75
81
|
*/
|
|
76
82
|
function attachGlobalFlags(cmd) {
|
|
@@ -87,6 +93,9 @@ function attachGlobalFlags(cmd) {
|
|
|
87
93
|
cmd.option('--quiet', 'suppress progress logs on stderr');
|
|
88
94
|
if (!has('--no-open'))
|
|
89
95
|
cmd.option('--no-open', 'never auto-open the browser/viewer');
|
|
96
|
+
// login/logout keep their own --profile (same semantics, different help text).
|
|
97
|
+
if (!has('--profile'))
|
|
98
|
+
cmd.option('--profile <name>', 'use a named account profile');
|
|
90
99
|
}
|
|
91
100
|
/**
|
|
92
101
|
* Resolve a flag along the command chain, innermost explicit value first.
|
|
@@ -101,6 +110,17 @@ function resolveGlobalFlag(cmd, key, def) {
|
|
|
101
110
|
}
|
|
102
111
|
return def;
|
|
103
112
|
}
|
|
113
|
+
/** Same walk for string-valued global options (--profile). */
|
|
114
|
+
function resolveGlobalString(cmd, key) {
|
|
115
|
+
for (let c = cmd; c; c = c.parent) {
|
|
116
|
+
const source = c.getOptionValueSource(key);
|
|
117
|
+
if (source !== undefined && source !== 'default') {
|
|
118
|
+
const value = c.getOptionValue(key);
|
|
119
|
+
return typeof value === 'string' ? value : undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
104
124
|
/** Run the CLI; converts errors into the stable exit-code contract. */
|
|
105
125
|
export async function runCli(argv) {
|
|
106
126
|
const program = buildProgram();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tripo-cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Tripo 3D intelligent CLI — turn a prompt or an image into engine-ready 3D assets from your terminal (Tripo V3 API)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tripo",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"url": "https://github.com/vast-enterprise/Tripo-API-CLI/issues"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"build": "tsc -p tsconfig.build.json && node scripts/postbuild.mjs",
|
|
37
|
+
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node scripts/postbuild.mjs",
|
|
38
38
|
"dev": "tsx src/cli.ts",
|
|
39
39
|
"typecheck": "tsc --noEmit",
|
|
40
40
|
"lint": "eslint src tests",
|
package/skill/SKILL.md
CHANGED
|
@@ -11,7 +11,19 @@ export TRIPO_API_KEY=tsk_... # highest-priority auth; no interaction needed
|
|
|
11
11
|
tripo doctor # verify key, network, balance
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
If there is no key,
|
|
14
|
+
If there is no key, run the device login **yourself** — it works without a TTY:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
tripo login --region ov # ov = international, cn = China mainland
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
It prints a verification URL and a one-time code, then blocks until the human
|
|
21
|
+
approves in a browser (up to ~15 minutes — do not kill it early). Relay the URL
|
|
22
|
+
and code to the human and tell them: sign in on that page first if it asks
|
|
23
|
+
(sign-up gives free credits), enter the code, pick an API key if the account has
|
|
24
|
+
several (one is created automatically if there are none), click Authorize. The
|
|
25
|
+
key is then saved locally; never ask the human to paste a key into the chat. If
|
|
26
|
+
the code expires (exit 3), just run the same command again.
|
|
15
27
|
|
|
16
28
|
Regions (ov = international, cn = China mainland) share one CLI: `tripo login --key tsk_...` auto-detects which region the key belongs to. On exit code 3 (auth), run `tripo doctor` — it diagnoses key-vs-region mismatches and prints the exact fix (`tripo config set region cn|ov`).
|
|
17
29
|
|
|
@@ -3,15 +3,28 @@
|
|
|
3
3
|
## Auth
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
export TRIPO_API_KEY=tsk_... #
|
|
7
|
-
tripo login # humans
|
|
6
|
+
export TRIPO_API_KEY=tsk_... # zero interaction when a key is already provisioned
|
|
7
|
+
tripo login # humans in a terminal: interactive browser flow
|
|
8
|
+
tripo login --region ov|cn # agents/CI without a key: headless device flow — prints a
|
|
9
|
+
# verification URL + one-time code on stderr, waits for the
|
|
10
|
+
# human to authorize in a browser (≤15 min), saves the key
|
|
8
11
|
tripo login --key tsk_... --region ov|cn
|
|
9
|
-
tripo whoami # masked key + region + balance
|
|
10
|
-
tripo logout # removes the local key only
|
|
12
|
+
tripo whoami # masked key + active profile + region + balance
|
|
13
|
+
tripo logout # removes the local key of the active profile only
|
|
11
14
|
```
|
|
12
15
|
|
|
13
16
|
Region decides the API host: ov → `openapi.tripo3d.ai`, cn → `openapi.tripo3d.com`.
|
|
14
17
|
|
|
18
|
+
## Multiple accounts (profiles)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
tripo login --profile work-cn # log a second account into its own profile (becomes active)
|
|
22
|
+
tripo use work-cn # switch the active profile (no arg = interactive picker)
|
|
23
|
+
tripo balance --profile work-cn # one-shot override on any command; TRIPO_PROFILE env works too
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
One profile = one account (key + region). Unknown `--profile` fails with exit 3 and the fix in the message — do not retry blindly.
|
|
27
|
+
|
|
15
28
|
## Credits
|
|
16
29
|
|
|
17
30
|
```bash
|
|
@@ -34,7 +47,8 @@ tripo config set llm.model gpt-4o-mini
|
|
|
34
47
|
tripo config context default_scenario=game-mobile output_dir=./assets # per-directory .tripo/context.json
|
|
35
48
|
```
|
|
36
49
|
|
|
37
|
-
Env overrides: `TRIPO_API_KEY` `TRIPO_REGION` `TRIPO_API_BASE_URL` `TRIPO_LLM_BASE_URL` `TRIPO_LLM_API_KEY` `TRIPO_LLM_MODEL`.
|
|
50
|
+
Env overrides: `TRIPO_API_KEY` `TRIPO_PROFILE` `TRIPO_REGION` `TRIPO_API_BASE_URL` `TRIPO_LLM_BASE_URL` `TRIPO_LLM_API_KEY` `TRIPO_LLM_MODEL`.
|
|
51
|
+
`region`/`api_base_url`/`platform_base_url` are stored per profile; `language`/`llm.*`/`default_output_dir` are shared.
|
|
38
52
|
|
|
39
53
|
## tripo doctor
|
|
40
54
|
|
package/dist/src/ai/experts.js
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-expert agent orchestration (conversation mode).
|
|
3
|
-
*
|
|
4
|
-
* Producer (router) → domain specialist(s) → plan card. The LLM only parses
|
|
5
|
-
* intent and phrases advice; every decision it may make is constrained to the
|
|
6
|
-
* knowledge base (scenario ids, step names, parameter windows), and the
|
|
7
|
-
* result is re-validated locally before anything is spent.
|
|
8
|
-
*/
|
|
9
|
-
import { getLocale, t, tList } from '../i18n.js';
|
|
10
|
-
import { parseThenExpression, validateChain, STEPS } from '../knowledge/chains.js';
|
|
11
|
-
import { chooseModel, detectLowPolyIntent } from '../knowledge/models.js';
|
|
12
|
-
import { getScenario, SCENARIOS } from '../knowledge/scenarios.js';
|
|
13
|
-
import { chatJson } from './llm.js';
|
|
14
|
-
const SCENARIO_TABLE = SCENARIOS.map((s) => `- ${s.id}: ${s.description.en} | model=${s.model} | chain=${s.chain.map((c) => c.step).join('→') || 'none'} | deliver=${s.deliverables.join('/')}`).join('\n');
|
|
15
|
-
const STEP_TABLE = STEPS.map((s) => s.name).join(', ');
|
|
16
|
-
const PRODUCER_SYSTEM = `You are the producer/router of the Tripo 3D generation CLI.
|
|
17
|
-
Classify the user's request into exactly one scenario and extract a clean English 3D generation prompt.
|
|
18
|
-
|
|
19
|
-
Scenarios:
|
|
20
|
-
${SCENARIO_TABLE}
|
|
21
|
-
|
|
22
|
-
Rules:
|
|
23
|
-
- "prompt" must describe the object to generate (translate to English, keep it concrete, <= 200 chars).
|
|
24
|
-
- Set "face_limit" only when the user states a face/polygon budget.
|
|
25
|
-
- Set "needs_animation" true only when the user wants skeleton/animations.
|
|
26
|
-
- If one critical piece of information is missing and materially changes the plan, set "question" (one short question in the user's language). Otherwise omit it.
|
|
27
|
-
Respond with JSON only: {"scenario","prompt","face_limit","needs_animation","question","notes"}`;
|
|
28
|
-
const SPECIALIST_PROMPTS = {
|
|
29
|
-
'game-mobile': `You are a senior game technical artist (mobile). Budgets: 500–20000 faces, 1–2K textures, clean topology, FBX/GLB delivery. P1 model is used (no quad/parts/geometry_quality).`,
|
|
30
|
-
'game-pc': `You are a senior game technical artist (PC/console). PBR detailed textures, optional decimate for LODs (bake=true), FBX delivery with engine presets.`,
|
|
31
|
-
film: `You are a film/offline-rendering artist. quad=true subdivision-friendly topology (FBX output), geometry_quality=detailed, texture_quality=detailed, 4K textures, USD/OBJ handoff via convert.`,
|
|
32
|
-
print: `You are a 3D-printing engineer. Geometry only (texture=false,pbr=false), watertight meshes, flatten_bottom for FDM, STL or colored 3MF via convert, real-world sizing via scale_factor/auto_size.`,
|
|
33
|
-
'ar-web': `You are a realtime AR/Web 3D engineer. Keep faces <= 20000 via decimate, 2K textures, deliver GLB + USDZ (iOS QuickLook).`,
|
|
34
|
-
anim: `You are a character animation TD. Chain: rig-check → rig (rig-v2.0, spec tripo|mixamo) → retarget (<=5 preset animations, billed per animation). Mixamo/Unity Humanoid → spec=mixamo.`,
|
|
35
|
-
toy: `You are a stylized collectible designer. P1 low-poly or stylize steps (lego/voxel/voronoi/minecraft). STL via convert for physical prints.`,
|
|
36
|
-
};
|
|
37
|
-
function specialistSystem(scenarioId) {
|
|
38
|
-
const persona = SPECIALIST_PROMPTS[scenarioId] ?? SPECIALIST_PROMPTS['game-mobile'];
|
|
39
|
-
return `${persona}
|
|
40
|
-
You fine-tune a Tripo generation plan. You may adjust generation params and the processing chain.
|
|
41
|
-
Valid chain steps: ${STEP_TABLE}. Chain syntax: "step:key=value,step2" (e.g. "texture,rig,convert:format=FBX").
|
|
42
|
-
Constraints: pbr=>texture; generate_parts excludes texture/pbr/quad; P1 face_limit 48-20000 and forbids quad/smart_low_poly/generate_parts/geometry_quality; retarget <= 5 animations.
|
|
43
|
-
Respond with JSON only: {"params":{...},"chain":"...","notes":["short practical notes in ${getLocale() === 'zh' ? 'Chinese' : 'English'}"]}. Omit fields you don't want to change.`;
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Run the expert pipeline. `askUser` is called when the producer needs one
|
|
47
|
-
* clarification (interactive mode only); return undefined to skip.
|
|
48
|
-
*/
|
|
49
|
-
export async function buildExpertPlan(request, askUser) {
|
|
50
|
-
const messages = [
|
|
51
|
-
{ role: 'system', content: PRODUCER_SYSTEM },
|
|
52
|
-
{ role: 'user', content: request },
|
|
53
|
-
];
|
|
54
|
-
let producer = await chatJson(messages);
|
|
55
|
-
if (producer.question && askUser) {
|
|
56
|
-
const answer = await askUser(producer.question);
|
|
57
|
-
if (answer) {
|
|
58
|
-
messages.push({ role: 'assistant', content: JSON.stringify(producer) });
|
|
59
|
-
messages.push({ role: 'user', content: `Additional info: ${answer}. Re-classify with the complete picture; do not ask again.` });
|
|
60
|
-
producer = await chatJson(messages);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
const scenario = getScenario(producer.scenario) ?? getScenario('game-mobile');
|
|
64
|
-
const specialist = await chatJson([
|
|
65
|
-
{ role: 'system', content: specialistSystem(scenario.id) },
|
|
66
|
-
{
|
|
67
|
-
role: 'user',
|
|
68
|
-
content: `User request: ${request}\nPrompt: ${producer.prompt}\nBase plan: model=${scenario.model} params=${JSON.stringify(scenario.genParams)} chain=${scenario.chain.map((c) => c.step).join('→') || 'none'}\nneeds_animation=${producer.needs_animation ?? false} face_limit=${producer.face_limit ?? 'default'}`,
|
|
69
|
-
},
|
|
70
|
-
]).catch(() => ({}));
|
|
71
|
-
// Merge + re-validate everything the LLM produced.
|
|
72
|
-
const params = { ...scenario.genParams, ...(specialist.params ?? {}) };
|
|
73
|
-
if (producer.face_limit)
|
|
74
|
-
params.face_limit = producer.face_limit;
|
|
75
|
-
let chain = scenario.chain;
|
|
76
|
-
if (specialist.chain) {
|
|
77
|
-
try {
|
|
78
|
-
chain = parseThenExpression(specialist.chain);
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
chain = scenario.chain; // discard invalid expert chain, keep the preset
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (producer.needs_animation && !chain.some((s) => s.step === 'rig')) {
|
|
85
|
-
chain = [
|
|
86
|
-
...chain.filter((s) => s.step !== 'convert'),
|
|
87
|
-
{ step: 'rig-check', args: {} },
|
|
88
|
-
{ step: 'rig', args: { model: 'rig-v2.0' } },
|
|
89
|
-
{ step: 'retarget', args: { animations: ['preset:idle', 'preset:walk'] } },
|
|
90
|
-
];
|
|
91
|
-
}
|
|
92
|
-
const choice = chooseModel({
|
|
93
|
-
explicit: scenario.model,
|
|
94
|
-
faceLimit: typeof params.face_limit === 'number' ? params.face_limit : undefined,
|
|
95
|
-
lowPoly: detectLowPolyIntent(request),
|
|
96
|
-
});
|
|
97
|
-
try {
|
|
98
|
-
validateChain(chain, 'text_to_model');
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
chain = scenario.chain;
|
|
102
|
-
}
|
|
103
|
-
const card = {
|
|
104
|
-
inputSummary: producer.prompt,
|
|
105
|
-
scenario: scenario.id,
|
|
106
|
-
model: choice.model,
|
|
107
|
-
modelReason: t({ zh: '制片人路由 + 场景预设', en: 'producer routing + scenario preset' }),
|
|
108
|
-
genParams: params,
|
|
109
|
-
chain,
|
|
110
|
-
deliverables: scenario.deliverables,
|
|
111
|
-
notes: tList(scenario.notes),
|
|
112
|
-
expertNotes: [...(producer.notes ?? []), ...(specialist.notes ?? [])],
|
|
113
|
-
};
|
|
114
|
-
return { card, scenarioId: scenario.id, prompt: producer.prompt, chain, params };
|
|
115
|
-
}
|
package/dist/src/ai/llm.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BYO-LLM client: any OpenAI-compatible /chat/completions endpoint.
|
|
3
|
-
* Configure via `tripo config set llm.base_url/llm.api_key/llm.model`
|
|
4
|
-
* or env TRIPO_LLM_BASE_URL / TRIPO_LLM_API_KEY / TRIPO_LLM_MODEL.
|
|
5
|
-
*/
|
|
6
|
-
import { loadConfig } from '../config/config.js';
|
|
7
|
-
import { CliError } from '../core/errors.js';
|
|
8
|
-
import { EXIT } from '../knowledge/error-catalog.js';
|
|
9
|
-
export function resolveLlmConfig() {
|
|
10
|
-
const env = {
|
|
11
|
-
base_url: process.env.TRIPO_LLM_BASE_URL,
|
|
12
|
-
api_key: process.env.TRIPO_LLM_API_KEY,
|
|
13
|
-
model: process.env.TRIPO_LLM_MODEL,
|
|
14
|
-
};
|
|
15
|
-
if (env.base_url && env.model)
|
|
16
|
-
return { base_url: env.base_url, api_key: env.api_key, model: env.model };
|
|
17
|
-
const config = loadConfig().llm;
|
|
18
|
-
if (config?.base_url && config.model)
|
|
19
|
-
return config;
|
|
20
|
-
return undefined;
|
|
21
|
-
}
|
|
22
|
-
export async function chatCompletion(messages, options = {}) {
|
|
23
|
-
const config = resolveLlmConfig();
|
|
24
|
-
if (!config)
|
|
25
|
-
throw new CliError('no LLM configured', EXIT.USAGE);
|
|
26
|
-
const url = `${config.base_url.replace(/\/$/, '')}/chat/completions`;
|
|
27
|
-
const controller = new AbortController();
|
|
28
|
-
const timer = setTimeout(() => controller.abort(), 60_000);
|
|
29
|
-
let response;
|
|
30
|
-
try {
|
|
31
|
-
response = await fetch(url, {
|
|
32
|
-
method: 'POST',
|
|
33
|
-
headers: {
|
|
34
|
-
'Content-Type': 'application/json',
|
|
35
|
-
...(config.api_key ? { Authorization: `Bearer ${config.api_key}` } : {}),
|
|
36
|
-
},
|
|
37
|
-
body: JSON.stringify({
|
|
38
|
-
model: config.model,
|
|
39
|
-
messages,
|
|
40
|
-
temperature: 0.2,
|
|
41
|
-
...(options.json ? { response_format: { type: 'json_object' } } : {}),
|
|
42
|
-
}),
|
|
43
|
-
signal: controller.signal,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
throw new CliError(`LLM request failed: ${err instanceof Error ? err.message : String(err)}`, EXIT.NETWORK);
|
|
48
|
-
}
|
|
49
|
-
finally {
|
|
50
|
-
clearTimeout(timer);
|
|
51
|
-
}
|
|
52
|
-
if (!response.ok) {
|
|
53
|
-
const body = await response.text().catch(() => '');
|
|
54
|
-
throw new CliError(`LLM endpoint returned HTTP ${response.status}: ${body.slice(0, 300)}`, EXIT.NETWORK);
|
|
55
|
-
}
|
|
56
|
-
const data = (await response.json());
|
|
57
|
-
const content = data.choices?.[0]?.message?.content;
|
|
58
|
-
if (!content)
|
|
59
|
-
throw new CliError('LLM returned an empty response', EXIT.NETWORK);
|
|
60
|
-
return content;
|
|
61
|
-
}
|
|
62
|
-
/** Chat expecting a JSON object back; tolerates markdown fences. */
|
|
63
|
-
export async function chatJson(messages) {
|
|
64
|
-
const raw = await chatCompletion(messages, { json: true }).catch(async (err) => {
|
|
65
|
-
// Some endpoints reject response_format; retry without it.
|
|
66
|
-
if (err instanceof CliError && err.exitCode === EXIT.NETWORK) {
|
|
67
|
-
return chatCompletion(messages, { json: false });
|
|
68
|
-
}
|
|
69
|
-
throw err;
|
|
70
|
-
});
|
|
71
|
-
return extractJson(raw);
|
|
72
|
-
}
|
|
73
|
-
export function extractJson(raw) {
|
|
74
|
-
const trimmed = raw.trim();
|
|
75
|
-
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
|
|
76
|
-
const candidate = fenced ? fenced[1] : trimmed;
|
|
77
|
-
const start = candidate.indexOf('{');
|
|
78
|
-
const end = candidate.lastIndexOf('}');
|
|
79
|
-
if (start === -1 || end === -1 || end < start) {
|
|
80
|
-
throw new CliError(`LLM did not return JSON: ${raw.slice(0, 200)}`, EXIT.NETWORK);
|
|
81
|
-
}
|
|
82
|
-
try {
|
|
83
|
-
return JSON.parse(candidate.slice(start, end + 1));
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
throw new CliError(`LLM returned invalid JSON: ${candidate.slice(0, 200)}`, EXIT.NETWORK);
|
|
87
|
-
}
|
|
88
|
-
}
|