@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/assets/contracts/authoring-device-acquire.schema.json +13 -0
- package/dist/assets/contracts/compat-script.schema.json +146 -5
- package/dist/assets/contracts/fixtures/compat-script.all-platform-actions.yaml +61 -0
- package/dist/assets/integrations/xagent-skill/references/host-adapters.md +16 -0
- package/dist/assets/integrations/xagent-skill/references/setup-and-doctor.md +1 -1
- package/dist/assets/skills/xagent-cli/SKILL.md +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +166 -54
- package/dist/cli.js.map +1 -1
- package/dist/config/store.d.ts +0 -1
- package/dist/config/store.d.ts.map +1 -1
- package/dist/config/store.js +1 -2
- package/dist/config/store.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.d.ts.map +1 -1
- package/dist/core/version.js +1 -1
- package/dist/core/version.js.map +1 -1
- package/dist/script/service.d.ts.map +1 -1
- package/dist/script/service.js +5 -2
- package/dist/script/service.js.map +1 -1
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +10 -1
- package/dist/sdk/client.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
191
|
-
|
|
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("--
|
|
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(
|
|
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",
|
|
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",
|
|
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
|
|
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("--
|
|
236
|
-
process.stderr.write(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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 (!
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
}
|