tripo-cli 0.1.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 (123) hide show
  1. package/README.md +105 -0
  2. package/dist/ai/experts.js +115 -0
  3. package/dist/ai/llm.js +88 -0
  4. package/dist/ai/plan-card.js +79 -0
  5. package/dist/ai/wizard.js +105 -0
  6. package/dist/cli.js +7 -0
  7. package/dist/commands/account.js +27 -0
  8. package/dist/commands/ai.js +114 -0
  9. package/dist/commands/batch.js +148 -0
  10. package/dist/commands/completion.js +84 -0
  11. package/dist/commands/config-cmd.js +84 -0
  12. package/dist/commands/docs.js +30 -0
  13. package/dist/commands/doctor.js +102 -0
  14. package/dist/commands/generate.js +81 -0
  15. package/dist/commands/home.js +68 -0
  16. package/dist/commands/login.js +309 -0
  17. package/dist/commands/make.js +142 -0
  18. package/dist/commands/mcp.js +194 -0
  19. package/dist/commands/process-groups.js +166 -0
  20. package/dist/commands/shared.js +75 -0
  21. package/dist/commands/task.js +128 -0
  22. package/dist/commands/topup.js +61 -0
  23. package/dist/commands/view.js +97 -0
  24. package/dist/config/config.js +99 -0
  25. package/dist/config/context.js +44 -0
  26. package/dist/config/history.js +104 -0
  27. package/dist/core/client.js +129 -0
  28. package/dist/core/device-auth.js +141 -0
  29. package/dist/core/download.js +107 -0
  30. package/dist/core/errors.js +64 -0
  31. package/dist/core/output.js +72 -0
  32. package/dist/core/region.js +45 -0
  33. package/dist/core/requests.js +197 -0
  34. package/dist/core/task-service.js +167 -0
  35. package/dist/core/upload.js +42 -0
  36. package/dist/core/viewer-assets.js +24 -0
  37. package/dist/core/viewer.js +59 -0
  38. package/dist/core/watch.js +42 -0
  39. package/dist/i18n.js +19 -0
  40. package/dist/knowledge/chains.js +157 -0
  41. package/dist/knowledge/error-catalog.js +91 -0
  42. package/dist/knowledge/models.js +158 -0
  43. package/dist/knowledge/params.js +203 -0
  44. package/dist/knowledge/scenarios.js +112 -0
  45. package/dist/pipeline/make.js +233 -0
  46. package/dist/pipeline/runner.js +73 -0
  47. package/dist/pipeline/stdin.js +40 -0
  48. package/dist/program.js +147 -0
  49. package/dist/src/ai/experts.js +115 -0
  50. package/dist/src/ai/llm.js +88 -0
  51. package/dist/src/ai/plan-card.js +79 -0
  52. package/dist/src/ai/wizard.js +104 -0
  53. package/dist/src/cli.js +7 -0
  54. package/dist/src/commands/account.js +27 -0
  55. package/dist/src/commands/ai.js +113 -0
  56. package/dist/src/commands/batch.js +148 -0
  57. package/dist/src/commands/completion.js +84 -0
  58. package/dist/src/commands/config-cmd.js +84 -0
  59. package/dist/src/commands/docs.js +30 -0
  60. package/dist/src/commands/doctor.js +94 -0
  61. package/dist/src/commands/generate.js +81 -0
  62. package/dist/src/commands/home.js +68 -0
  63. package/dist/src/commands/login.js +225 -0
  64. package/dist/src/commands/make.js +142 -0
  65. package/dist/src/commands/mcp.js +194 -0
  66. package/dist/src/commands/process-groups.js +164 -0
  67. package/dist/src/commands/shared.js +75 -0
  68. package/dist/src/commands/task.js +127 -0
  69. package/dist/src/commands/topup.js +61 -0
  70. package/dist/src/commands/view.js +97 -0
  71. package/dist/src/config/config.js +79 -0
  72. package/dist/src/config/context.js +44 -0
  73. package/dist/src/config/history.js +104 -0
  74. package/dist/src/core/client.js +120 -0
  75. package/dist/src/core/download.js +107 -0
  76. package/dist/src/core/errors.js +64 -0
  77. package/dist/src/core/output.js +72 -0
  78. package/dist/src/core/requests.js +169 -0
  79. package/dist/src/core/task-service.js +161 -0
  80. package/dist/src/core/upload.js +36 -0
  81. package/dist/src/core/viewer-assets.js +24 -0
  82. package/dist/src/core/viewer.js +59 -0
  83. package/dist/src/core/watch.js +41 -0
  84. package/dist/src/i18n.js +19 -0
  85. package/dist/src/knowledge/chains.js +152 -0
  86. package/dist/src/knowledge/error-catalog.js +91 -0
  87. package/dist/src/knowledge/models.js +110 -0
  88. package/dist/src/knowledge/params.js +174 -0
  89. package/dist/src/knowledge/scenarios.js +112 -0
  90. package/dist/src/pipeline/make.js +233 -0
  91. package/dist/src/pipeline/runner.js +73 -0
  92. package/dist/src/pipeline/stdin.js +40 -0
  93. package/dist/src/program.js +147 -0
  94. package/dist/src/types.js +2 -0
  95. package/dist/src/util/input-detect.js +92 -0
  96. package/dist/src/util/notify.js +17 -0
  97. package/dist/src/util/open.js +18 -0
  98. package/dist/src/util/spinner.js +62 -0
  99. package/dist/src/util/term-image.js +28 -0
  100. package/dist/src/version.js +10 -0
  101. package/dist/types.js +2 -0
  102. package/dist/util/input-detect.js +93 -0
  103. package/dist/util/notify.js +17 -0
  104. package/dist/util/open.js +18 -0
  105. package/dist/util/spinner.js +62 -0
  106. package/dist/util/term-image.js +28 -0
  107. package/dist/version.js +10 -0
  108. package/package.json +63 -0
  109. package/skill/SKILL.md +70 -0
  110. package/skill/commands/account.md +41 -0
  111. package/skill/commands/batch.md +33 -0
  112. package/skill/commands/generate.md +34 -0
  113. package/skill/commands/make.md +47 -0
  114. package/skill/commands/process.md +50 -0
  115. package/skill/commands/task.md +27 -0
  116. package/skill/commands/view.md +25 -0
  117. package/skill/common-errors.md +38 -0
  118. package/skill/examples/animation.md +33 -0
  119. package/skill/examples/ar-web.md +23 -0
  120. package/skill/examples/film.md +22 -0
  121. package/skill/examples/game-asset.md +35 -0
  122. package/skill/examples/pipes.md +39 -0
  123. package/skill/examples/print.md +31 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Onboarding loop: `tripo login` (device flow: browser approves, key arrives
3
+ * automatically; falls back to open-keys-page-and-paste), `tripo logout`,
4
+ * `tripo whoami`. Key precedence: TRIPO_API_KEY env > ~/.tripo/config.json.
5
+ */
6
+ import * as p from '@clack/prompts';
7
+ import { deleteApiKey, loadConfig, maskKey, REGION_DEFAULTS, resolveApiKey, resolvePlatformBaseUrl, resolveRegion, updateConfig, } from '../config/config.js';
8
+ import { TripoClient } from '../core/client.js';
9
+ import { DeviceAuthUnavailableError, requestDeviceCode, waitForDeviceApproval } from '../core/device-auth.js';
10
+ import { AuthError, CliError, UsageError } from '../core/errors.js';
11
+ import { detectRegionForKey } from '../core/region.js';
12
+ import { emitResult, isInteractive, log, shouldOpen, success, warn } from '../core/output.js';
13
+ import { t } from '../i18n.js';
14
+ import { EXIT } from '../knowledge/error-catalog.js';
15
+ import { openExternal } from '../util/open.js';
16
+ import { createSpinner } from '../util/spinner.js';
17
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
18
+ export function registerAuthCommands(program) {
19
+ program
20
+ .command('login')
21
+ .description('sign in: browser authorizes, the key arrives automatically')
22
+ .option('--paste', 'skip the browser flow; just paste an API key')
23
+ .option('--key <key>', 'provide the API key directly (tsk_...); region is auto-detected')
24
+ .option('--region <region>', 'ov or cn — only needed for the browser flow; auto-detected once a key exists')
25
+ .action(async (opts) => {
26
+ await runLogin(opts);
27
+ });
28
+ program
29
+ .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' }));
35
+ }
36
+ else {
37
+ deleteApiKey();
38
+ success(t({ zh: '已删除本地 API Key', en: 'local API key removed' }));
39
+ }
40
+ log(t({
41
+ zh: `如需吊销 Key 本身,请前往 ${resolvePlatformBaseUrl()} 的 API Keys 页面`,
42
+ en: `to revoke the key itself, visit the API Keys page at ${resolvePlatformBaseUrl()}`,
43
+ }));
44
+ });
45
+ program
46
+ .command('whoami')
47
+ .description('show the active key (masked), region, and balance')
48
+ .action(async () => {
49
+ const config = loadConfig();
50
+ 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';
54
+ const region = resolveRegion(config);
55
+ const client = new TripoClient();
56
+ const balance = await client.getBalance();
57
+ emitResult({
58
+ key: maskKey(key),
59
+ source,
60
+ region,
61
+ api_base_url: client.baseUrl,
62
+ balance: balance.balance,
63
+ 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'));
70
+ });
71
+ }
72
+ export async function runLogin(opts) {
73
+ let region = normalizeRegion(opts.region);
74
+ if (opts.key) {
75
+ // Key in hand → region is detected, not asked (a key lives in exactly one region).
76
+ await validateAndSave(opts.key, region);
77
+ return;
78
+ }
79
+ if (!isInteractive()) {
80
+ throw new UsageError(t({
81
+ zh: '非交互环境请用 TRIPO_API_KEY 环境变量或 tripo login --key tsk_...',
82
+ en: 'in non-interactive environments use the TRIPO_API_KEY env var or "tripo login --key tsk_..."',
83
+ }));
84
+ }
85
+ p.intro('tripo login');
86
+ // The browser/paste flows need a console URL *before* any key exists — the
87
+ // one moment region cannot be probed. So: guess from the system timezone/
88
+ // locale to pre-select the right option (Enter = correct for ~everyone),
89
+ // and even a wrong answer is harmless — it only opens the wrong console
90
+ // page; once a key is in hand it is probed against both regions and the
91
+ // key's real region wins.
92
+ if (!region) {
93
+ const answer = await p.select({
94
+ message: t({
95
+ zh: '你的账号在哪个站?(选错没关系,验证 Key 时会自动纠正)',
96
+ en: 'Where is your account? (a wrong pick self-corrects once the key is verified)',
97
+ }),
98
+ initialValue: guessRegion(),
99
+ options: [
100
+ { value: 'ov', label: t({ zh: '海外站 developers.tripo3d.ai(Stripe 支付)', en: 'International — developers.tripo3d.ai (Stripe)' }) },
101
+ { value: 'cn', label: t({ zh: '国内站 developers.tripo3d.com(支付宝)', en: 'China mainland — developers.tripo3d.com (Alipay)' }) },
102
+ ],
103
+ });
104
+ if (p.isCancel(answer))
105
+ throw new CliError('cancelled', EXIT.FATAL);
106
+ region = answer;
107
+ }
108
+ const key = opts.paste ? await keyFromConsole(region, false) : await deviceFlowWithFallback(region);
109
+ await validateAndSave(key, region);
110
+ p.outro(t({
111
+ zh: '登录完成!试试: tripo make "一只可爱的机器人"',
112
+ en: 'logged in! try: tripo make "a cute robot"',
113
+ }));
114
+ }
115
+ /**
116
+ * Preferred path: RFC 8628 device flow — the browser does the sign-in and a
117
+ * one-click approve, the key arrives over the CLI↔console-API channel, and
118
+ * nothing is ever pasted. Falls back to the open-/keys-and-paste flow when
119
+ * the backend feature flag is off (endpoints 404) or the console API is
120
+ * unreachable, so login never hard-fails on a backend rollout state.
121
+ */
122
+ async function deviceFlowWithFallback(region) {
123
+ let grant;
124
+ try {
125
+ grant = await requestDeviceCode(region);
126
+ }
127
+ catch (err) {
128
+ if (err instanceof DeviceAuthUnavailableError) {
129
+ if (err.kind === 'backend_error') {
130
+ // Deployed but broken: fall back so the user is not blocked, but
131
+ // report the failure loudly — a masked 5xx is undebuggable.
132
+ warn(t({
133
+ zh: `浏览器授权服务故障(${err.message}),请把此错误报给平台;已改用粘贴方式登录`,
134
+ en: `device authorization backend error (${err.message}) — please report this; falling back to paste login`,
135
+ }));
136
+ }
137
+ else {
138
+ log(t({ zh: '浏览器授权服务暂未开通,改用粘贴方式登录', en: 'device authorization not available yet — falling back to paste login' }));
139
+ }
140
+ return keyFromConsole(region, true);
141
+ }
142
+ throw err;
143
+ }
144
+ // Guidance before the browser steals focus: the code and the exact steps
145
+ // must already be on screen when the user lands on the login page — the
146
+ // page deliberately never pre-fills the code (anti-phishing), so typing it
147
+ // is the one thing the user has to do and the one thing we spell out.
148
+ p.note(grant.user_code, t({ zh: '你的终端码', en: 'Your one-time code' }));
149
+ const willOpen = shouldOpen();
150
+ log(t({
151
+ zh: [
152
+ '接下来三步:',
153
+ willOpen ? ' 1. 浏览器即将打开授权页面(如未登录,先登录或注册 Tripo 账号)' : ` 1. 在浏览器打开: ${grant.verification_url_complete}(如未登录,先登录或注册)`,
154
+ ` 2. 在页面的「Verification code」输入框填入终端码: ${grant.user_code}`,
155
+ ' 3. 点击「Authorize」完成授权,回到这个终端等待即可',
156
+ ].join('\n'),
157
+ en: [
158
+ 'Three steps:',
159
+ willOpen ? ' 1. your browser will open the authorization page (sign in or sign up first if needed)' : ` 1. open in a browser: ${grant.verification_url_complete} (sign in or sign up first if needed)`,
160
+ ` 2. type this one-time code into the "Verification code" field: ${grant.user_code}`,
161
+ ' 3. click "Authorize", then come back to this terminal and wait',
162
+ ].join('\n'),
163
+ }));
164
+ if (willOpen) {
165
+ openExternal(grant.verification_url_complete);
166
+ log(t({ zh: `(浏览器未弹出?手动访问: ${grant.verification_url_complete})`, en: `(browser didn't open? visit: ${grant.verification_url_complete})` }));
167
+ }
168
+ const spinner = createSpinner(t({
169
+ zh: `等待网页授权… 终端码 ${grant.user_code}(Ctrl+C 取消,或改用 tripo login --paste)`,
170
+ en: `waiting for approval… code ${grant.user_code} (Ctrl+C to cancel, or use tripo login --paste)`,
171
+ }));
172
+ const result = await waitForDeviceApproval(region, grant);
173
+ if (result.status === 'approved') {
174
+ spinner.succeed(t({ zh: '网页授权完成', en: 'authorization approved' }));
175
+ return result.apikey;
176
+ }
177
+ spinner.stop();
178
+ if (result.status === 'denied') {
179
+ throw new CliError(t({ zh: '授权被拒绝;重新运行 tripo login 再试', en: 'authorization denied — run tripo login to try again' }), EXIT.AUTH);
180
+ }
181
+ throw new CliError(t({
182
+ zh: '授权超时或已过期;重新运行 tripo login,或改用 tripo login --paste 粘贴 Key',
183
+ en: 'authorization expired — run tripo login again, or use tripo login --paste',
184
+ }), EXIT.AUTH);
185
+ }
186
+ function normalizeRegion(value) {
187
+ if (value === undefined)
188
+ return undefined;
189
+ if (value === 'ov' || value === 'cn')
190
+ return value;
191
+ throw new UsageError('--region must be ov or cn');
192
+ }
193
+ /**
194
+ * Pre-key region guess for the login prompt's default option. Mainland
195
+ * timezone or zh_CN locale → cn, everything else → ov. Only positions the
196
+ * cursor — never decides silently (expats and cross-region accounts exist).
197
+ */
198
+ export function guessRegion(timeZone = safeTimeZone(), lang = process.env.LC_ALL || process.env.LANG || '') {
199
+ if (/^Asia\/(Shanghai|Chongqing|Urumqi|Harbin)$/i.test(timeZone))
200
+ return 'cn';
201
+ if (/zh_CN/i.test(lang))
202
+ return 'cn';
203
+ return 'ov';
204
+ }
205
+ function safeTimeZone() {
206
+ try {
207
+ return Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
208
+ }
209
+ catch {
210
+ return '';
211
+ }
212
+ }
213
+ /**
214
+ * Fallback path: get a key from the console's API Keys page — open it in the
215
+ * browser (unless --paste / --no-open), then wait for the user to paste the
216
+ * key back. Used when the device-auth endpoints are not deployed/reachable,
217
+ * and directly via `tripo login --paste`. Sign-in method differences (ov:
218
+ * email-first; cn: +86 SMS-first) live entirely on the website either way.
219
+ */
220
+ async function keyFromConsole(region, openBrowser) {
221
+ const keysUrl = `${platformUrlFor(region)}/keys`;
222
+ if (openBrowser && shouldOpen()) {
223
+ openExternal(keysUrl);
224
+ log(t({ zh: `已打开控制台 API Keys 页面(未弹出请手动访问):\n ${keysUrl}`, en: `opened the console API Keys page (visit manually if it didn't open):\n ${keysUrl}` }));
225
+ }
226
+ else {
227
+ log(t({ zh: `请访问: ${keysUrl}`, en: `visit: ${keysUrl}` }));
228
+ }
229
+ log(t({
230
+ zh: `在网页上登录并创建/复制 Key,回来粘贴(新用户注册可领免费额度;选错站?Ctrl+C 后运行 tripo login --region ${region === 'cn' ? 'ov' : 'cn'})`,
231
+ en: `sign in on the page, create/copy a key, then paste it here (new accounts get free credits; wrong site? Ctrl+C and run: tripo login --region ${region === 'cn' ? 'ov' : 'cn'})`,
232
+ }));
233
+ const pastePromise = p
234
+ .password({
235
+ message: t({ zh: '粘贴 API Key (tsk_...)', en: 'Paste your API key (tsk_...)' }),
236
+ validate: (v) => (v && v.trim().startsWith('tsk_') ? undefined : t({ zh: 'Key 应以 tsk_ 开头', en: 'keys start with tsk_' })),
237
+ })
238
+ .then((v) => {
239
+ if (p.isCancel(v))
240
+ throw new CliError('cancelled', EXIT.FATAL);
241
+ return v.trim();
242
+ });
243
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new CliError(t({ zh: '登录超时(5 分钟)', en: 'login timed out (5 minutes)' }), EXIT.AUTH)), LOGIN_TIMEOUT_MS));
244
+ try {
245
+ return await Promise.race([pastePromise, timeoutPromise]);
246
+ }
247
+ finally {
248
+ // Ensure the pending clack prompt does not hold the process open on timeout.
249
+ process.stdin.pause();
250
+ }
251
+ }
252
+ /**
253
+ * Validate the key and persist it together with its *actual* region.
254
+ *
255
+ * With a custom TRIPO_API_BASE_URL the region concept does not apply (the
256
+ * caller owns routing), so we validate against that URL directly. Otherwise
257
+ * we probe both regions with the key — the probe doubles as validation, and
258
+ * its verdict beats whatever the user selected, so a wrong region choice
259
+ * self-heals instead of producing a config that 401s on every call.
260
+ */
261
+ async function validateAndSave(key, selectedRegion) {
262
+ if (process.env.TRIPO_API_BASE_URL) {
263
+ const client = new TripoClient({ apiKey: key });
264
+ try {
265
+ const balance = await client.getBalance();
266
+ finishSave(key, selectedRegion ?? 'ov', balance.balance);
267
+ }
268
+ catch (err) {
269
+ throw asAuthError(err);
270
+ }
271
+ return;
272
+ }
273
+ const detected = await detectRegionForKey(key);
274
+ if (!detected) {
275
+ throw new AuthError(t({
276
+ zh: 'Key 验证失败:国内(openapi.tripo3d.com)和海外(openapi.tripo3d.ai)都不接受这把 Key — 无效或已被删除',
277
+ en: 'key validation failed: rejected by both cn (openapi.tripo3d.com) and ov (openapi.tripo3d.ai) — invalid or deleted',
278
+ }));
279
+ }
280
+ if (selectedRegion && selectedRegion !== detected.region) {
281
+ warn(t({
282
+ zh: `这把 Key 属于${detected.region === 'cn' ? '国内' : '海外'}站,已自动切换 region=${detected.region}(你选的 ${selectedRegion} 不生效)`,
283
+ en: `this key belongs to the ${detected.region} region — auto-switched (your choice "${selectedRegion}" was wrong)`,
284
+ }));
285
+ }
286
+ finishSave(key, detected.region, detected.balance.balance);
287
+ }
288
+ function finishSave(key, region, balance) {
289
+ updateConfig({ api_key: key, region });
290
+ 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)`,
293
+ }));
294
+ if (Number(balance) === 0) {
295
+ warn(t({
296
+ zh: '余额为 0:新账号可在控制台领取免费额度,或运行 tripo topup 充值',
297
+ en: 'balance is 0: claim free credits in the console for new accounts, or run "tripo topup"',
298
+ }));
299
+ }
300
+ }
301
+ function asAuthError(err) {
302
+ if (err instanceof CliError && err.exitCode === EXIT.AUTH) {
303
+ return new AuthError(t({ zh: 'Key 验证失败:无效或已被删除', en: 'key validation failed: invalid or deleted' }));
304
+ }
305
+ return err instanceof Error ? err : new Error(String(err));
306
+ }
307
+ function platformUrlFor(region) {
308
+ return process.env.TRIPO_PLATFORM_BASE_URL || REGION_DEFAULTS[region].platform;
309
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `tripo make` — the magic command. Give it anything (a sentence, an image,
3
+ * 2–4 views, a model file, or @last/task id) and it picks the endpoint, the
4
+ * model, the parameters, runs the chain, and downloads the artifacts.
5
+ */
6
+ import * as p from '@clack/prompts';
7
+ import { UsageError } from '../core/errors.js';
8
+ import { emitResult, isInteractive, log, warn } from '../core/output.js';
9
+ import { t } from '../i18n.js';
10
+ import { scenarioIds } from '../knowledge/scenarios.js';
11
+ import { executeMake, planMake } from '../pipeline/make.js';
12
+ import { newClient, parseChainFlag, printTaskResult, withTaskOptions } from './shared.js';
13
+ import { printInlineImage } from '../util/term-image.js';
14
+ export function registerMakeCommand(program) {
15
+ const cmd = program
16
+ .command('make')
17
+ .description('turn text / images / a model file / a task ref into 3D artifacts (the magic command)')
18
+ .argument('<input...>', 'prompt text, image file(s), model file, URL, task id, or @last')
19
+ .option('--for <scenario>', `scenario preset: ${scenarioIds().join('|')}`)
20
+ .option('--then <steps>', 'processing chain, e.g. "texture,rig,convert:fbx"')
21
+ .option('--model <model>', 'force model version (tripo-v3.1 / tripo-p1 / ...)')
22
+ .option('-n, --candidates <n>', 'generate N candidates in parallel', (v) => Number(v), 1)
23
+ .option('--seed <n>', 'model_seed for reproducible geometry', (v) => Number(v));
24
+ withTaskOptions(cmd);
25
+ cmd.action(async (inputs, flags) => {
26
+ const result = await runMake(inputs, flags);
27
+ emitMakeOutcome(result);
28
+ });
29
+ }
30
+ export async function runMake(inputs, flags) {
31
+ const client = newClient();
32
+ const params = { ...flags.param };
33
+ if (flags.seed !== undefined)
34
+ params.model_seed = flags.seed;
35
+ const options = {
36
+ inputs,
37
+ scenario: flags.for,
38
+ chain: parseChainFlag(flags.then),
39
+ model: flags.model,
40
+ params,
41
+ name: flags.name,
42
+ outDir: flags.out,
43
+ noWait: flags.wait === false,
44
+ noDownload: flags.download === false,
45
+ notify: flags.notify,
46
+ timeoutMs: flags.timeout ? flags.timeout * 1000 : undefined,
47
+ };
48
+ const n = flags.candidates;
49
+ if (n <= 1) {
50
+ const plan = await planMake(client, options);
51
+ const outcome = await executeMake(client, plan, options);
52
+ return { results: [outcome.primary], chainResults: outcome.chainResults, stoppedEarly: outcome.stoppedEarly };
53
+ }
54
+ // -n candidates: N parallel generations with distinct seeds.
55
+ if (n > 4)
56
+ throw new UsageError('at most 4 candidates per run (-n 4)');
57
+ const basePlan = await planMake(client, options);
58
+ if (!basePlan.generation) {
59
+ throw new UsageError('-n only applies to generation inputs (text/image), not task references');
60
+ }
61
+ const chain = basePlan.chain;
62
+ const interactive = isInteractive();
63
+ if (chain.length > 0 && !interactive) {
64
+ throw new UsageError('-n with --then needs an interactive terminal to pick the winner; run the chain afterwards with "tripo make <task-id> --then ..."');
65
+ }
66
+ log(t({ zh: `并行生成 ${n} 个候选…`, en: `generating ${n} candidates in parallel…` }));
67
+ const seeds = Array.from({ length: n }, () => Math.floor(Math.random() * 2 ** 31));
68
+ const runs = seeds.map((seed, i) => (async () => {
69
+ const candidateOptions = {
70
+ ...options,
71
+ chain: [],
72
+ params: { ...params, model_seed: seed },
73
+ name: `${flags.name ?? inputs[0] ?? 'make'}-c${i + 1}`,
74
+ };
75
+ const plan = await planMake(client, candidateOptions);
76
+ plan.chain = [];
77
+ return executeMake(client, plan, candidateOptions);
78
+ })());
79
+ const settled = await Promise.allSettled(runs);
80
+ const results = [];
81
+ for (const s of settled) {
82
+ if (s.status === 'fulfilled')
83
+ results.push(s.value.primary);
84
+ else
85
+ warn(String(s.reason instanceof Error ? s.reason.message : s.reason));
86
+ }
87
+ if (results.length === 0)
88
+ throw new UsageError('all candidates failed');
89
+ for (const r of results) {
90
+ if (r.preview)
91
+ printInlineImage(r.preview);
92
+ }
93
+ let chainResults = [];
94
+ let stoppedEarly;
95
+ if (chain.length > 0 && interactive) {
96
+ const pick = await p.select({
97
+ message: t({ zh: '选择一个候选继续加工链', en: 'pick a candidate to continue the chain' }),
98
+ options: results.map((r, i) => ({
99
+ value: r.task_id,
100
+ label: `#${i + 1} ${r.task_id.slice(0, 8)} ${r.output_dir ?? ''}`,
101
+ })),
102
+ });
103
+ if (!p.isCancel(pick)) {
104
+ const winner = results.find((r) => r.task_id === pick);
105
+ const { runChain } = await import('../pipeline/runner.js');
106
+ const chainRun = await runChain({
107
+ client,
108
+ chain,
109
+ sourceTaskId: winner.task_id,
110
+ sourceType: winner.type,
111
+ namePrefix: flags.name ?? 'make',
112
+ outDir: flags.out,
113
+ timeoutMs: options.timeoutMs,
114
+ });
115
+ chainResults = chainRun.results;
116
+ stoppedEarly = chainRun.stoppedEarly;
117
+ }
118
+ }
119
+ return { results, chainResults, stoppedEarly };
120
+ }
121
+ export function emitMakeOutcome(outcome) {
122
+ const finalChain = outcome.chainResults[outcome.chainResults.length - 1];
123
+ if (outcome.results.length === 1) {
124
+ const primary = outcome.results[0];
125
+ const merged = finalChain
126
+ ? { ...finalChain, chain: undefined, source_task_id: primary.task_id }
127
+ : primary;
128
+ if (outcome.chainResults.length > 0)
129
+ merged.chain = outcome.chainResults;
130
+ if (outcome.stoppedEarly)
131
+ merged.stopped_early = outcome.stoppedEarly;
132
+ printTaskResult(merged);
133
+ if (merged.preview)
134
+ printInlineImage(merged.preview);
135
+ }
136
+ else {
137
+ const human = outcome.results
138
+ .map((r, i) => `#${i + 1} ${r.task_id} ${r.output_dir ?? r.status}`)
139
+ .join('\n');
140
+ emitResult({ candidates: outcome.results, chain: outcome.chainResults }, human);
141
+ }
142
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `tripo mcp` — MCP (Model Context Protocol) server over stdio, exposing the
3
+ * CLI's capabilities as tools for Cursor / Claude Desktop / any MCP client.
4
+ *
5
+ * Implements the stdio transport (newline-delimited JSON-RPC 2.0):
6
+ * initialize, ping, tools/list, tools/call. Self-contained: no SDK
7
+ * dependency, no stdout pollution (stdout carries protocol frames only).
8
+ */
9
+ import { createInterface } from 'node:readline';
10
+ import { readHistory } from '../config/history.js';
11
+ import { TripoClient } from '../core/client.js';
12
+ import { downloadArtifacts, defaultOutputDir } from '../core/download.js';
13
+ import { configureOutput } from '../core/output.js';
14
+ import { watchTask } from '../core/watch.js';
15
+ import { parseThenExpression } from '../knowledge/chains.js';
16
+ import { scenarioIds } from '../knowledge/scenarios.js';
17
+ import { executeMake, planMake } from '../pipeline/make.js';
18
+ import { cliVersion } from '../version.js';
19
+ const TOOLS = [
20
+ {
21
+ name: 'tripo_make',
22
+ description: 'Generate a 3D asset from text, an image path/URL, or an existing task id. Blocks until finished; downloads artifacts locally and returns file paths (model_file, preview). Use scenario presets for domain defaults.',
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ input: { type: 'string', description: 'prompt text, image file path, image URL, or task id' },
27
+ scenario: { type: 'string', enum: scenarioIds(), description: 'production scenario preset' },
28
+ then: { type: 'string', description: 'processing chain, e.g. "texture,rig,convert:fbx"' },
29
+ model: { type: 'string', description: 'force model: tripo-v3.1 or tripo-p1' },
30
+ output_dir: { type: 'string', description: 'directory for artifacts' },
31
+ },
32
+ required: ['input'],
33
+ },
34
+ },
35
+ {
36
+ name: 'tripo_task_get',
37
+ description: 'Get the status/details of a Tripo task by id.',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: { task_id: { type: 'string' } },
41
+ required: ['task_id'],
42
+ },
43
+ },
44
+ {
45
+ name: 'tripo_task_wait',
46
+ description: 'Block until a Tripo task finishes; optionally download artifacts.',
47
+ inputSchema: {
48
+ type: 'object',
49
+ properties: {
50
+ task_id: { type: 'string' },
51
+ download: { type: 'boolean', description: 'download artifacts on success (default true)' },
52
+ },
53
+ required: ['task_id'],
54
+ },
55
+ },
56
+ {
57
+ name: 'tripo_balance',
58
+ description: 'Get the account credit balance.',
59
+ inputSchema: { type: 'object', properties: {} },
60
+ },
61
+ {
62
+ name: 'tripo_history',
63
+ description: 'List recent tasks created by this CLI (task ids, names, statuses, output dirs).',
64
+ inputSchema: {
65
+ type: 'object',
66
+ properties: { limit: { type: 'number', description: 'max entries (default 10)' } },
67
+ },
68
+ },
69
+ ];
70
+ export function registerMcpCommand(program) {
71
+ program
72
+ .command('mcp')
73
+ .description('run as an MCP server over stdio (tools: make/task/balance/history)')
74
+ .action(async () => {
75
+ configureOutput({ quiet: true, yes: true, noOpen: true, json: false });
76
+ await serveMcp();
77
+ });
78
+ }
79
+ async function serveMcp() {
80
+ const rl = createInterface({ input: process.stdin, terminal: false });
81
+ const write = (message) => {
82
+ process.stdout.write(JSON.stringify(message) + '\n');
83
+ };
84
+ for await (const line of rl) {
85
+ const text = line.trim();
86
+ if (!text)
87
+ continue;
88
+ let request;
89
+ try {
90
+ request = JSON.parse(text);
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ if (request.id === undefined || request.id === null) {
96
+ continue; // notification — nothing to answer
97
+ }
98
+ try {
99
+ const result = await dispatch(request.method, request.params ?? {});
100
+ if (result !== SKIP)
101
+ write({ jsonrpc: '2.0', id: request.id, result });
102
+ }
103
+ catch (err) {
104
+ write({
105
+ jsonrpc: '2.0',
106
+ id: request.id,
107
+ error: { code: -32000, message: err instanceof Error ? err.message : String(err) },
108
+ });
109
+ }
110
+ }
111
+ }
112
+ const SKIP = Symbol('skip');
113
+ async function dispatch(method, params) {
114
+ switch (method) {
115
+ case 'initialize':
116
+ return {
117
+ protocolVersion: params.protocolVersion ?? '2024-11-05',
118
+ capabilities: { tools: {} },
119
+ serverInfo: { name: 'tripo-cli', version: cliVersion() },
120
+ };
121
+ case 'ping':
122
+ return {};
123
+ case 'tools/list':
124
+ return { tools: TOOLS };
125
+ case 'tools/call':
126
+ return callTool(params);
127
+ default:
128
+ return SKIP;
129
+ }
130
+ }
131
+ async function callTool(params) {
132
+ const args = params.arguments ?? {};
133
+ const asText = (value) => ({
134
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
135
+ });
136
+ try {
137
+ switch (params.name) {
138
+ case 'tripo_make': {
139
+ const client = new TripoClient();
140
+ const options = {
141
+ inputs: [String(args.input ?? '')],
142
+ scenario: args.scenario,
143
+ chain: typeof args.then === 'string' ? parseThenExpression(args.then) : undefined,
144
+ model: args.model,
145
+ outDir: args.output_dir,
146
+ };
147
+ const plan = await planMake(client, options);
148
+ const outcome = await executeMake(client, plan, options);
149
+ const final = outcome.chainResults[outcome.chainResults.length - 1] ?? outcome.primary;
150
+ return asText({
151
+ task_id: final.task_id,
152
+ status: final.status,
153
+ output_dir: final.output_dir ?? outcome.primary.output_dir,
154
+ model_file: final.model_file ?? outcome.primary.model_file,
155
+ preview: final.preview ?? outcome.primary.preview,
156
+ chain: outcome.chainResults.map((r) => ({ task_id: r.task_id, type: r.type })),
157
+ stopped_early: outcome.stoppedEarly,
158
+ });
159
+ }
160
+ case 'tripo_task_get': {
161
+ const client = new TripoClient();
162
+ return asText(await client.getTask(String(args.task_id)));
163
+ }
164
+ case 'tripo_task_wait': {
165
+ const client = new TripoClient();
166
+ const taskId = String(args.task_id);
167
+ const detail = await watchTask(client, taskId, { throwOnFailure: false });
168
+ if (detail.status === 'success' && args.download !== false) {
169
+ const entry = readHistory().find((e) => e.task_id === taskId);
170
+ const dir = entry?.output_dir ?? defaultOutputDir(process.cwd(), entry?.name ?? 'task', taskId);
171
+ const artifacts = await downloadArtifacts(detail, dir);
172
+ return asText({ ...detail, output_dir: artifacts.outputDir, model_file: artifacts.modelFile, preview: artifacts.preview });
173
+ }
174
+ return asText(detail);
175
+ }
176
+ case 'tripo_balance': {
177
+ const client = new TripoClient();
178
+ return asText(await client.getBalance());
179
+ }
180
+ case 'tripo_history': {
181
+ const limit = typeof args.limit === 'number' ? args.limit : 10;
182
+ return asText(readHistory().slice(-limit));
183
+ }
184
+ default:
185
+ return { content: [{ type: 'text', text: `unknown tool: ${params.name}` }], isError: true };
186
+ }
187
+ }
188
+ catch (err) {
189
+ return {
190
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
191
+ isError: true,
192
+ };
193
+ }
194
+ }