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.
- package/README.md +105 -0
- package/dist/ai/experts.js +115 -0
- package/dist/ai/llm.js +88 -0
- package/dist/ai/plan-card.js +79 -0
- package/dist/ai/wizard.js +105 -0
- package/dist/cli.js +7 -0
- package/dist/commands/account.js +27 -0
- package/dist/commands/ai.js +114 -0
- package/dist/commands/batch.js +148 -0
- package/dist/commands/completion.js +84 -0
- package/dist/commands/config-cmd.js +84 -0
- package/dist/commands/docs.js +30 -0
- package/dist/commands/doctor.js +102 -0
- package/dist/commands/generate.js +81 -0
- package/dist/commands/home.js +68 -0
- package/dist/commands/login.js +309 -0
- package/dist/commands/make.js +142 -0
- package/dist/commands/mcp.js +194 -0
- package/dist/commands/process-groups.js +166 -0
- package/dist/commands/shared.js +75 -0
- package/dist/commands/task.js +128 -0
- package/dist/commands/topup.js +61 -0
- package/dist/commands/view.js +97 -0
- package/dist/config/config.js +99 -0
- package/dist/config/context.js +44 -0
- package/dist/config/history.js +104 -0
- package/dist/core/client.js +129 -0
- package/dist/core/device-auth.js +141 -0
- package/dist/core/download.js +107 -0
- package/dist/core/errors.js +64 -0
- package/dist/core/output.js +72 -0
- package/dist/core/region.js +45 -0
- package/dist/core/requests.js +197 -0
- package/dist/core/task-service.js +167 -0
- package/dist/core/upload.js +42 -0
- package/dist/core/viewer-assets.js +24 -0
- package/dist/core/viewer.js +59 -0
- package/dist/core/watch.js +42 -0
- package/dist/i18n.js +19 -0
- package/dist/knowledge/chains.js +157 -0
- package/dist/knowledge/error-catalog.js +91 -0
- package/dist/knowledge/models.js +158 -0
- package/dist/knowledge/params.js +203 -0
- package/dist/knowledge/scenarios.js +112 -0
- package/dist/pipeline/make.js +233 -0
- package/dist/pipeline/runner.js +73 -0
- package/dist/pipeline/stdin.js +40 -0
- package/dist/program.js +147 -0
- package/dist/src/ai/experts.js +115 -0
- package/dist/src/ai/llm.js +88 -0
- package/dist/src/ai/plan-card.js +79 -0
- package/dist/src/ai/wizard.js +104 -0
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/account.js +27 -0
- package/dist/src/commands/ai.js +113 -0
- package/dist/src/commands/batch.js +148 -0
- package/dist/src/commands/completion.js +84 -0
- package/dist/src/commands/config-cmd.js +84 -0
- package/dist/src/commands/docs.js +30 -0
- package/dist/src/commands/doctor.js +94 -0
- package/dist/src/commands/generate.js +81 -0
- package/dist/src/commands/home.js +68 -0
- package/dist/src/commands/login.js +225 -0
- package/dist/src/commands/make.js +142 -0
- package/dist/src/commands/mcp.js +194 -0
- package/dist/src/commands/process-groups.js +164 -0
- package/dist/src/commands/shared.js +75 -0
- package/dist/src/commands/task.js +127 -0
- package/dist/src/commands/topup.js +61 -0
- package/dist/src/commands/view.js +97 -0
- package/dist/src/config/config.js +79 -0
- package/dist/src/config/context.js +44 -0
- package/dist/src/config/history.js +104 -0
- package/dist/src/core/client.js +120 -0
- package/dist/src/core/download.js +107 -0
- package/dist/src/core/errors.js +64 -0
- package/dist/src/core/output.js +72 -0
- package/dist/src/core/requests.js +169 -0
- package/dist/src/core/task-service.js +161 -0
- package/dist/src/core/upload.js +36 -0
- package/dist/src/core/viewer-assets.js +24 -0
- package/dist/src/core/viewer.js +59 -0
- package/dist/src/core/watch.js +41 -0
- package/dist/src/i18n.js +19 -0
- package/dist/src/knowledge/chains.js +152 -0
- package/dist/src/knowledge/error-catalog.js +91 -0
- package/dist/src/knowledge/models.js +110 -0
- package/dist/src/knowledge/params.js +174 -0
- package/dist/src/knowledge/scenarios.js +112 -0
- package/dist/src/pipeline/make.js +233 -0
- package/dist/src/pipeline/runner.js +73 -0
- package/dist/src/pipeline/stdin.js +40 -0
- package/dist/src/program.js +147 -0
- package/dist/src/types.js +2 -0
- package/dist/src/util/input-detect.js +92 -0
- package/dist/src/util/notify.js +17 -0
- package/dist/src/util/open.js +18 -0
- package/dist/src/util/spinner.js +62 -0
- package/dist/src/util/term-image.js +28 -0
- package/dist/src/version.js +10 -0
- package/dist/types.js +2 -0
- package/dist/util/input-detect.js +93 -0
- package/dist/util/notify.js +17 -0
- package/dist/util/open.js +18 -0
- package/dist/util/spinner.js +62 -0
- package/dist/util/term-image.js +28 -0
- package/dist/version.js +10 -0
- package/package.json +63 -0
- package/skill/SKILL.md +70 -0
- package/skill/commands/account.md +41 -0
- package/skill/commands/batch.md +33 -0
- package/skill/commands/generate.md +34 -0
- package/skill/commands/make.md +47 -0
- package/skill/commands/process.md +50 -0
- package/skill/commands/task.md +27 -0
- package/skill/commands/view.md +25 -0
- package/skill/common-errors.md +38 -0
- package/skill/examples/animation.md +33 -0
- package/skill/examples/ar-web.md +23 -0
- package/skill/examples/film.md +22 -0
- package/skill/examples/game-asset.md +35 -0
- package/skill/examples/pipes.md +39 -0
- package/skill/examples/print.md +31 -0
|
@@ -0,0 +1,164 @@
|
|
|
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.name} only accepts ${def.accepts.join('/')} tasks (input is a ${resolved.sourceType} task)`);
|
|
35
|
+
}
|
|
36
|
+
const args = { ...extraDefaults, ...collectStepArgs(stepName, flags), ...flags.param };
|
|
37
|
+
const built = buildStepRequest(stepName, resolved.value, args);
|
|
38
|
+
for (const w of built.warnings)
|
|
39
|
+
warn(w);
|
|
40
|
+
const result = await runTask({
|
|
41
|
+
client,
|
|
42
|
+
request: built.request,
|
|
43
|
+
taskType: built.taskType,
|
|
44
|
+
name: flags.name ?? `${stepName}`,
|
|
45
|
+
outDir: flags.out,
|
|
46
|
+
noWait: flags.wait === false,
|
|
47
|
+
noDownload: flags.download === false,
|
|
48
|
+
notify: flags.notify,
|
|
49
|
+
timeoutMs: flags.timeout ? flags.timeout * 1000 : undefined,
|
|
50
|
+
});
|
|
51
|
+
printTaskResult(result);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Map first-class flags (e.g. --format) into step args. */
|
|
55
|
+
function collectStepArgs(step, flags) {
|
|
56
|
+
const pick = (keys) => {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const key of keys) {
|
|
59
|
+
const flagKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
60
|
+
const v = flags[flagKey] ?? flags[key];
|
|
61
|
+
if (v !== undefined)
|
|
62
|
+
out[key] = v;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
switch (step) {
|
|
67
|
+
case 'convert':
|
|
68
|
+
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']);
|
|
69
|
+
case 'texture':
|
|
70
|
+
return pick(['model', 'texture_quality', 'texture_alignment', 'pbr']);
|
|
71
|
+
case 'stylize':
|
|
72
|
+
return pick(['style', 'block_size']);
|
|
73
|
+
case 'rig':
|
|
74
|
+
return pick(['model', 'rig_type', 'spec', 'out_format']);
|
|
75
|
+
case 'retarget':
|
|
76
|
+
return pick(['animation', 'out_format', 'animate_in_place']);
|
|
77
|
+
case 'decimate':
|
|
78
|
+
return pick(['face_limit', 'quad', 'bake']);
|
|
79
|
+
case 'segment':
|
|
80
|
+
return pick(['model']);
|
|
81
|
+
case 'complete':
|
|
82
|
+
return pick(['completion_mode']);
|
|
83
|
+
case 'smartsegment':
|
|
84
|
+
return pick(['seg_type', 'granularity', 'hint']);
|
|
85
|
+
default:
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function registerProcessCommands(program) {
|
|
90
|
+
// ---- tripo model ----
|
|
91
|
+
const model = program.command('model').description('model processing: refine / texture / stylize / convert / import');
|
|
92
|
+
withTaskOptions(model.command('refine [input]').description('refine a draft text-to-model task (task id / @last)')).action(makeStepAction('refine'));
|
|
93
|
+
withTaskOptions(model
|
|
94
|
+
.command('texture [input]')
|
|
95
|
+
.description('re-texture a model (task/@last/file/url)')
|
|
96
|
+
.option('--texture-quality <q>', 'standard|detailed|extreme')
|
|
97
|
+
.option('--texture-alignment <a>', 'original_image|geometry')).action(makeStepAction('texture'));
|
|
98
|
+
withTaskOptions(model
|
|
99
|
+
.command('stylize [input]')
|
|
100
|
+
.description('stylize a model (lego/voxel/voronoi/minecraft)')
|
|
101
|
+
.option('--style <style>', 'lego|voxel|voronoi|minecraft')
|
|
102
|
+
.option('--block-size <n>', 'minecraft block size 32-128', (v) => Number(v))).action(makeStepAction('stylize'));
|
|
103
|
+
withTaskOptions(model
|
|
104
|
+
.command('convert [input]')
|
|
105
|
+
.description('convert format (GLTF/USDZ/FBX/OBJ/STL/3MF)')
|
|
106
|
+
.option('--format <format>', 'target format', 'GLTF')
|
|
107
|
+
.option('--texture-size <n>', 'texture size in px', (v) => Number(v))
|
|
108
|
+
.option('--texture-format <f>', 'JPEG/PNG/OPEN_EXR/...')
|
|
109
|
+
.option('--face-limit <n>', 'face limit', (v) => Number(v))
|
|
110
|
+
.option('--quad', 'quad remesh')
|
|
111
|
+
.option('--flatten-bottom', 'flatten the bottom (3D printing)')
|
|
112
|
+
.option('--fbx-preset <p>', 'blender|3dsmax|mixamo')
|
|
113
|
+
.option('--export-orientation <o>', '+x|-x|+y|-y')
|
|
114
|
+
.option('--scale-factor <n>', 'scale factor', (v) => Number(v))
|
|
115
|
+
.option('--auto-size', 'scale to real-world size')
|
|
116
|
+
.option('--pivot-to-center-bottom', 'pivot at center bottom')
|
|
117
|
+
.option('--export-vertex-colors', 'include vertex colors (OBJ/GLTF)')).action(makeStepAction('convert'));
|
|
118
|
+
withTaskOptions(model.command('import <input>').description('import an external model file/url (GLB/GLTF/FBX/OBJ/STL ≤150MB)')).action(makeStepAction('import'));
|
|
119
|
+
// ---- tripo anim ----
|
|
120
|
+
const anim = program.command('anim').description('animation: check (riggability) / rig / retarget');
|
|
121
|
+
withTaskOptions(anim.command('check [input]').description('rig-check: is this model riggable? (GLB input)')).action(makeStepAction('rig-check'));
|
|
122
|
+
withTaskOptions(anim
|
|
123
|
+
.command('rig [input]')
|
|
124
|
+
.description('rig a model (skeleton binding)')
|
|
125
|
+
.option('--rig-type <type>', 'biped|quadruped|hexapod|octopod|avian|serpentine|aquatic')
|
|
126
|
+
.option('--spec <spec>', 'tripo|mixamo')
|
|
127
|
+
.option('--out-format <f>', 'glb|fbx')).action(makeStepAction('rig'));
|
|
128
|
+
withTaskOptions(anim
|
|
129
|
+
.command('retarget [input]')
|
|
130
|
+
.description('apply preset animations to a rigged task')
|
|
131
|
+
.option('--animation <name...>', 'preset:walk preset:run ... (≤5, billed per animation)')
|
|
132
|
+
.option('--out-format <f>', 'glb|fbx')
|
|
133
|
+
.option('--animate-in-place', 'no root motion')).action(async (input, flags) => {
|
|
134
|
+
// --animation may be a list → map to animations[]
|
|
135
|
+
const anims = flags.animation;
|
|
136
|
+
if (Array.isArray(anims) && anims.length > 1) {
|
|
137
|
+
flags.param = { ...flags.param, animations: anims };
|
|
138
|
+
delete flags.animation;
|
|
139
|
+
}
|
|
140
|
+
else if (Array.isArray(anims)) {
|
|
141
|
+
flags.animation = anims[0];
|
|
142
|
+
}
|
|
143
|
+
await makeStepAction('retarget')(input, flags);
|
|
144
|
+
});
|
|
145
|
+
// ---- tripo mesh ----
|
|
146
|
+
const mesh = program.command('mesh').description('mesh ops: segment / complete / decimate / smartsegment');
|
|
147
|
+
withTaskOptions(mesh.command('segment [input]').description('segment a model into parts').option('--model <v>', 'v1.0-20250506|v2.0-20260430')).action(makeStepAction('segment'));
|
|
148
|
+
withTaskOptions(mesh
|
|
149
|
+
.command('complete [input]')
|
|
150
|
+
.description('complete/fill segmented parts (needs a segment task)')
|
|
151
|
+
.option('--completion-mode <m>', 'ai_completion|quick_cap')).action(makeStepAction('complete'));
|
|
152
|
+
withTaskOptions(mesh
|
|
153
|
+
.command('decimate [input]')
|
|
154
|
+
.description('high-poly → low-poly (bakes textures by default)')
|
|
155
|
+
.option('--face-limit <n>', 'target faces 500–20000', (v) => Number(v))
|
|
156
|
+
.option('--quad', 'quad output')
|
|
157
|
+
.option('--no-bake', 'skip baking textures onto the low-poly')).action(makeStepAction('decimate'));
|
|
158
|
+
withTaskOptions(mesh
|
|
159
|
+
.command('smartsegment [input]')
|
|
160
|
+
.description('smart segmentation (image or model pipeline)')
|
|
161
|
+
.option('--seg-type <t>', 'image|model', 'model')
|
|
162
|
+
.option('--granularity <g>', 'coarse|medium|fine')
|
|
163
|
+
.option('--hint <text>', 'hint for the parts to find')).action(makeStepAction('smartsegment'));
|
|
164
|
+
}
|
|
@@ -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,127 @@
|
|
|
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 list = await client.listTasks(ids);
|
|
67
|
+
const human = list
|
|
68
|
+
.map((d) => `${d.task_id} ${(d.type ?? '').padEnd(24)} ${d.status}${typeof d.progress === 'number' ? ` ${d.progress}%` : ''}`)
|
|
69
|
+
.join('\n');
|
|
70
|
+
emitResult(list, human);
|
|
71
|
+
});
|
|
72
|
+
task
|
|
73
|
+
.command('watch <task-id>')
|
|
74
|
+
.description('block until a task finishes (streams NDJSON progress in --json mode)')
|
|
75
|
+
.option('--download', 'download artifacts on success')
|
|
76
|
+
.option('-o, --out <dir>', 'output directory for --download')
|
|
77
|
+
.option('--timeout <seconds>', 'timeout in seconds', (v) => Number(v))
|
|
78
|
+
.action(async (target, opts) => {
|
|
79
|
+
const client = new TripoClient();
|
|
80
|
+
const taskId = resolveTaskId(target);
|
|
81
|
+
const spinner = isJsonMode() ? undefined : createSpinner(`watch ${taskId}`, isQuiet());
|
|
82
|
+
let lastProgress = -1;
|
|
83
|
+
const detail = await watchTask(client, taskId, {
|
|
84
|
+
timeoutMs: opts.timeout ? opts.timeout * 1000 : undefined,
|
|
85
|
+
onProgress: (d) => {
|
|
86
|
+
if (spinner) {
|
|
87
|
+
spinner.update(`${d.type ?? 'task'} · ${d.status}${typeof d.progress === 'number' ? ` ${d.progress}%` : ''}`);
|
|
88
|
+
}
|
|
89
|
+
else if (isJsonMode() && typeof d.progress === 'number' && d.progress !== lastProgress) {
|
|
90
|
+
lastProgress = d.progress;
|
|
91
|
+
emitNdjson({ event: 'progress', task_id: d.task_id, status: d.status, progress: d.progress });
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
spinner?.succeed(`${detail.type} · success`);
|
|
96
|
+
updateHistoryStatus(taskId, detail.status, { credits_consumed: detail.credits_consumed });
|
|
97
|
+
if (opts.download) {
|
|
98
|
+
const entry = readHistory().find((e) => e.task_id === taskId);
|
|
99
|
+
const dir = opts.out ?? entry?.output_dir ?? defaultOutputDir(process.cwd(), entry?.name ?? 'task', taskId);
|
|
100
|
+
log(t({ zh: '下载产物…', en: 'downloading artifacts…' }));
|
|
101
|
+
const artifacts = await downloadArtifacts(detail, dir);
|
|
102
|
+
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}` })}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
emitResult(detail, humanTask(detail));
|
|
106
|
+
});
|
|
107
|
+
const files = program.command('files').description('file uploads');
|
|
108
|
+
files
|
|
109
|
+
.command('upload <path>')
|
|
110
|
+
.description('upload a local image/model and print the file_token')
|
|
111
|
+
.action(async (path) => {
|
|
112
|
+
const client = newClient();
|
|
113
|
+
const token = await uploadLocalFile(client, path);
|
|
114
|
+
emitResult({ file_token: token }, token);
|
|
115
|
+
});
|
|
116
|
+
program
|
|
117
|
+
.command('history')
|
|
118
|
+
.description('recent tasks created by this CLI (@name references)')
|
|
119
|
+
.option('--limit <n>', 'rows', (v) => Number(v), 20)
|
|
120
|
+
.action((opts) => {
|
|
121
|
+
const entries = readHistory().slice(-opts.limit);
|
|
122
|
+
const human = entries
|
|
123
|
+
.map((e) => `${e.created_at.slice(0, 19)} ${(e.name ?? '').padEnd(24)} ${e.type.padEnd(22)} ${e.status.padEnd(9)} ${e.task_id}`)
|
|
124
|
+
.join('\n');
|
|
125
|
+
emitResult(entries, human || t({ zh: '本地历史为空', en: 'no local history yet' }));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -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 { resolvePlatformBaseUrl } from '../config/config.js';
|
|
7
|
+
import { sleep, TripoClient } from '../core/client.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 = `${resolvePlatformBaseUrl()}/billing`;
|
|
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,79 @@
|
|
|
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
|
+
export const REGION_DEFAULTS = {
|
|
9
|
+
ov: { api: 'https://openapi.tripo3d.ai', platform: 'https://platform.tripo3d.ai' },
|
|
10
|
+
cn: { api: 'https://openapi.tripo3d.com', platform: 'https://platform.tripo3d.ai' },
|
|
11
|
+
};
|
|
12
|
+
export function tripoHome() {
|
|
13
|
+
return process.env.TRIPO_HOME || join(homedir(), '.tripo');
|
|
14
|
+
}
|
|
15
|
+
function configPath() {
|
|
16
|
+
return join(tripoHome(), 'config.json');
|
|
17
|
+
}
|
|
18
|
+
export function loadConfig() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function saveConfig(config) {
|
|
27
|
+
const dir = tripoHome();
|
|
28
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
const file = configPath();
|
|
30
|
+
writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
31
|
+
// writeFileSync mode only applies on creation; enforce on every save.
|
|
32
|
+
chmodSync(file, 0o600);
|
|
33
|
+
}
|
|
34
|
+
export function updateConfig(patch) {
|
|
35
|
+
const next = { ...loadConfig(), ...patch };
|
|
36
|
+
saveConfig(next);
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
export function deleteApiKey() {
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
delete config.api_key;
|
|
42
|
+
if (Object.keys(config).length === 0) {
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(configPath());
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
saveConfig(config);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Resolve the API key: env wins, then global config. */
|
|
53
|
+
export function resolveApiKey(config = loadConfig()) {
|
|
54
|
+
return process.env.TRIPO_API_KEY || config.api_key;
|
|
55
|
+
}
|
|
56
|
+
export function resolveRegion(config = loadConfig()) {
|
|
57
|
+
const env = process.env.TRIPO_REGION;
|
|
58
|
+
if (env === 'cn' || env === 'ov')
|
|
59
|
+
return env;
|
|
60
|
+
return config.region ?? 'ov';
|
|
61
|
+
}
|
|
62
|
+
export function resolveApiBaseUrl(config = loadConfig()) {
|
|
63
|
+
return (process.env.TRIPO_API_BASE_URL ||
|
|
64
|
+
config.api_base_url ||
|
|
65
|
+
REGION_DEFAULTS[resolveRegion(config)].api);
|
|
66
|
+
}
|
|
67
|
+
export function resolvePlatformBaseUrl(config = loadConfig()) {
|
|
68
|
+
return (process.env.TRIPO_PLATFORM_BASE_URL ||
|
|
69
|
+
config.platform_base_url ||
|
|
70
|
+
REGION_DEFAULTS[resolveRegion(config)].platform);
|
|
71
|
+
}
|
|
72
|
+
export function configFileExists() {
|
|
73
|
+
return existsSync(configPath());
|
|
74
|
+
}
|
|
75
|
+
export function maskKey(key) {
|
|
76
|
+
if (key.length <= 10)
|
|
77
|
+
return key.slice(0, 4) + '…';
|
|
78
|
+
return `${key.slice(0, 8)}…${key.slice(-4)}`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory-level context at .tripo/context.json — project defaults that
|
|
3
|
+
* apply when the CLI runs inside that directory (like .git discovery).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
/** Walk up from cwd to find the nearest .tripo/context.json. */
|
|
8
|
+
export function findContextFile(from = process.cwd()) {
|
|
9
|
+
let dir = from;
|
|
10
|
+
for (;;) {
|
|
11
|
+
const candidate = join(dir, '.tripo', 'context.json');
|
|
12
|
+
if (existsSync(candidate))
|
|
13
|
+
return candidate;
|
|
14
|
+
const parent = dirname(dir);
|
|
15
|
+
if (parent === dir)
|
|
16
|
+
return undefined;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function loadContext(from = process.cwd()) {
|
|
21
|
+
const file = findContextFile(from);
|
|
22
|
+
if (!file)
|
|
23
|
+
return {};
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Update (or create) the context file in the current directory. */
|
|
32
|
+
export function updateContext(patch, cwd = process.cwd()) {
|
|
33
|
+
const existing = findContextFile(cwd);
|
|
34
|
+
const file = existing ?? join(cwd, '.tripo', 'context.json');
|
|
35
|
+
let current = {};
|
|
36
|
+
if (existing) {
|
|
37
|
+
try {
|
|
38
|
+
current = JSON.parse(readFileSync(existing, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
43
|
+
writeFileSync(file, JSON.stringify({ ...current, ...patch }, null, 2) + '\n');
|
|
44
|
+
}
|