@autifyhq/aximo-cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +20 -5
  2. package/dist/client.d.ts +5 -0
  3. package/dist/client.js +23 -22
  4. package/dist/client.test.js +28 -0
  5. package/dist/commands/case/archive.d.ts +20 -0
  6. package/dist/commands/case/archive.js +42 -0
  7. package/dist/commands/case/case.test.js +181 -0
  8. package/dist/commands/case/delete.js +4 -1
  9. package/dist/commands/case/index.js +2 -0
  10. package/dist/commands/case/restore.d.ts +20 -0
  11. package/dist/commands/case/restore.js +42 -0
  12. package/dist/commands/case/update.d.ts +22 -0
  13. package/dist/commands/case/update.js +34 -3
  14. package/dist/commands/device/status.d.ts +1 -10
  15. package/dist/commands/mcp/index.js +6 -3
  16. package/dist/commands/mcp/server.test.js +32 -1
  17. package/dist/commands/mcp/tools.js +242 -9
  18. package/dist/commands/mcp/tools.test.js +434 -1
  19. package/dist/commands/mcp/wait.d.ts +20 -0
  20. package/dist/commands/mcp/wait.js +46 -0
  21. package/dist/commands/mcp/wait.test.d.ts +1 -0
  22. package/dist/commands/mcp/wait.test.js +91 -0
  23. package/dist/commands/plan/archive.d.ts +20 -0
  24. package/dist/commands/plan/archive.js +42 -0
  25. package/dist/commands/plan/delete.d.ts +20 -0
  26. package/dist/commands/plan/delete.js +42 -0
  27. package/dist/commands/plan/index.js +3 -0
  28. package/dist/commands/plan/plan.test.js +115 -3
  29. package/dist/commands/plan/restore.d.ts +20 -0
  30. package/dist/commands/plan/restore.js +42 -0
  31. package/dist/commands/session/export.d.ts +30 -0
  32. package/dist/commands/session/export.js +58 -0
  33. package/dist/commands/session/index.js +1 -0
  34. package/dist/commands/session/session.test.js +62 -0
  35. package/dist/commands/upload/upload.test.js +5 -2
  36. package/dist/errors.d.ts +5 -0
  37. package/dist/errors.js +110 -2
  38. package/dist/errors.test.js +106 -3
  39. package/dist/session-export.d.ts +30 -0
  40. package/dist/session-export.js +72 -0
  41. package/dist/session-export.test.d.ts +1 -0
  42. package/dist/session-export.test.js +107 -0
  43. package/dist/types.d.ts +41 -3
  44. package/dist/validate.d.ts +1 -0
  45. package/dist/validate.js +15 -3
  46. package/dist/validate.test.js +18 -1
  47. package/package.json +1 -1
@@ -3,7 +3,7 @@ import { request } from "../../client.js";
3
3
  import { handleApiError } from "../../errors.js";
4
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
5
  import { formatSuccess, printResult, validationError } from "../../output.js";
