@autifyhq/aximo-cli 0.12.0 → 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 22 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 8 that author content (create, update, 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 22 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();
@@ -42,12 +42,14 @@ describe("createMcpServer wiring", () => {
42
42
  "aximo_create_case",
43
43
  "aximo_update_case",
44
44
  "aximo_delete_case",
45
+ "aximo_run_case",
45
46
  "aximo_create_plan",
46
47
  "aximo_add_case_to_plan",
47
48
  "aximo_remove_case_from_plan",
48
49
  "aximo_list_plans",
49
50
  "aximo_get_plan",
50
51
  "aximo_update_plan",
52
+ "aximo_run_plan",
51
53
  "aximo_list_plan_runs",
52
54
  "aximo_get_plan_run",
53
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
  {
@@ -243,6 +259,45 @@ export function defineTools(deps = {}) {
243
259
  return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
244
260
  }),
245
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
+ },
246
301
  {
247
302
  name: "aximo_create_plan",
248
303
  title: "Create test plan",
@@ -323,6 +378,54 @@ export function defineTools(deps = {}) {
323
378
  return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "PATCH", body }, deps);
324
379
  }),
325
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
+ },
326
429
  {
327
430
  name: "aximo_list_plan_runs",
328
431
  title: "List plan runs",
@@ -34,12 +34,14 @@ describe("defineTools", () => {
34
34
  "aximo_create_case",
35
35
  "aximo_update_case",
36
36
  "aximo_delete_case",
37
+ "aximo_run_case",
37
38
  "aximo_create_plan",
38
39
  "aximo_add_case_to_plan",
39
40
  "aximo_remove_case_from_plan",
40
41
  "aximo_list_plans",
41
42
  "aximo_get_plan",
42
43
  "aximo_update_plan",
44
+ "aximo_run_plan",
43
45
  "aximo_list_plan_runs",
44
46
  "aximo_get_plan_run",
45
47
  "aximo_save_session_as_case",
@@ -203,6 +205,75 @@ describe("aximo_update_case", () => {
203
205
  expect(body).toEqual({ startUrl: null });
204
206
  });
205
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
+ });
206
277
  describe("aximo_list_plans", () => {
207
278
  const projectId = "44444444-4444-4444-8444-444444444444";
208
279
  it("calls /api/plans, passing filters through as query params", async () => {
@@ -304,6 +375,63 @@ describe("aximo_update_plan", () => {
304
375
  expect(called).toBe(false);
305
376
  });
306
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
+ });
307
435
  describe("aximo_list_plan_runs", () => {
308
436
  const planId = "77777777-7777-4777-9777-777777777777";
309
437
  it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
@@ -471,14 +599,23 @@ describe("call() error mapping", () => {
471
599
  const payload = parsePayload(result);
472
600
  expect(payload.error).toBe("Session was archived");
473
601
  });
474
- 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 () => {
475
603
  server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429 })));
476
604
  const tool = byName(defineTools(), "aximo_get_session_timeline");
477
605
  const result = await tool.handler({ sessionId });
478
606
  expect(result.isError).toBe(true);
479
607
  const payload = parsePayload(result);
480
608
  expect(payload.error).toBe("Rate limited");
481
- 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.");
482
619
  });
483
620
  it("distinguishes a 500 with the HTTP-prefixed message", async () => {
484
621
  server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Internal server error" }, { status: 500 })));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",