@testin-official/xagent 0.3.9 → 0.3.11

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/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import { existsSync, realpathSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
- import { Command } from "commander";
5
+ import { Command, CommanderError } from "commander";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { AuthoringDraftStore } from "./authoring/draft-store.js";
8
8
  import { buildDeviceActionRequest } from "./action/protocol.js";
@@ -29,9 +29,20 @@ const API_EXIT = {
29
29
  authoring_session_expired: 5, authoring_session_not_active: 5,
30
30
  test_failed: 1, infrastructure_failed: 6, invalid_response: 6, authoring_session_renewal_failed: 6, timeout: 7,
31
31
  };
32
+ // `fail()` runs from the top-level catch, where the parsed options are out of
33
+ // scope. It used to re-derive the output format from `process.argv` — a second
34
+ // source of truth next to the `ctx.json` every other path uses, and the wrong
35
+ // array entirely when `runCli()` is handed an explicit argv. Keep the resolved
36
+ // flag, plus the argv actually parsed as a fallback for failures raised before
37
+ // any command has built a Context.
38
+ let parsedJsonMode;
39
+ let parsedArgv = process.argv;
40
+ function jsonOutputRequested() { return parsedJsonMode ?? parsedArgv.includes("--json"); }
32
41
  function context(command) {
33
42
  const options = command.optsWithGlobals();
34
- return { json: Boolean(options.json), profile: options.profile, userConfig: options.userConfig, projectConfig: options.config, nonInteractive: Boolean(options.nonInteractive), config: new ConfigStore(options.userConfig) };
43
+ const json = Boolean(options.json);
44
+ parsedJsonMode = json;
45
+ return { json, profile: options.profile, userConfig: options.userConfig, projectConfig: options.config, nonInteractive: Boolean(options.nonInteractive), config: new ConfigStore(options.userConfig) };
35
46
  }
36
47
  function emit(ctx, data, human) {
37
48
  if (ctx.json)
@@ -59,23 +70,15 @@ function unexpected(error) {
59
70
  }
60
71
  function fail(error) {
61
72
  const known = error instanceof CliError || error instanceof XAgentApiError ? error : unexpected(error);
62
- const json = process.argv.includes("--json");
73
+ const json = jsonOutputRequested();
63
74
  const payload = known.toJSON();
64
75
  if (json)
65
76
  process.stdout.write(`${JSON.stringify({ error: payload })}\n`);
66
77
  else
67
- process.stderr.write(`Error: ${String(payload.message)}\n${payload.application_url ? `Apply at: ${payload.application_url}\n` : ""}`);
78
+ process.stderr.write(`Error: ${String(payload.message)}\n`);
68
79
  process.exit(API_EXIT[known.code] ?? (known instanceof CliError ? known.exitCode : 6));
69
80
  }
70
- function tokenApplicationUrl(baseUrl, tokenType) {
71
- try {
72
- const url = new URL(baseUrl);
73
- return `${url.origin}/agent/developer-tokens/index.html?type=${encodeURIComponent(tokenType)}`;
74
- }
75
- catch {
76
- return `${baseUrl.replace(/\/+$/, "")}/agent/developer-tokens/index.html?type=${encodeURIComponent(tokenType)}`;
77
- }
78
- }
81
+ const TOKEN_CONTACT_MESSAGE = "请联系云小宝并依据用户id获取相关token";
79
82
  async function readSecret(prompt) {
80
83
  if (!input.isTTY || typeof input.setRawMode !== "function")
81
84
  throw new CliError("authentication_required", "--token-stdin is required when no interactive terminal is available", 3);
@@ -122,7 +125,7 @@ async function authenticatedClient(ctx, uploadTimeoutMs) {
122
125
  }
123
126
  catch (error) {
124
127
  if (error instanceof CliError && error.code === "authentication_required")
125
- throw new CliError(error.code, error.message, 3, { required_token_type: profile.data.expected_token_type, application_url: profile.data.token_apply_url });
128
+ throw new CliError(error.code, `${error.message}。${TOKEN_CONTACT_MESSAGE}`, 3, { required_token_type: profile.data.expected_token_type });
126
129
  throw error;
127
130
  }
128
131
  const client = new XAgentClient(profile.data.base_url, token, profile.data.upload_base_url, 30_000, uploadTimeoutMs ?? 900_000);
@@ -178,7 +181,26 @@ function billingText(value) {
178
181
  function emitRun(ctx, run, useStatusExit = true) { emit(ctx, run, `Run ${run.id} status=${run.status}${billingText(run)}`); if (!useStatusExit)
179
182
  return; process.exitCode = { failed: 1, cancelled: 4, infrastructure_failed: 6, timed_out: 7 }[run.status] ?? 0; }
180
183
  function progressReporter(ctx) { return createRunProgressReporter(ctx); }
184
+ /** Human summary for `doctor`, derived from the same checks the JSON reports. */
185
+ function doctorSummary(checks, profile) {
186
+ // `configuration` used to be a hardcoded "ok", so a machine with no user
187
+ // configuration file at all still reported a healthy configuration while every
188
+ // other field said not_configured.
189
+ if (checks.configuration !== "ok")
190
+ return "Local configuration is incomplete: no user configuration file was found";
191
+ if (checks.profile !== "ok")
192
+ return profile
193
+ ? `Local configuration is incomplete: Profile '${profile}' does not exist`
194
+ : "Local configuration is incomplete: no Profile is selected";
195
+ if (checks.credential_source !== "configured")
196
+ return `Local configuration is incomplete: Profile '${profile}' has no credential source`;
197
+ return `Local configuration is valid (Profile '${profile}')`;
198
+ }
181
199
  const program = new Command().name("xagent").description("XAgent Developer CLI").showHelpAfterError();
200
+ // Without this, commander prints parse errors itself and calls process.exit(1)
201
+ // before `runCli` can see them. That exit code collides with `test_failed: 1`
202
+ // and the plain-text output ignored `--json` entirely. Take over the exit path.
203
+ program.exitOverride();
182
204
  program.option("--json", "Emit stable JSON output").option("--profile <name>", "Select a named authentication Profile").option("--config <path>", "Use a project xagent.yaml").option("--user-config <path>", "Use an explicit user Profile configuration").option("--non-interactive", "Disable interactive prompts");
183
205
  program.command("version").description("Show CLI and contract versions").action(async function () {
184
206
  const ctx = context(this);
@@ -187,15 +209,34 @@ program.command("version").description("Show CLI and contract versions").action(
187
209
  program.command("doctor").description("Check local configuration and credential source").action(async function () {
188
210
  const ctx = context(this);
189
211
  const payload = await ctx.config.load();
190
- const profile = ctx.profile || payload.default_profile;
191
- emit(ctx, { checks: { configuration: "ok", profile: profile ? "ok" : "not_configured", credential_source: profile && payload.profiles[profile] && (payload.profiles[profile].credential_env || payload.profiles[profile].credential_ref) ? "configured" : "not_configured", online_checks_performed: false } }, "Local configuration is valid");
212
+ const selected = ctx.profile || payload.default_profile;
213
+ // `ctx.profile` is not validated anywhere in this command, so a typo used to be
214
+ // reported as `profile: "ok"` while `credential_source` said not_configured.
215
+ const profileExists = typeof selected === "string" && Object.hasOwn(payload.profiles, selected);
216
+ const stored = profileExists ? payload.profiles[selected] : undefined;
217
+ const checks = {
218
+ // Whether the user configuration file is actually present. `ConfigStore.load()`
219
+ // treats ENOENT as an empty configuration rather than an error, so this field
220
+ // has to look at the filesystem to mean anything.
221
+ configuration: existsSync(ctx.config.filePath) ? "ok" : "not_configured",
222
+ profile: profileExists ? "ok" : "not_configured",
223
+ credential_source: stored && (stored.credential_env || stored.credential_ref) ? "configured" : "not_configured",
224
+ online_checks_performed: false,
225
+ };
226
+ // The summary used to be the constant "Local configuration is valid" and the
227
+ // exit code was always 0, so a fresh machine — or a mistyped --profile — read
228
+ // as healthy while the JSON said not_configured. That made the check useless
229
+ // as a gate.
230
+ emit(ctx, { checks }, doctorSummary(checks, profileExists ? String(selected) : selected ?? undefined));
231
+ if (checks.configuration !== "ok" || checks.profile !== "ok" || checks.credential_source !== "configured")
232
+ process.exitCode = 2;
192
233
  });
193
234
  const config = program.command("config");
194
235
  config.command("path").action(async function () { const ctx = context(this); emit(ctx, { path: ctx.config.filePath }, ctx.config.filePath); });
195
236
  config.command("validate").action(async function () { const ctx = context(this); await ctx.config.load(); emit(ctx, { valid: true, schema_version: CONFIG_SCHEMA_VERSION }, "Configuration is valid"); });
196
237
  config.command("show").action(async function () { const ctx = context(this); const payload = await ctx.config.load(); emit(ctx, { schema_version: payload.schema_version, default_profile: payload.default_profile, profiles: Object.fromEntries(Object.entries(payload.profiles).sort(([a], [b]) => a.localeCompare(b)).map(([name, item]) => [name, Profile.from(name, item).publicData()])) }); });
197
238
  const profiles = program.command("profiles");
198
- profiles.command("add").argument("<name>").requiredOption("--base-url <url>").requiredOption("--expected-token-type <type>").option("--token-env <name>").option("--token-apply-url <url>").option("--upload-base-url <url>").option("--use").action(async function (name, options) { const ctx = context(this); const data = { base_url: options.baseUrl, expected_token_type: options.expectedTokenType, ...(options.tokenEnv ? { credential_env: options.tokenEnv } : {}), ...(options.uploadBaseUrl ? { upload_base_url: options.uploadBaseUrl } : {}), token_apply_url: options.tokenApplyUrl ?? tokenApplicationUrl(options.baseUrl, options.expectedTokenType) }; const profile = Profile.from(name, data); await ctx.config.putProfile(profile); if (options.use)
239
+ profiles.command("add").argument("<name>").requiredOption("--base-url <url>").requiredOption("--expected-token-type <type>").option("--token-env <name>").option("--upload-base-url <url>").option("--use").action(async function (name, options) { const ctx = context(this); const data = { base_url: options.baseUrl, expected_token_type: options.expectedTokenType, ...(options.tokenEnv ? { credential_env: options.tokenEnv } : {}), ...(options.uploadBaseUrl ? { upload_base_url: options.uploadBaseUrl } : {}) }; const profile = Profile.from(name, data); await ctx.config.putProfile(profile); if (options.use)
199
240
  await ctx.config.useProfile(name); emit(ctx, profile.publicData(), `Profile '${name}' added`); });
200
241
  profiles.command("list").action(async function () { const ctx = context(this); const payload = await ctx.config.load(); emit(ctx, { profiles: (await ctx.config.listProfiles()).map((profile) => ({ ...profile.publicData(), default: profile.name === payload.default_profile })) }); });
201
242
  profiles.command("show").argument("[name]").action(async function (name) { const ctx = context(this); const profile = await ctx.config.getProfile(name ?? ctx.profile); emit(ctx, profile.publicData()); });
@@ -205,18 +246,18 @@ const auth = program.command("auth");
205
246
  auth.command("status").action(async function () { const ctx = context(this); const { profile, client } = await authenticatedClient(ctx); const principal = await client.getPrincipal(); if (principal.token_type !== profile.data.expected_token_type)
206
247
  throw new CliError("token_type_mismatch", `Profile expects ${profile.data.expected_token_type}, server resolved ${principal.token_type}`, 3); emit(ctx, { profile: profile.name, principal }, `${profile.name}: ${principal.token_type} eid=${principal.eid} project=${principal.project_id}`); });
207
248
  auth.command("login").option("--save").option("--token-stdin").action(async function (options) { const ctx = context(this); const profile = await selectedProfile(ctx); if (!options.tokenStdin && !ctx.json)
208
- process.stderr.write(`Token 申请地址:${profile.data.token_apply_url ?? tokenApplicationUrl(profile.data.base_url, profile.data.expected_token_type)}\n`); let token = ""; if (options.tokenStdin)
249
+ process.stderr.write(`${TOKEN_CONTACT_MESSAGE}\n`); let token = ""; if (options.tokenStdin)
209
250
  token = (await readFile(0, "utf8")).trim().replace(/^\uFEFF/, "");
210
251
  else if (ctx.nonInteractive)
211
- throw new CliError("authentication_required", "--token-stdin is required in non-interactive mode", 3);
252
+ throw new CliError("authentication_required", `--token-stdin is required in non-interactive mode。${TOKEN_CONTACT_MESSAGE}`, 3);
212
253
  else
213
254
  token = (await readSecret("Developer Token: ")).trim(); if (!token)
214
- throw new CliError("authentication_required", "Developer Token is required", 3); const client = new XAgentClient(profile.data.base_url, token); let principal; try {
255
+ throw new CliError("authentication_required", `Developer Token is required。${TOKEN_CONTACT_MESSAGE}`, 3); const client = new XAgentClient(profile.data.base_url, token); let principal; try {
215
256
  principal = await client.getPrincipal();
216
257
  }
217
258
  catch (error) {
218
259
  if (error instanceof XAgentApiError && ["token_invalid", "token_expired", "token_revoked"].includes(error.code))
219
- throw new CliError(error.code, error.message, 3, { required_token_type: profile.data.expected_token_type, application_url: profile.data.token_apply_url });
260
+ throw new CliError(error.code, `${error.message}。${TOKEN_CONTACT_MESSAGE}`, 3, { required_token_type: profile.data.expected_token_type });
220
261
  throw error;
221
262
  } if (principal.token_type !== profile.data.expected_token_type)
222
263
  throw new CliError("token_type_mismatch", `Profile expects ${profile.data.expected_token_type}, server resolved ${principal.token_type}`, 3); let saved = false; if (options.save) {
@@ -232,22 +273,22 @@ catch (error) {
232
273
  saved = true;
233
274
  } emit(ctx, { profile: profile.name, saved, principal }, `Authenticated Profile '${profile.name}'`); });
234
275
  auth.command("logout").action(async function () { const ctx = context(this); const profile = await selectedProfile(ctx); await new CredentialResolver().delete(profile); await ctx.config.setCredentialReference(profile.name); emit(ctx, { profile: profile.name, credential_removed: true }, `Profile '${profile.name}' logged out`); });
235
- program.command("init").requiredOption("--base-url <url>").option("--token-type <type>", undefined, "personal").option("--name <name>", undefined, "default").option("--token-env <name>").option("--token-apply-url <url>").option("--upload-base-url <url>").action(async function (options) { const ctx = context(this); const profile = Profile.from(options.name, { base_url: options.baseUrl, expected_token_type: options.tokenType, ...(options.tokenEnv ? { credential_env: options.tokenEnv } : {}), ...(options.uploadBaseUrl ? { upload_base_url: options.uploadBaseUrl } : {}), token_apply_url: options.tokenApplyUrl ?? tokenApplicationUrl(options.baseUrl, options.tokenType) }); await ctx.config.putProfile(profile); await ctx.config.useProfile(profile.name); emit(ctx, profile.publicData(), `Profile '${profile.name}' initialized`); if (!ctx.json)
236
- process.stderr.write(`Token 申请地址:${profile.data.token_apply_url}\n`); });
276
+ program.command("init").requiredOption("--base-url <url>").option("--token-type <type>", undefined, "personal").option("--name <name>", undefined, "default").option("--token-env <name>").option("--upload-base-url <url>").action(async function (options) { const ctx = context(this); const profile = Profile.from(options.name, { base_url: options.baseUrl, expected_token_type: options.tokenType, ...(options.tokenEnv ? { credential_env: options.tokenEnv } : {}), ...(options.uploadBaseUrl ? { upload_base_url: options.uploadBaseUrl } : {}) }); await ctx.config.putProfile(profile); await ctx.config.useProfile(profile.name); emit(ctx, profile.publicData(), `Profile '${profile.name}' initialized`); if (!ctx.json)
277
+ process.stderr.write(`${TOKEN_CONTACT_MESSAGE}\n`); });
237
278
  program.command("validate").argument("<scriptFile>").action(async function (file) { const ctx = context(this); const parsed = await loadScript(file); emit(ctx, { valid: true, schema_version: parsed.inputSchemaVersion ?? parsed.script.schema_version, execution_schema_version: parsed.script.schema_version, source_hash: parsed.sourceHash, step_count: parsed.script.steps.length }, `${file}: valid (${parsed.script.steps.length} steps)`); });
238
- const skills = program.command("skills");
239
- skills.command("list").action(async function () { const ctx = context(this); emit(ctx, await (await authenticatedClient(ctx)).client.listEffectiveSkills()); });
240
- skills.command("show").argument("<name>").action(async function (name) { const ctx = context(this); emit(ctx, await (await authenticatedClient(ctx)).client.getEffectiveSkill(name)); });
241
- skills.command("reference").argument("<name>").argument("<path>").action(async function (name, reference) { const ctx = context(this); emit(ctx, await (await authenticatedClient(ctx)).client.getEffectiveSkillReference(name, reference)); });
242
- const packaged = skills.command("packaged");
243
- packaged.command("list").action(async function () { const ctx = context(this); emit(ctx, { skills: listSkills() }); });
244
- packaged.command("show").argument("<name>").action(async function (name) { const ctx = context(this); try {
279
+ const skills = program.command("skills").description("Inspect the Skills the XAgent service serves for the current Principal");
280
+ skills.command("list").description("List the Skills effective for the current Principal").action(async function () { const ctx = context(this); emit(ctx, await (await authenticatedClient(ctx)).client.listEffectiveSkills()); });
281
+ skills.command("show").argument("<name>").description("Show a service-side Skill's effective content").action(async function (name) { const ctx = context(this); emit(ctx, await (await authenticatedClient(ctx)).client.getEffectiveSkill(name)); });
282
+ skills.command("reference").argument("<name>").argument("<path>").description("Show one reference document of a service-side Skill").action(async function (name, reference) { const ctx = context(this); emit(ctx, await (await authenticatedClient(ctx)).client.getEffectiveSkillReference(name, reference)); });
283
+ const packaged = skills.command("packaged").description("Inspect the Skills bundled with this local CLI (no service call)");
284
+ packaged.command("list").description("List locally packaged Skills").action(async function () { const ctx = context(this); emit(ctx, { skills: listSkills() }); });
285
+ packaged.command("show").argument("<name>").description("Show a locally packaged Skill's content").action(async function (name) { const ctx = context(this); try {
245
286
  emit(ctx, { name, content: await readSkill(name) });
246
287
  }
247
288
  catch {
248
289
  throw new XAgentApiError("resource_not_found", "Skill was not found");
249
290
  } });
250
- packaged.command("references").argument("<name>").action(async function (name) { const ctx = context(this); try {
291
+ packaged.command("references").argument("<name>").description("List the reference documents of a locally packaged Skill").action(async function (name) { const ctx = context(this); try {
251
292
  emit(ctx, { name, references: await listSkillReferences(name) });
252
293
  }
253
294
  catch {
@@ -271,47 +312,65 @@ function domainArgument(value) {
271
312
  return value;
272
313
  }
273
314
  const integrations = program.command("integrations").description("Install and verify the XAgent Agent Host Skill");
274
- integrations.command("detect").option("--host <host>").action(async function (options) {
315
+ integrations.command("detect").description("Report which Agent Hosts have the XAgent Skill installed").option("--host <host>").action(async function (options) {
275
316
  const ctx = context(this);
276
- emit(ctx, { hosts: await detectHost(options.host ? hostArgument(options.host) : undefined) });
317
+ const hosts = await detectHost(options.host ? hostArgument(options.host) : undefined);
318
+ const installed = hosts.filter((item) => item.installed).map((item) => String(item.host));
319
+ emit(ctx, { hosts }, installed.length ? `XAgent Skill installed for: ${installed.join(", ")}` : "XAgent Skill is not installed for any Agent Host");
277
320
  });
278
321
  integrations.command("install").argument("<host>").option("--force", "Overwrite the existing XAgent Skill only").action(async function (host, options) {
279
322
  const ctx = context(this);
280
323
  emit(ctx, await installHost(hostArgument(host), Boolean(options.force)), `Installed xagent-skill for ${host}`);
281
324
  });
282
- integrations.command("doctor").option("--host <host>").action(async function (options) {
325
+ integrations.command("doctor").description("Check whether an installed XAgent Skill needs syncing").option("--host <host>").action(async function (options) {
283
326
  const ctx = context(this);
284
- emit(ctx, { checks: await doctorHost(options.host ? hostArgument(options.host) : undefined) });
327
+ const checks = await doctorHost(options.host ? hostArgument(options.host) : undefined);
328
+ // `action_required` was reported in the payload but never reflected in the exit
329
+ // code, so a Host whose Skill still needs syncing exited 0. `verify-skill`
330
+ // already gates on 2; keep the same contract here.
331
+ const pending = checks.filter((check) => String(check.action_required ?? "none") !== "none");
332
+ emit(ctx, { checks }, pending.length ? `XAgent Skill needs attention: ${pending.map((check) => `${String(check.host)}: ${String(check.action_required)}`).join(", ")}` : "XAgent Skill is up to date");
333
+ if (pending.length)
334
+ process.exitCode = 2;
285
335
  });
286
- integrations.command("sync").option("--host <host>").action(async function (options) {
336
+ integrations.command("sync").description("Reinstall the XAgent Skill for every Agent Host that has it").option("--host <host>").action(async function (options) {
287
337
  const ctx = context(this);
288
- emit(ctx, { synced: await syncHost(options.host ? hostArgument(options.host) : undefined) });
338
+ const synced = await syncHost(options.host ? hostArgument(options.host) : undefined);
339
+ emit(ctx, { synced }, synced.length ? `Synced xagent-skill for: ${synced.map((item) => String(item.host)).join(", ")}` : "No installed Agent Host needed syncing");
289
340
  });
290
341
  integrations.command("uninstall").argument("<host>").requiredOption("--force", "Confirm removal of the XAgent Skill directory").action(async function (host, options) {
291
342
  const ctx = context(this);
292
343
  emit(ctx, await uninstallHost(hostArgument(host), Boolean(options.force)), `Removed xagent-skill for ${host}`);
293
344
  });
294
- integrations.command("verify-skill").argument("<directory>").action(async function (directory) {
345
+ integrations.command("verify-skill").description("Validate that a Skill directory is structurally compatible").argument("<directory>").action(async function (directory) {
295
346
  const ctx = context(this);
296
347
  const result = await verifySkillDirectory(directory);
297
348
  emit(ctx, result, result.valid ? "XAgent Skill is compatible" : "XAgent Skill is incompatible");
298
349
  if (!result.valid)
299
350
  process.exitCode = 2;
300
351
  });
301
- integrations.command("route").argument("<domain>").option("--profile <name>", "Profile name to include in the MCP command").action(async function (domain, options) {
352
+ // No local `--profile` here. Redefining the global option made two values mean
353
+ // the same thing: the local copy silently won over the global one, so
354
+ // `xagent --profile A integrations route action --profile B` dropped A with no
355
+ // warning. The global option is accepted after the subcommand name, so a single
356
+ // definition covers both placements.
357
+ integrations.command("route").argument("<domain>").description("Show the MCP command for a user behavior domain").action(async function (domain) {
302
358
  const ctx = context(this);
303
359
  const selected = domainArgument(domain);
304
360
  routeForDomain(selected);
305
- emit(ctx, routeOutput(selected, options.profile ?? ctx.profile));
361
+ const output = routeOutput(selected, ctx.profile);
362
+ const command = output.mcp_command;
363
+ emit(ctx, output, `${String(output.domain)}: skill=${String(output.skill)} mode=${String(output.mcp_mode)} side_effects=${String(output.side_effects)}; MCP: ${command.command} ${command.args.join(" ")}`);
306
364
  });
307
- integrations.command("options").action(async function () {
365
+ integrations.command("options").description("List the user behavior domains this CLI can route").action(async function () {
308
366
  const ctx = context(this);
367
+ const choices = USER_WORKFLOW_ROUTES.map(({ domain, mode, skill, side_effects, description }) => ({ domain, mode, skill, side_effects, description }));
309
368
  emit(ctx, {
310
369
  default_domain: DEFAULT_WORKFLOW_DOMAIN,
311
370
  default_mode: routeForDomain(DEFAULT_WORKFLOW_DOMAIN).mode,
312
- choices: USER_WORKFLOW_ROUTES.map(({ domain, mode, skill, side_effects, description }) => ({ domain, mode, skill, side_effects, description })),
371
+ choices,
313
372
  selection_required_before_side_effects: false,
314
- });
373
+ }, `Default domain: ${DEFAULT_WORKFLOW_DOMAIN}; domains: ${USER_WORKFLOW_DOMAINS.join(", ")}`);
315
374
  });
316
375
  const authoring = program.command("authoring");
317
376
  authoring.command("start").requiredOption("--device-id <id>").requiredOption("--idempotency-key <key>").action(async function (options) { const ctx = context(this); const result = await (await authenticatedClient(ctx)).client.authoring.createSession(options.deviceId, options.idempotencyKey); emit(ctx, result, `Authoring Session ${result.id} status=${result.status}${billingText(result)}`); });
@@ -351,7 +410,9 @@ device.command("keepalive").argument("<sessionId>").option("--poll-interval <sec
351
410
  const ctx = context(this);
352
411
  const { client } = await authenticatedClient(ctx);
353
412
  const manager = new SessionLeaseManager(client.action, { onEvent: (event) => emit(ctx, event, event.type === "renewed" ? `Device Session ${event.session_id} renewed until ${event.expires_at ?? "unknown"}` : undefined) });
354
- const deregister = onInterrupt(async () => { manager.unregister(sessionId); });
413
+ // Ctrl+C must release the device, not just forget the local lease: leaving it
414
+ // until expiry keeps billing the session for time the user already stopped using.
415
+ const deregister = onInterrupt(async () => { await manager.releaseAll(); });
355
416
  try {
356
417
  await manager.keepAlive(sessionId, { pollMs: Number(options.pollInterval) * 1000 });
357
418
  emit(ctx, { session_id: sessionId, status: "TERMINAL", lease: manager.getLease(sessionId) ?? null }, `Device Session ${sessionId} is terminal`);
@@ -388,6 +449,7 @@ program.command("run")
388
449
  .option("--timeout-ms <milliseconds>", undefined, "600000")
389
450
  .option("--wait")
390
451
  .option("--wait-timeout <seconds>", undefined, "900")
452
+ .option("--poll-interval <seconds>", "Seconds between Run status checks while --wait is active", "2")
391
453
  .option("--upload-timeout <seconds>", undefined, "900")
392
454
  .action(async function (scriptFile, options) {
393
455
  const ctx = context(this);
@@ -401,6 +463,36 @@ program.command("run")
401
463
  const packageId = options.packageId ? Number(options.packageId) : script.package_id;
402
464
  if (packageId !== undefined && (!Number.isInteger(Number(packageId)) || Number(packageId) < 1))
403
465
  throw new CliError("invalid_request", "--package-id must be a positive integer");
466
+ // contracts/developer/v1/run-create.schema.json requires an integer in
467
+ // 1000..28800000. Without this check `--timeout-ms abc` shipped
468
+ // `"timeout_ms":null` and `--timeout-ms -1` / `1500.7` went out verbatim,
469
+ // all with exit code 0.
470
+ const timeoutMs = Number(options.timeoutMs);
471
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 28_800_000)
472
+ throw new CliError("invalid_request", "--timeout-ms must be an integer from 1000 to 28800000");
473
+ // `--upload-timeout abc` reached AbortSignal.timeout(NaN) deeper in the SDK,
474
+ // which surfaced the reason as an infrastructure failure instead of a bad
475
+ // argument.
476
+ const uploadTimeoutSeconds = Number(options.uploadTimeout);
477
+ if (!Number.isFinite(uploadTimeoutSeconds) || uploadTimeoutSeconds <= 0)
478
+ throw new CliError("invalid_request", "--upload-timeout must be a positive number of seconds");
479
+ // Checked before createRun on purpose: failing afterwards would leave the Run
480
+ // created but never waited on, and the finally block does not cancel it — an
481
+ // orphan Run holding a device.
482
+ const waitTimeoutSeconds = Number(options.waitTimeout);
483
+ if (!Number.isFinite(waitTimeoutSeconds) || waitTimeoutSeconds < 0)
484
+ throw new CliError("invalid_request", "--wait-timeout must be a non-negative number of seconds");
485
+ // `runs wait` already exposed this; `run --wait` had it hardcoded to 2, so the
486
+ // same polling behavior had two different parameter surfaces.
487
+ const pollIntervalSeconds = Number(options.pollInterval);
488
+ if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0)
489
+ throw new CliError("invalid_request", "--poll-interval must be a positive number of seconds");
490
+ // One definition for "was a key supplied, and does it carry anything": the
491
+ // personal branch tested the raw value while the project_ci branch tested the
492
+ // trimmed one, so `--idempotency-key ""` slipped past the first check.
493
+ const suppliedIdempotencyKey = options.idempotencyKey === undefined ? undefined : String(options.idempotencyKey).trim();
494
+ if (options.idempotencyKey !== undefined && !suppliedIdempotencyKey)
495
+ throw new CliError("invalid_request", "--idempotency-key must not be blank");
404
496
  const hasInstall = script.steps.some((step) => step.action === "install");
405
497
  if (packageId === undefined && !options.appUploadId && !options.apk && hasInstall)
406
498
  throw new CliError("invalid_request", "package_id or --app-upload-id is required when the script contains install");
@@ -413,24 +505,24 @@ program.command("run")
413
505
  throw new CliError("invalid_request", "--apk requires an APK file");
414
506
  if (!options.deviceId)
415
507
  throw new CliError("invalid_request", "--device-id is required for a personal Profile");
416
- if (options.idempotencyKey)
508
+ if (suppliedIdempotencyKey)
417
509
  throw new CliError("invalid_request", "--idempotency-key is only valid for a project_ci Profile");
418
- request = { schema_version: "xagent-run-create/v1", script, device_id: options.deviceId, timeout_ms: Number(options.timeoutMs), source_hash: parsed.sourceHash };
510
+ request = { schema_version: "xagent-run-create/v1", script, device_id: options.deviceId, timeout_ms: timeoutMs, source_hash: parsed.sourceHash };
419
511
  }
420
512
  else {
421
513
  if (options.apk)
422
514
  throw new CliError("token_type_mismatch", "project_ci Profile must use apps upload and --app-upload-id", 3);
423
515
  if (options.deviceId)
424
516
  throw new CliError("token_type_mismatch", "project_ci Profile cannot use --device-id", 3);
425
- if (!String(options.idempotencyKey ?? "").trim())
517
+ if (!suppliedIdempotencyKey)
426
518
  throw new CliError("invalid_request", "--idempotency-key is required for a project_ci Run");
427
519
  const target = (await loadProjectConfig(ctx.projectConfig)).resolveTarget(options.target);
428
- request = { schema_version: "xagent-run-create/v2", script, target_name: target.name, target_hash: target.targetHash, target: target.definition, timeout_ms: Number(options.timeoutMs), source_hash: parsed.sourceHash };
520
+ request = { schema_version: "xagent-run-create/v2", script, target_name: target.name, target_hash: target.targetHash, target: target.definition, timeout_ms: timeoutMs, source_hash: parsed.sourceHash };
429
521
  }
430
522
  const variables = parseVariables(options.var ?? []);
431
523
  if (Object.keys(variables).length)
432
524
  request.variables = variables;
433
- const { client } = await authenticatedClient(ctx, Number(options.uploadTimeout) * 1000);
525
+ const { client } = await authenticatedClient(ctx, uploadTimeoutSeconds * 1000);
434
526
  let uploadedId;
435
527
  let primaryError;
436
528
  let run;
@@ -438,7 +530,10 @@ program.command("run")
438
530
  // and a Run occupying a device allocated until each expires on its own.
439
531
  let createdRunId;
440
532
  const waiting = new AbortController();
441
- const releaseOnInterrupt = onInterrupt(async () => {
533
+ // `onInterrupt()` registers a cleanup and hands back its deregistration
534
+ // function, so this is "detach", not "release" — calling it does not release
535
+ // anything on its own.
536
+ const detachInterruptHandler = onInterrupt(async () => {
442
537
  // Stop the poll loop first so it cannot keep issuing requests while we release.
443
538
  waiting.abort();
444
539
  // Both releases are independent: a failed cancel must not skip the APK release.
@@ -472,10 +567,10 @@ program.command("run")
472
567
  request.package_id = Number(packageId);
473
568
  if (options.appUploadId)
474
569
  request.app_upload_id = options.appUploadId;
475
- run = await client.createRun(request, options.idempotencyKey?.trim());
570
+ run = await client.createRun(request, suppliedIdempotencyKey);
476
571
  createdRunId = String(run.id ?? "") || undefined;
477
572
  if (options.wait) {
478
- run = await client.waitRun(run.id, Number(options.waitTimeout), 2, progressReporter(ctx), waiting.signal);
573
+ run = await client.waitRun(run.id, waitTimeoutSeconds, pollIntervalSeconds, progressReporter(ctx), waiting.signal);
479
574
  createdRunId = undefined; // reached a terminal state, nothing left to cancel
480
575
  }
481
576
  }
@@ -496,7 +591,7 @@ program.command("run")
496
591
  throw error;
497
592
  }
498
593
  }
499
- releaseOnInterrupt();
594
+ detachInterruptHandler();
500
595
  }
501
596
  emitRun(ctx, run);
502
597
  });
@@ -507,10 +602,27 @@ runs.command("cancel").argument("<runId>").action(async function (runId) { const
507
602
  program.command("mcp").option("--mode <mode>", `Isolated Tool surface: ${MCP_MODES.join(", ")}`, "action").action(async function (options) { const ctx = context(this); if (!MCP_MODES.includes(options.mode))
508
603
  throw new CliError("invalid_request", `mcp --mode must be one of: ${MCP_MODES.join(", ")}`); await runStdioServer(ctx.profile, ctx.userConfig, undefined, options.mode); });
509
604
  export async function runCli(argv = process.argv) {
605
+ parsedArgv = argv;
606
+ parsedJsonMode = undefined;
607
+ // When the caller asked for JSON, commander must not write its own human text to
608
+ // stderr — the structured error below is the whole contract. `--json` is a global
609
+ // option, so the argv being parsed is the single source for this decision.
610
+ program.configureOutput({ writeErr: argv.includes("--json") ? () => { } : (text) => process.stderr.write(text) });
510
611
  try {
511
612
  await program.parseAsync(argv);
512
613
  }
513
614
  catch (error) {
615
+ if (error instanceof CommanderError) {
616
+ // `--help` already printed to stdout; keep the conventional success code.
617
+ if (error.exitCode === 0)
618
+ return;
619
+ const known = new CliError("invalid_request", error.message, 2);
620
+ // Human mode: commander has already explained the problem on stderr (plus the
621
+ // usage block from showHelpAfterError), so do not repeat it through fail().
622
+ if (jsonOutputRequested())
623
+ fail(known);
624
+ process.exit(API_EXIT[known.code]);
625
+ }
514
626
  fail(error);
515
627
  }
516
628
  }