6
- import { optionalString, parseCaseFormat, parseCaseType, requireId } from "../../validate.js";
6
+ import { optionalString, parseCaseFormat, parseCaseType, parseJsonArray, parseJsonObject, requireId, } from "../../validate.js";
7
7
  export async function runCaseUpdate(options, deps) {
8
8
  let caseId;
9
9
  let body;
@@ -21,6 +21,7 @@ export async function runCaseUpdate(options, deps) {
21
21
  }
22
22
  else {
23
23
  try {
24
+ const desktopDevice = optionalString("desktopDevice", options.desktopDevice);
24
25
  body = {
25
26
  title: optionalString("title", options.title),
26
27
  description: optionalString("description", options.description),
@@ -28,6 +29,10 @@ export async function runCaseUpdate(options, deps) {
28
29
  type: parseCaseType(options.type),
29
30
  format: parseCaseFormat(options.format),
30
31
  startUrl: optionalString("startUrl", options.startUrl),
32
+ apiMobileConfig: parseJsonObject("mobileConfig", options.mobileConfig),
33
+ apiDesktopConfig: desktopDevice === undefined ? undefined : { userDeviceId: desktopDevice },
34
+ apiMultiDeviceConfig: parseJsonArray("multiDeviceConfig", options.multiDeviceConfig),
35
+ apiWebConfig: parseJsonObject("webConfig", options.webConfig),
31
36
  };
32
37
  }
33
38
  catch (err) {
@@ -54,11 +59,33 @@ export default defineCommand({
54
59
  args: {
55
60
  caseId: { type: "positional", required: true },
56
61
  title: { type: "string" },
57
- description: { type: "string" },
62
+ description: {
63
+ type: "string",
64
+ description: "Cannot clear; use --json '{\"description\": null}' for that",
65
+ },
58
66
  scenario: { type: "string" },
59
67
  type: { type: "string", description: "web | mobile | desktop | multi_device" },
60
68
  format: { type: "string", description: "gherkin | markdown | plain_text" },
61
- "start-url": { type: "string" },
69
+ "start-url": {
70
+ type: "string",
71
+ description: "Cannot clear; use --json '{\"startUrl\": null}' for that",
72
+ },
73
+ "mobile-config": {
74
+ type: "string",
75
+ description: "JSON object for apiMobileConfig ({deviceArn, deviceName, platform?, uploadArn?, uploadType?, appName?, bundleId?, apps?}); replaces the whole config. Clear with --json '{\"apiMobileConfig\":null}'",
76
+ },
77
+ "desktop-device": {
78
+ type: "string",
79
+ description: "Registered BYOD desktop device id (aximo device status) for apiDesktopConfig. Clear with --json '{\"apiDesktopConfig\":null}'",
80
+ },
81
+ "multi-device-config": {
82
+ type: "string",
83
+ description: "JSON array of 2-3 device entries ({kind: web|mobile, name, ...}) for apiMultiDeviceConfig; replaces the whole config. Clear with --json '{\"apiMultiDeviceConfig\":null}'",
84
+ },
85
+ "web-config": {
86
+ type: "string",
87
+ description: "JSON object for apiWebConfig ({hostnameOverrideIds: [...]}); replaces the whole config. Clear with --json '{\"apiWebConfig\":null}'",
88
+ },
62
89
  json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
63
90
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
64
91
  },
@@ -71,6 +98,10 @@ export default defineCommand({
71
98
  type: args.type,
72
99
  format: args.format,
73
100
  startUrl: args["start-url"],
101
+ mobileConfig: args["mobile-config"],
102
+ desktopDevice: args["desktop-device"],
103
+ multiDeviceConfig: args["multi-device-config"],
104
+ webConfig: args["web-config"],
74
105
  json: args.json,
75
106
  dryRun: args["dry-run"],
76
107
  }));
@@ -1,14 +1,5 @@
1
1
  import { type ClientDeps } from "../../client.js";
2
- import type { Envelope } from "../../types.js";
3
- interface UserDevice {
4
- id: string;
5
- name: string;
6
- platform: string;
7
- os?: string;
8
- status: string;
9
- lastSeenAt?: string;
10
- createdAt: string;
11
- }
2
+ import type { Envelope, UserDevice } from "../../types.js";
12
3
  export declare function runDeviceStatus(deps?: ClientDeps): Promise<Envelope<{
13
4
  devices: UserDevice[];
14
5
  }>>;
@@ -10,15 +10,18 @@ export default defineCommand({
10
10
  },
11
11
  args: {},
12
12
  async run() {
13
- // Fail fast before the JSON-RPC handshake; diagnostics go to stderr only.
13
+ // Serve even without a key. A server that exits before the handshake shows
14
+ // up in the client as an empty tool list with no explanation; a server that
15
+ // answers every call with the `aximo configure` hint tells the agent what
16
+ // to do. The key is re-read on each request, so no restart is needed after
17
+ // configuring. Diagnostics go to stderr only.
14
18
  try {
15
19
  getApiKey();
16
20
  }
17
21
  catch (err) {
18
22
  const message = err instanceof Error ? err.message : String(err);
19
23
  process.stderr.write(`aximo mcp: ${message}\n`);
20
- process.exitCode = 1;
21
- return;
24
+ process.stderr.write("aximo mcp: serving anyway; tools return this error until a key is set\n");
22
25
  }
23
26
  const baseUrl = getBaseUrl();
24
27
  process.stderr.write(`aximo mcp: serving stdio MCP against ${baseUrl}\n`);
@@ -24,31 +24,43 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 24 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 36 tools through tools/list with valid JSON Schema", async () => {
28
28
  const { client, cleanup } = await connect();
29
29
  try {
30
30
  const { tools } = await client.listTools();
31
31
  expect(tools.map((t) => t.name).sort()).toEqual([
32
32
  "aximo_get_case",
33
33
  "aximo_get_session",
34
+ "aximo_wait_for_session",
34
35
  "aximo_get_session_capture",
35
36
  "aximo_get_session_snapshot",
37
+ "aximo_export_session_pdf",
38
+ "aximo_export_session_screenshots",
36
39
  "aximo_get_session_messages",
37
40
  "aximo_get_session_screenshots",
38
41
  "aximo_get_session_timeline",
39
42
  "aximo_list_cases",
43
+ "aximo_list_devices",
40
44
  "aximo_list_projects",
41
45
  "aximo_list_sessions",
46
+ "aximo_list_uploads",
47
+ "aximo_list_user_devices",
42
48
  "aximo_create_case",
43
49
  "aximo_update_case",
50
+ "aximo_archive_case",
51
+ "aximo_restore_case",
44
52
  "aximo_delete_case",
45
53
  "aximo_run_case",
54
+ "aximo_create_session",
46
55
  "aximo_create_plan",
47
56
  "aximo_add_case_to_plan",
48
57
  "aximo_remove_case_from_plan",
49
58
  "aximo_list_plans",
50
59
  "aximo_get_plan",
51
60
  "aximo_update_plan",
61
+ "aximo_archive_plan",
62
+ "aximo_restore_plan",
63
+ "aximo_delete_plan",
52
64
  "aximo_run_plan",
53
65
  "aximo_list_plan_runs",
54
66
  "aximo_get_plan_run",
@@ -80,6 +92,25 @@ describe("createMcpServer wiring", () => {
80
92
  await cleanup();
81
93
  }
82
94
  });
95
+ it("serves the full tool list without an API key and answers calls with the configure hint", async () => {
96
+ const prev = process.env["AXIMO_API_KEY"];
97
+ delete process.env["AXIMO_API_KEY"];
98
+ const { client, cleanup } = await connect();
99
+ try {
100
+ const { tools } = await client.listTools();
101
+ expect(tools.length).toBe(36);
102
+ const result = await client.callTool({ name: "aximo_list_projects", arguments: {} });
103
+ expect(result.isError).toBe(true);
104
+ const content = result.content;
105
+ const payload = JSON.parse(content[0].text);
106
+ expect(payload.error).toBe("API key not configured");
107
+ expect(payload.hint).toBe("Run `aximo configure`, then call the tool again.");
108
+ }
109
+ finally {
110
+ process.env["AXIMO_API_KEY"] = prev;
111
+ await cleanup();
112
+ }
113
+ });
83
114
  it("returns isError with a validation message when input fails the schema", async () => {
84
115
  const { client, cleanup } = await connect();
85
116
  try {
@@ -1,5 +1,9 @@
1
+ import { isAbsolute } from "node:path";
1
2
  import { z } from "zod";
2
3
  import { request } from "../../client.js";
4
+ import { WAIT_DEFAULT_SECONDS, WAIT_MAX_SECONDS, waitForSession } from "./wait.js";
5
+ import { exportSession, SESSION_EXPORT_LOCALES, } from "../../session-export.js";
6
+ import { ARCHIVE_FIRST_HINT, featureHint, isArchiveFirstConflict, withDetails, } from "../../errors.js";
3
7
  function ok(payload) {
4
8
  return {
5
9
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
@@ -23,24 +27,33 @@ async function call(fn) {
23
27
  catch (e) {
24
28
  if (!(e instanceof Error))
25
29
  return err(String(e));
26
- const status = e.status;
30
+ const { status, details } = e;
31
+ const message = withDetails(e.message, details);
27
32
  if (status === 401)
28
33
  return err("Unauthorized: check AXIMO_API_KEY", "Run `aximo configure`.");
29
34
  if (status === 403) {
30
- return err(e.message || "Forbidden", "API key may not have access to this resource.");
35
+ return err(message || "Forbidden", featureHint(e.message, details) ?? "API key may not have access to this resource.");
31
36
  }
32
37
  if (status === 404)
33
- return err(e.message || "Not found");
38
+ return err(message || "Not found");
34
39
  if (status === 429) {
35
40
  const retryAfter = e.retryAfter;
36
41
  return err("Rate limited", retryAfter
37
42
  ? `Back off and retry after ${retryAfter} seconds.`
38
43
  : "Back off before retrying; Aximo rate-limits requests per organization.");
39
44
  }
45
+ // Mirrors the 409 branch in handleApiError. Without this, an archive-first
46
+ // 409 falls through to the bare `HTTP 409: ...` case below and the agent is
47
+ // told to call a REST endpoint it has no way to reach, while the tool that
48
+ // would work goes unmentioned. Keyed on the details-enriched message, the
49
+ // same input handleApiError feeds the predicate, so the two cannot disagree.
50
+ if (status === 409) {
51
+ return err(message || "Conflict", isArchiveFirstConflict(message) ? ARCHIVE_FIRST_HINT : undefined);
52
+ }
40
53
  if (typeof status === "number")
41
- return err(`HTTP ${status}: ${e.message}`);
54
+ return err(`HTTP ${status}: ${message}`);
42
55
  if (e.message.startsWith("No API key configured")) {
43
- return err("API key not configured", "Run `aximo configure` and restart the MCP server.");
56
+ return err("API key not configured", "Run `aximo configure`, then call the tool again.");
44
57
  }
45
58
  const cause = e.cause;
46
59
  const networkCode = cause?.code;
@@ -70,6 +83,22 @@ const runAppsSchema = z
70
83
  bundleId: z.string().optional(),
71
84
  }))
72
85
  .max(3);
86
+ const exportOutputPathSchema = z
87
+ .string()
88
+ .min(1)
89
+ .describe("Absolute file path to write, e.g. /tmp/result.pdf. Parent directories are created and an existing file is replaced.");
90
+ const exportLocaleSchema = z
91
+ .enum(SESSION_EXPORT_LOCALES)
92
+ .optional()
93
+ .describe("Language of the report labels. Default en.");
94
+ // The MCP server's working directory is wherever the client launched it, so a
95
+ // relative path would land somewhere the caller cannot predict.
96
+ function exportSessionTo(input, deps) {
97
+ if (!isAbsolute(input.outputPath)) {
98
+ throw new Error("outputPath must be an absolute path");
99
+ }
100
+ return exportSession(input, deps);
101
+ }
73
102
  export function defineTools(deps = {}) {
74
103
  return [
75
104
  {
@@ -82,6 +111,53 @@ export function defineTools(deps = {}) {
82
111
  return res;
83
112
  }),
84
113
  },
114
+ {
115
+ name: "aximo_list_devices",
116
+ title: "List Device Farm devices",
117
+ description: "List the cloud (AWS Device Farm) mobile devices available to your organization — not BYOD devices (see aximo_list_user_devices). Each device's arn is the encrypted token to pass as deviceArn, and its name as deviceName, in a case's apiMobileConfig or multi-device mobile entries (aximo_update_case) and in per-run overrides (aximo_run_case, aximo_run_plan). Filter by platform (ANDROID | IOS) or availableOnly.",
118
+ inputSchema: {
119
+ platform: z.enum(["ANDROID", "IOS"]).optional(),
120
+ availableOnly: z
121
+ .boolean()
122
+ .optional()
123
+ .describe("Only devices that are currently free to run on."),
124
+ },
125
+ handler: async ({ platform, availableOnly }) => call(async () => {
126
+ return await request("/api/devices", { query: { platform, availableOnly: availableOnly || undefined } }, deps);
127
+ }),
128
+ },
129
+ {
130
+ name: "aximo_list_user_devices",
131
+ title: "List registered BYOD devices",
132
+ description: "List the BYOD (bring-your-own) desktop and mobile devices registered with `aximo device register` by the owner of this API key — other members' devices are not included. Each device's id is the userDeviceId to pass in a case's apiDesktopConfig or multi-device local entries (aximo_update_case) and in per-run overrides. status shows whether the device is online. Returns 404 when BYOD is not enabled for the organization.",
133
+ inputSchema: {},
134
+ handler: async () => call(async () => {
135
+ try {
136
+ return await request("/api/devices/user", {}, deps);
137
+ }
138
+ catch (e) {
139
+ // The route answers a bare 404 when the org has no BYOD feature.
140
+ if (e.status === 404) {
141
+ throw Object.assign(new Error("BYOD is not enabled for this organization"), {
142
+ status: 403,
143
+ details: { feature: "byodEnabled" },
144
+ });
145
+ }
146
+ throw e;
147
+ }
148
+ }),
149
+ },
150
+ {
151
+ name: "aximo_list_uploads",
152
+ title: "List app uploads",
153
+ description: "List the mobile app binaries (APK / IPA) uploaded to your organization. Each upload's uploadArn is the encrypted token to pass as uploadArn in a case's apiMobileConfig or multi-device mobile entries (aximo_update_case) and in per-run overrides; only uploads with status succeeded can be installed. identifier is the app's bundleId. Filter by fileType (ANDROID_APP | IOS_APP).",
154
+ inputSchema: {
155
+ fileType: z.enum(["ANDROID_APP", "IOS_APP"]).optional(),
156
+ },
157
+ handler: async ({ fileType }) => call(async () => {
158
+ return await request("/api/uploads", { query: { fileType } }, deps);
159
+ }),
160
+ },
85
161
  {
86
162
  name: "aximo_list_sessions",
87
163
  title: "List sessions",
@@ -108,6 +184,22 @@ export function defineTools(deps = {}) {
108
184
  return await request(`/api/sessions/${encodeURIComponent(sessionId)}`, {}, deps);
109
185
  }),
110
186
  },
187
+ {
188
+ name: "aximo_wait_for_session",
189
+ title: "Wait for session",
190
+ description: "Wait for a session to finish and return within timeoutSeconds whatever its state. Polls every 5 seconds. Returns done=true with the final session (status succeeded, failed, terminated, error, or archived), or done=false with the current status so you can call again. Default 45 seconds, maximum 90: most MCP clients cut a tool call off at 60, so only go above that where the client has no limit. A multi-minute run is a handful of these calls.",
191
+ inputSchema: {
192
+ sessionId: z.string().uuid(),
193
+ timeoutSeconds: z
194
+ .number()
195
+ .int()
196
+ .min(1)
197
+ .max(WAIT_MAX_SECONDS)
198
+ .optional()
199
+ .describe(`Seconds to wait before returning. Default ${WAIT_DEFAULT_SECONDS}.`),
200
+ },
201
+ handler: async ({ sessionId, timeoutSeconds }) => call(() => waitForSession(sessionId, timeoutSeconds ?? WAIT_DEFAULT_SECONDS, deps)),
202
+ },
111
203
  {
112
204
  name: "aximo_get_session_messages",
113
205
  title: "Get session messages",
@@ -183,6 +275,28 @@ export function defineTools(deps = {}) {
183
275
  return await request(`/api/sessions/${encodeURIComponent(sessionId)}/snapshots/${encodeURIComponent(snapshotId)}`, {}, deps);
184
276
  }),
185
277
  },
278
+ {
279
+ name: "aximo_export_session_pdf",
280
+ title: "Export session result as PDF",
281
+ description: "Download a session's test result report (overview, result summary, and every step with its screenshot) as a PDF and write it to outputPath on this machine. Returns the written path and size, not the file bytes. A running session exports the steps recorded so far. Sessions over 500 steps return HTTP 413: export the screenshots archive instead. For a plan run, take each case's sessionId from aximo_get_plan_run.",
282
+ inputSchema: {
283
+ sessionId: z.string().uuid(),
284
+ outputPath: exportOutputPathSchema,
285
+ locale: exportLocaleSchema,
286
+ },
287
+ handler: async ({ sessionId, outputPath, locale }) => call(() => exportSessionTo({ sessionId, format: "pdf", outputPath, locale }, deps)),
288
+ },
289
+ {
290
+ name: "aximo_export_session_screenshots",
291
+ title: "Export session screenshots as ZIP",
292
+ description: "Download a ZIP of every step screenshot of a session (screenshots/step-001.png or .jpg, as stored) plus metadata.csv (one row per step: screenshot file, AI thoughts, tool, details) and session.csv, and write it to outputPath on this machine. Returns the written path and size, not the file bytes. The download is verified complete before success is reported; a truncated archive is deleted and returned as an error. A session with no screenshots returns HTTP 409.",
293
+ inputSchema: {
294
+ sessionId: z.string().uuid(),
295
+ outputPath: exportOutputPathSchema,
296
+ locale: exportLocaleSchema,
297
+ },
298
+ handler: async ({ sessionId, outputPath, locale }) => call(() => exportSessionTo({ sessionId, format: "screenshots", outputPath, locale }, deps)),
299
+ },
186
300
  {
187
301
  name: "aximo_list_cases",
188
302
  title: "List test cases",
@@ -231,15 +345,36 @@ export function defineTools(deps = {}) {
231
345
  {
232
346
  name: "aximo_update_case",
233
347
  title: "Update test case",
234
- description: "Update a test case's title, description, scenario, type, format, or startUrl (pass null to clear startUrl). Omitted fields are left unchanged; at least one field is required. Per-target device configuration cannot be changed over the API, and changing type does not create or clear it (e.g. switching to mobile leaves apiMobileConfig null until set in the dashboard).",
348
+ description: "Update a test case's title, description, scenario, type, format, or startUrl (pass null to clear description or startUrl; an empty description string is stored as-is, not a clear). Omitted fields are left unchanged; at least one field is required. Pass apiMobileConfig / apiDesktopConfig / apiMultiDeviceConfig / apiWebConfig to set the case's default targets — the same shapes aximo_get_case returns; null clears one, omit leaves it unchanged. Each config value is replaced whole — send the complete config object you want stored (never a partial), and omit a config key entirely to leave that target unchanged. deviceArn = aximo_list_devices devices[].arn and deviceName = devices[].name; uploadArn = aximo_list_uploads uploads[].uploadArn (status succeeded), bundleId = uploads[].identifier, uploadType is usually \"own_app\"; userDeviceId = aximo_list_user_devices devices[].id. apiMobileConfig.platform is ANDROID|IOS; multi-device mobilePlatform is android|ios. There is no top-level userDeviceId/deviceArn here — they live inside the config objects. Desktop and multi-device require the organization's BYOD / multi-device feature. You can set type in the same call.",
235
349
  inputSchema: {
236
350
  caseId: z.string().uuid(),
237
351
  title: z.string().min(1).max(500).optional(),
238
- description: z.string().optional(),
352
+ description: z.string().nullable().optional(),
239
353
  scenario: z.string().min(1).optional(),
240
354
  type: z.enum(["web", "mobile", "desktop", "multi_device"]).optional(),
241
355
  format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
242
356
  startUrl: z.string().url().nullable().optional(),
357
+ apiMobileConfig: z
358
+ .record(z.unknown())
359
+ .nullable()
360
+ .optional()
361
+ .describe("{ deviceArn, deviceName, platform?: ANDROID|IOS, uploadArn?, uploadType?: own_app|existing|sample, appName?, bundleId?, apps?: [{ uploadArn, uploadType, appName, bundleId? }] (max 3) }. null clears."),
362
+ apiDesktopConfig: z
363
+ .object({ userDeviceId: z.string().uuid() })
364
+ .passthrough()
365
+ .nullable()
366
+ .optional()
367
+ .describe("{ userDeviceId } of a registered BYOD desktop device (aximo_list_user_devices); name/platform/os are resolved server-side. null clears."),
368
+ apiMultiDeviceConfig: z
369
+ .array(z.record(z.unknown()))
370
+ .nullable()
371
+ .optional()
372
+ .describe('2-3 entries, each { kind: "web", name, startUrl?, userDeviceId? } or { kind: "mobile", name, mobilePlatform: android|ios, deviceArn?, deviceName?, userDeviceId?, uploadArn?, uploadType?, appName?, bundleId?, apps? }. null clears.'),
373
+ apiWebConfig: z
374
+ .record(z.unknown())
375
+ .nullable()
376
+ .optional()
377
+ .describe("{ hostnameOverrideIds?: string[] }. null clears."),
243
378
  },
244
379
  handler: async ({ caseId, ...body }) => call(async () => {
245
380
  if (Object.keys(body).length === 0) {
@@ -248,10 +383,32 @@ export function defineTools(deps = {}) {
248
383
  return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "PATCH", body }, deps);
249
384
  }),
250
385
  },
386
+ {
387
+ name: "aximo_archive_case",
388
+ title: "Archive test case",
389
+ description: "Archive a test case: hidden from lists, plans, and new runs; run history kept. Idempotent. Required before aximo_delete_case can permanently delete it.",
390
+ inputSchema: {
391
+ caseId: z.string().uuid(),
392
+ },
393
+ handler: async ({ caseId }) => call(async () => {
394
+ return await request(`/api/cases/${encodeURIComponent(caseId)}/archive`, { method: "POST" }, deps);
395
+ }),
396
+ },
397
+ {
398
+ name: "aximo_restore_case",
399
+ title: "Restore test case",
400
+ description: "Restore an archived test case: it returns to lists, directory counts, and plan membership. Idempotent. A case whose directory was deleted while archived returns to the project root.",
401
+ inputSchema: {
402
+ caseId: z.string().uuid(),
403
+ },
404
+ handler: async ({ caseId }) => call(async () => {
405
+ return await request(`/api/cases/${encodeURIComponent(caseId)}/restore`, { method: "POST" }, deps);
406
+ }),
407
+ },
251
408
  {
252
409
  name: "aximo_delete_case",
253
410
  title: "Delete test case",
254
- description: "Permanently delete a test case. It is removed from any plans it belongs to; past run sessions are kept but detached. This cannot be undone.",
411
+ description: "Permanently delete an already-archived test case. Archive it first with aximo_archive_case. Requires an organization admin or owner API key. It is removed from any plans it belongs to, and its rows in past plan runs go with it, so those runs list fewer cases than their stored totals; the agent sessions themselves are kept but detached. This cannot be undone.",
255
412
  inputSchema: {
256
413
  caseId: z.string().uuid(),
257
414
  },
@@ -298,6 +455,49 @@ export function defineTools(deps = {}) {
298
455
  return await request(`/api/triggers/cases/${encodeURIComponent(caseId)}/run`, { method: "POST", body }, deps);
299
456
  }),
300
457
  },
458
+ {
459
+ name: "aximo_create_session",
460
+ title: "Create session",
461
+ description: "Start an agent session from a prompt, without saving a test case. Consumes organization credits. Returns the new sessionId; follow it with aximo_wait_for_session. Runs in Aximo's cloud browser unless userDeviceId names a registered local device (aximo_list_user_devices); for an app on localhost use a local device and put the app's URL in the prompt itself.",
462
+ inputSchema: {
463
+ title: z.string().min(1).max(500).describe("Shown in the session list."),
464
+ prompt: z
465
+ .string()
466
+ .min(1)
467
+ .max(10000)
468
+ .describe("What to test, in plain language: the steps and what counts as a pass."),
469
+ projectId: z.string().uuid(),
470
+ platform: z.enum(["desktop", "android", "ios"]).optional().describe("Default desktop."),
471
+ model: z
472
+ .string()
473
+ .optional()
474
+ .describe("Agent model (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
475
+ startUrl: z
476
+ .string()
477
+ .url()
478
+ .optional()
479
+ .describe("Web sessions on cloud browsers: first URL to open."),
480
+ tags: z.array(z.string().max(64)).max(10).optional(),
481
+ hostnameOverrideIds: z.array(z.string().uuid()).optional(),
482
+ userDeviceId: z
483
+ .string()
484
+ .uuid()
485
+ .optional()
486
+ .describe("A user-owned (BYOD) device id; runs there instead of the cloud."),
487
+ deviceArn: z.string().optional().describe("Mobile: encrypted Device Farm device token."),
488
+ deviceName: z.string().optional(),
489
+ uploadArn: z.string().optional().describe("Mobile: encrypted app upload token."),
490
+ uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
491
+ appName: z.string().optional(),
492
+ bundleId: z.string().optional(),
493
+ apps: runAppsSchema
494
+ .optional()
495
+ .describe("Mobile multi-app sessions: up to 3 apps; takes precedence over the single-app fields."),
496
+ },
497
+ handler: async (body) => call(async () => {
498
+ return await request("/api/sessions", { method: "POST", body }, deps);
499
+ }),
500
+ },
301
501
  {
302
502
  name: "aximo_create_plan",
303
503
  title: "Create test plan",
@@ -378,6 +578,39 @@ export function defineTools(deps = {}) {
378
578
  return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "PATCH", body }, deps);
379
579
  }),
380
580
  },
581
+ {
582
+ name: "aximo_archive_plan",
583
+ title: "Archive test plan",
584
+ description: "Archive a test plan: hidden from lists and blocked from new runs; its cases, run history, and schedule are all kept. The scheduler skips archived plans, so restoring one resumes its normal cadence. Idempotent. Required before aximo_delete_plan can permanently delete it.",
585
+ inputSchema: {
586
+ planId: z.string().uuid(),
587
+ },
588
+ handler: async ({ planId }) => call(async () => {
589
+ return await request(`/api/plans/${encodeURIComponent(planId)}/archive`, { method: "POST" }, deps);
590
+ }),
591
+ },
592
+ {
593
+ name: "aximo_restore_plan",
594
+ title: "Restore test plan",
595
+ description: "Restore an archived test plan: it returns to lists and can be run again, and its schedule resumes exactly as configured. Idempotent.",
596
+ inputSchema: {
597
+ planId: z.string().uuid(),
598
+ },
599
+ handler: async ({ planId }) => call(async () => {
600
+ return await request(`/api/plans/${encodeURIComponent(planId)}/restore`, { method: "POST" }, deps);
601
+ }),
602
+ },
603
+ {
604
+ name: "aximo_delete_plan",
605
+ title: "Delete test plan",
606
+ description: "Permanently delete an already-archived test plan. Archive it first with aximo_archive_plan. Requires an organization admin or owner API key. Its member cases are kept, and so is its run history: those runs are detached from the plan and remain readable as standalone results. This cannot be undone.",
607
+ inputSchema: {
608
+ planId: z.string().uuid(),
609
+ },
610
+ handler: async ({ planId }) => call(async () => {
611
+ return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE" }, deps);
612
+ }),
613
+ },
381
614
  {
382
615
  name: "aximo_run_plan",
383
616
  title: "Run test plan",
@@ -467,7 +700,7 @@ export function defineTools(deps = {}) {
467
700
  {
468
701
  name: "aximo_get_plan_run",
469
702
  title: "Get plan run status",
470
- description: "Get the overall status and per-case breakdown of a plan run (from aximo_list_plan_runs, or the planRunId returned when a run was triggered). Poll this to observe a run in progress; wait at least 5 seconds between polls. A run's status reflects execution, not the test verdict: a run can complete with failedCases > 0, so read passedCases/failedCases and the per-case statuses to judge outcomes.",
703
+ description: "Get the overall status and per-case breakdown of a plan run (from aximo_list_plan_runs, or the planRunId returned when a run was triggered). Poll this to observe a run in progress; wait at least 5 seconds between polls. A run's status reflects execution, not the test verdict: a run can complete with failedCases > 0, so read passedCases/failedCases and the per-case statuses to judge outcomes. Each case carries its sessionId (null until its session is created) for aximo_get_session and the export tools.",
471
704
  inputSchema: {
472
705
  planId: z.string().uuid(),
473
706
  planRunId: z.string().uuid(),