@autifyhq/aximo-cli 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -660,7 +660,7 @@ claude mcp add aximo -- aximo mcp
660
660
 
661
661
  VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
662
662
 
663
- The server exposes 21 tools: 14 read-only ones for inspecting sessions, cases, plans, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plans` and `aximo_get_plan` cover plan discovery; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling) plus 7 that author content (create and delete cases, create and update plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
663
+ The server exposes 24 tools: 14 read-only ones for inspecting sessions, cases, plans, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plans` and `aximo_get_plan` cover plan discovery; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling), 8 that author content (create, update, and delete cases, create and update plans, save a session as a case), and 2 that trigger runs (`aximo_run_case` starts a case run and returns the `sessionId`; `aximo_run_plan` starts a plan run and returns the `planRunId` — both spend organization credits and require the case or plan to be `apiEnabled`). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
664
664
 
665
665
  ## Agent Skills
666
666
 
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 21 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 24 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();
@@ -40,13 +40,16 @@ describe("createMcpServer wiring", () => {
40
40
  "aximo_list_projects",
41
41
  "aximo_list_sessions",
42
42
  "aximo_create_case",
43
+ "aximo_update_case",
43
44
  "aximo_delete_case",
45
+ "aximo_run_case",
44
46
  "aximo_create_plan",
45
47
  "aximo_add_case_to_plan",
46
48
  "aximo_remove_case_from_plan",
47
49
  "aximo_list_plans",
48
50
  "aximo_get_plan",
49
51
  "aximo_update_plan",
52
+ "aximo_run_plan",
50
53
  "aximo_list_plan_runs",
51
54
  "aximo_get_plan_run",
52
55
  "aximo_save_session_as_case",
@@ -31,8 +31,12 @@ async function call(fn) {
31
31
  }
32
32
  if (status === 404)
33
33
  return err(e.message || "Not found");
34
- if (status === 429)
35
- return err("Rate limited", "Aximo enforces a daily session limit.");
34
+ if (status === 429) {
35
+ const retryAfter = e.retryAfter;
36
+ return err("Rate limited", retryAfter
37
+ ? `Back off and retry after ${retryAfter} seconds.`
38
+ : "Back off before retrying; Aximo rate-limits requests per organization.");
39
+ }
36
40
  if (typeof status === "number")
37
41
  return err(`HTTP ${status}: ${e.message}`);
38
42
  if (e.message.startsWith("No API key configured")) {
@@ -54,6 +58,18 @@ function buildQuery(params) {
54
58
  }
55
59
  return out;
56
60
  }
61
+ // Mobile app selection accepted by the run triggers (multi-app, max 3).
62
+ // ARN fields are the encrypted tokens Aximo returns, never raw AWS ARNs.
63
+ const runAppsSchema = z
64
+ .array(z.object({
65
+ uploadArn: z
66
+ .string()
67
+ .describe("Encrypted upload token (from aximo_get_case config or `aximo upload`)."),
68
+ uploadType: z.enum(["own_app", "existing", "sample"]),
69
+ appName: z.string().nullable(),
70
+ bundleId: z.string().optional(),
71
+ }))
72
+ .max(3);
57
73
  export function defineTools(deps = {}) {
58
74
  return [
59
75
  {
@@ -186,7 +202,7 @@ export function defineTools(deps = {}) {
186
202
  {
187
203
  name: "aximo_get_case",
188
204
  title: "Get test case",
189
- description: "Get the full scenario text and metadata for a saved test case. Useful for grounding generated Playwright specs.",
205
+ description: "Get the full scenario text and metadata for a saved test case, including its per-target default configuration (mobile, web hostname overrides, desktop, multi-device) — the run trigger's per-run override fields supersede those defaults. Useful for grounding generated Playwright specs.",
190
206
  inputSchema: {
191
207
  caseId: z.string().uuid(),
192
208
  },
@@ -201,17 +217,37 @@ export function defineTools(deps = {}) {
201
217
  inputSchema: {
202
218
  projectId: z.string().uuid(),
203
219
  scenario: z.string().min(1),
204
- type: z.enum(["web", "mobile", "desktop"]).optional(),
220
+ type: z.enum(["web", "mobile", "desktop", "multi_device"]).optional(),
205
221
  title: z.string().min(1).max(500).optional(),
206
222
  description: z.string().optional(),
207
223
  format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
208
- startUrl: z.string().url().optional(),
224
+ startUrl: z.string().url().nullable().optional(),
209
225
  directoryId: z.string().uuid().optional(),
210
226
  },
211
227
  handler: async (input) => call(async () => {
212
228
  return await request("/api/cases", { method: "POST", body: input }, deps);
213
229
  }),
214
230
  },
231
+ {
232
+ name: "aximo_update_case",
233
+ 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).",
235
+ inputSchema: {
236
+ caseId: z.string().uuid(),
237
+ title: z.string().min(1).max(500).optional(),
238
+ description: z.string().optional(),
239
+ scenario: z.string().min(1).optional(),
240
+ type: z.enum(["web", "mobile", "desktop", "multi_device"]).optional(),
241
+ format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
242
+ startUrl: z.string().url().nullable().optional(),
243
+ },
244
+ handler: async ({ caseId, ...body }) => call(async () => {
245
+ if (Object.keys(body).length === 0) {
246
+ throw new Error("at least one field to update is required");
247
+ }
248
+ return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "PATCH", body }, deps);
249
+ }),
250
+ },
215
251
  {
216
252
  name: "aximo_delete_case",
217
253
  title: "Delete test case",
@@ -223,6 +259,45 @@ export function defineTools(deps = {}) {
223
259
  return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
224
260
  }),
225
261
  },
262
+ {
263
+ name: "aximo_run_case",
264
+ title: "Run test case",
265
+ description: "Start a run of a saved test case and return the new sessionId. The case must be apiEnabled (aximo_get_case shows this). All fields except caseId are per-run overrides of the case's stored per-target configuration: omit them to run with the saved defaults. Poll aximo_get_session with the returned sessionId to follow the run; wait at least 5 seconds between polls.",
266
+ inputSchema: {
267
+ caseId: z.string().uuid(),
268
+ model: z
269
+ .string()
270
+ .optional()
271
+ .describe("Agent model override (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
272
+ hostnameOverrideIds: z
273
+ .array(z.string().uuid())
274
+ .optional()
275
+ .describe("Web cases: hostname override ids to apply for this run."),
276
+ userDeviceId: z
277
+ .string()
278
+ .uuid()
279
+ .optional()
280
+ .describe("A user-owned (BYOD) device id; overrides the device bound on the case."),
281
+ deviceArn: z
282
+ .string()
283
+ .optional()
284
+ .describe("Mobile cases: encrypted device token; overrides the saved device."),
285
+ deviceName: z.string().optional(),
286
+ platform: z.enum(["ANDROID", "IOS"]).optional(),
287
+ uploadArn: z
288
+ .string()
289
+ .optional()
290
+ .describe("Mobile cases: encrypted app upload token; overrides the saved app."),
291
+ uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
292
+ appName: z.string().optional(),
293
+ apps: runAppsSchema
294
+ .optional()
295
+ .describe("Mobile multi-app runs: up to 3 apps to install; overrides the saved apps and takes precedence over the single-app uploadArn/uploadType/appName fields."),
296
+ },
297
+ handler: async ({ caseId, ...body }) => call(async () => {
298
+ return await request(`/api/triggers/cases/${encodeURIComponent(caseId)}/run`, { method: "POST", body }, deps);
299
+ }),
300
+ },
226
301
  {
227
302
  name: "aximo_create_plan",
228
303
  title: "Create test plan",
@@ -303,20 +378,90 @@ export function defineTools(deps = {}) {
303
378
  return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "PATCH", body }, deps);
304
379
  }),
305
380
  },
381
+ {
382
+ name: "aximo_run_plan",
383
+ title: "Run test plan",
384
+ description: "Start a run of a test plan (all its cases) and return the new planRunId, totalCases, and the effective executionMode. The plan must be apiEnabled (aximo_get_plan shows this). All fields except planId are per-run overrides of the plan's stored settings: omit them to run with the saved defaults. Poll aximo_get_plan_run with the returned planRunId to follow the run; wait at least 5 seconds between polls.",
385
+ inputSchema: {
386
+ planId: z.string().uuid(),
387
+ model: z
388
+ .string()
389
+ .optional()
390
+ .describe("Agent model override (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
391
+ executionMode: z
392
+ .enum(["parallel", "sequential"])
393
+ .optional()
394
+ .describe("Override the plan's stored Execution setting for this run only."),
395
+ mobileConfig: z
396
+ .object({
397
+ deviceArn: z.string().describe("Encrypted device token."),
398
+ deviceName: z.string(),
399
+ platform: z.enum(["ANDROID", "IOS"]).optional(),
400
+ uploadArn: z.string().optional().describe("Encrypted app upload token."),
401
+ uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
402
+ appName: z.string().optional(),
403
+ apps: runAppsSchema
404
+ .optional()
405
+ .describe("Takes precedence over the single-app uploadArn/uploadType/appName fields."),
406
+ })
407
+ .optional()
408
+ .describe("Device + app for the plan's mobile cases; overrides the plan's stored config."),
409
+ desktopConfig: z
410
+ .object({
411
+ devices: z.array(z.object({ userDeviceId: z.string().uuid() })).min(1),
412
+ })
413
+ .optional()
414
+ .describe("User-owned (BYOD) desktop devices to run desktop cases on; overrides the plan's stored desktop config."),
415
+ variables: z
416
+ .array(z.object({
417
+ name: z
418
+ .string()
419
+ .describe("Uppercase letters, digits, underscores; must start with a letter."),
420
+ value: z.string(),
421
+ }))
422
+ .optional()
423
+ .describe("Per-run variable values merged over the plan's stored variables by name; never persisted on the plan."),
424
+ },
425
+ handler: async ({ planId, ...body }) => call(async () => {
426
+ return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/run`, { method: "POST", body }, deps);
427
+ }),
428
+ },
306
429
  {
307
430
  name: "aximo_list_plan_runs",
308
431
  title: "List plan runs",
309
- description: "List a plan's runs, newest first, without per-case detail. Use aximo_get_plan_run for one run's per-case breakdown. Paginate by passing the previous page's nextCursor as cursor. A run's status reflects execution, not the test verdict: read passedCases/failedCases to judge outcomes.",
432
+ description: "List plan runs, newest first, without per-case detail. Pass planId for one plan's runs, or projectId for run history across every plan in a workspace (each item then carries its plan's id and title); exactly one of the two is required, and status filtering is available with projectId. Use aximo_get_plan_run for one run's per-case breakdown. Paginate by passing the previous page's nextCursor as cursor. A run's status reflects execution, not the test verdict: read passedCases/failedCases to judge outcomes.",
310
433
  inputSchema: {
311
- planId: z.string().uuid(),
434
+ planId: z
435
+ .string()
436
+ .uuid()
437
+ .optional()
438
+ .describe("Exactly one of planId or projectId is required."),
439
+ projectId: z
440
+ .string()
441
+ .uuid()
442
+ .optional()
443
+ .describe("Exactly one of planId or projectId is required."),
444
+ status: z
445
+ .enum(["created", "running", "completed", "failed", "cancelled"])
446
+ .optional()
447
+ .describe("Only with projectId: restrict to runs with this execution status."),
312
448
  limit: z.number().int().positive().max(50).optional(),
313
449
  cursor: z
314
450
  .string()
315
451
  .optional()
316
452
  .describe("nextCursor from the previous page (ISO 8601 createdAt of its last run)."),
317
453
  },
318
- handler: async ({ planId, ...rest }) => call(async () => {
319
- return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs`, { query: buildQuery(rest) }, deps);
454
+ handler: async ({ planId, projectId, status, ...rest }) => call(async () => {
455
+ if (!planId === !projectId) {
456
+ throw new Error("pass exactly one of planId or projectId");
457
+ }
458
+ if (planId) {
459
+ if (status) {
460
+ throw new Error("the status filter requires projectId");
461
+ }
462
+ return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs`, { query: buildQuery(rest) }, deps);
463
+ }
464
+ return await request("/api/plan-runs", { query: buildQuery({ projectId, status, ...rest }) }, deps);
320
465
  }),
321
466
  },
322
467
  {
@@ -32,13 +32,16 @@ describe("defineTools", () => {
32
32
  "aximo_list_cases",
33
33
  "aximo_get_case",
34
34
  "aximo_create_case",
35
+ "aximo_update_case",
35
36
  "aximo_delete_case",
37
+ "aximo_run_case",
36
38
  "aximo_create_plan",
37
39
  "aximo_add_case_to_plan",
38
40
  "aximo_remove_case_from_plan",
39
41
  "aximo_list_plans",
40
42
  "aximo_get_plan",
41
43
  "aximo_update_plan",
44
+ "aximo_run_plan",
42
45
  "aximo_list_plan_runs",
43
46
  "aximo_get_plan_run",
44
47
  "aximo_save_session_as_case",
@@ -146,6 +149,131 @@ describe("aximo_list_projects", () => {
146
149
  expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
147
150
  });
148
151
  });
152
+ describe("aximo_update_case", () => {
153
+ const caseId = "55555555-5555-4555-9555-555555555555";
154
+ it("PATCHes /api/cases/:id with only the provided fields", async () => {
155
+ let body = null;
156
+ const payload = {
157
+ success: true,
158
+ case: {
159
+ id: caseId,
160
+ projectId: "p1",
161
+ type: "web",
162
+ title: "Renamed",
163
+ description: null,
164
+ scenario: "Given a renamed page",
165
+ format: "gherkin",
166
+ startUrl: null,
167
+ updatedAt: "2026-08-14T00:00:00.000Z",
168
+ },
169
+ };
170
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
171
+ body = await req.json();
172
+ return HttpResponse.json(payload);
173
+ }));
174
+ const tool = byName(defineTools(), "aximo_update_case");
175
+ const result = await tool.handler({
176
+ caseId,
177
+ title: "Renamed",
178
+ scenario: "Given a renamed page",
179
+ });
180
+ expect(result.isError).toBeUndefined();
181
+ expect(parsePayload(result)).toEqual(payload);
182
+ expect(body).toEqual({ title: "Renamed", scenario: "Given a renamed page" });
183
+ });
184
+ it("rejects an update with no fields instead of issuing an empty PATCH", async () => {
185
+ let called = false;
186
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => {
187
+ called = true;
188
+ return HttpResponse.json({ success: true });
189
+ }));
190
+ const tool = byName(defineTools(), "aximo_update_case");
191
+ const result = await tool.handler({ caseId });
192
+ expect(result.isError).toBe(true);
193
+ expect(parsePayload(result).error).toBe("at least one field to update is required");
194
+ expect(called).toBe(false);
195
+ });
196
+ it("passes startUrl: null through as an explicit clear, not an empty update", async () => {
197
+ let body = null;
198
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
199
+ body = await req.json();
200
+ return HttpResponse.json({ success: true });
201
+ }));
202
+ const tool = byName(defineTools(), "aximo_update_case");
203
+ const result = await tool.handler({ caseId, startUrl: null });
204
+ expect(result.isError).toBeUndefined();
205
+ expect(body).toEqual({ startUrl: null });
206
+ });
207
+ });
208
+ describe("aximo_run_case", () => {
209
+ const caseId = "77777777-7777-4777-9777-777777777777";
210
+ it("POSTs /api/triggers/cases/:id/run with the per-run overrides and returns sessionId", async () => {
211
+ let body = null;
212
+ let apiKey = null;
213
+ const payload = { success: true, sessionId: "88888888-8888-4888-9888-888888888888" };
214
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, async ({ request: req }) => {
215
+ apiKey = req.headers.get("x-api-key");
216
+ body = await req.json();
217
+ return HttpResponse.json(payload, { status: 201 });
218
+ }));
219
+ const tool = byName(defineTools(), "aximo_run_case");
220
+ const result = await tool.handler({
221
+ caseId,
222
+ model: "sonnet",
223
+ hostnameOverrideIds: ["99999999-9999-4999-9999-999999999999"],
224
+ apps: [{ uploadArn: "tok-1", uploadType: "own_app", appName: null }],
225
+ });
226
+ expect(result.isError).toBeUndefined();
227
+ expect(parsePayload(result)).toEqual(payload);
228
+ expect(apiKey).toBe("test-key");
229
+ expect(body).toEqual({
230
+ model: "sonnet",
231
+ hostnameOverrideIds: ["99999999-9999-4999-9999-999999999999"],
232
+ apps: [{ uploadArn: "tok-1", uploadType: "own_app", appName: null }],
233
+ });
234
+ });
235
+ it("sends an empty body when only caseId is given, so the saved config applies", async () => {
236
+ let body = null;
237
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, async ({ request: req }) => {
238
+ body = await req.json();
239
+ return HttpResponse.json({ success: true, sessionId: "s" }, { status: 201 });
240
+ }));
241
+ const tool = byName(defineTools(), "aximo_run_case");
242
+ const result = await tool.handler({ caseId });
243
+ expect(result.isError).toBeUndefined();
244
+ expect(body).toEqual({});
245
+ });
246
+ it("surfaces the apiEnabled rejection as the error message", async () => {
247
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "API access is not enabled for this case" }, { status: 403 })));
248
+ const tool = byName(defineTools(), "aximo_run_case");
249
+ const result = await tool.handler({ caseId });
250
+ expect(result.isError).toBe(true);
251
+ expect(parsePayload(result).error).toBe("API access is not enabled for this case");
252
+ });
253
+ it("surfaces the insufficient-credits rejection distinctly", async () => {
254
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Insufficient credits to start session" }, { status: 403 })));
255
+ const tool = byName(defineTools(), "aximo_run_case");
256
+ const result = await tool.handler({ caseId });
257
+ expect(result.isError).toBe(true);
258
+ expect(parsePayload(result).error).toBe("Insufficient credits to start session");
259
+ });
260
+ it("maps a 429 to a rate-limit error an agent can back off on", async () => {
261
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429, headers: { "Retry-After": "60" } })));
262
+ const tool = byName(defineTools(), "aximo_run_case");
263
+ const result = await tool.handler({ caseId });
264
+ expect(result.isError).toBe(true);
265
+ const payload = parsePayload(result);
266
+ expect(payload.error).toMatch(/Rate limited/);
267
+ expect(payload.hint).toBe("Back off and retry after 60 seconds.");
268
+ });
269
+ it("returns the API's 404 for a case outside the organization without leaking existence", async () => {
270
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Test case not found" }, { status: 404 })));
271
+ const tool = byName(defineTools(), "aximo_run_case");
272
+ const result = await tool.handler({ caseId });
273
+ expect(result.isError).toBe(true);
274
+ expect(parsePayload(result).error).toBe("Test case not found");
275
+ });
276
+ });
149
277
  describe("aximo_list_plans", () => {
150
278
  const projectId = "44444444-4444-4444-8444-444444444444";
151
279
  it("calls /api/plans, passing filters through as query params", async () => {
@@ -247,6 +375,63 @@ describe("aximo_update_plan", () => {
247
375
  expect(called).toBe(false);
248
376
  });
249
377
  });
378
+ describe("aximo_run_plan", () => {
379
+ const planId = "aaaaaaaa-aaaa-4aaa-9aaa-aaaaaaaaaaaa";
380
+ it("POSTs /api/triggers/plans/:id/run with the per-run overrides and returns planRunId", async () => {
381
+ let body = null;
382
+ const payload = {
383
+ success: true,
384
+ planRunId: "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb",
385
+ totalCases: 3,
386
+ executionMode: "sequential",
387
+ };
388
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, async ({ request: req }) => {
389
+ body = await req.json();
390
+ return HttpResponse.json(payload, { status: 201 });
391
+ }));
392
+ const tool = byName(defineTools(), "aximo_run_plan");
393
+ const result = await tool.handler({
394
+ planId,
395
+ executionMode: "sequential",
396
+ mobileConfig: { deviceArn: "tok-device", deviceName: "Pixel 8", platform: "ANDROID" },
397
+ desktopConfig: { devices: [{ userDeviceId: "cccccccc-cccc-4ccc-9ccc-cccccccccccc" }] },
398
+ variables: [{ name: "BASE_URL", value: "https://staging.example.com" }],
399
+ });
400
+ expect(result.isError).toBeUndefined();
401
+ expect(parsePayload(result)).toEqual(payload);
402
+ expect(body).toEqual({
403
+ executionMode: "sequential",
404
+ mobileConfig: { deviceArn: "tok-device", deviceName: "Pixel 8", platform: "ANDROID" },
405
+ desktopConfig: { devices: [{ userDeviceId: "cccccccc-cccc-4ccc-9ccc-cccccccccccc" }] },
406
+ variables: [{ name: "BASE_URL", value: "https://staging.example.com" }],
407
+ });
408
+ });
409
+ it("sends an empty body when only planId is given, so the plan's stored settings apply", async () => {
410
+ let body = null;
411
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, async ({ request: req }) => {
412
+ body = await req.json();
413
+ return HttpResponse.json({ success: true, planRunId: "r", totalCases: 1 }, { status: 201 });
414
+ }));
415
+ const tool = byName(defineTools(), "aximo_run_plan");
416
+ const result = await tool.handler({ planId });
417
+ expect(result.isError).toBeUndefined();
418
+ expect(body).toEqual({});
419
+ });
420
+ it("surfaces the apiEnabled rejection as the error message", async () => {
421
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, () => HttpResponse.json({ error: "API access is not enabled for this plan" }, { status: 403 })));
422
+ const tool = byName(defineTools(), "aximo_run_plan");
423
+ const result = await tool.handler({ planId });
424
+ expect(result.isError).toBe(true);
425
+ expect(parsePayload(result).error).toBe("API access is not enabled for this plan");
426
+ });
427
+ it("returns the API's 404 for a plan outside the organization without leaking existence", async () => {
428
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
429
+ const tool = byName(defineTools(), "aximo_run_plan");
430
+ const result = await tool.handler({ planId });
431
+ expect(result.isError).toBe(true);
432
+ expect(parsePayload(result).error).toBe("Test plan not found");
433
+ });
434
+ });
250
435
  describe("aximo_list_plan_runs", () => {
251
436
  const planId = "77777777-7777-4777-9777-777777777777";
252
437
  it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
@@ -255,7 +440,7 @@ describe("aximo_list_plan_runs", () => {
255
440
  items: [
256
441
  {
257
442
  id: "88888888-8888-4888-9888-888888888888",
258
- status: "succeeded",
443
+ status: "completed",
259
444
  triggerSource: "api",
260
445
  executionMode: "parallel",
261
446
  totalCases: 3,
@@ -292,6 +477,66 @@ describe("aximo_list_plan_runs", () => {
292
477
  expect(result.isError).toBe(true);
293
478
  expect(parsePayload(result).error).toBe("Test plan not found");
294
479
  });
480
+ it("calls /api/plan-runs when given a projectId, passing status through", async () => {
481
+ const projectId = "44444444-4444-4444-8444-444444444444";
482
+ let url = null;
483
+ const payload = {
484
+ items: [
485
+ {
486
+ id: "88888888-8888-4888-9888-888888888888",
487
+ planId,
488
+ plan: { id: planId, title: "Nightly regression" },
489
+ status: "failed",
490
+ triggerSource: "api",
491
+ executionMode: "parallel",
492
+ totalCases: 3,
493
+ completedCases: 3,
494
+ passedCases: 2,
495
+ failedCases: 1,
496
+ startedAt: "2026-08-01T00:00:00.000Z",
497
+ completedAt: "2026-08-01T00:10:00.000Z",
498
+ createdAt: "2026-08-01T00:00:00.000Z",
499
+ },
500
+ ],
501
+ nextCursor: "2026-08-01T00:00:00.000Z",
502
+ };
503
+ server.use(http.get(`${TEST_BASE_URL}/api/plan-runs`, ({ request: req }) => {
504
+ url = new URL(req.url);
505
+ return HttpResponse.json(payload);
506
+ }));
507
+ const tool = byName(defineTools(), "aximo_list_plan_runs");
508
+ const result = await tool.handler({
509
+ projectId,
510
+ status: "failed",
511
+ limit: 5,
512
+ cursor: "2026-08-02T00:00:00.000Z",
513
+ });
514
+ expect(result.isError).toBeUndefined();
515
+ expect(parsePayload(result)).toEqual(payload);
516
+ expect(url).not.toBeNull();
517
+ expect(url.searchParams.get("projectId")).toBe(projectId);
518
+ expect(url.searchParams.get("status")).toBe("failed");
519
+ expect(url.searchParams.get("limit")).toBe("5");
520
+ expect(url.searchParams.get("cursor")).toBe("2026-08-02T00:00:00.000Z");
521
+ });
522
+ it("rejects calls with both, or neither, of planId and projectId", async () => {
523
+ const tool = byName(defineTools(), "aximo_list_plan_runs");
524
+ const both = await tool.handler({
525
+ planId,
526
+ projectId: "44444444-4444-4444-8444-444444444444",
527
+ });
528
+ expect(both.isError).toBe(true);
529
+ expect(parsePayload(both).error).toBe("pass exactly one of planId or projectId");
530
+ const neither = await tool.handler({});
531
+ expect(neither.isError).toBe(true);
532
+ expect(parsePayload(neither).error).toBe("pass exactly one of planId or projectId");
533
+ });
534
+ it("rejects the status filter without projectId", async () => {
535
+ const tool = byName(defineTools(), "aximo_list_plan_runs");
536
+ const result = await tool.handler({ planId, status: "failed" });
537
+ expect(result.isError).toBe(true);
538
+ expect(parsePayload(result).error).toBe("the status filter requires projectId");
539
+ });
295
540
  });
296
541
  describe("aximo_get_plan_run", () => {
297
542
  const planId = "77777777-7777-4777-9777-777777777777";
@@ -299,7 +544,7 @@ describe("aximo_get_plan_run", () => {
299
544
  it("calls /api/triggers/plans/:id/runs/:runId and returns the run with cases", async () => {
300
545
  const payload = {
301
546
  id: planRunId,
302
- status: "succeeded",
547
+ status: "completed",
303
548
  triggerSource: "api",
304
549
  executionMode: "sequential",
305
550
  totalCases: 1,
@@ -354,14 +599,23 @@ describe("call() error mapping", () => {
354
599
  const payload = parsePayload(result);
355
600
  expect(payload.error).toBe("Session was archived");
356
601
  });
357
- it("flags 429 with the rate-limit hint", async () => {
602
+ it("flags 429 with a back-off hint when no Retry-After header is present", async () => {
358
603
  server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429 })));
359
604
  const tool = byName(defineTools(), "aximo_get_session_timeline");
360
605
  const result = await tool.handler({ sessionId });
361
606
  expect(result.isError).toBe(true);
362
607
  const payload = parsePayload(result);
363
608
  expect(payload.error).toBe("Rate limited");
364
- expect(payload.hint).toMatch(/daily/i);
609
+ expect(payload.hint).toMatch(/back off/i);
610
+ });
611
+ it("carries the Retry-After value into the 429 hint so an agent knows how long to wait", async () => {
612
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429, headers: { "Retry-After": "42" } })));
613
+ const tool = byName(defineTools(), "aximo_get_session_timeline");
614
+ const result = await tool.handler({ sessionId });
615
+ expect(result.isError).toBe(true);
616
+ const payload = parsePayload(result);
617
+ expect(payload.error).toBe("Rate limited");
618
+ expect(payload.hint).toBe("Back off and retry after 42 seconds.");
365
619
  });
366
620
  it("distinguishes a 500 with the HTTP-prefixed message", async () => {
367
621
  server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Internal server error" }, { status: 500 })));
package/dist/types.d.ts CHANGED
@@ -161,7 +161,7 @@ export interface PlanDetail {
161
161
  export interface PlanRunSummary {
162
162
  id: string;
163
163
  status: string;
164
- triggerSource: string | null;
164
+ triggerSource: string;
165
165
  executionMode: string;
166
166
  totalCases: number;
167
167
  completedCases: number;
@@ -171,8 +171,15 @@ export interface PlanRunSummary {
171
171
  completedAt: string | null;
172
172
  createdAt: string;
173
173
  }
174
- export interface PlanRunList {
175
- items: PlanRunSummary[];
174
+ export interface ProjectPlanRunSummary extends PlanRunSummary {
175
+ planId: string;
176
+ plan: {
177
+ id: string;
178
+ title: string;
179
+ };
180
+ }
181
+ export interface PlanRunList<T = PlanRunSummary> {
182
+ items: T[];
176
183
  nextCursor?: string | undefined;
177
184
  }
178
185
  export interface Device {
@@ -236,11 +243,11 @@ export interface UpdateCaseResult {
236
243
  case: {
237
244
  id: string;
238
245
  projectId: string;
239
- type: string;
246
+ type: CaseType;
240
247
  title: string;
241
248
  description: string | null;
242
249
  scenario: string;
243
- format: string;
250
+ format: CaseFormat;
244
251
  startUrl: string | null;
245
252
  updatedAt: string;
246
253
  };
@@ -366,6 +373,53 @@ export interface SessionCapture {
366
373
  createdAt: string;
367
374
  updatedAt: string;
368
375
  }
376
+ export interface CaseAppInfo {
377
+ uploadArn: string;
378
+ uploadType: "own_app" | "existing" | "sample";
379
+ appName: string | null;
380
+ bundleId?: string;
381
+ }
382
+ export interface CaseMobileConfig {
383
+ deviceArn: string;
384
+ deviceName: string;
385
+ platform?: "ANDROID" | "IOS";
386
+ uploadArn?: string;
387
+ uploadType?: "own_app" | "existing" | "sample";
388
+ appName?: string;
389
+ bundleId?: string;
390
+ apps?: CaseAppInfo[];
391
+ }
392
+ export interface CaseWebConfig {
393
+ hostnameOverrideIds?: string[];
394
+ }
395
+ export interface CaseDesktopConfig {
396
+ userDeviceId: string;
397
+ name: string;
398
+ platform: "windows" | "macos" | "linux";
399
+ os: string | null;
400
+ }
401
+ export interface CaseMultiDeviceWebDevice {
402
+ kind: "web";
403
+ name: string;
404
+ startUrl?: string | null;
405
+ userDeviceId?: string;
406
+ localDeviceName?: string;
407
+ }
408
+ export interface CaseMultiDeviceMobileDevice {
409
+ kind: "mobile";
410
+ name: string;
411
+ mobilePlatform: "android" | "ios";
412
+ deviceArn?: string;
413
+ deviceName?: string;
414
+ userDeviceId?: string;
415
+ localDeviceName?: string;
416
+ uploadArn?: string;
417
+ uploadType?: "own_app" | "existing" | "sample";
418
+ appName?: string;
419
+ bundleId?: string;
420
+ apps?: CaseAppInfo[];
421
+ }
422
+ export type CaseMultiDeviceDevice = CaseMultiDeviceWebDevice | CaseMultiDeviceMobileDevice;
369
423
  export interface CaseDetail {
370
424
  id: string;
371
425
  projectId: string;
@@ -380,4 +434,8 @@ export interface CaseDetail {
380
434
  lastStatus: string | null;
381
435
  createdAt: string;
382
436
  updatedAt: string;
437
+ apiMobileConfig: CaseMobileConfig | null;
438
+ apiWebConfig: CaseWebConfig | null;
439
+ apiDesktopConfig: CaseDesktopConfig | null;
440
+ apiMultiDeviceConfig: CaseMultiDeviceDevice[] | null;
383
441
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",
@@ -59,6 +59,7 @@
59
59
  "oxfmt": "0.45.0",
60
60
  "oxlint": "1.60.0",
61
61
  "postject": "1.0.0-alpha.6",
62
+ "resedit": "3.0.2",
62
63
  "secretlint": "11.7.1",
63
64
  "tsx": "4.21.0",
64
65
  "typescript": "6.0.2",