tripo-cli 0.6.1 → 0.7.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
@@ -105,12 +105,25 @@ tripo batch run assets.yaml --concurrency 2
105
105
  | `TRIPO_API_BASE_URL` / `TRIPO_PLATFORM_BASE_URL` | endpoint overrides |
106
106
  | `TRIPO_HOME` | config/history directory (default `~/.tripo`) |
107
107
  | `TRIPO_NO_UPDATE_CHECK` | disable the startup "new version available" hint (also off when `CI` is set, headless, or `--json`) |
108
+ | `TRIPO_ANALYTICS_ENABLED` | set to `0` to turn off anonymous usage analytics (see [Telemetry](#telemetry)) |
108
109
  | `TRIPO_NPM_REGISTRY` | npm registry used by `tripo update` and the version check (default `https://registry.npmjs.org`; e.g. a mirror) |
109
110
  | `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | standard proxy variables, honored for all CLI traffic (plain Node fetch ignores them) |
110
111
  | `TRIPO_LLM_BASE_URL` / `TRIPO_LLM_API_KEY` / `TRIPO_LLM_MODEL` | optional OpenAI-compatible LLM for `tripo ai` conversation mode |
111
112
 
112
113
  Setting `TRIPO_API_KEY` per shell — bash/zsh: `export TRIPO_API_KEY=tsk_...` · PowerShell: `$env:TRIPO_API_KEY = "tsk_..."` (persist for future sessions with `setx TRIPO_API_KEY "tsk_..."`) · CMD: `set TRIPO_API_KEY=tsk_...`
113
114
 
115
+ ## Telemetry
116
+
117
+ Since 0.7.0 the CLI sends anonymous usage events to Tripo (`https://sshu-api.tripo3d.ai`) for calls to the international API (`openapi.tripo3d.ai`). It is on by default, needs no setup, prints nothing, and never changes stdout, exit codes, or credit behavior.
118
+
119
+ - **Sent:** command or MCP tool name and its result, sign-in method and result, error category with HTTP status / API code, task id (and parent task id for chains), task type, model, status, credits consumed, durations, downloaded format and file count, CLI version, and the calling agent with its version — `codex` / `workbuddy` / `zcode` / `kimi` (otherwise `unknown`), from `TRIPO_CLIENT_APP` / `TRIPO_DISTRIBUTION_CHANNEL` (set by agent plugins) or the MCP host's client info.
120
+ - **Identity:** a random installation id (`analytics_distinct_id` in `~/.tripo/config.json`), never your account.
121
+ - **Never sent:** prompts, file paths, input images or models, API keys, download URLs.
122
+ - **Nothing is sent for:** China-region (`cn`) calls, custom API endpoints, `--dry-run`, help/version, `docs`, `completion`, `config`, `update`, `use`, `logout`, and the bare `tripo` home screen.
123
+ - **Opt out:** `TRIPO_ANALYTICS_ENABLED=0`.
124
+
125
+ Delivery is best-effort: each send (including the flush on exit) is capped at 2 seconds, the in-memory queue holds at most 100 events, and nothing is retried or written to disk. Maintainers can redirect or tag events with `TRIPO_ANALYTICS_SERVER_URL`, `TRIPO_ANALYTICS_APP_ID`, and `TRIPO_ANALYTICS_ENVIRONMENT=production|pre|development`; tests send nothing unless `TRIPO_ANALYTICS_ENABLED=1` and a local receiver are both set.
126
+
114
127
  ## Development
115
128
 
116
129
  ```bash
package/dist/cli.js CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { installProxyFromEnv } from './core/proxy.js';
3
3
  import { runCli } from './program.js';
4
- process.on('SIGINT', () => {
5
- process.stderr.write('\n');
6
- process.exit(130);
7
- });
4
+ import { finishOperation } from './core/analytics.js';
5
+ for (const signal of ['SIGINT', 'SIGTERM']) {
6
+ process.on(signal, () => {
7
+ process.stderr.write('\n');
8
+ void finishOperation('interrupted').finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
9
+ });
10
+ }
8
11
  await installProxyFromEnv();
9
12
  void runCli(process.argv);
@@ -11,6 +11,7 @@
11
11
  import * as p from '@clack/prompts';
12
12
  import { activeProfileName, DEFAULT_PROFILE, deleteProfile, describeKeySource, isValidProfileName, listProfiles, loadConfig, maskKey, REGION_DEFAULTS, resolveApiKey, resolvePlatformBaseUrl, resolveRegion, setActiveProfile, updateProfile, } from '../config/config.js';
13
13
  import { TripoClient } from '../core/client.js';
14
+ import { trackEvent } from '../core/analytics.js';
14
15
  import { DeviceAuthUnavailableError, requestDeviceCode, waitForDeviceApproval, } from '../core/device-auth.js';
15
16
  import { AuthError, CliError, NetworkError, UsageError } from '../core/errors.js';
16
17
  import { detectRegionForKey } from '../core/region.js';
@@ -220,8 +221,11 @@ async function runHeadlessLogin(region, profile, paste) {
220
221
  }
221
222
  if (!region) {
222
223
  throw new UsageError(t({
223
- zh: '非交互登录请指定 --region ov(海外站)或 cn(国内站),例如: tripo login --region ov;或改用 TRIPO_API_KEY / tripo login --key tsk_...',
224
- en: 'non-interactive login needs an explicit --region ov (international) or cn (China mainland), e.g. "tripo login --region ov" — or use TRIPO_API_KEY / "tripo login --key tsk_..."',
224
+ zh: `非交互登录需要 --region:先问用户账号在哪个站,不要猜(授权只在该站有效)— cn = ${siteLabel('cn')};ov = ${siteLabel('ov')};还没有账号:中国大陆选 cn,其它地区选 ov`,
225
+ en: `non-interactive login needs --region: ask the human which site their account is on, do not guess (authorization only works on that site) — cn = ${siteLabel('cn')}; ov = ${siteLabel('ov')}; no account yet: mainland China → cn, elsewhere → ov`,
226
+ }), t({
227
+ zh: 'tripo login --region cn|ov;已有 Key 时用 tripo login --key tsk_...(自动识别站点)',
228
+ en: '"tripo login --region cn|ov" — or, with a key in hand: "tripo login --key tsk_..." (site auto-detected)',
225
229
  }));
226
230
  }
227
231
  let grant;
@@ -273,6 +277,12 @@ async function runHeadlessLogin(region, profile, paste) {
273
277
  const saved = await validateAndSave(result.apikey, region, profile);
274
278
  emitResult({ status: 'logged_in', profile, region: saved.region, key: maskKey(result.apikey), balance: saved.balance }, t({ zh: `登录完成(profile=${profile}, region=${saved.region})`, en: `logged in (profile=${profile}, region=${saved.region})` }));
275
279
  }
280
+ /** What a human recognizes a console by — they know their sign-in method and how they pay, rarely the region code. */
281
+ function siteLabel(region) {
282
+ return region === 'cn'
283
+ ? t({ zh: '国内站 developers.tripo3d.com(手机号登录,支付宝)', en: 'China mainland site developers.tripo3d.com (phone/SMS sign-in, Alipay)' })
284
+ : t({ zh: '海外站 developers.tripo3d.ai(邮箱登录,Stripe)', en: 'international site developers.tripo3d.ai (email sign-in, Stripe)' });
285
+ }
276
286
  /**
277
287
  * Preferred path: RFC 8628 device flow — the browser does the sign-in and a
278
288
  * one-click approve, the key arrives over the CLI↔console-API channel, and
@@ -494,6 +504,7 @@ function finishSave(key, region, balance, profile) {
494
504
  // Saving activates the profile: "the account I just logged into is the
495
505
  // account I am using" — no second command needed for the common case.
496
506
  updateProfile(profile, { api_key: key, region });
507
+ trackEvent('agent_auth_result', { result: 'success', auth_method: 'login' }, region, REGION_DEFAULTS[region].api);
497
508
  const where = profile === DEFAULT_PROFILE ? '~/.tripo/config.json' : `~/.tripo/config.json · profile "${profile}"`;
498
509
  // "mode 0600" is only true where POSIX modes exist; on Windows the file
499
510
  // relies on the user profile's NTFS ACLs, so the claim would be wrong.
@@ -16,6 +16,7 @@ import { parseThenExpression } from '../knowledge/chains.js';
16
16
  import { scenarioIds } from '../knowledge/scenarios.js';
17
17
  import { executeMake, planMake } from '../pipeline/make.js';
18
18
  import { cliVersion } from '../version.js';
19
+ import { beginOperation, finishOperation } from '../core/analytics.js';
19
20
  const TOOLS = [
20
21
  {
21
22
  name: 'tripo_make',
@@ -77,6 +78,7 @@ export function registerMcpCommand(program) {
77
78
  });
78
79
  }
79
80
  async function serveMcp() {
81
+ let clientInfo = {};
80
82
  const rl = createInterface({ input: process.stdin, terminal: false });
81
83
  const write = (message) => {
82
84
  process.stdout.write(JSON.stringify(message) + '\n');
@@ -96,11 +98,22 @@ async function serveMcp() {
96
98
  continue; // notification — nothing to answer
97
99
  }
98
100
  try {
101
+ if (request.method === 'initialize' && request.params?.clientInfo && typeof request.params.clientInfo === 'object') {
102
+ clientInfo = request.params.clientInfo;
103
+ }
104
+ if (request.method === 'tools/call')
105
+ beginOperation(String(request.params?.name ?? 'unknown'), false, clientInfo);
99
106
  const result = await dispatch(request.method, request.params ?? {});
100
107
  if (result !== SKIP)
101
108
  write({ jsonrpc: '2.0', id: request.id, result });
109
+ if (request.method === 'tools/call') {
110
+ const failed = typeof result === 'object' && result !== null && 'isError' in result && result.isError === true;
111
+ await finishOperation(failed ? 'failed' : 'success');
112
+ }
102
113
  }
103
114
  catch (err) {
115
+ if (request.method === 'tools/call')
116
+ await finishOperation('failed', err);
104
117
  write({
105
118
  jsonrpc: '2.0',
106
119
  id: request.id,
@@ -179,7 +192,7 @@ async function callTool(params) {
179
192
  }
180
193
  case 'tripo_history': {
181
194
  const limit = typeof args.limit === 'number' ? args.limit : 10;
182
- return asText(readHistory().slice(-limit));
195
+ return asText(readHistory().slice(-limit).map(({ analytics_origin: _origin, analytics_finished: _finished, ...entry }) => entry));
183
196
  }
184
197
  default:
185
198
  return { content: [{ type: 'text', text: `unknown tool: ${params.name}` }], isError: true };
@@ -119,7 +119,7 @@ export function registerTaskCommands(program) {
119
119
  .description('recent tasks created by this CLI (@name references)')
120
120
  .option('--limit <n>', 'rows', (v) => Number(v), 20)
121
121
  .action((opts) => {
122
- const entries = readHistory().slice(-opts.limit);
122
+ const entries = readHistory().slice(-opts.limit).map(({ analytics_origin: _origin, analytics_finished: _finished, ...entry }) => entry);
123
123
  const human = entries
124
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
125
  .join('\n');
@@ -0,0 +1,251 @@
1
+ /** Anonymous Agent analytics. Collection must never change a business result. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import { loadConfig, updateConfig, resolveApiBaseUrl, resolveRegion } from '../config/config.js';
5
+ import { readHistory, updateHistoryStatus } from '../config/history.js';
6
+ import { cliVersion } from '../version.js';
7
+ import { ApiError, AuthError, NetworkError, TaskFailedError, UsageError } from './errors.js';
8
+ export const ANALYTICS_SERVER_URL = "https://sshu-api.tripo3d.ai";
9
+ export const ANALYTICS_APP_ID = "2908364badd34996a1cd44a5f7a71223";
10
+ const operations = new AsyncLocalStorage();
11
+ let activeOperation;
12
+ let installationId;
13
+ const currentOperation = () => operations.getStore() ?? activeOperation;
14
+ export function beginOperation(command, disabled = false, info, regionHint) {
15
+ activeOperation = { command, started: Date.now(), disabled, finished: false, collectors: new Map(), regionHint, context: {
16
+ ...attribution(process.env.TRIPO_CLIENT_APP, process.env.TRIPO_DISTRIBUTION_CHANNEL, info),
17
+ distinctId: randomUUID(), operation_id: randomUUID(), integration_mode: info ? "local_mcp" : "cli",
18
+ } };
19
+ operations.enterWith(activeOperation);
20
+ }
21
+ export function analyticsContext() { return currentOperation()?.context; }
22
+ function collector(region = currentOperation()?.regionHint ?? resolveRegion(), apiUrl = resolveApiBaseUrl()) {
23
+ const operation = currentOperation();
24
+ if (!operation || operation.disabled)
25
+ return undefined;
26
+ const key = `${region}:${apiUrl}`;
27
+ let analytics = operation.collectors.get(key);
28
+ if (!analytics) {
29
+ analytics = new Analytics("tripo-cli", cliVersion(), region, apiUrl);
30
+ operation.collectors.set(key, analytics);
31
+ }
32
+ if (analytics.enabled && !installationId) {
33
+ try {
34
+ const config = loadConfig();
35
+ installationId = typeof config.analytics_distinct_id === "string" && /^[a-f0-9-]{36}$/.test(config.analytics_distinct_id)
36
+ ? config.analytics_distinct_id : randomUUID();
37
+ if (config.analytics_distinct_id !== installationId)
38
+ updateConfig({ analytics_distinct_id: installationId });
39
+ }
40
+ catch {
41
+ installationId = randomUUID();
42
+ }
43
+ }
44
+ if (installationId)
45
+ operation.context.distinctId = installationId;
46
+ return analytics;
47
+ }
48
+ export function errorProperties(error) {
49
+ const category = error instanceof TaskFailedError ? error.status === 'timeout' ? 'waiting_timeout' : 'task_failed'
50
+ : error instanceof ApiError ? error.apiCode === 2010 ? 'balance' : 'upstream'
51
+ : error instanceof AuthError ? 'authorization' : error instanceof NetworkError ? 'network'
52
+ : error instanceof UsageError ? 'validation' : 'internal';
53
+ return { error_category: category, ...(error instanceof ApiError ? { http_status: error.httpStatus, api_code: error.apiCode } : {}) };
54
+ }
55
+ export function trackEvent(event, properties = {}, region, apiUrl) {
56
+ const operation = currentOperation();
57
+ // At most one authorization outcome per operation; later reports are dropped.
58
+ if (operation && event === 'agent_auth_started')
59
+ operation.authMethod = properties.auth_method;
60
+ if (operation && event === 'agent_auth_result') {
61
+ if (operation.authReported)
62
+ return;
63
+ operation.authReported = true;
64
+ }
65
+ const analytics = collector(region, apiUrl);
66
+ const context = analyticsContext();
67
+ if (analytics && context)
68
+ analytics.track(event, context, properties);
69
+ }
70
+ export async function flushAnalytics() {
71
+ const operation = currentOperation();
72
+ if (operation)
73
+ await Promise.all([...operation.collectors.values()].map((analytics) => analytics.flush()));
74
+ }
75
+ export async function finishOperation(result, error) {
76
+ const operation = currentOperation();
77
+ if (!operation || operation.finished)
78
+ return;
79
+ operation.finished = true;
80
+ if (operation.command === 'login' && result !== 'success') {
81
+ trackEvent('agent_auth_result', { result, auth_method: operation.authMethod ?? 'login', ...(error ? errorProperties(error) : {}) });
82
+ }
83
+ trackEvent('agent_tool_result', { command: operation.command,
84
+ ...(operation.context.integration_mode === 'local_mcp' ? { tool_name: operation.command } : {}),
85
+ result, duration_ms: Date.now() - operation.started, ...(error ? errorProperties(error) : {}) });
86
+ await flushAnalytics();
87
+ }
88
+ export function taskOrigin(model) {
89
+ const context = analyticsContext();
90
+ return context ? { creation_client_app: context.client_app, creation_distribution_channel: context.distribution_channel,
91
+ creation_integration_mode: context.integration_mode, ...(typeof model === 'string' ? { model } : {}) } : undefined;
92
+ }
93
+ const observed = new Set();
94
+ export function observeTask(task, region, apiUrl) {
95
+ if (!collector(region, apiUrl)?.enabled || !['success', 'failed', 'cancelled', 'banned', 'expired'].includes(task.status))
96
+ return;
97
+ const key = `${region}:${task.task_id}`;
98
+ const history = readHistory().find((entry) => entry.task_id === task.task_id);
99
+ if (observed.has(key) || history?.analytics_finished)
100
+ return;
101
+ observed.add(key);
102
+ // Bound this cache for long-lived stdio hosts; history preserves known task markers.
103
+ if (observed.size > 1000)
104
+ observed.delete(observed.values().next().value);
105
+ trackEvent('agent_task_finished', { ...history?.analytics_origin,
106
+ creation_client_app: history?.analytics_origin?.creation_client_app ?? 'unknown',
107
+ creation_distribution_channel: history?.analytics_origin?.creation_distribution_channel ?? 'unknown',
108
+ origin_known: Boolean(history?.analytics_origin), task_id: task.task_id, task_type: task.type,
109
+ status: task.status, credits_consumed: task.credits_consumed,
110
+ generation_duration_ms: task.created_at && task.completed_at ? Math.max(0, Date.parse(task.completed_at) - Date.parse(task.created_at)) : undefined,
111
+ }, region, apiUrl);
112
+ if (history) {
113
+ try {
114
+ updateHistoryStatus(task.task_id, task.status, { analytics_finished: true });
115
+ }
116
+ catch { /* best effort marker */ }
117
+ }
118
+ }
119
+ export function clientApp(value) {
120
+ if (typeof value !== "string")
121
+ return "unknown";
122
+ const name = value.trim().toLowerCase();
123
+ for (const app of ["codex", "workbuddy", "zcode", "kimi"]) {
124
+ if (new RegExp(`(^|[\\s_./-])${app}($|[\\s_./-])`).test(name))
125
+ return app;
126
+ }
127
+ return "unknown";
128
+ }
129
+ export function attribution(explicit, distribution, info, oauthName) {
130
+ const tagged = clientApp(explicit);
131
+ const initialized = clientApp(info?.name);
132
+ const oauth = clientApp(oauthName);
133
+ const chosen = tagged !== "unknown" ? tagged : initialized !== "unknown" ? initialized : oauth;
134
+ const channels = ["codex_plugin", "workbuddy_plugin", "zcode_plugin", "kimi_plugin"];
135
+ return {
136
+ client_app: chosen,
137
+ distribution_channel: typeof distribution === "string" && channels.includes(distribution) ? distribution : "unknown",
138
+ attribution_method: tagged !== "unknown" ? "explicit_tag" : initialized !== "unknown" ? "mcp_client_info" : oauth !== "unknown" ? "oauth_client_name" : "unknown",
139
+ attribution_conflict: [tagged, initialized, oauth].some((app) => app !== "unknown" && chosen !== app),
140
+ ...(typeof info?.version === "string" && /^[a-zA-Z0-9._+-]{1,64}$/.test(info.version) && !/tsk_|bearer|token/i.test(info.version) ? { client_version: info.version } : {}),
141
+ };
142
+ }
143
+ const FIELDS = new Set([
144
+ "tool_name", "command", "result", "status", "task_id", "parent_task_id", "model", "task_type",
145
+ "duration_ms", "generation_duration_ms", "credits_consumed", "error_category", "http_status", "api_code",
146
+ "auth_flow_id", "auth_method", "observation_source", "creation_client_app", "creation_distribution_channel",
147
+ "creation_integration_mode", "origin_known", "model_downloaded", "file_count", "format", "interrupted",
148
+ ]);
149
+ export class Analytics {
150
+ app;
151
+ version;
152
+ enabled;
153
+ url;
154
+ appId;
155
+ environment;
156
+ queue = [];
157
+ pending;
158
+ scheduled = false;
159
+ stats = { sent: 0, failed: 0, dropped: 0 };
160
+ constructor(app, version, region, apiUrl, env = process.env) {
161
+ this.app = app;
162
+ this.version = version;
163
+ const receiver = env.TRIPO_ANALYTICS_SERVER_URL ?? ANALYTICS_SERVER_URL;
164
+ this.appId = env.TRIPO_ANALYTICS_APP_ID ?? ANALYTICS_APP_ID;
165
+ const environment = env.TRIPO_ANALYTICS_ENVIRONMENT ?? (env.NODE_ENV === "production" ? "production" : app === "tripo-cli" ? "production" : "development");
166
+ this.environment = ["production", "pre", "development"].includes(environment) ? environment : "development";
167
+ this.url = "";
168
+ let officialApi = false;
169
+ let testReceiver = false;
170
+ try {
171
+ const url = new URL(receiver);
172
+ this.url = new URL("sync_json", url.href.endsWith("/") ? url : `${url.href}/`).href;
173
+ officialApi = new URL(apiUrl).hostname === "openapi.tripo3d.ai";
174
+ testReceiver = (url.protocol === "http:" || url.protocol === "https:") && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
175
+ if (url.username || url.password || url.search || url.hash || !["http:", "https:"].includes(url.protocol))
176
+ this.url = "";
177
+ }
178
+ catch { /* invalid analytics configuration is disabled, never fatal */ }
179
+ const testing = env.NODE_ENV === "test" || Boolean(env.VITEST);
180
+ this.enabled = region === "ov" && Boolean(this.url && this.appId) && env.TRIPO_ANALYTICS_ENABLED !== "0"
181
+ && (testing ? env.TRIPO_ANALYTICS_ENABLED === "1" && testReceiver : officialApi || testReceiver);
182
+ }
183
+ track(event, context, properties = {}) {
184
+ if (!this.enabled || !/^agent_[a-z_]+$/.test(event))
185
+ return;
186
+ if (this.queue.length >= 100) {
187
+ this.stats.dropped++;
188
+ return;
189
+ }
190
+ const safe = {};
191
+ for (const [key, value] of Object.entries(properties)) {
192
+ if (!FIELDS.has(key))
193
+ continue;
194
+ if (typeof value === "number" && Number.isFinite(value) || typeof value === "boolean")
195
+ safe[key] = value;
196
+ else if (typeof value === "string" && /^[a-zA-Z0-9_.:-]{1,128}$/.test(value) && !/tsk_|bearer|token/i.test(value))
197
+ safe[key] = value;
198
+ }
199
+ this.queue.push({ appid: this.appId, data: {
200
+ "#type": "track", "#event_name": event, "#distinct_id": context.distinctId,
201
+ "#time": new Date().toISOString().replace("T", " ").replace("Z", ""),
202
+ properties: {
203
+ ...safe, ...attribution(context.client_app, context.distribution_channel, { version: context.client_version }),
204
+ attribution_method: context.attribution_method, attribution_conflict: context.attribution_conflict ?? false,
205
+ app: this.app, event_source: this.app === "tripo-mcp" ? "server" : "cli", site_region: "global",
206
+ environment: this.environment, schema_version: 2, agent_schema_version: 1, app_version: this.version,
207
+ integration_mode: context.integration_mode, operation_id: context.operation_id, event_id: randomUUID(),
208
+ identity_type: this.app === "tripo-mcp" ? "authorization" : "installation", observation_source: "client", "#zone_offset": 0,
209
+ },
210
+ } });
211
+ if (!this.scheduled) {
212
+ this.scheduled = true;
213
+ queueMicrotask(() => { this.scheduled = false; void this.flush(); });
214
+ }
215
+ }
216
+ async flush() {
217
+ if (this.pending)
218
+ return this.pending;
219
+ if (!this.queue.length)
220
+ return;
221
+ // ponytail: bounded best-effort delivery; no persistent spool or retries in v1.
222
+ const send = async () => {
223
+ const deadline = Date.now() + 2000;
224
+ while (this.queue.length && Date.now() < deadline) {
225
+ const batch = this.queue.splice(0, 100);
226
+ try {
227
+ const response = await fetch(this.url, {
228
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(batch),
229
+ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
230
+ });
231
+ const body = await response.json();
232
+ if (!response.ok || body.code !== 0)
233
+ throw new Error("analytics rejected");
234
+ this.stats.sent += batch.length;
235
+ }
236
+ catch {
237
+ this.stats.failed += batch.length;
238
+ }
239
+ }
240
+ this.stats.dropped += this.queue.length;
241
+ this.queue = [];
242
+ };
243
+ this.pending = send();
244
+ try {
245
+ await this.pending;
246
+ }
247
+ finally {
248
+ this.pending = undefined;
249
+ }
250
+ }
251
+ }
@@ -8,7 +8,10 @@ import { activeProfileName, DEFAULT_PROFILE, listProfiles, loadConfig, resolveAp
8
8
  import { t } from '../i18n.js';
9
9
  import { EXIT } from '../knowledge/error-catalog.js';
10
10
  import { ApiError, AuthError, NetworkError } from './errors.js';
11
+ import { observeTask, trackEvent, taskOrigin } from './analytics.js';
12
+ import { resolveRegion } from '../config/config.js';
11
13
  export class TripoClient {
14
+ region = resolveRegion();
12
15
  baseUrl;
13
16
  apiKey;
14
17
  timeoutMs;
@@ -117,9 +120,11 @@ export class TripoClient {
117
120
  return envelope.data;
118
121
  }
119
122
  // ---- typed convenience wrappers ----
120
- getTask(taskId, step = false) {
123
+ async getTask(taskId, step = false) {
121
124
  const suffix = step ? '?step=1' : '';
122
- return this.request('GET', `/v3/tasks/${encodeURIComponent(taskId)}${suffix}`);
125
+ const task = await this.request('GET', `/v3/tasks/${encodeURIComponent(taskId)}${suffix}`);
126
+ observeTask(task, this.region, this.baseUrl);
127
+ return task;
123
128
  }
124
129
  /**
125
130
  * Batch task query. The server returns `{tasks: {id: detail}, missed: [id]}`
@@ -128,6 +133,8 @@ export class TripoClient {
128
133
  async listTasks(taskIds) {
129
134
  const data = await this.request('POST', '/v3/tasks/list', { task_ids: taskIds });
130
135
  const byId = data.tasks ?? {};
136
+ for (const [id, task] of Object.entries(byId))
137
+ observeTask({ ...task, task_id: task.task_id ?? id }, this.region, this.baseUrl);
131
138
  const tasks = taskIds
132
139
  .filter((id) => byId[id])
133
140
  .map((id) => ({ ...byId[id], task_id: byId[id].task_id ?? id }));
@@ -140,9 +147,11 @@ export class TripoClient {
140
147
  return this.request('GET', '/v3/account/usage');
141
148
  }
142
149
  /** Create a task; returns its id. Response may be {task_id} or a full detail. */
143
- async createTask(endpoint, payload) {
150
+ async createTask(endpoint, payload, taskType) {
144
151
  try {
145
152
  const data = await this.request('POST', endpoint, payload, { idempotent: false });
153
+ trackEvent('agent_task_created', { ...taskOrigin(payload.model), task_id: data.task_id, task_type: taskType,
154
+ ...([payload.original_model_task_id, payload.task_id, payload.input].some((value) => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value)) ? { parent_task_id: [payload.original_model_task_id, payload.task_id, payload.input].find((value) => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value)) } : {}) }, this.region, this.baseUrl);
146
155
  return data.task_id;
147
156
  }
148
157
  catch (err) {
@@ -13,6 +13,7 @@ import { hostname } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import { REGION_DEFAULTS, tripoHome } from '../config/config.js';
15
15
  import { sleep } from './client.js';
16
+ import { trackEvent } from './analytics.js';
16
17
  export const DEVICE_CODE_EXPIRED = 4290;
17
18
  export const DEVICE_CODE_SLOW_DOWN = 4291;
18
19
  export const DEVICE_CODE_DENIED = 4292;
@@ -79,6 +80,7 @@ export function deviceKeyName(profile, host = hostname(), id = deviceId()) {
79
80
  return `${prefix} - ${shownHost} ${id}`.replace(/\s+/g, ' ').slice(0, 32).trim();
80
81
  }
81
82
  export async function requestDeviceCode(region, profile) {
83
+ trackEvent('agent_auth_started', { auth_method: 'device' }, region, REGION_DEFAULTS[region].api);
82
84
  const body = await post(`${consoleApiBaseUrl(region)}/cli/device/code`, { name: deviceKeyName(profile) });
83
85
  const grant = body.data;
84
86
  if (!grant ||
@@ -138,9 +140,12 @@ export async function waitForDeviceApproval(region, grant, onTick) {
138
140
  intervalMs += 5000;
139
141
  continue;
140
142
  }
141
- if (poll.status !== 'pending')
143
+ if (poll.status !== 'pending') {
144
+ trackEvent('agent_auth_result', { result: poll.status === 'approved' ? 'success' : poll.status, auth_method: 'device' }, region, REGION_DEFAULTS[region].api);
142
145
  return poll;
146
+ }
143
147
  }
148
+ trackEvent('agent_auth_result', { result: 'expired', auth_method: 'device' }, region, REGION_DEFAULTS[region].api);
144
149
  return { status: 'expired' };
145
150
  }
146
151
  /**
@@ -10,6 +10,7 @@ import { basename, extname, join } from 'node:path';
10
10
  import { Readable } from 'node:stream';
11
11
  import { pipeline } from 'node:stream/promises';
12
12
  import { NetworkError } from './errors.js';
13
+ import { trackEvent, errorProperties } from './analytics.js';
13
14
  const MODEL_EXTS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.stl', '.3mf', '.usdz', '.zip', '.ply']);
14
15
  const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
15
16
  /** Collect every downloadable URL in the output map (string or {url} object). */
@@ -59,48 +60,57 @@ async function downloadOne(url, dest) {
59
60
  }
60
61
  /** Download all artifacts of a finished task + write task.json. */
61
62
  export async function downloadArtifacts(task, outputDir, historyEntry, options = {}) {
62
- await mkdir(outputDir, { recursive: true });
63
- const urls = collectOutputUrls(task.output);
64
- const files = [];
65
- let modelFile;
66
- let preview;
67
- for (const { field, url } of urls) {
68
- const name = fileNameFor(field, url);
69
- const dest = join(outputDir, name);
70
- options.onFile?.(name);
71
- await downloadOne(url, dest);
72
- files.push(dest);
73
- const ext = extname(name).toLowerCase();
74
- if (!modelFile && MODEL_EXTS.has(ext))
75
- modelFile = dest;
76
- // uv_render (mesh/uv) is a flat UV atlas, not a render of the model.
77
- if (!preview && !/uv_render/.test(field) && (IMAGE_EXTS.has(ext) || /rendered|preview/i.test(field)))
78
- preview = dest;
79
- }
80
- // Normalize the preview name so agents can rely on preview.png existing.
81
- if (preview && basename(preview) !== 'preview.png') {
82
- const { copyFile } = await import('node:fs/promises');
83
- const canonical = join(outputDir, 'preview.png');
84
- try {
85
- await copyFile(preview, canonical);
86
- files.push(canonical);
87
- preview = canonical;
63
+ const started = Date.now();
64
+ try {
65
+ await mkdir(outputDir, { recursive: true });
66
+ const urls = collectOutputUrls(task.output);
67
+ const files = [];
68
+ let modelFile;
69
+ let preview;
70
+ for (const { field, url } of urls) {
71
+ const name = fileNameFor(field, url);
72
+ const dest = join(outputDir, name);
73
+ options.onFile?.(name);
74
+ await downloadOne(url, dest);
75
+ files.push(dest);
76
+ const ext = extname(name).toLowerCase();
77
+ if (!modelFile && MODEL_EXTS.has(ext))
78
+ modelFile = dest;
79
+ // uv_render (mesh/uv) is a flat UV atlas, not a render of the model.
80
+ if (!preview && !/uv_render/.test(field) && (IMAGE_EXTS.has(ext) || /rendered|preview/i.test(field)))
81
+ preview = dest;
82
+ }
83
+ // Normalize the preview name so agents can rely on preview.png existing.
84
+ if (preview && basename(preview) !== 'preview.png') {
85
+ const { copyFile } = await import('node:fs/promises');
86
+ const canonical = join(outputDir, 'preview.png');
87
+ try {
88
+ await copyFile(preview, canonical);
89
+ files.push(canonical);
90
+ preview = canonical;
91
+ }
92
+ catch { }
88
93
  }
89
- catch { }
94
+ const record = {
95
+ task_id: task.task_id,
96
+ type: task.type,
97
+ status: task.status,
98
+ input: task.input ?? historyEntry?.payload ?? {},
99
+ output_fields: urls.map((u) => u.field),
100
+ credits_consumed: task.credits_consumed,
101
+ created_at: task.created_at,
102
+ completed_at: task.completed_at,
103
+ files: files.map((f) => basename(f)),
104
+ };
105
+ await writeFile(join(outputDir, 'task.json'), JSON.stringify(record, null, 2) + '\n');
106
+ trackEvent('agent_download_result', { task_id: task.task_id, result: 'success', model_downloaded: Boolean(modelFile),
107
+ file_count: files.length, duration_ms: Date.now() - started, format: modelFile ? extname(modelFile).slice(1) : undefined });
108
+ return { outputDir, files, modelFile, preview };
109
+ }
110
+ catch (error) {
111
+ trackEvent('agent_download_result', { task_id: task.task_id, result: 'failed', duration_ms: Date.now() - started, ...errorProperties(error) });
112
+ throw error;
90
113
  }
91
- const record = {
92
- task_id: task.task_id,
93
- type: task.type,
94
- status: task.status,
95
- input: task.input ?? historyEntry?.payload ?? {},
96
- output_fields: urls.map((u) => u.field),
97
- credits_consumed: task.credits_consumed,
98
- created_at: task.created_at,
99
- completed_at: task.completed_at,
100
- files: files.map((f) => basename(f)),
101
- };
102
- await writeFile(join(outputDir, 'task.json'), JSON.stringify(record, null, 2) + '\n');
103
- return { outputDir, files, modelFile, preview };
104
114
  }
105
115
  /** Default output directory: ./tripo-out/<slug>-<id8>/ */
106
116
  export function defaultOutputDir(baseDir, slug, taskId) {
@@ -13,6 +13,7 @@ import { detectInput } from '../util/input-detect.js';
13
13
  import { notifyDone } from '../util/notify.js';
14
14
  import { createSpinner } from '../util/spinner.js';
15
15
  import { defaultOutputDir, downloadArtifacts } from './download.js';
16
+ import { taskOrigin } from './analytics.js';
16
17
  import { ApiError, CliError, TaskFailedError, UsageError, balanceSuggestion } from './errors.js';
17
18
  import { isQuiet, log, warn } from './output.js';
18
19
  import { uploadLocalFile } from './upload.js';
@@ -85,9 +86,10 @@ export async function runTask(options) {
85
86
  const { client, request, taskType } = options;
86
87
  if (options.precheckBalance !== false)
87
88
  await precheckBalance(client);
88
- const taskId = await client.createTask(request.endpoint, request.payload);
89
+ const taskId = await client.createTask(request.endpoint, request.payload, taskType);
89
90
  const name = uniqueName(slugify(options.name));
90
91
  const entry = {
92
+ analytics_origin: taskOrigin(request.payload.model),
91
93
  task_id: taskId,
92
94
  type: taskType,
93
95
  name,
package/dist/program.js CHANGED
@@ -25,6 +25,7 @@ import { configureOutput, errorLine, isJsonMode } from './core/output.js';
25
25
  import { setLocale, t } from './i18n.js';
26
26
  import { EXIT } from './knowledge/error-catalog.js';
27
27
  import { cliVersion } from './version.js';
28
+ import { beginOperation, finishOperation } from './core/analytics.js';
28
29
  export function buildProgram() {
29
30
  const program = new Command();
30
31
  program
@@ -56,11 +57,18 @@ export function buildProgram() {
56
57
  const lang = loadConfig().language;
57
58
  if (lang)
58
59
  setLocale(lang);
60
+ const path = [];
61
+ for (let command = actionCommand; command && command !== program; command = command.parent)
62
+ path.unshift(command.name());
63
+ const excluded = ['mcp', 'docs', 'completion', 'config', 'update', 'use', 'logout', 'home'];
64
+ if (actionCommand.name() !== 'mcp')
65
+ beginOperation(path.join(':'), path.length === 0 || excluded.includes(path[0]) || actionCommand.opts().dryRun === true, undefined, actionCommand.opts().region === 'cn' || actionCommand.opts().region === 'ov' ? actionCommand.opts().region : undefined);
59
66
  // Startup version hint (cached, non-blocking). `update` reports on its
60
67
  // own; `mcp` speaks JSON-RPC over stdio and must stay silent.
61
68
  if (actionCommand.name() !== 'update' && actionCommand.name() !== 'mcp')
62
69
  notifyIfOutdated();
63
70
  });
71
+ program.hook('postAction', async () => { await finishOperation('success'); });
64
72
  registerMakeCommand(program);
65
73
  registerAiCommand(program);
66
74
  registerViewCommands(program);
@@ -134,6 +142,7 @@ export async function runCli(argv) {
134
142
  await program.parseAsync(argv);
135
143
  }
136
144
  catch (err) {
145
+ await finishOperation('failed', err);
137
146
  handleError(err);
138
147
  }
139
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tripo-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Tripo 3D intelligent CLI — turn a prompt or an image into engine-ready 3D assets from your terminal (Tripo V3 API)",
5
5
  "keywords": [
6
6
  "tripo",
package/skill/SKILL.md CHANGED
@@ -9,13 +9,20 @@ One command turns a text prompt or an image into local files: a 3D model (glb/fb
9
9
  npm install -g tripo-cli # or: npx tripo-cli ...
10
10
  export TRIPO_API_KEY=tsk_... # highest-priority auth; no interaction needed
11
11
  # (PowerShell: $env:TRIPO_API_KEY = "tsk_...")
12
+ # China-site key? also export TRIPO_REGION=cn (env keys default to ov)
12
13
  tripo doctor # verify key, network, balance
13
14
  ```
14
15
 
15
- If there is no key, run the device login **yourself** — it works without a TTY:
16
+ If there is no key, run the device login **yourself** — it works without a TTY.
17
+ First ask the human which Tripo site their account is on, and do not guess: the
18
+ login only works on the site it was started for.
19
+
20
+ - `cn` — China mainland site developers.tripo3d.com (phone/SMS sign-in, Alipay)
21
+ - `ov` — international site developers.tripo3d.ai (email sign-in, Stripe)
22
+ - no account yet: mainland China → `cn`, everywhere else → `ov`
16
23
 
17
24
  ```bash
18
- tripo login --region ov # ov = international, cn = China mainland
25
+ tripo login --region <cn|ov> # the site the human named
19
26
  ```
20
27
 
21
28
  It prints a verification URL and a one-time code, then blocks until the human
@@ -24,9 +31,10 @@ and code to the human and tell them: sign in on that page first if it asks
24
31
  (sign-up gives free credits), enter the code, pick an API key if the account has
25
32
  several (one is created automatically if there are none), click Authorize. The
26
33
  key is then saved locally; never ask the human to paste a key into the chat. If
27
- the code expires (exit 3), just run the same command again.
34
+ the code expires (exit 3), just run the same command again; if the human says
35
+ the page is the wrong site, stop it and rerun with the other `--region`.
28
36
 
29
- Regions (ov = international, cn = China mainland) share one CLI: `tripo login --key tsk_...` auto-detects which region the key belongs to. On exit code 3 (auth), run `tripo doctor` — it diagnoses key-vs-region mismatches and prints the exact fix (`tripo config set region cn|ov`).
37
+ Regions (ov = international, cn = China mainland) share one CLI: `tripo login --key tsk_...` auto-detects which region the key belongs to. Already logged into the wrong site? `tripo login --region <other>` replaces the active profile's key and region. On exit code 3 (auth), run `tripo doctor` — it diagnoses key-vs-region mismatches and prints the exact fix (`tripo config set region cn|ov`).
30
38
 
31
39
  ## Behavior rules (read first)
32
40