@autifyhq/aximo-cli 0.2.0 → 0.3.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
@@ -36,11 +36,7 @@ Get your API key from **Settings → API Keys** in the [Aximo dashboard](https:/
36
36
 
37
37
  ## Configuration
38
38
 
39
- The CLI resolves each credential independently in this order:
40
-
41
- 1. Value in `$AXIMO_HOME/config.json` (written by `aximo configure`)
42
- 2. Environment variable
43
- 3. Default (for base URL only: `https://aximo.autify.com`)
39
+ Each credential resolves independently: the value in `$AXIMO_HOME/config.json` (written by `aximo configure`) wins, then the environment variable, then the default.
44
40
 
45
41
  | Variable | Required | Default | Notes |
46
42
  | ---------------- | -------- | -------------------------- | ------------------------------------------------------------ |
@@ -48,60 +44,59 @@ The CLI resolves each credential independently in this order:
48
44
  | `AXIMO_BASE_URL` | No | `https://aximo.autify.com` | Override for sandbox / self-hosted |
49
45
  | `AXIMO_HOME` | No | `~/.aximo` | Data directory (config, device credentials, embedded Python) |
50
46
 
51
- ### Using `aximo configure`
47
+ Set them interactively or pass flags — config is written to `$AXIMO_HOME/config.json` (mode `0600` where the OS supports it; Windows falls back to file ACLs), and an empty prompt keeps the existing value:
52
48
 
53
49
  ```bash
54
50
  aximo configure # interactive prompts
55
51
  aximo configure --base-url URL --api-key KEY # non-interactive
56
52
  ```
57
53
 
58
- The interactive flow prompts first for the base URL (empty → keep existing, else falls back to the Aximo default), then for the API key (empty → keep existing; errors if nothing is saved). Config is written to `$AXIMO_HOME/config.json` with mode `0600`. To change sandbox/prod, re-run `aximo configure` and enter a new URL.
59
-
60
- ### Using environment variables
61
-
62
- If you'd rather keep credentials out of a file (CI, ephemeral shells), set env vars:
54
+ Or keep credentials out of a file (CI, ephemeral shells) with env vars:
63
55
 
64
56
  ```bash
65
- # macOS / Linux
66
57
  export AXIMO_API_KEY=your-api-key
67
58
  export AXIMO_BASE_URL=https://aximo-sbx.autify.dev # optional, prod is the default
68
-
69
- # Windows (PowerShell, persistent)
70
- [Environment]::SetEnvironmentVariable("AXIMO_API_KEY", "your-api-key", "User")
71
- [Environment]::SetEnvironmentVariable("AXIMO_BASE_URL", "https://aximo-sbx.autify.dev", "User")
72
59
  ```
73
60
 
61
+ Credentials and device registrations are **per-environment** — to switch between production and sandbox (`https://aximo-sbx.autify.dev`), re-run `aximo configure` with the other `--base-url`.
62
+
74
63
  ## Commands
75
64
 
76
65
  Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` returns machine-readable argument introspection (e.g. `aximo schema session.create`).
77
66
 
78
- | Command | Description |
79
- | ------------------------- | ---------------------------------------------------- |
80
- | `aximo configure` | Save credentials to `$AXIMO_HOME/config.json` |
81
- | `aximo health check` | Check API reachability |
82
- | `aximo project list` | List organization projects |
83
- | `aximo session create` | Create and queue a test session |
84
- | `aximo session get` | Get session status |
85
- | `aximo trigger case run` | Trigger a dashboard-created test case |
86
- | `aximo trigger case get` | Get triggered case status |
87
- | `aximo trigger plan run` | Trigger a test plan |
88
- | `aximo trigger plan get` | Get plan run status |
89
- | `aximo upload create` | Create an upload slot for an APK/IPA |
90
- | `aximo upload list` | List previously uploaded apps |
91
- | `aximo upload get` | Poll upload processing status |
92
- | `aximo upload put` | Upload binary file to the slot |
93
- | `aximo upload submit` | Finalize upload and trigger processing |
94
- | `aximo device list` | List mobile devices from Device Farm |
95
- | `aximo device setup` | First-time BYOD onboarding (API + Python + register) |
96
- | `aximo device register` | Register this machine as a BYOD device |
97
- | `aximo device connect` | Start the BYOD agent and connect to the relay |
98
- | `aximo device status` | List your organization's BYOD devices |
99
- | `aximo device ping` | Check your BYOD device's server-side status |
100
- | `aximo device doctor` | Diagnose BYOD setup (env, Python, relay, status) |
101
- | `aximo device logs` | Show the BYOD agent log |
102
- | `aximo device unregister` | Remove a BYOD device server-side + clear local creds |
103
- | `aximo device reset` | Remove BYOD local state (Python, deps, credentials) |
104
- | `aximo schema` | Show CLI surface as JSON |
67
+ | Command | Description |
68
+ | ---------------------------- | ---------------------------------------------------- |
69
+ | `aximo configure` | Save credentials to `$AXIMO_HOME/config.json` |
70
+ | `aximo health check` | Check API reachability |
71
+ | `aximo project list` | List organization projects |
72
+ | `aximo session create` | Create and queue a test session |
73
+ | `aximo session get` | Get session status |
74
+ | `aximo session save-as-case` | Save a finished session as a reusable test case |
75
+ | `aximo case create` | Create a test case |
76
+ | `aximo plan create` | Create a test plan |
77
+ | `aximo plan add-case` | Add a case to a plan |
78
+ | `aximo plan remove-case` | Remove a case from a plan |
79
+ | `aximo trigger case run` | Trigger a dashboard-created test case |
80
+ | `aximo trigger case get` | Get triggered case status |
81
+ | `aximo trigger plan run` | Trigger a test plan |
82
+ | `aximo trigger plan get` | Get plan run status |
83
+ | `aximo upload create` | Create an upload slot for an APK/IPA |
84
+ | `aximo upload list` | List previously uploaded apps |
85
+ | `aximo upload get` | Poll upload processing status |
86
+ | `aximo upload put` | Upload binary file to the slot |
87
+ | `aximo upload submit` | Finalize upload and trigger processing |
88
+ | `aximo device list` | List mobile devices from Device Farm |
89
+ | `aximo device setup` | First-time BYOD onboarding (API + Python + register) |
90
+ | `aximo device register` | Register this machine as a BYOD device |
91
+ | `aximo device connect` | Start the BYOD agent and connect to the relay |
92
+ | `aximo device status` | List your organization's BYOD devices |
93
+ | `aximo device ping` | Check your BYOD device's server-side status |
94
+ | `aximo device doctor` | Diagnose BYOD setup (env, Python, relay, status) |
95
+ | `aximo device logs` | Show the BYOD agent log |
96
+ | `aximo device unregister` | Remove a BYOD device server-side + clear local creds |
97
+ | `aximo device reset` | Remove BYOD local state (Python, deps, credentials) |
98
+ | `aximo schema` | Show CLI surface as JSON |
99
+ | `aximo mcp` | Run a stdio MCP server for agents (see below) |
105
100
 
106
101
  Mutating commands support `--json '<raw body>'` for direct OpenAPI payloads and `--dry-run` to preview requests without executing.
107
102
 
@@ -137,6 +132,32 @@ aximo session get <sessionId>
137
132
 
138
133
  Desktop sessions default to `platform=desktop`, `model=sonnet`. Mobile sessions require both `--device-arn` and `--upload-arn`.
139
134
 
135
+ ### Test cases
136
+
137
+ ```bash
138
+ aximo case create --project-id <id> --scenario <text> \
139
+ [--type web|mobile|desktop] [--title <t>] [--description <d>] \
140
+ [--format gherkin|markdown|plain_text] [--start-url <url>] \
141
+ [--directory-id <id>] [--json '<body>'] [--dry-run]
142
+
143
+ aximo session save-as-case <sessionId> \
144
+ [--title <t>] [--type web|mobile|desktop] \
145
+ [--scenario <text>] [--format gherkin|markdown|plain_text] \
146
+ [--json '<body>'] [--dry-run]
147
+ ```
148
+
149
+ `case create` returns a `caseId` you can run with `aximo trigger case run`. `session save-as-case` persists a finished session (terminal status, not yet linked to a case) as a reusable case, carrying over its scenario and run config. Cases created either way have API access enabled by default, so they are triggerable immediately.
150
+
151
+ ### Test plans
152
+
153
+ ```bash
154
+ aximo plan create --project-id <id> --title <t> [--description <d>] [--json '<body>'] [--dry-run]
155
+ aximo plan add-case <planId> --case-id <caseId> [--json '<body>'] [--dry-run]
156
+ aximo plan remove-case <planId> <caseId> [--dry-run]
157
+ ```
158
+
159
+ Build a runnable plan: `plan create` → `plan add-case` (repeat per case) → `aximo trigger plan run`. Plans created this way have API access enabled by default. Cases must belong to the same workspace as the plan.
160
+
140
161
  ### Triggers (test cases and plans)
141
162
 
142
163
  ```bash
@@ -146,7 +167,7 @@ aximo trigger plan run <planId> [...]
146
167
  aximo trigger plan get <planId> <planRunId>
147
168
  ```
148
169
 
149
- Triggers dashboard-created test cases or plans. API access must be enabled in the Aximo dashboard settings. `trigger case run` returns a `sessionId` you can poll; `trigger plan run` returns per-case breakdown.
170
+ Triggers a test case or plan. API access must be enabled on the resource — on by default for cases/plans created via this CLI; for dashboard-created ones, enable it in the Aximo dashboard settings. `trigger case run` returns a `sessionId` you can poll; `trigger plan run` returns a per-case breakdown.
150
171
 
151
172
  ### Uploads
152
173
 
@@ -335,17 +356,33 @@ The agent disables DPI-awareness and uses a calibrated scroll amount — if you
335
356
 
336
357
  Default `$AXIMO_HOME` is `~/.aximo` on macOS/Linux, `%USERPROFILE%\.aximo` on Windows. Override via the `AXIMO_HOME` env var.
337
358
 
338
- ### Sandbox vs production
359
+ > Devices are registered per-environment (see [Configuration](#configuration)) — a device registered against sandbox can't connect to production and vice versa.
360
+
361
+ ## MCP server
362
+
363
+ `aximo mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, exposing your recorded sessions, screenshots, and test cases to MCP-capable agents (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, …). The motivating use: let an agent read a finished session and generate a Playwright spec without you piping JSON between commands.
364
+
365
+ It reuses the same credentials as every other command, so run `aximo configure` (or set `AXIMO_API_KEY`) first.
366
+
367
+ **Claude Code** — one command:
339
368
 
340
369
  ```bash
341
- # Sandbox
342
- aximo configure --base-url https://aximo-sbx.autify.dev --api-key <sbx-key>
370
+ claude mcp add aximo -- aximo mcp
371
+ ```
372
+
373
+ **Other clients** — point them at the `aximo mcp` stdio command. Claude Desktop, Cursor, and Windsurf use the `mcpServers` key:
343
374
 
344
- # Production (default)
345
- aximo configure --base-url https://aximo.autify.com --api-key <prod-key>
375
+ ```json
376
+ {
377
+ "mcpServers": {
378
+ "aximo": { "command": "aximo", "args": ["mcp"] }
379
+ }
380
+ }
346
381
  ```
347
382
 
348
- Registrations are per-environment — a device registered against sandbox can't connect to production and vice versa.
383
+ VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
384
+
385
+ The server exposes 15 tools: 10 read-only ones for inspecting sessions and cases (`aximo_get_session_timeline` is the load-bearing one for Playwright export) plus 5 that author content (create cases and 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.
349
386
 
350
387
  ## Agent Skills
351
388
 
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import pkg from "../package.json" with { type: "json" };
5
5
  // is JSON-RPC.
6
6
  const args = process.argv.slice(2);
7
7
  if (args[0] === "mcp" && args.some((a) => a === "--help" || a === "-h")) {
8
- process.stderr.write(`aximo mcp — stdio MCP server exposing read-only Aximo data.\n` +
8
+ process.stderr.write(`aximo mcp — stdio MCP server exposing Aximo data plus create operations.\n` +
9
9
  `\n` +
10
10
  `Wire into an MCP client (e.g. Claude Desktop, Claude Code) by pointing it\n` +
11
11
  `at this binary. Auth uses ~/.aximo/config.json (run \`aximo configure\`).\n` +
@@ -23,6 +23,8 @@ const main = defineCommand({
23
23
  configure: () => import("./commands/configure.js").then((m) => m.default),
24
24
  health: () => import("./commands/health/index.js").then((m) => m.default),
25
25
  session: () => import("./commands/session/index.js").then((m) => m.default),
26
+ case: () => import("./commands/case/index.js").then((m) => m.default),
27
+ plan: () => import("./commands/plan/index.js").then((m) => m.default),
26
28
  trigger: () => import("./commands/trigger/index.js").then((m) => m.default),
27
29
  device: () => import("./commands/device/index.js").then((m) => m.default),
28
30
  upload: () => import("./commands/upload/index.js").then((m) => m.default),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
+ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
+ import { runCaseCreate } from "./create.js";
4
+ beforeAll(() => {
5
+ process.env["AXIMO_API_KEY"] = "test-key";
6
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
7
+ server.listen();
8
+ });
9
+ afterEach(() => server.resetHandlers());
10
+ afterAll(() => server.close());
11
+ const MIN = {
12
+ projectId: "proj-1",
13
+ scenario: "Open the login page and verify it loads",
14
+ };
15
+ describe("runCaseCreate", () => {
16
+ it("POSTs body and returns success envelope", async () => {
17
+ let seen;
18
+ server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
19
+ seen = await req.json();
20
+ return HttpResponse.json({ success: true, caseId: "c1" }, { status: 201 });
21
+ }));
22
+ const result = await runCaseCreate({ ...MIN, type: "web", title: "Login" });
23
+ expect(result.ok).toBe(true);
24
+ if (result.ok && !("dryRun" in result.data))
25
+ expect(result.data.caseId).toBe("c1");
26
+ expect(seen).toMatchObject({
27
+ projectId: "proj-1",
28
+ scenario: "Open the login page and verify it loads",
29
+ type: "web",
30
+ title: "Login",
31
+ });
32
+ });
33
+ it("rejects missing projectId", async () => {
34
+ const result = await runCaseCreate({ scenario: "x" });
35
+ expect(result.ok).toBe(false);
36
+ if (!result.ok)
37
+ expect(result.error.code).toBe("validation_error");
38
+ });
39
+ it("rejects missing scenario", async () => {
40
+ const result = await runCaseCreate({ projectId: "p1" });
41
+ expect(result.ok).toBe(false);
42
+ if (!result.ok)
43
+ expect(result.error.code).toBe("validation_error");
44
+ });
45
+ it("--json replaces individual flags and POSTs raw body", async () => {
46
+ let seen;
47
+ server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
48
+ seen = await req.json();
49
+ return HttpResponse.json({ success: true, caseId: "c2" }, { status: 201 });
50
+ }));
51
+ const raw = JSON.stringify({ projectId: "p1", scenario: "raw", extra: true });
52
+ const result = await runCaseCreate({ json: raw });
53
+ expect(result.ok).toBe(true);
54
+ expect(seen).toEqual({ projectId: "p1", scenario: "raw", extra: true });
55
+ });
56
+ it("--dry-run returns request without calling API", async () => {
57
+ const result = await runCaseCreate({ ...MIN, dryRun: true });
58
+ expect(result.ok).toBe(true);
59
+ if (result.ok && "dryRun" in result.data) {
60
+ expect(result.data.dryRun).toBe(true);
61
+ expect(result.data.method).toBe("POST");
62
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases`);
63
+ }
64
+ });
65
+ it("returns unauthorized envelope when AXIMO_API_KEY unset", async () => {
66
+ const prev = process.env["AXIMO_API_KEY"];
67
+ delete process.env["AXIMO_API_KEY"];
68
+ try {
69
+ const result = await runCaseCreate(MIN);
70
+ expect(result.ok).toBe(false);
71
+ if (!result.ok)
72
+ expect(result.error.code).toBe("unauthorized");
73
+ }
74
+ finally {
75
+ if (prev !== undefined)
76
+ process.env["AXIMO_API_KEY"] = prev;
77
+ }
78
+ });
79
+ it("maps 404 to not_found", async () => {
80
+ server.use(http.post(`${TEST_BASE_URL}/api/cases`, () => HttpResponse.json({ error: "Project not found" }, { status: 404 })));
81
+ const result = await runCaseCreate(MIN);
82
+ expect(result.ok).toBe(false);
83
+ if (!result.ok)
84
+ expect(result.error.code).toBe("not_found");
85
+ });
86
+ });
@@ -0,0 +1,53 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { CreateCaseResult, Envelope } from "../../types.js";
4
+ export interface CreateCaseOptions {
5
+ projectId?: unknown;
6
+ scenario?: unknown;
7
+ type?: unknown;
8
+ title?: unknown;
9
+ description?: unknown;
10
+ format?: unknown;
11
+ startUrl?: unknown;
12
+ directoryId?: unknown;
13
+ json?: unknown;
14
+ dryRun?: unknown;
15
+ }
16
+ export declare function runCaseCreate(options: CreateCaseOptions, deps?: ClientDeps): Promise<Envelope<CreateCaseResult | DryRunResult>>;
17
+ declare const _default: import("citty").CommandDef<{
18
+ readonly "project-id": {
19
+ readonly type: "string";
20
+ };
21
+ readonly scenario: {
22
+ readonly type: "string";
23
+ };
24
+ readonly type: {
25
+ readonly type: "string";
26
+ readonly description: "web | mobile | desktop";
27
+ };
28
+ readonly title: {
29
+ readonly type: "string";
30
+ };
31
+ readonly description: {
32
+ readonly type: "string";
33
+ };
34
+ readonly format: {
35
+ readonly type: "string";
36
+ readonly description: "gherkin | markdown | plain_text";
37
+ };
38
+ readonly "start-url": {
39
+ readonly type: "string";
40
+ };
41
+ readonly "directory-id": {
42
+ readonly type: "string";
43
+ };
44
+ readonly json: {
45
+ readonly type: "string";
46
+ readonly description: "Raw JSON request body (replaces individual flags)";
47
+ };
48
+ readonly "dry-run": {
49
+ readonly type: "boolean";
50
+ readonly description: "Validate and show request without calling API";
51
+ };
52
+ }>;
53
+ export default _default;
@@ -0,0 +1,71 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { optionalString, requireString } from "../../validate.js";
7
+ export async function runCaseCreate(options, deps) {
8
+ let body;
9
+ const override = resolveJsonOverride(options.json);
10
+ if (override && "error" in override)
11
+ return override.error;
12
+ if (override) {
13
+ body = override.body;
14
+ }
15
+ else {
16
+ try {
17
+ body = {
18
+ projectId: requireString("projectId", options.projectId),
19
+ scenario: requireString("scenario", options.scenario),
20
+ type: optionalString("type", options.type),
21
+ title: optionalString("title", options.title),
22
+ description: optionalString("description", options.description),
23
+ format: optionalString("format", options.format),
24
+ startUrl: optionalString("startUrl", options.startUrl),
25
+ directoryId: optionalString("directoryId", options.directoryId),
26
+ };
27
+ }
28
+ catch (err) {
29
+ return validationError(err);
30
+ }
31
+ }
32
+ if (options.dryRun) {
33
+ return dryRunEnvelope("POST", "/api/cases", body);
34
+ }
35
+ try {
36
+ const res = await request("/api/cases", { method: "POST", body }, deps ?? {});
37
+ return formatSuccess(res);
38
+ }
39
+ catch (err) {
40
+ return handleApiError(err);
41
+ }
42
+ }
43
+ export default defineCommand({
44
+ meta: { name: "create", description: "Create a test case" },
45
+ args: {
46
+ "project-id": { type: "string" },
47
+ scenario: { type: "string" },
48
+ type: { type: "string", description: "web | mobile | desktop" },
49
+ title: { type: "string" },
50
+ description: { type: "string" },
51
+ format: { type: "string", description: "gherkin | markdown | plain_text" },
52
+ "start-url": { type: "string" },
53
+ "directory-id": { type: "string" },
54
+ json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
55
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
56
+ },
57
+ async run({ args }) {
58
+ printResult(await runCaseCreate({
59
+ projectId: args["project-id"],
60
+ scenario: args.scenario,
61
+ type: args.type,
62
+ title: args.title,
63
+ description: args.description,
64
+ format: args.format,
65
+ startUrl: args["start-url"],
66
+ directoryId: args["directory-id"],
67
+ json: args.json,
68
+ dryRun: args["dry-run"],
69
+ }));
70
+ },
71
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("citty").CommandDef<import("citty").ArgsDef>;
2
+ export default _default;
@@ -0,0 +1,7 @@
1
+ import { defineCommand } from "citty";
2
+ export default defineCommand({
3
+ meta: { name: "case", description: "Test case commands" },
4
+ subCommands: {
5
+ create: () => import("./create.js").then((m) => m.default),
6
+ },
7
+ });
@@ -1,7 +1,7 @@
1
- import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
2
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
- import { HttpResponse, TEST_BASE_URL, http, server } from "../../test-utils.js";
3
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
4
+ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
5
5
  import { createMcpServer } from "./server.js";
6
6
  beforeAll(() => {
7
7
  process.env["AXIMO_API_KEY"] = "test-key";
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 10 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 15 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();
@@ -39,6 +39,11 @@ describe("createMcpServer wiring", () => {
39
39
  "aximo_list_cases",
40
40
  "aximo_list_projects",
41
41
  "aximo_list_sessions",
42
+ "aximo_create_case",
43
+ "aximo_create_plan",
44
+ "aximo_add_case_to_plan",
45
+ "aximo_remove_case_from_plan",
46
+ "aximo_save_session_as_case",
42
47
  ].sort());
43
48
  const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
44
49
  expect(timeline?.inputSchema?.required).toContain("sessionId");
@@ -194,5 +194,75 @@ export function defineTools(deps = {}) {
194
194
  return await request(`/api/cases/${encodeURIComponent(caseId)}`, {}, deps);
195
195
  }),
196
196
  },
197
+ {
198
+ name: "aximo_create_case",
199
+ title: "Create test case",
200
+ description: "Create a saved test case in a workspace. The case is API-enabled, so it can be triggered immediately. Returns the new caseId.",
201
+ inputSchema: {
202
+ projectId: z.string().uuid(),
203
+ scenario: z.string().min(1),
204
+ type: z.enum(["web", "mobile", "desktop"]).optional(),
205
+ title: z.string().min(1).max(500).optional(),
206
+ description: z.string().optional(),
207
+ format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
208
+ startUrl: z.string().url().optional(),
209
+ directoryId: z.string().uuid().optional(),
210
+ },
211
+ handler: async (input) => call(async () => {
212
+ return await request("/api/cases", { method: "POST", body: input }, deps);
213
+ }),
214
+ },
215
+ {
216
+ name: "aximo_create_plan",
217
+ title: "Create test plan",
218
+ description: "Create an empty test plan in a workspace. Add cases with aximo_add_case_to_plan, then run it. Returns the new planId.",
219
+ inputSchema: {
220
+ projectId: z.string().uuid(),
221
+ title: z.string().min(1).max(500),
222
+ description: z.string().optional(),
223
+ },
224
+ handler: async (input) => call(async () => {
225
+ return await request("/api/plans", { method: "POST", body: input }, deps);
226
+ }),
227
+ },
228
+ {
229
+ name: "aximo_add_case_to_plan",
230
+ title: "Add case to plan",
231
+ description: "Append a test case to the end of a plan. The case must belong to the same workspace as the plan.",
232
+ inputSchema: {
233
+ planId: z.string().uuid(),
234
+ caseId: z.string().uuid(),
235
+ },
236
+ handler: async ({ planId, caseId }) => call(async () => {
237
+ return await request(`/api/plans/${encodeURIComponent(planId)}/cases`, { method: "POST", body: { caseId } }, deps);
238
+ }),
239
+ },
240
+ {
241
+ name: "aximo_remove_case_from_plan",
242
+ title: "Remove case from plan",
243
+ description: "Remove a test case from a plan. Idempotent.",
244
+ inputSchema: {
245
+ planId: z.string().uuid(),
246
+ caseId: z.string().uuid(),
247
+ },
248
+ handler: async ({ planId, caseId }) => call(async () => {
249
+ return await request(`/api/plans/${encodeURIComponent(planId)}/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
250
+ }),
251
+ },
252
+ {
253
+ name: "aximo_save_session_as_case",
254
+ title: "Save session as test case",
255
+ description: "Persist a finished agent session as a reusable test case and link the two. The session must be in a terminal state and not already linked to a case. The scenario defaults to the session summary. Returns the new caseId and projectId.",
256
+ inputSchema: {
257
+ sessionId: z.string().uuid(),
258
+ title: z.string().min(1).max(500).optional(),
259
+ type: z.enum(["web", "mobile", "desktop"]).optional(),
260
+ scenario: z.string().min(1).optional(),
261
+ format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
262
+ },
263
+ handler: async ({ sessionId, ...body }) => call(async () => {
264
+ return await request(`/api/sessions/${encodeURIComponent(sessionId)}/save-as-case`, { method: "POST", body }, deps);
265
+ }),
266
+ },
197
267
  ];
198
268
  }
@@ -1,5 +1,5 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
- import { HttpResponse, TEST_BASE_URL, http, server } from "../../test-utils.js";
2
+ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
3
  import { defineTools } from "./tools.js";
4
4
  beforeAll(() => {
5
5
  process.env["AXIMO_API_KEY"] = "test-key";
@@ -31,6 +31,11 @@ describe("defineTools", () => {
31
31
  "aximo_get_session_snapshot",
32
32
  "aximo_list_cases",
33
33
  "aximo_get_case",
34
+ "aximo_create_case",
35
+ "aximo_create_plan",
36
+ "aximo_add_case_to_plan",
37
+ "aximo_remove_case_from_plan",
38
+ "aximo_save_session_as_case",
34
39
  ]);
35
40
  });
36
41
  });
