tripo-cli 0.3.2 → 0.5.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.
package/README.md CHANGED
@@ -20,7 +20,8 @@ Works with both Tripo regions from the same install — international (`openapi.
20
20
  ## Why this CLI
21
21
 
22
22
  - **One magic command.** `tripo make <anything>` detects what you gave it — prompt text, an image, 2–4 view images, a model file, a URL, a task id, or `@last` — picks the right endpoint, model, and parameters, then waits and downloads the artifacts. No file_token, no polling loops.
23
- - **A 3D domain brain, not a thin API wrapper.** Model choice is a fixed rule (low-poly intent or face budget ≤ 20000 → `tripo-p1`, everything else → `tripo-v3.1`), P1's illegal parameters are stripped locally, quad + GLB is rejected before it costs you credits, and 7 scenario presets (`--for game-mobile|game-pc|film|print|ar-web|anim|toy`) encode sane parameters + processing chains + delivery formats.
23
+ - **A 3D domain brain, not a thin API wrapper.** Model choice is a fixed rule (low-poly intent or face budget ≤ 20000 → `tripo-p1`, everything else → `tripo-v3.1`; opt into the P-series **Preview** with `--model tripo-p2` for quad low-poly). Those are CLI aliases — the wire value the API receives is `P1-20260311` / `v3.1-20260211` / `P2-20260801` (full alias ↔ wire ↔ status table: `tripo docs --topic commands/generate`; official [Models & Versions](https://developers.tripo3d.ai/en/docs/models-and-versions) and [P Series](https://developers.tripo3d.ai/en/models/p1) pages). P-series illegal parameters are stripped locally, quad + GLB is rejected before it costs you credits, and 7 scenario presets (`--for game-mobile|game-pc|film|print|ar-web|anim|toy`) encode sane parameters + processing chains + delivery formats.
24
+ - **You see the plan before you pay.** `tripo make` prints a plan card — one numbered line per API call (endpoint, what it does, its arguments) — and asks before submitting; afterwards it reports the total credits **and** a per-task breakdown. `--yes` (automatic when headless) skips the question but keeps the card in the log.
24
25
  - **Agent-native.** Non-TTY runs are automatically headless (`--json --yes --no-open`), stdout carries exactly one JSON line per command, progress goes to stderr, exit codes are stable, and NDJSON pipes compose: `tripo make cat.png | tripo model texture | tripo anim rig`. An LLM-facing skill package ships inside the npm package (`tripo docs --llm`).
25
26
 
26
27
  ## Commands
@@ -39,7 +40,7 @@ Works with both Tripo regions from the same install — international (`openapi.
39
40
  | `tripo files upload` | upload a file, print the `file_token` |
40
41
  | `tripo batch run <manifest.yaml>` | bulk pipelines with concurrency, retries, and resume |
41
42
  | `tripo config / doctor` | settings (global + per-directory `.tripo/context.json`) and self-diagnosis |
42
- | `tripo docs [--topic t]` | print the bundled agent/skill docs |
43
+ | `tripo docs [--topic t]` | print the bundled agent/skill docs (`--topic commands/generate` has the model versions / P series table) |
43
44
  | `tripo mcp` | run as an MCP server (Cursor / Claude Desktop) |
44
45
  | `tripo completion <shell>` | bash/zsh/fish completion |
45
46
 
@@ -88,6 +89,7 @@ tripo batch run assets.yaml --concurrency 2
88
89
  ## For AI coding agents
89
90
 
90
91
  - `make`/`watch` are blocking: run them, wait for exit, read the single JSON line on stdout. Do not poll yourself.
92
+ - The plan card on stderr lists every call before it runs; the final JSON carries `credits_consumed` (total for the run) plus `credits_breakdown` (per task) when a chain ran.
91
93
  - Exit codes: `0` ok · `2` usage · `3` auth · `4` insufficient credits · `5` content policy · `6` task failed (credits auto-refunded) · `7` network · `8` not found · `9` rate limit.
92
94
  - `preview.png` in every output directory is your eyes: look at it, then decide to `tripo redo` or continue the chain.
93
95
  - Full behavior rules: `tripo docs --llm`, per-command docs via `tripo docs --topic commands/make`.
@@ -7,12 +7,12 @@
7
7
  * result is re-validated locally before anything is spent.
8
8
  */
9
9
  import { getLocale, t, tList } from '../i18n.js';
10
- import { parseThenExpression, validateChain, STEPS } from '../knowledge/chains.js';
10
+ import { availableSteps, parseThenExpression, validateChain } from '../knowledge/chains.js';
11
11
  import { chooseModel, detectLowPolyIntent, RIG_MODEL_V25 } from '../knowledge/models.js';
12
12
  import { getScenario, SCENARIOS } from '../knowledge/scenarios.js';
13
13
  import { chatJson } from './llm.js';
14
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(', ');
15
+ const STEP_TABLE = availableSteps().map((s) => s.name).join(', ');
16
16
  const PRODUCER_SYSTEM = `You are the producer/router of the Tripo 3D generation CLI.
17
17
  Classify the user's request into exactly one scenario and extract a clean English 3D generation prompt.
18
18
 
@@ -5,7 +5,71 @@
5
5
  */
6
6
  import pc from 'picocolors';
7
7
  import { t } from '../i18n.js';
8
+ import { getStep } from '../knowledge/chains.js';
8
9
  import { getScenario } from '../knowledge/scenarios.js';
10
+ const GENERATION_DESCRIBE = {
11
+ text_to_model: { zh: '文本生成 3D 模型', en: 'generate a 3D model from text' },
12
+ image_to_model: { zh: '单图生成 3D 模型', en: 'generate a 3D model from one image' },
13
+ multiview_to_model: { zh: '多视图生成 3D 模型', en: 'generate a 3D model from multiple views' },
14
+ };
15
+ /** Keys that describe the input itself (already shown on the "input" line). */
16
+ const INPUT_KEYS = new Set(['prompt', 'input', 'inputs']);
17
+ /** Build the pre-run card for `tripo make` from the plan it is about to execute. */
18
+ export function cardFromMakePlan(plan, opts) {
19
+ const steps = [];
20
+ const genParams = {};
21
+ if (plan.generation) {
22
+ const { taskType, request } = plan.generation;
23
+ const args = {};
24
+ for (const [k, v] of Object.entries(request.payload)) {
25
+ if (INPUT_KEYS.has(k))
26
+ continue;
27
+ args[k] = v;
28
+ if (k !== 'model')
29
+ genParams[k] = v;
30
+ }
31
+ const importDef = taskType === 'import_model' ? getStep('import') : undefined;
32
+ steps.push({
33
+ name: importDef ? importDef.name : taskType.replace(/_/g, '-'),
34
+ endpoint: request.endpoint,
35
+ describe: importDef ? t(importDef.describe) : t(GENERATION_DESCRIBE[taskType] ?? { zh: '生成', en: 'generate' }),
36
+ args,
37
+ });
38
+ }
39
+ for (const spec of plan.chain) {
40
+ const def = getStep(spec.step);
41
+ steps.push({
42
+ name: spec.step,
43
+ endpoint: def?.endpoint ?? '',
44
+ describe: def ? t(def.describe) : '',
45
+ args: spec.args,
46
+ });
47
+ }
48
+ const scenario = opts.scenario ? getScenario(opts.scenario) : undefined;
49
+ const notes = [];
50
+ if (opts.candidates && opts.candidates > 1) {
51
+ notes.push(t({
52
+ zh: `并行生成 ${opts.candidates} 个候选(不同 seed),选中一个后再继续加工链`,
53
+ en: `${opts.candidates} candidates in parallel (distinct seeds); pick one before the chain continues`,
54
+ }));
55
+ }
56
+ // Task refs (@last / @name) resolve to an id the human has not seen yet — show it.
57
+ let inputSummary = opts.inputs.join(' ');
58
+ if (plan.sourceTaskId && inputSummary !== plan.sourceTaskId) {
59
+ inputSummary += ` → ${plan.sourceTaskId}${plan.sourceType ? ` (${plan.sourceType})` : ''}`;
60
+ }
61
+ return {
62
+ inputSummary,
63
+ scenario: opts.scenario,
64
+ model: plan.model,
65
+ modelReason: plan.modelReason,
66
+ genParams,
67
+ chain: plan.chain,
68
+ deliverables: scenario?.deliverables ?? [],
69
+ notes,
70
+ steps,
71
+ };
72
+ }
9
73
  /** Cost-impact annotations derived from the parameter set. */
10
74
  export function costNotes(card) {
11
75
  const notes = [];
@@ -48,15 +112,27 @@ export function renderPlanCard(card) {
48
112
  if (card.model) {
49
113
  lines.push(`${t({ zh: '模型', en: 'model' })}: ${card.model}${card.modelReason ? pc.dim(` — ${card.modelReason}`) : ''}`);
50
114
  }
51
- const params = Object.entries(card.genParams);
52
- if (params.length > 0) {
53
- lines.push(`${t({ zh: '参数', en: 'params' })}: ${params.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ')}`);
115
+ const fmtArgs = (args) => Object.entries(args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ');
116
+ if (card.steps && card.steps.length > 0) {
117
+ // Numbered step list: what each call does, which endpoint, with which args.
118
+ lines.push(`${t({ zh: '步骤', en: 'steps' })}:`);
119
+ card.steps.forEach((step, i) => {
120
+ lines.push(` ${i + 1}. ${step.name} ${pc.dim(`POST ${step.endpoint}`)}${step.describe ? ` — ${step.describe}` : ''}`);
121
+ if (Object.keys(step.args).length > 0)
122
+ lines.push(pc.dim(` ${fmtArgs(step.args)}`));
123
+ });
54
124
  }
55
- if (card.chain.length > 0) {
56
- const chainStr = card.chain
57
- .map((s) => (Object.keys(s.args).length > 0 ? `${s.step}(${Object.entries(s.args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(',')})` : s.step))
58
- .join(' → ');
59
- lines.push(`${t({ zh: '加工链', en: 'chain' })}: ${chainStr}`);
125
+ else {
126
+ const params = Object.entries(card.genParams);
127
+ if (params.length > 0) {
128
+ lines.push(`${t({ zh: '参数', en: 'params' })}: ${fmtArgs(card.genParams)}`);
129
+ }
130
+ if (card.chain.length > 0) {
131
+ const chainStr = card.chain
132
+ .map((s) => (Object.keys(s.args).length > 0 ? `${s.step}(${Object.entries(s.args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(',')})` : s.step))
133
+ .join(' → ');
134
+ lines.push(`${t({ zh: '加工链', en: 'chain' })}: ${chainStr}`);
135
+ }
60
136
  }
61
137
  if (card.deliverables.length > 0) {
62
138
  lines.push(`${t({ zh: '交付物', en: 'deliverables' })}: ${card.deliverables.join(', ')}`);
@@ -1,5 +1,6 @@
1
1
  /** `tripo completion <shell>` — static shell completion scripts. */
2
2
  import { UsageError } from '../core/errors.js';
3
+ import { isMeshEditEnabled } from '../knowledge/chains.js';
3
4
  const COMMANDS = [
4
5
  'login', 'logout', 'whoami', 'use', 'balance', 'usage', 'topup',
5
6
  'make', 'view', 'redo', 'ai',
@@ -10,7 +11,7 @@ const SUBS = {
10
11
  generate: ['text-to-model', 'image-to-model', 'multiview-to-model', 'text-to-image', 'image-to-image', 'image-to-multiview', 'edit-multiview', 'image-to-splat'],
11
12
  model: ['refine', 'texture', 'stylize', 'convert', 'import'],
12
13
  anim: ['check', 'rig', 'retarget'],
13
- mesh: ['segment', 'complete', 'decimate', 'smartsegment'],
14
+ mesh: ['segment', 'complete', 'decimate', ...(isMeshEditEnabled() ? ['edit'] : []), 'smartsegment'],
14
15
  task: ['get', 'list', 'watch'],
15
16
  files: ['upload'],
16
17
  batch: ['run'],
@@ -4,12 +4,18 @@
4
4
  * model, the parameters, runs the chain, and downloads the artifacts.
5
5
  */
6
6
  import * as p from '@clack/prompts';
7
- import { UsageError } from '../core/errors.js';
7
+ import { TripoClient } from '../core/client.js';
8
+ import { AuthError, UsageError } from '../core/errors.js';
8
9
  import { emitResult, isInteractive, log, warn } from '../core/output.js';
10
+ import { buildStepRequest } from '../core/requests.js';
9
11
  import { t } from '../i18n.js';
12
+ import { ChainError, getStep } from '../knowledge/chains.js';
13
+ import { EXIT } from '../knowledge/error-catalog.js';
10
14
  import { scenarioIds } from '../knowledge/scenarios.js';
11
- import { executeMake, planMake } from '../pipeline/make.js';
12
- import { newClient, parseChainFlag, printTaskResult, withTaskOptions } from './shared.js';
15
+ import { cardFromMakePlan, costNotes, renderPlanCard } from '../ai/plan-card.js';
16
+ import { checkPlanChain, executeMake, planMake } from '../pipeline/make.js';
17
+ import { detectInput, InputDetectError } from '../util/input-detect.js';
18
+ import { newClient, parseChainFlag, printTaskResult, summarizeCredits, withTaskOptions } from './shared.js';
13
19
  import { printInlineImage } from '../util/term-image.js';
14
20
  export function registerMakeCommand(program) {
15
21
  const cmd = program
@@ -18,17 +24,40 @@ export function registerMakeCommand(program) {
18
24
  .argument('<input...>', 'prompt text, image file(s), model file, URL, task id, or @last')
19
25
  .option('--for <scenario>', `scenario preset: ${scenarioIds().join('|')}`)
20
26
  .option('--then <steps>', 'processing chain, e.g. "texture,rig,convert:fbx"')
21
- .option('--model <model>', 'force model version (tripo-v3.1 / tripo-p1 / ...)')
27
+ .option('--model <model>', 'force model version (tripo-v3.1 / tripo-p1 / tripo-p2 / ...)')
22
28
  .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));
29
+ .option('--seed <n>', 'model_seed for reproducible geometry', (v) => Number(v))
30
+ .option('--dry-run', 'plan + validate only: print model, completed params and chain steps; no upload, no submit, no credits');
24
31
  withTaskOptions(cmd);
25
32
  cmd.action(async (inputs, flags) => {
26
33
  const result = await runMake(inputs, flags);
34
+ if (result.cancelled)
35
+ return;
27
36
  emitMakeOutcome(result);
28
37
  });
29
38
  }
39
+ /**
40
+ * Show what is about to run (one numbered line per API call) and ask before
41
+ * spending credits. `--yes` / headless callers see the card on stderr but are
42
+ * not prompted. Returns false when the human declines.
43
+ *
44
+ * Gate on isInteractive() (both stdin and stdout TTYs), not just --yes: when
45
+ * stdout is a pipe (`tripo make x | tripo model texture`) the clack prompt
46
+ * would land in the pipe, corrupt the JSON contract and block forever.
47
+ */
48
+ async function confirmPlan(plan, inputs, flags) {
49
+ const card = cardFromMakePlan(plan, { inputs, scenario: flags.for, candidates: flags.candidates });
50
+ log(renderPlanCard(card));
51
+ if (!isInteractive())
52
+ return true;
53
+ const go = await p.confirm({ message: t({ zh: '按方案执行?', en: 'Run this plan?' }) });
54
+ if (p.isCancel(go) || !go) {
55
+ log(t({ zh: '已取消,未消耗积分', en: 'cancelled; no credits spent' }));
56
+ return false;
57
+ }
58
+ return true;
59
+ }
30
60
  export async function runMake(inputs, flags) {
31
- const client = newClient();
32
61
  const params = { ...flags.param };
33
62
  if (flags.seed !== undefined)
34
63
  params.model_seed = flags.seed;
@@ -45,9 +74,18 @@ export async function runMake(inputs, flags) {
45
74
  notify: flags.notify,
46
75
  timeoutMs: flags.timeout ? flags.timeout * 1000 : undefined,
47
76
  };
77
+ const cancelled = { results: [], chainResults: [], cancelled: true };
78
+ if (flags.dryRun) {
79
+ // The plan itself is the result; nothing is submitted.
80
+ await dryRunMake(inputs, flags, options);
81
+ return cancelled;
82
+ }
83
+ const client = newClient();
48
84
  const n = flags.candidates;
49
85
  if (n <= 1) {
50
86
  const plan = await planMake(client, options);
87
+ if (!(await confirmPlan(plan, inputs, flags)))
88
+ return cancelled;
51
89
  const outcome = await executeMake(client, plan, options);
52
90
  return { results: [outcome.primary], chainResults: outcome.chainResults, stoppedEarly: outcome.stoppedEarly };
53
91
  }
@@ -63,6 +101,8 @@ export async function runMake(inputs, flags) {
63
101
  if (chain.length > 0 && !interactive) {
64
102
  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
103
  }
104
+ if (!(await confirmPlan(basePlan, inputs, flags)))
105
+ return cancelled;
66
106
  log(t({ zh: `并行生成 ${n} 个候选…`, en: `generating ${n} candidates in parallel…` }));
67
107
  const seeds = Array.from({ length: n }, () => Math.floor(Math.random() * 2 ** 31));
68
108
  const runs = seeds.map((seed, i) => (async () => {
@@ -118,8 +158,156 @@ export async function runMake(inputs, flags) {
118
158
  }
119
159
  return { results, chainResults, stoppedEarly };
120
160
  }
161
+ const UPSTREAM_PLACEHOLDER = '<upstream>';
162
+ /** Dry runs never send a request, so a missing API key must not block them. */
163
+ function dryRunClient() {
164
+ try {
165
+ return newClient();
166
+ }
167
+ catch (err) {
168
+ if (err instanceof AuthError)
169
+ return new TripoClient({ apiKey: 'dry-run' });
170
+ throw err;
171
+ }
172
+ }
173
+ /**
174
+ * `--dry-run`: build and validate the plan with zero network calls, then emit
175
+ * it as the business result (JSON or the plan card). Blocking problems set
176
+ * exit code 2 so an agent can fix the request before spending credits.
177
+ */
178
+ async function dryRunMake(inputs, flags, options) {
179
+ const warnings = [];
180
+ const errors = [];
181
+ const pendingUploads = inputs.filter((raw) => {
182
+ try {
183
+ const kind = detectInput(raw).kind;
184
+ return kind === 'image-file' || kind === 'model-file';
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ });
190
+ let plan;
191
+ try {
192
+ plan = await planMake(dryRunClient(), { ...options, dryRun: true });
193
+ }
194
+ catch (err) {
195
+ if (!(err instanceof UsageError || err instanceof InputDetectError || err instanceof ChainError))
196
+ throw err;
197
+ errors.push(err.message);
198
+ }
199
+ if (!plan) {
200
+ emitDryRun({
201
+ dry_run: true,
202
+ valid: false,
203
+ input: inputs.join(' '),
204
+ scenario: flags.for,
205
+ steps: [],
206
+ pending_uploads: pendingUploads,
207
+ deliverables: [],
208
+ candidates: flags.candidates,
209
+ warnings,
210
+ errors,
211
+ cost_notes: [],
212
+ });
213
+ return;
214
+ }
215
+ warnings.push(...plan.warnings);
216
+ try {
217
+ checkPlanChain(plan);
218
+ }
219
+ catch (err) {
220
+ if (!(err instanceof UsageError))
221
+ throw err;
222
+ errors.push(err.message);
223
+ }
224
+ if (plan.chain.length > 0 && !plan.generation && !plan.sourceType) {
225
+ warnings.push(`chain legality unverified: upstream task type unknown (no local history for ${plan.sourceTaskId}); the real run checks it via the API`);
226
+ }
227
+ if (flags.candidates > 4)
228
+ errors.push('at most 4 candidates per run (-n 4)');
229
+ if (flags.candidates > 1 && !plan.generation) {
230
+ errors.push('-n only applies to generation inputs (text/image), not task references');
231
+ }
232
+ // Same card the real run shows; chain-step args are replaced by the
233
+ // normalized payloads so the human sees the completed parameters too.
234
+ const card = cardFromMakePlan(plan, { inputs, scenario: flags.for, candidates: flags.candidates });
235
+ const cardSteps = card.steps ?? [];
236
+ const steps = [];
237
+ if (plan.generation) {
238
+ steps.push({
239
+ name: cardSteps[0]?.name ?? plan.generation.taskType,
240
+ endpoint: plan.generation.request.endpoint,
241
+ task_type: plan.generation.taskType,
242
+ payload: plan.generation.request.payload,
243
+ });
244
+ }
245
+ plan.chain.forEach((spec, i) => {
246
+ const cardStep = cardSteps[(plan.generation ? 1 : 0) + i];
247
+ try {
248
+ const built = buildStepRequest(spec.step, UPSTREAM_PLACEHOLDER, spec.args);
249
+ warnings.push(...built.warnings);
250
+ if (cardStep) {
251
+ const args = { ...built.request.payload };
252
+ delete args.input;
253
+ cardStep.args = args;
254
+ }
255
+ steps.push({ name: spec.step, endpoint: built.request.endpoint, task_type: built.taskType, payload: built.request.payload });
256
+ }
257
+ catch (err) {
258
+ if (!(err instanceof UsageError))
259
+ throw err;
260
+ errors.push(`${spec.step}: ${err.message}`);
261
+ steps.push({
262
+ name: spec.step,
263
+ endpoint: cardStep?.endpoint ?? '',
264
+ task_type: getStep(spec.step)?.produces ?? '',
265
+ payload: { input: UPSTREAM_PLACEHOLDER, ...spec.args },
266
+ });
267
+ }
268
+ });
269
+ emitDryRun({
270
+ dry_run: true,
271
+ valid: errors.length === 0,
272
+ input: card.inputSummary,
273
+ source_task_id: plan.sourceTaskId,
274
+ source_type: plan.sourceType,
275
+ scenario: flags.for,
276
+ model: plan.model,
277
+ model_reason: plan.modelReason,
278
+ steps,
279
+ pending_uploads: pendingUploads,
280
+ deliverables: card.deliverables,
281
+ candidates: flags.candidates,
282
+ warnings,
283
+ errors,
284
+ cost_notes: costNotes(card),
285
+ }, renderPlanCard(card));
286
+ }
287
+ /** stdout: one JSON line, or the card followed by warnings/errors and the verdict. */
288
+ function emitDryRun(result, cardText) {
289
+ const lines = cardText ? [cardText] : [];
290
+ if (result.warnings.length > 0) {
291
+ lines.push(t({ zh: '警告(已自动调整):', en: 'warnings (auto-adjusted):' }));
292
+ for (const w of result.warnings)
293
+ lines.push(` ! ${w}`);
294
+ }
295
+ if (result.errors.length > 0) {
296
+ lines.push(t({ zh: '错误(阻断):', en: 'errors (blocking):' }));
297
+ for (const e of result.errors)
298
+ lines.push(` ✗ ${e}`);
299
+ }
300
+ lines.push(result.valid
301
+ ? t({ zh: 'dry run:方案有效;未提交任务,未消耗积分', en: 'dry run: plan is valid; nothing submitted, no credits spent' })
302
+ : t({ zh: 'dry run:方案无效;修正上述错误后重试', en: 'dry run: plan is invalid; fix the errors above and retry' }));
303
+ emitResult(result, lines.join('\n'));
304
+ if (!result.valid)
305
+ process.exitCode = EXIT.USAGE;
306
+ }
121
307
  export function emitMakeOutcome(outcome) {
122
308
  const finalChain = outcome.chainResults[outcome.chainResults.length - 1];
309
+ // Every task this run paid for: generation(s) first, then each chain step.
310
+ const credits = summarizeCredits([...outcome.results, ...outcome.chainResults]);
123
311
  if (outcome.results.length === 1) {
124
312
  const primary = outcome.results[0];
125
313
  const merged = finalChain
@@ -129,14 +317,26 @@ export function emitMakeOutcome(outcome) {
129
317
  merged.chain = outcome.chainResults;
130
318
  if (outcome.stoppedEarly)
131
319
  merged.stopped_early = outcome.stoppedEarly;
320
+ if (credits.breakdown.length > 1) {
321
+ merged.credits_consumed = credits.total;
322
+ merged.credits_breakdown = credits.breakdown;
323
+ }
132
324
  printTaskResult(merged);
133
325
  if (merged.preview)
134
326
  printInlineImage(merged.preview);
135
327
  }
136
328
  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);
329
+ const lines = outcome.results.map((r, i) => `#${i + 1} ${r.task_id} ${r.output_dir ?? r.status}`);
330
+ if (credits.breakdown.length > 0) {
331
+ lines.push(t({ zh: `消耗积分合计: ${credits.total}`, en: `credits consumed (total): ${credits.total}` }));
332
+ for (const line of credits.breakdown)
333
+ lines.push(` ${line.type} ${line.task_id} ${line.credits_consumed}`);
334
+ }
335
+ emitResult({
336
+ candidates: outcome.results,
337
+ chain: outcome.chainResults,
338
+ credits_consumed: credits.total,
339
+ credits_breakdown: credits.breakdown,
340
+ }, lines.join('\n'));
141
341
  }
142
342
  }
@@ -26,7 +26,7 @@ const TOOLS = [
26
26
  input: { type: 'string', description: 'prompt text, image file path, image URL, or task id' },
27
27
  scenario: { type: 'string', enum: scenarioIds(), description: 'production scenario preset' },
28
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' },
29
+ model: { type: 'string', description: 'force model: tripo-v3.1, tripo-p1 or tripo-p2 (P-series preview with quad)' },
30
30
  output_dir: { type: 'string', description: 'directory for artifacts' },
31
31
  },
32
32
  required: ['input'],
@@ -9,7 +9,7 @@ import { log, warn } from '../core/output.js';
9
9
  import { buildStepRequest } from '../core/requests.js';
10
10
  import { resolveInputValue, runTask } from '../core/task-service.js';
11
11
  import { t } from '../i18n.js';
12
- import { getStep } from '../knowledge/chains.js';
12
+ import { getStep, isMeshEditEnabled } from '../knowledge/chains.js';
13
13
  import { readPipedTask } from '../pipeline/stdin.js';
14
14
  import { newClient, printTaskResult, withTaskOptions } from './shared.js';
15
15
  async function resolveStepInput(client, raw) {
@@ -84,6 +84,8 @@ function collectStepArgs(step, flags) {
84
84
  return pick(['completion_mode']);
85
85
  case 'smartsegment':
86
86
  return pick(['seg_type', 'granularity', 'hint']);
87
+ case 'edit':
88
+ return pick(['bbox', 'face_limit', 'ref_image', 'model']);
87
89
  default:
88
90
  return {};
89
91
  }
@@ -157,6 +159,22 @@ export function registerProcessCommands(program) {
157
159
  .option('--face-limit <n>', 'target faces 500–20000 tri / 500–10000 quad', (v) => Number(v))
158
160
  .option('--quad', 'quad output')
159
161
  .option('--no-bake', 'skip baking textures onto the low-poly')).action(makeStepAction('decimate'));
162
+ // Hidden until /v3/mesh/edit is live server-side (see isMeshEditEnabled).
163
+ if (isMeshEditEnabled()) {
164
+ withTaskOptions(mesh
165
+ .command('edit [input]')
166
+ .description('local retry/refine inside a bounding box (P2 pipeline, model P2-20260801)')
167
+ .option('--bbox <x1,y1,z1,x2,y2,z2>', 'required 2×3 axis-aligned bbox in model space')
168
+ .option('--face-limit <n>', 'output face limit (≥100)', (v) => Number(v))
169
+ .option('--ref-image <file-or-url>', 'reference image for semantic guidance')
170
+ .option('--model <v>', 'P2-20260801 (only supported value)')).action(async (input, flags) => {
171
+ // --ref-image may be a local file: upload it to a file_token first.
172
+ if (typeof flags.refImage === 'string') {
173
+ flags.refImage = (await resolveInputValue(newClient(), flags.refImage)).value;
174
+ }
175
+ await makeStepAction('edit')(input, flags);
176
+ });
177
+ }
160
178
  withTaskOptions(mesh
161
179
  .command('smartsegment [input]')
162
180
  .description('smart segmentation (image or model pipeline)')
@@ -51,6 +51,21 @@ export function parseChainFlag(value) {
51
51
  return undefined;
52
52
  return parseThenExpression(value);
53
53
  }
54
+ /**
55
+ * Sum the spend of every task in a run and keep the per-task lines. Tasks
56
+ * without a settled `credits_consumed` (e.g. --no-wait) are skipped.
57
+ */
58
+ export function summarizeCredits(tasks) {
59
+ const breakdown = [];
60
+ let total = 0;
61
+ for (const task of tasks) {
62
+ if (task.credits_consumed === undefined)
63
+ continue;
64
+ breakdown.push({ task_id: task.task_id, type: task.type, credits_consumed: task.credits_consumed });
65
+ total += task.credits_consumed;
66
+ }
67
+ return { total, breakdown };
68
+ }
54
69
  /** Standard result output for a finished task (human + JSON modes). */
55
70
  export function printTaskResult(result) {
56
71
  const lines = [];
@@ -58,7 +73,14 @@ export function printTaskResult(result) {
58
73
  zh: `任务 ${result.task_id} ${result.status === 'success' ? '完成' : result.status}`,
59
74
  en: `task ${result.task_id} ${result.status}`,
60
75
  }));
61
- if (result.credits_consumed !== undefined) {
76
+ if (result.credits_breakdown && result.credits_breakdown.length > 1) {
77
+ lines.push(t({ zh: `消耗积分合计: ${result.credits_consumed}`, en: `credits consumed (total): ${result.credits_consumed}` }));
78
+ const typeWidth = Math.max(...result.credits_breakdown.map((l) => l.type.length));
79
+ for (const line of result.credits_breakdown) {
80
+ lines.push(` ${line.type.padEnd(typeWidth)} ${line.task_id} ${line.credits_consumed}`);
81
+ }
82
+ }
83
+ else if (result.credits_consumed !== undefined) {
62
84
  lines.push(t({ zh: `消耗积分: ${result.credits_consumed}`, en: `credits consumed: ${result.credits_consumed}` }));
63
85
  }
64
86
  if (result.output_dir) {
@@ -5,7 +5,7 @@
5
5
  * `parameters`), so new API options work without a CLI release.
6
6
  */
7
7
  import { getStep } from '../knowledge/chains.js';
8
- import { validateConvertParams, validateDecimateParams, validateGenerationParams, validateRetargetParams, } from '../knowledge/params.js';
8
+ import { validateConvertParams, validateDecimateParams, validateGenerationParams, validateMeshEditParams, validateRetargetParams, } from '../knowledge/params.js';
9
9
  import { CAPABILITY_DEFAULTS, SEGMENT_MODELS, TEXTURE_MODELS, normalizeModel, normalizeRigModel, } from '../knowledge/models.js';
10
10
  import { UsageError } from './errors.js';
11
11
  function ensure(result) {
@@ -135,6 +135,13 @@ export function buildStepRequest(stepName, input, args) {
135
135
  warnings.push(...v.warnings);
136
136
  break;
137
137
  }
138
+ case 'edit': {
139
+ const v = validateMeshEditParams(params);
140
+ ensure(v);
141
+ params = v.params;
142
+ warnings.push(...v.warnings);
143
+ break;
144
+ }
138
145
  case 'rig': {
139
146
  // Marketing names (rig-v1.0/rig-v2.0) are rejected by the server; normalize to wire versions.
140
147
  params.model = params.model === undefined
@@ -16,8 +16,16 @@ import { defaultOutputDir, downloadArtifacts } from './download.js';
16
16
  import { ApiError, UsageError, balanceSuggestion } from './errors.js';
17
17
  import { isQuiet, log, warn } from './output.js';
18
18
  import { uploadLocalFile } from './upload.js';
19
- /** Resolve a user input string into the API `input` value (upload as needed). */
20
- export async function resolveInputValue(client, raw) {
19
+ /** Placeholder `input` value for a local file that a dry run would upload. */
20
+ export function uploadPlaceholder(path) {
21
+ return `<upload:${path}>`;
22
+ }
23
+ /**
24
+ * Resolve a user input string into the API `input` value (upload as needed).
25
+ * `dryRun` skips the upload and returns a `<upload:path>` placeholder so the
26
+ * plan can be built with zero network calls.
27
+ */
28
+ export async function resolveInputValue(client, raw, opts = {}) {
21
29
  const detected = detectInput(raw);
22
30
  switch (detected.kind) {
23
31
  case 'history-ref': {
@@ -36,6 +44,8 @@ export async function resolveInputValue(client, raw) {
36
44
  }
37
45
  case 'image-file':
38
46
  case 'model-file': {
47
+ if (opts.dryRun)
48
+ return { value: uploadPlaceholder(detected.value) };
39
49
  const token = await uploadLocalFile(client, detected.value);
40
50
  return { value: token };
41
51
  }