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,166 @@
1
+ /**
2
+ * Processing command groups: `tripo model`, `tripo anim`, `tripo mesh`.
3
+ * Input resolution order: explicit argument > piped NDJSON from stdin > @last.
4
+ * That makes both human implicit-context and shell pipelines work:
5
+ * tripo make cat.png | tripo anim rig | tripo anim retarget --param animation=preset:walk
6
+ */
7
+ import { UsageError } from '../core/errors.js';
8
+ import { log, warn } from '../core/output.js';
9
+ import { buildStepRequest } from '../core/requests.js';
10
+ import { resolveInputValue, runTask } from '../core/task-service.js';
11
+ import { t } from '../i18n.js';
12
+ import { getStep } from '../knowledge/chains.js';
13
+ import { readPipedTask } from '../pipeline/stdin.js';
14
+ import { newClient, printTaskResult, withTaskOptions } from './shared.js';
15
+ async function resolveStepInput(client, raw) {
16
+ if (raw)
17
+ return resolveInputValue(client, raw);
18
+ const piped = await readPipedTask();
19
+ if (piped) {
20
+ log(t({ zh: `使用管道输入任务 ${piped.task_id}`, en: `using piped task ${piped.task_id}` }));
21
+ return { value: piped.task_id, sourceType: piped.type };
22
+ }
23
+ const fallback = await resolveInputValue(client, '@last');
24
+ log(t({ zh: `未指定输入,使用上一个成功任务 @last`, en: `no input given; defaulting to the last successful task (@last)` }));
25
+ return fallback;
26
+ }
27
+ function makeStepAction(stepName, extraDefaults = {}) {
28
+ return async (input, flags) => {
29
+ const client = newClient();
30
+ const resolved = await resolveStepInput(client, input);
31
+ // Local chain legality: catch e.g. retarget-on-non-rig before spending credits.
32
+ const def = getStep(stepName);
33
+ if (Array.isArray(def.accepts) && resolved.sourceType && !def.accepts.includes(resolved.sourceType)) {
34
+ throw new UsageError(def.accepts.length === 0
35
+ ? `${def.name} takes a file/URL input, not a task (input is a ${resolved.sourceType} task)`
36
+ : `${def.name} only accepts ${def.accepts.join('/')} tasks (input is a ${resolved.sourceType} task)`);
37
+ }
38
+ const args = { ...extraDefaults, ...collectStepArgs(stepName, flags), ...flags.param };
39
+ const built = buildStepRequest(stepName, resolved.value, args);
40
+ for (const w of built.warnings)
41
+ warn(w);
42
+ const result = await runTask({
43
+ client,
44
+ request: built.request,
45
+ taskType: built.taskType,
46
+ name: flags.name ?? `${stepName}`,
47
+ outDir: flags.out,
48
+ noWait: flags.wait === false,
49
+ noDownload: flags.download === false,
50
+ notify: flags.notify,
51
+ timeoutMs: flags.timeout ? flags.timeout * 1000 : undefined,
52
+ });
53
+ printTaskResult(result);
54
+ };
55
+ }
56
+ /** Map first-class flags (e.g. --format) into step args. */
57
+ function collectStepArgs(step, flags) {
58
+ const pick = (keys) => {
59
+ const out = {};
60
+ for (const key of keys) {
61
+ const flagKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
62
+ const v = flags[flagKey] ?? flags[key];
63
+ if (v !== undefined)
64
+ out[key] = v;
65
+ }
66
+ return out;
67
+ };
68
+ switch (step) {
69
+ case 'convert':
70
+ return pick(['format', 'texture_size', 'texture_format', 'face_limit', 'quad', 'flatten_bottom', 'fbx_preset', 'export_orientation', 'scale_factor', 'auto_size', 'pivot_to_center_bottom', 'export_vertex_colors']);
71
+ case 'texture':
72
+ return pick(['model', 'texture_quality', 'texture_alignment', 'pbr']);
73
+ case 'stylize':
74
+ return pick(['style', 'block_size']);
75
+ case 'rig':
76
+ return pick(['model', 'rig_type', 'spec', 'out_format']);
77
+ case 'retarget':
78
+ return pick(['animation', 'out_format', 'animate_in_place']);
79
+ case 'decimate':
80
+ return pick(['face_limit', 'quad', 'bake']);
81
+ case 'segment':
82
+ return pick(['model']);
83
+ case 'complete':
84
+ return pick(['completion_mode']);
85
+ case 'smartsegment':
86
+ return pick(['seg_type', 'granularity', 'hint']);
87
+ default:
88
+ return {};
89
+ }
90
+ }
91
+ export function registerProcessCommands(program) {
92
+ // ---- tripo model ----
93
+ const model = program.command('model').description('model processing: refine / texture / stylize / convert / import');
94
+ withTaskOptions(model.command('refine [input]').description('refine a draft text-to-model task (task id / @last)')).action(makeStepAction('refine'));
95
+ withTaskOptions(model
96
+ .command('texture [input]')
97
+ .description('re-texture a model (task/@last/file/url)')
98
+ .option('--texture-quality <q>', 'standard|detailed|extreme')
99
+ .option('--texture-alignment <a>', 'original_image|geometry')).action(makeStepAction('texture'));
100
+ withTaskOptions(model
101
+ .command('stylize [input]')
102
+ .description('stylize a model (lego/voxel/voronoi/minecraft)')
103
+ .option('--style <style>', 'lego|voxel|voronoi|minecraft')
104
+ .option('--block-size <n>', 'minecraft block size 32-128', (v) => Number(v))).action(makeStepAction('stylize'));
105
+ withTaskOptions(model
106
+ .command('convert [input]')
107
+ .description('convert format (GLTF/USDZ/FBX/OBJ/STL/3MF)')
108
+ .option('--format <format>', 'target format', 'GLTF')
109
+ .option('--texture-size <n>', 'texture size in px', (v) => Number(v))
110
+ .option('--texture-format <f>', 'JPEG/PNG/OPEN_EXR/...')
111
+ .option('--face-limit <n>', 'face limit', (v) => Number(v))
112
+ .option('--quad', 'quad remesh')
113
+ .option('--flatten-bottom', 'flatten the bottom (3D printing)')
114
+ .option('--fbx-preset <p>', 'blender|3dsmax|mixamo')
115
+ .option('--export-orientation <o>', '+x|-x|+y|-y')
116
+ .option('--scale-factor <n>', 'scale factor', (v) => Number(v))
117
+ .option('--auto-size', 'scale to real-world size')
118
+ .option('--pivot-to-center-bottom', 'pivot at center bottom')
119
+ .option('--export-vertex-colors', 'include vertex colors (OBJ/GLTF)')).action(makeStepAction('convert'));
120
+ withTaskOptions(model.command('import <input>').description('import an external model file/url (GLB/GLTF/FBX/OBJ/STL ≤150MB)')).action(makeStepAction('import'));
121
+ // ---- tripo anim ----
122
+ const anim = program.command('anim').description('animation: check (riggability) / rig / retarget');
123
+ withTaskOptions(anim.command('check [input]').description('rig-check: is this model riggable? (GLB input)')).action(makeStepAction('rig-check'));
124
+ withTaskOptions(anim
125
+ .command('rig [input]')
126
+ .description('rig a model (skeleton binding)')
127
+ .option('--rig-type <type>', 'biped|quadruped|hexapod|octopod|avian|serpentine|aquatic')
128
+ .option('--spec <spec>', 'tripo|mixamo')
129
+ .option('--out-format <f>', 'glb|fbx')).action(makeStepAction('rig'));
130
+ withTaskOptions(anim
131
+ .command('retarget [input]')
132
+ .description('apply preset animations to a rigged task')
133
+ .option('--animation <name...>', 'preset:walk preset:run ... (≤5, billed per animation)')
134
+ .option('--out-format <f>', 'glb|fbx')
135
+ .option('--animate-in-place', 'no root motion')).action(async (input, flags) => {
136
+ // --animation may be a list → map to animations[]
137
+ const anims = flags.animation;
138
+ if (Array.isArray(anims) && anims.length > 1) {
139
+ flags.param = { ...flags.param, animations: anims };
140
+ delete flags.animation;
141
+ }
142
+ else if (Array.isArray(anims)) {
143
+ flags.animation = anims[0];
144
+ }
145
+ await makeStepAction('retarget')(input, flags);
146
+ });
147
+ // ---- tripo mesh ----
148
+ const mesh = program.command('mesh').description('mesh ops: segment / complete / decimate / smartsegment');
149
+ withTaskOptions(mesh.command('segment [input]').description('segment a model into parts').option('--model <v>', 'v1.0-20250506|v2.0-20260430')).action(makeStepAction('segment'));
150
+ withTaskOptions(mesh
151
+ .command('complete [input]')
152
+ .description('complete/fill segmented parts (needs a segment task)')
153
+ .option('--completion-mode <m>', 'ai_completion|quick_cap')).action(makeStepAction('complete'));
154
+ withTaskOptions(mesh
155
+ .command('decimate [input]')
156
+ .description('high-poly → low-poly (bakes textures by default)')
157
+ .option('--face-limit <n>', 'target faces 500–20000 tri / 500–10000 quad', (v) => Number(v))
158
+ .option('--quad', 'quad output')
159
+ .option('--no-bake', 'skip baking textures onto the low-poly')).action(makeStepAction('decimate'));
160
+ withTaskOptions(mesh
161
+ .command('smartsegment [input]')
162
+ .description('smart segmentation (image or model pipeline)')
163
+ .option('--seg-type <t>', 'image|model', 'model')
164
+ .option('--granularity <g>', 'coarse|medium|fine')
165
+ .option('--hint <text>', 'hint for the parts to find')).action(makeStepAction('smartsegment'));
166
+ }
@@ -0,0 +1,75 @@
1
+ /** Helpers shared by task-producing commands: flag parsing and result output. */
2
+ import { loadConfig } from '../config/config.js';
3
+ import { TripoClient } from '../core/client.js';
4
+ import { UsageError } from '../core/errors.js';
5
+ import { emitResult } from '../core/output.js';
6
+ import { t } from '../i18n.js';
7
+ import { parseThenExpression } from '../knowledge/chains.js';
8
+ /** repeatable --param key=value collector */
9
+ export function collectParam(value, previous) {
10
+ const eq = value.indexOf('=');
11
+ if (eq <= 0)
12
+ throw new UsageError(`--param expects key=value (got "${value}")`);
13
+ const key = value.slice(0, eq).trim();
14
+ const raw = value.slice(eq + 1).trim();
15
+ previous[key] = coerceValue(raw);
16
+ return previous;
17
+ }
18
+ export function coerceValue(raw) {
19
+ if (raw === 'true')
20
+ return true;
21
+ if (raw === 'false')
22
+ return false;
23
+ if (/^-?\d+$/.test(raw))
24
+ return Number(raw);
25
+ if (/^-?\d*\.\d+$/.test(raw))
26
+ return Number(raw);
27
+ if ((raw.startsWith('[') && raw.endsWith(']')) || (raw.startsWith('{') && raw.endsWith('}'))) {
28
+ try {
29
+ return JSON.parse(raw);
30
+ }
31
+ catch { }
32
+ }
33
+ return raw;
34
+ }
35
+ /** Attach the common generation/processing option set to a command. */
36
+ export function withTaskOptions(cmd) {
37
+ return cmd
38
+ .option('-o, --out <dir>', 'output directory (default ./tripo-out/...)')
39
+ .option('--no-wait', 'submit only; do not wait for completion')
40
+ .option('--no-download', 'wait but skip downloading artifacts')
41
+ .option('--notify', 'desktop notification when done')
42
+ .option('--timeout <seconds>', 'watch timeout in seconds', (v) => Number(v))
43
+ .option('--name <name>', 'history name for @references')
44
+ .option('-p, --param <key=value>', 'extra API parameter (repeatable)', collectParam, {});
45
+ }
46
+ export function newClient() {
47
+ return new TripoClient({ baseUrl: undefined, apiKey: undefined });
48
+ }
49
+ export function parseChainFlag(value) {
50
+ if (!value)
51
+ return undefined;
52
+ return parseThenExpression(value);
53
+ }
54
+ /** Standard result output for a finished task (human + JSON modes). */
55
+ export function printTaskResult(result) {
56
+ const lines = [];
57
+ lines.push(t({
58
+ zh: `任务 ${result.task_id} ${result.status === 'success' ? '完成' : result.status}`,
59
+ en: `task ${result.task_id} ${result.status}`,
60
+ }));
61
+ if (result.credits_consumed !== undefined) {
62
+ lines.push(t({ zh: `消耗积分: ${result.credits_consumed}`, en: `credits consumed: ${result.credits_consumed}` }));
63
+ }
64
+ if (result.output_dir) {
65
+ lines.push(t({ zh: `产物目录: ${result.output_dir}`, en: `artifacts: ${result.output_dir}` }));
66
+ }
67
+ if (result.model_file)
68
+ lines.push(t({ zh: `模型文件: ${result.model_file}`, en: `model file: ${result.model_file}` }));
69
+ if (result.preview)
70
+ lines.push(t({ zh: `预览图: ${result.preview}`, en: `preview: ${result.preview}` }));
71
+ emitResult(result, lines.join('\n'));
72
+ }
73
+ export function llmConfigured() {
74
+ return Boolean(loadConfig().llm?.base_url && loadConfig().llm?.model);
75
+ }
@@ -0,0 +1,128 @@
1
+ /** `tripo task get|list|watch` and `tripo files upload`. */
2
+ import { readHistory, resolveReference, updateHistoryStatus } from '../config/history.js';
3
+ import { TripoClient } from '../core/client.js';
4
+ import { defaultOutputDir, downloadArtifacts } from '../core/download.js';
5
+ import { emitNdjson, emitResult, isJsonMode, isQuiet, log } from '../core/output.js';
6
+ import { watchTask } from '../core/watch.js';
7
+ import { uploadLocalFile } from '../core/upload.js';
8
+ import { t } from '../i18n.js';
9
+ import { createSpinner } from '../util/spinner.js';
10
+ import { newClient } from './shared.js';
11
+ function resolveTaskId(target) {
12
+ if (target.startsWith('@')) {
13
+ const entry = resolveReference(target);
14
+ if (entry)
15
+ return entry.task_id;
16
+ }
17
+ return target;
18
+ }
19
+ function humanTask(task) {
20
+ const lines = [
21
+ `${task.task_id} ${task.type} ${task.status}${typeof task.progress === 'number' ? ` ${task.progress}%` : ''}`,
22
+ ];
23
+ if (task.credits_consumed !== undefined)
24
+ lines.push(t({ zh: `消耗积分 ${task.credits_consumed}`, en: `credits ${task.credits_consumed}` }));
25
+ const output = task.output ?? {};
26
+ for (const [key, value] of Object.entries(output)) {
27
+ if (typeof value === 'string' && value.startsWith('http'))
28
+ lines.push(`${key}: ${value}`);
29
+ }
30
+ return lines.join('\n');
31
+ }
32
+ export function registerTaskCommands(program) {
33
+ const task = program.command('task').description('query, list, and watch tasks');
34
+ task
35
+ .command('get <task-id>')
36
+ .description('fetch one task (accepts @last/@name)')
37
+ .option('--step', 'include pipeline step details')
38
+ .option('--download', 'download artifacts if successful')
39
+ .option('-o, --out <dir>', 'output directory for --download')
40
+ .action(async (target, opts) => {
41
+ const client = new TripoClient();
42
+ const taskId = resolveTaskId(target);
43
+ const detail = await client.getTask(taskId, opts.step);
44
+ if (opts.download && detail.status === 'success') {
45
+ const entry = readHistory().find((e) => e.task_id === taskId);
46
+ const dir = opts.out ?? entry?.output_dir ?? defaultOutputDir(process.cwd(), entry?.name ?? 'task', taskId);
47
+ const artifacts = await downloadArtifacts(detail, dir);
48
+ emitResult({ ...detail, output_dir: artifacts.outputDir, files: artifacts.files }, `${humanTask(detail)}\n${t({ zh: `产物目录: ${artifacts.outputDir}`, en: `artifacts: ${artifacts.outputDir}` })}`);
49
+ return;
50
+ }
51
+ emitResult(detail, humanTask(detail));
52
+ });
53
+ task
54
+ .command('list [task-ids...]')
55
+ .description('batch-query tasks (default: recent history)')
56
+ .option('--limit <n>', 'how many from history', (v) => Number(v), 10)
57
+ .action(async (targets, opts) => {
58
+ const client = new TripoClient();
59
+ const ids = targets.length > 0
60
+ ? targets.map(resolveTaskId)
61
+ : readHistory().slice(-opts.limit).map((e) => e.task_id);
62
+ if (ids.length === 0) {
63
+ emitResult([], t({ zh: '本地历史为空', en: 'no local history yet' }));
64
+ return;
65
+ }
66
+ const { tasks, missed } = await client.listTasks(ids);
67
+ const lines = tasks.map((d) => `${d.task_id} ${(d.type ?? '').padEnd(24)} ${d.status}${typeof d.progress === 'number' ? ` ${d.progress}%` : ''}`);
68
+ if (missed.length > 0) {
69
+ lines.push(t({ zh: `未找到: ${missed.join(', ')}`, en: `missed: ${missed.join(', ')}` }));
70
+ }
71
+ emitResult({ tasks, missed }, lines.join('\n'));
72
+ });
73
+ task
74
+ .command('watch <task-id>')
75
+ .description('block until a task finishes (streams NDJSON progress in --json mode)')
76
+ .option('--download', 'download artifacts on success')
77
+ .option('-o, --out <dir>', 'output directory for --download')
78
+ .option('--timeout <seconds>', 'timeout in seconds', (v) => Number(v))
79
+ .action(async (target, opts) => {
80
+ const client = new TripoClient();
81
+ const taskId = resolveTaskId(target);
82
+ const spinner = isJsonMode() ? undefined : createSpinner(`watch ${taskId}`, isQuiet());
83
+ let lastProgress = -1;
84
+ const detail = await watchTask(client, taskId, {
85
+ timeoutMs: opts.timeout ? opts.timeout * 1000 : undefined,
86
+ onProgress: (d) => {
87
+ if (spinner) {
88
+ spinner.update(`${d.type ?? 'task'} · ${d.status}${typeof d.progress === 'number' ? ` ${d.progress}%` : ''}`);
89
+ }
90
+ else if (isJsonMode() && typeof d.progress === 'number' && d.progress !== lastProgress) {
91
+ lastProgress = d.progress;
92
+ emitNdjson({ event: 'progress', task_id: d.task_id, status: d.status, progress: d.progress });
93
+ }
94
+ },
95
+ });
96
+ spinner?.succeed(`${detail.type} · success`);
97
+ updateHistoryStatus(taskId, detail.status, { credits_consumed: detail.credits_consumed });
98
+ if (opts.download) {
99
+ const entry = readHistory().find((e) => e.task_id === taskId);
100
+ const dir = opts.out ?? entry?.output_dir ?? defaultOutputDir(process.cwd(), entry?.name ?? 'task', taskId);
101
+ log(t({ zh: '下载产物…', en: 'downloading artifacts…' }));
102
+ const artifacts = await downloadArtifacts(detail, dir);
103
+ emitResult({ ...detail, output_dir: artifacts.outputDir, files: artifacts.files, model_file: artifacts.modelFile, preview: artifacts.preview }, `${humanTask(detail)}\n${t({ zh: `产物目录: ${artifacts.outputDir}`, en: `artifacts: ${artifacts.outputDir}` })}`);
104
+ return;
105
+ }
106
+ emitResult(detail, humanTask(detail));
107
+ });
108
+ const files = program.command('files').description('file uploads');
109
+ files
110
+ .command('upload <path>')
111
+ .description('upload a local image/model and print the file_token')
112
+ .action(async (path) => {
113
+ const client = newClient();
114
+ const token = await uploadLocalFile(client, path);
115
+ emitResult({ file_token: token }, token);
116
+ });
117
+ program
118
+ .command('history')
119
+ .description('recent tasks created by this CLI (@name references)')
120
+ .option('--limit <n>', 'rows', (v) => Number(v), 20)
121
+ .action((opts) => {
122
+ const entries = readHistory().slice(-opts.limit);
123
+ const human = entries
124
+ .map((e) => `${e.created_at.slice(0, 19)} ${(e.name ?? '').padEnd(24)} ${e.type.padEnd(22)} ${e.status.padEnd(9)} ${e.task_id}`)
125
+ .join('\n');
126
+ emitResult(entries, human || t({ zh: '本地历史为空', en: 'no local history yet' }));
127
+ });
128
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `tripo topup` — payment stays in the browser (Stripe/Alipay pages already
3
+ * handle it); the CLI opens the billing page and then polls the balance so
4
+ * the user sees "credits arrived" without leaving the terminal.
5
+ */
6
+ import { sleep, TripoClient } from '../core/client.js';
7
+ import { topupUrl } from '../core/task-service.js';
8
+ import { emitResult, log, shouldOpen } from '../core/output.js';
9
+ import { t } from '../i18n.js';
10
+ import { createSpinner } from '../util/spinner.js';
11
+ import { openExternal } from '../util/open.js';
12
+ const POLL_INTERVAL_MS = 3000;
13
+ const POLL_TIMEOUT_MS = 10 * 60 * 1000;
14
+ export function registerTopupCommand(program) {
15
+ program
16
+ .command('topup')
17
+ .description('open the billing page and watch for the credits to arrive')
18
+ .option('--no-watch', 'just open the page; skip balance polling')
19
+ .action(async (opts) => {
20
+ const client = new TripoClient();
21
+ const before = await client.getBalance();
22
+ const url = topupUrl();
23
+ if (shouldOpen()) {
24
+ openExternal(url);
25
+ log(t({ zh: `已打开充值页面: ${url}`, en: `opened the billing page: ${url}` }));
26
+ }
27
+ else {
28
+ log(t({ zh: `充值页面: ${url}`, en: `billing page: ${url}` }));
29
+ }
30
+ log(t({ zh: `当前余额 ${before.balance} 积分`, en: `current balance: ${before.balance} credits` }));
31
+ if (!opts.watch || !shouldOpen()) {
32
+ emitResult({ billing_url: url, balance: before.balance }, url);
33
+ return;
34
+ }
35
+ const spinner = createSpinner(t({ zh: '等待到账…(Ctrl+C 退出,不影响支付)', en: 'waiting for credits… (Ctrl+C is safe; payment continues in the browser)' }));
36
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
37
+ let current = Number(before.balance);
38
+ while (Date.now() < deadline) {
39
+ await sleep(POLL_INTERVAL_MS);
40
+ try {
41
+ const now = await client.getBalance();
42
+ const value = Number(now.balance);
43
+ if (value > current) {
44
+ spinner.succeed(t({
45
+ zh: `到账 +${value - current} 积分,当前余额 ${value}`,
46
+ en: `+${value - current} credits arrived; balance is now ${value}`,
47
+ }));
48
+ emitResult({ billing_url: url, balance: value, delta: value - current });
49
+ return;
50
+ }
51
+ current = value;
52
+ }
53
+ catch {
54
+ // transient polling errors are fine; keep waiting
55
+ }
56
+ }
57
+ spinner.stop();
58
+ log(t({ zh: '未检测到到账(超时 10 分钟);支付完成后余额会自动生效', en: 'no balance change detected within 10 minutes; credits apply automatically once payment completes' }));
59
+ emitResult({ billing_url: url, balance: current });
60
+ });
61
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `tripo view [task|@last|file.glb]` — local model-viewer preview.
3
+ * `tripo redo [task|@last]` — re-roll the same request with a fresh seed.
4
+ */
5
+ import { existsSync, readdirSync } from 'node:fs';
6
+ import { extname, join } from 'node:path';
7
+ import { readHistory, resolveReference } from '../config/history.js';
8
+ import { TripoClient } from '../core/client.js';
9
+ import { downloadArtifacts, defaultOutputDir } from '../core/download.js';
10
+ import { UsageError } from '../core/errors.js';
11
+ import { emitResult, log, shouldOpen } from '../core/output.js';
12
+ import { startViewer } from '../core/viewer.js';
13
+ import { t } from '../i18n.js';
14
+ import { runTask } from '../core/task-service.js';
15
+ import { openExternal } from '../util/open.js';
16
+ import { printTaskResult, newClient } from './shared.js';
17
+ const VIEWABLE = new Set(['.glb', '.gltf']);
18
+ export function registerViewCommands(program) {
19
+ program
20
+ .command('view')
21
+ .description('preview a 3D result in the browser (model-viewer, local server)')
22
+ .argument('[target]', 'task id, @last, @name, or a local .glb path (default @last)', '@last')
23
+ .option('--keep', 'keep the preview server running until Ctrl+C')
24
+ .action(async (target, opts) => {
25
+ const modelFile = await resolveModelFile(target);
26
+ const handle = await startViewer(modelFile, target);
27
+ if (shouldOpen()) {
28
+ openExternal(handle.url);
29
+ log(t({ zh: `预览已打开: ${handle.url}`, en: `preview opened: ${handle.url}` }));
30
+ }
31
+ emitResult({ url: handle.url, model_file: modelFile }, `${handle.url}\n${modelFile}`);
32
+ if (opts.keep || shouldOpen()) {
33
+ log(t({ zh: 'Ctrl+C 关闭预览服务', en: 'Ctrl+C to stop the preview server' }));
34
+ await new Promise(() => { }); // hold until interrupted
35
+ }
36
+ else {
37
+ handle.close();
38
+ }
39
+ });
40
+ program
41
+ .command('redo')
42
+ .description('re-run a previous task with a fresh model_seed (same everything else)')
43
+ .argument('[target]', 'task id or @reference (default @last)', '@last')
44
+ .option('--seed <n>', 'explicit model_seed', (v) => Number(v))
45
+ .action(async (target, opts) => {
46
+ const entry = target.startsWith('@')
47
+ ? resolveReference(target)
48
+ : readHistory().find((e) => e.task_id === target);
49
+ if (!entry)
50
+ throw new UsageError(`no history entry for ${target} — redo only works for tasks created by this CLI`);
51
+ if (!entry.endpoint || !entry.payload) {
52
+ throw new UsageError(`history entry ${target} has no recorded request payload`);
53
+ }
54
+ const payload = { ...entry.payload, model_seed: opts.seed ?? Math.floor(Math.random() * 2 ** 31) };
55
+ const client = newClient();
56
+ const result = await runTask({
57
+ client,
58
+ request: { endpoint: entry.endpoint, payload },
59
+ taskType: entry.type,
60
+ name: `${entry.name ?? 'redo'}-redo`,
61
+ });
62
+ printTaskResult(result);
63
+ });
64
+ }
65
+ async function resolveModelFile(target) {
66
+ // Direct file path
67
+ if (VIEWABLE.has(extname(target).toLowerCase()) && existsSync(target))
68
+ return target;
69
+ const entry = target.startsWith('@')
70
+ ? resolveReference(target)
71
+ : readHistory().find((e) => e.task_id === target);
72
+ // Local artifacts already downloaded?
73
+ if (entry?.output_dir && existsSync(entry.output_dir)) {
74
+ const glb = readdirSync(entry.output_dir).find((f) => VIEWABLE.has(extname(f).toLowerCase()));
75
+ if (glb)
76
+ return join(entry.output_dir, glb);
77
+ }
78
+ // Fetch fresh (URLs expire ~5min, so always re-query then download).
79
+ const taskId = entry?.task_id ?? target;
80
+ if (!/^(task_[A-Za-z0-9_-]+|[0-9a-f-]{36})$/i.test(taskId)) {
81
+ throw new UsageError(`cannot resolve "${target}" to a viewable model (.glb path, task id, or @ref)`);
82
+ }
83
+ const client = new TripoClient();
84
+ const task = await client.getTask(taskId);
85
+ if (task.status !== 'success')
86
+ throw new UsageError(`task ${taskId} is ${task.status}, nothing to view yet`);
87
+ log(t({ zh: '正在下载模型用于预览…', en: 'downloading the model for preview…' }));
88
+ const dir = entry?.output_dir ?? defaultOutputDir(process.cwd(), entry?.name ?? 'view', taskId);
89
+ const artifacts = await downloadArtifacts(task, dir);
90
+ if (!artifacts.modelFile || !VIEWABLE.has(extname(artifacts.modelFile).toLowerCase())) {
91
+ const fallback = artifacts.files.find((f) => VIEWABLE.has(extname(f).toLowerCase()));
92
+ if (!fallback)
93
+ throw new UsageError(`task ${taskId} has no glb/gltf artifact to preview (files: ${artifacts.files.join(', ') || 'none'})`);
94
+ return fallback;
95
+ }
96
+ return artifacts.modelFile;
97
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Global config at ~/.tripo/config.json (mode 0600 — it holds the API key).
3
+ * Precedence for every setting: env var > directory context > global config.
4
+ */
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ /**
9
+ * Everything region-specific reduces to these three URLs: the generation API
10
+ * (`api`), the console website (`platform`), and the console's own backend
11
+ * (`consoleApi`, which hosts the CLI device-auth endpoints). The consoles do
12
+ * differ beyond the domain — ov signs in email-first with Stripe top-up on
13
+ * /billing, cn signs in +86-SMS-first with Alipay top-up on /billing/recharge
14
+ * — but all of that lives in the website; the CLI only ever *opens* the
15
+ * right console. API endpoints, params, and key format are identical, which
16
+ * is why this is one CLI with a `region` field and not two CLIs. A key is
17
+ * valid in exactly one region, so region is *detected* from the key at login
18
+ * time, never asked when a key is already in hand.
19
+ */
20
+ export const REGION_DEFAULTS = {
21
+ ov: {
22
+ api: 'https://openapi.tripo3d.ai',
23
+ platform: 'https://developers.tripo3d.ai',
24
+ consoleApi: 'https://apiplatform.tripo3d.ai',
25
+ },
26
+ cn: {
27
+ api: 'https://openapi.tripo3d.com',
28
+ platform: 'https://developers.tripo3d.com',
29
+ consoleApi: 'https://apiplatform.tripo3d.com',
30
+ },
31
+ };
32
+ export function tripoHome() {
33
+ return process.env.TRIPO_HOME || join(homedir(), '.tripo');
34
+ }
35
+ function configPath() {
36
+ return join(tripoHome(), 'config.json');
37
+ }
38
+ export function loadConfig() {
39
+ try {
40
+ return JSON.parse(readFileSync(configPath(), 'utf8'));
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ }
46
+ export function saveConfig(config) {
47
+ const dir = tripoHome();
48
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ const file = configPath();
50
+ writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
51
+ // writeFileSync mode only applies on creation; enforce on every save.
52
+ chmodSync(file, 0o600);
53
+ }
54
+ export function updateConfig(patch) {
55
+ const next = { ...loadConfig(), ...patch };
56
+ saveConfig(next);
57
+ return next;
58
+ }
59
+ export function deleteApiKey() {
60
+ const config = loadConfig();
61
+ delete config.api_key;
62
+ if (Object.keys(config).length === 0) {
63
+ try {
64
+ unlinkSync(configPath());
65
+ }
66
+ catch { }
67
+ }
68
+ else {
69
+ saveConfig(config);
70
+ }
71
+ }
72
+ /** Resolve the API key: env wins, then global config. */
73
+ export function resolveApiKey(config = loadConfig()) {
74
+ return process.env.TRIPO_API_KEY || config.api_key;
75
+ }
76
+ export function resolveRegion(config = loadConfig()) {
77
+ const env = process.env.TRIPO_REGION;
78
+ if (env === 'cn' || env === 'ov')
79
+ return env;
80
+ return config.region ?? 'ov';
81
+ }
82
+ export function resolveApiBaseUrl(config = loadConfig()) {
83
+ return (process.env.TRIPO_API_BASE_URL ||
84
+ config.api_base_url ||
85
+ REGION_DEFAULTS[resolveRegion(config)].api);
86
+ }
87
+ export function resolvePlatformBaseUrl(config = loadConfig()) {
88
+ return (process.env.TRIPO_PLATFORM_BASE_URL ||
89
+ config.platform_base_url ||
90
+ REGION_DEFAULTS[resolveRegion(config)].platform);
91
+ }
92
+ export function configFileExists() {
93
+ return existsSync(configPath());
94
+ }
95
+ export function maskKey(key) {
96
+ if (key.length <= 10)
97
+ return key.slice(0, 4) + '…';
98
+ return `${key.slice(0, 8)}…${key.slice(-4)}`;
99
+ }