@@ -203,3 +208,62 @@ describe("call() error mapping", () => {
203
208
  expect(payload.hint).toMatch(/AXIMO_BASE_URL/);
204
209
  });
205
210
  });
211
+ describe("write tools", () => {
212
+ it("aximo_create_case POSTs the body to /api/cases", async () => {
213
+ let seen = null;
214
+ server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
215
+ seen = { method: req.method, body: await req.json() };
216
+ return HttpResponse.json({ success: true, caseId: "c1" }, { status: 201 });
217
+ }));
218
+ const tool = byName(defineTools(), "aximo_create_case");
219
+ const result = await tool.handler({ projectId: "p1", scenario: "do a thing", type: "web" });
220
+ expect(result.isError).toBeUndefined();
221
+ expect(parsePayload(result)).toEqual({ success: true, caseId: "c1" });
222
+ expect(seen.method).toBe("POST");
223
+ expect(seen.body).toMatchObject({ projectId: "p1", scenario: "do a thing", type: "web" });
224
+ });
225
+ it("aximo_create_plan POSTs the body to /api/plans", async () => {
226
+ let body;
227
+ server.use(http.post(`${TEST_BASE_URL}/api/plans`, async ({ request: req }) => {
228
+ body = await req.json();
229
+ return HttpResponse.json({ success: true, planId: "pl1" }, { status: 201 });
230
+ }));
231
+ const tool = byName(defineTools(), "aximo_create_plan");
232
+ const result = await tool.handler({ projectId: "p1", title: "Regression" });
233
+ expect(parsePayload(result)).toEqual({ success: true, planId: "pl1" });
234
+ expect(body).toMatchObject({ projectId: "p1", title: "Regression" });
235
+ });
236
+ it("aximo_add_case_to_plan POSTs caseId to the plan-cases path", async () => {
237
+ let body;
238
+ server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/cases`, async ({ request: req }) => {
239
+ body = await req.json();
240
+ return HttpResponse.json({ success: true }, { status: 201 });
241
+ }));
242
+ const tool = byName(defineTools(), "aximo_add_case_to_plan");
243
+ const result = await tool.handler({ planId: "pl1", caseId: "c1" });
244
+ expect(parsePayload(result)).toEqual({ success: true });
245
+ expect(body).toEqual({ caseId: "c1" });
246
+ });
247
+ it("aximo_remove_case_from_plan DELETEs the plan-case path", async () => {
248
+ let method = null;
249
+ server.use(http.delete(`${TEST_BASE_URL}/api/plans/pl1/cases/c1`, ({ request: req }) => {
250
+ method = req.method;
251
+ return HttpResponse.json({ success: true });
252
+ }));
253
+ const tool = byName(defineTools(), "aximo_remove_case_from_plan");
254
+ const result = await tool.handler({ planId: "pl1", caseId: "c1" });
255
+ expect(parsePayload(result)).toEqual({ success: true });
256
+ expect(method).toBe("DELETE");
257
+ });
258
+ it("aximo_save_session_as_case POSTs to the save-as-case path without the id in the body", async () => {
259
+ let body;
260
+ server.use(http.post(`${TEST_BASE_URL}/api/sessions/s1/save-as-case`, async ({ request: req }) => {
261
+ body = await req.json();
262
+ return HttpResponse.json({ success: true, caseId: "c1", projectId: "p1" }, { status: 201 });
263
+ }));
264
+ const tool = byName(defineTools(), "aximo_save_session_as_case");
265
+ const result = await tool.handler({ sessionId: "s1", title: "Saved" });
266
+ expect(parsePayload(result)).toEqual({ success: true, caseId: "c1", projectId: "p1" });
267
+ expect(body).toEqual({ title: "Saved" });
268
+ });
269
+ });