@autifyhq/aximo-cli 0.1.0 → 0.2.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/dist/cli.js +13 -0
- package/dist/commands/device/index.js +4 -1
- package/dist/commands/device/list.js +4 -1
- package/dist/commands/device/register.js +1 -1
- package/dist/commands/device/status.js +4 -1
- package/dist/commands/mcp/index.d.ts +2 -0
- package/dist/commands/mcp/index.js +29 -0
- package/dist/commands/mcp/server.d.ts +3 -0
- package/dist/commands/mcp/server.js +19 -0
- package/dist/commands/mcp/server.test.d.ts +1 -0
- package/dist/commands/mcp/server.test.js +84 -0
- package/dist/commands/mcp/tools.d.ts +18 -0
- package/dist/commands/mcp/tools.js +198 -0
- package/dist/commands/mcp/tools.test.d.ts +1 -0
- package/dist/commands/mcp/tools.test.js +205 -0
- package/dist/commands/schema.js +2 -2
- package/dist/types.d.ts +110 -0
- package/package.json +4 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
3
|
import pkg from "../package.json" with { type: "json" };
|
|
4
|
+
// citty's --help prints to stdout; route to stderr for `mcp` since its stdout
|
|
5
|
+
// is JSON-RPC.
|
|
6
|
+
const args = process.argv.slice(2);
|
|
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` +
|
|
9
|
+
`\n` +
|
|
10
|
+
`Wire into an MCP client (e.g. Claude Desktop, Claude Code) by pointing it\n` +
|
|
11
|
+
`at this binary. Auth uses ~/.aximo/config.json (run \`aximo configure\`).\n` +
|
|
12
|
+
`\n` +
|
|
13
|
+
`Stdout is reserved for JSON-RPC messages — diagnostics go to stderr.\n`);
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
4
16
|
const main = defineCommand({
|
|
5
17
|
meta: {
|
|
6
18
|
name: "aximo",
|
|
@@ -16,6 +28,7 @@ const main = defineCommand({
|
|
|
16
28
|
upload: () => import("./commands/upload/index.js").then((m) => m.default),
|
|
17
29
|
project: () => import("./commands/project/index.js").then((m) => m.default),
|
|
18
30
|
schema: () => import("./commands/schema.js").then((m) => m.default),
|
|
31
|
+
mcp: () => import("./commands/mcp/index.js").then((m) => m.default),
|
|
19
32
|
},
|
|
20
33
|
});
|
|
21
34
|
runMain(main);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
export default defineCommand({
|
|
3
|
-
meta: {
|
|
3
|
+
meta: {
|
|
4
|
+
name: "device",
|
|
5
|
+
description: "BYOD device commands (register/connect/status/...); 'device list' lists Device Farm mobile devices",
|
|
6
|
+
},
|
|
4
7
|
subCommands: {
|
|
5
8
|
list: () => import("./list.js").then((m) => m.default),
|
|
6
9
|
register: () => import("./register.js").then((m) => m.default),
|
|
@@ -26,7 +26,10 @@ export async function runDeviceList(options = {}, deps) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
export default defineCommand({
|
|
29
|
-
meta: {
|
|
29
|
+
meta: {
|
|
30
|
+
name: "list",
|
|
31
|
+
description: "List Device Farm mobile devices (ANDROID/IOS) — not BYOD; for your registered machines use 'device status'",
|
|
32
|
+
},
|
|
30
33
|
args: {
|
|
31
34
|
platform: { type: "string", description: "ANDROID | IOS" },
|
|
32
35
|
"available-only": { type: "boolean", description: "Only available devices" },
|
|
@@ -65,7 +65,7 @@ export async function runDeviceRegister(options, deps) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
export default defineCommand({
|
|
68
|
-
meta: { name: "register", description: "Register this machine as
|
|
68
|
+
meta: { name: "register", description: "Register this machine as a BYOD device" },
|
|
69
69
|
args: {
|
|
70
70
|
name: {
|
|
71
71
|
type: "string",
|
|
@@ -13,7 +13,10 @@ export async function runDeviceStatus(deps) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
export default defineCommand({
|
|
16
|
-
meta: {
|
|
16
|
+
meta: {
|
|
17
|
+
name: "status",
|
|
18
|
+
description: "List your registered BYOD devices with online/offline status — not Device Farm (see 'device list')",
|
|
19
|
+
},
|
|
17
20
|
async run() {
|
|
18
21
|
printResult(await runDeviceStatus());
|
|
19
22
|
},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createMcpServer } from "./server.js";
|
|
4
|
+
import { getApiKey, getBaseUrl } from "../../client.js";
|
|
5
|
+
// --help / -h are intercepted in cli.ts before citty parses argv.
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: "mcp",
|
|
9
|
+
description: "Run an MCP server exposing Aximo data over stdio",
|
|
10
|
+
},
|
|
11
|
+
args: {},
|
|
12
|
+
async run() {
|
|
13
|
+
// Fail fast before the JSON-RPC handshake; diagnostics go to stderr only.
|
|
14
|
+
try {
|
|
15
|
+
getApiKey();
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
19
|
+
process.stderr.write(`aximo mcp: ${message}\n`);
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const baseUrl = getBaseUrl();
|
|
24
|
+
process.stderr.write(`aximo mcp: serving stdio MCP against ${baseUrl}\n`);
|
|
25
|
+
const server = createMcpServer();
|
|
26
|
+
const transport = new StdioServerTransport();
|
|
27
|
+
await server.connect(transport);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { defineTools } from "./tools.js";
|
|
3
|
+
import pkg from "../../../package.json" with { type: "json" };
|
|
4
|
+
export function createMcpServer(deps = {}) {
|
|
5
|
+
const server = new McpServer({
|
|
6
|
+
name: "aximo",
|
|
7
|
+
version: pkg.version,
|
|
8
|
+
});
|
|
9
|
+
for (const tool of defineTools(deps)) {
|
|
10
|
+
server.registerTool(tool.name, {
|
|
11
|
+
title: tool.title,
|
|
12
|
+
description: tool.description,
|
|
13
|
+
inputSchema: tool.inputSchema,
|
|
14
|
+
},
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: SDK's registerTool signature is generic over schema; safe at runtime
|
|
16
|
+
tool.handler);
|
|
17
|
+
}
|
|
18
|
+
return server;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { HttpResponse, TEST_BASE_URL, http, server } from "../../test-utils.js";
|
|
5
|
+
import { createMcpServer } from "./server.js";
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
8
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
9
|
+
server.listen();
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => server.resetHandlers());
|
|
12
|
+
afterAll(() => server.close());
|
|
13
|
+
async function connect() {
|
|
14
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
15
|
+
const mcpServer = createMcpServer();
|
|
16
|
+
const client = new Client({ name: "test-client", version: "0.0.0" }, { capabilities: {} });
|
|
17
|
+
await Promise.all([mcpServer.connect(serverTransport), client.connect(clientTransport)]);
|
|
18
|
+
return {
|
|
19
|
+
client,
|
|
20
|
+
cleanup: async () => {
|
|
21
|
+
await client.close();
|
|
22
|
+
await mcpServer.close();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
describe("createMcpServer wiring", () => {
|
|
27
|
+
it("exposes all 10 tools through tools/list with valid JSON Schema", async () => {
|
|
28
|
+
const { client, cleanup } = await connect();
|
|
29
|
+
try {
|
|
30
|
+
const { tools } = await client.listTools();
|
|
31
|
+
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
32
|
+
"aximo_get_case",
|
|
33
|
+
"aximo_get_session",
|
|
34
|
+
"aximo_get_session_capture",
|
|
35
|
+
"aximo_get_session_snapshot",
|
|
36
|
+
"aximo_get_session_messages",
|
|
37
|
+
"aximo_get_session_screenshots",
|
|
38
|
+
"aximo_get_session_timeline",
|
|
39
|
+
"aximo_list_cases",
|
|
40
|
+
"aximo_list_projects",
|
|
41
|
+
"aximo_list_sessions",
|
|
42
|
+
].sort());
|
|
43
|
+
const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
|
|
44
|
+
expect(timeline?.inputSchema?.required).toContain("sessionId");
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await cleanup();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it("dispatches a tools/call and returns the backend payload", async () => {
|
|
51
|
+
const sessionId = "77777777-7777-4777-9777-777777777777";
|
|
52
|
+
const payload = { sessionId, stepCount: 0, steps: [] };
|
|
53
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json(payload)));
|
|
54
|
+
const { client, cleanup } = await connect();
|
|
55
|
+
try {
|
|
56
|
+
const result = await client.callTool({
|
|
57
|
+
name: "aximo_get_session_timeline",
|
|
58
|
+
arguments: { sessionId },
|
|
59
|
+
});
|
|
60
|
+
expect(result.isError).toBeFalsy();
|
|
61
|
+
const content = result.content;
|
|
62
|
+
const text = content[0].text;
|
|
63
|
+
expect(JSON.parse(text)).toEqual(payload);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await cleanup();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it("returns isError with a validation message when input fails the schema", async () => {
|
|
70
|
+
const { client, cleanup } = await connect();
|
|
71
|
+
try {
|
|
72
|
+
const result = await client.callTool({
|
|
73
|
+
name: "aximo_get_session_timeline",
|
|
74
|
+
arguments: { sessionId: "not-a-uuid" },
|
|
75
|
+
});
|
|
76
|
+
expect(result.isError).toBe(true);
|
|
77
|
+
const content = result.content;
|
|
78
|
+
expect(content[0].text).toMatch(/Invalid uuid|Input validation/);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await cleanup();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type ClientDeps } from "../../client.js";
|
|
3
|
+
interface ToolResult {
|
|
4
|
+
content: Array<{
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
}>;
|
|
8
|
+
isError?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface Tool<TInput extends z.ZodRawShape = z.ZodRawShape> {
|
|
11
|
+
name: string;
|
|
12
|
+
title: string;
|
|
13
|
+
description: string;
|
|
14
|
+
inputSchema: TInput;
|
|
15
|
+
handler: (input: z.infer<z.ZodObject<TInput>>) => Promise<ToolResult>;
|
|
16
|
+
}
|
|
17
|
+
export declare function defineTools(deps?: ClientDeps): Tool[];
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { request } from "../../client.js";
|
|
3
|
+
function ok(payload) {
|
|
4
|
+
return {
|
|
5
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function err(message, hint) {
|
|
9
|
+
return {
|
|
10
|
+
content: [
|
|
11
|
+
{
|
|
12
|
+
type: "text",
|
|
13
|
+
text: JSON.stringify({ error: message, ...(hint ? { hint } : {}) }, null, 2),
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
isError: true,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function call(fn) {
|
|
20
|
+
try {
|
|
21
|
+
return ok(await fn());
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (!(e instanceof Error))
|
|
25
|
+
return err(String(e));
|
|
26
|
+
const status = e.status;
|
|
27
|
+
if (status === 401)
|
|
28
|
+
return err("Unauthorized: check AXIMO_API_KEY", "Run `aximo configure`.");
|
|
29
|
+
if (status === 403) {
|
|
30
|
+
return err(e.message || "Forbidden", "API key may not have access to this resource.");
|
|
31
|
+
}
|
|
32
|
+
if (status === 404)
|
|
33
|
+
return err(e.message || "Not found");
|
|
34
|
+
if (status === 429)
|
|
35
|
+
return err("Rate limited", "Aximo enforces a daily session limit.");
|
|
36
|
+
if (typeof status === "number")
|
|
37
|
+
return err(`HTTP ${status}: ${e.message}`);
|
|
38
|
+
if (e.message.startsWith("No API key configured")) {
|
|
39
|
+
return err("API key not configured", "Run `aximo configure` and restart the MCP server.");
|
|
40
|
+
}
|
|
41
|
+
const cause = e.cause;
|
|
42
|
+
const networkCode = cause?.code;
|
|
43
|
+
if (networkCode) {
|
|
44
|
+
return err(`Network error (${networkCode}): ${cause?.message ?? e.message}`, "Check AXIMO_BASE_URL and your network connection.");
|
|
45
|
+
}
|
|
46
|
+
return err(e.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function buildQuery(params) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [k, v] of Object.entries(params)) {
|
|
52
|
+
if (v !== undefined && v !== "")
|
|
53
|
+
out[k] = v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
export function defineTools(deps = {}) {
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
name: "aximo_list_projects",
|
|
61
|
+
title: "List workspaces",
|
|
62
|
+
description: "List workspaces (projects) available to your organization. Use a project's id when listing sessions or cases.",
|
|
63
|
+
inputSchema: {},
|
|
64
|
+
handler: async () => call(async () => {
|
|
65
|
+
const res = await request("/api/organizations/projects", {}, deps);
|
|
66
|
+
return res;
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "aximo_list_sessions",
|
|
71
|
+
title: "List sessions",
|
|
72
|
+
description: "List sessions visible to your organization, newest first. Filter by projectId, caseId, or status. Paginated.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
projectId: z.string().uuid().optional(),
|
|
75
|
+
caseId: z.string().uuid().optional(),
|
|
76
|
+
status: z.string().optional(),
|
|
77
|
+
page: z.number().int().positive().optional(),
|
|
78
|
+
pageSize: z.number().int().positive().max(100).optional(),
|
|
79
|
+
},
|
|
80
|
+
handler: async (input) => call(async () => {
|
|
81
|
+
return await request("/api/sessions", { query: buildQuery(input) }, deps);
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "aximo_get_session",
|
|
86
|
+
title: "Get session",
|
|
87
|
+
description: "Get session metadata, status, summary, observations, credits used, and counts (screenshots, tool calls).",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
sessionId: z.string().uuid(),
|
|
90
|
+
},
|
|
91
|
+
handler: async ({ sessionId }) => call(async () => {
|
|
92
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}`, {}, deps);
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "aximo_get_session_messages",
|
|
97
|
+
title: "Get session messages",
|
|
98
|
+
description: "Get the message timeline (tool calls, assistant text, user input) for a session. Cursor-paginated.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
sessionId: z.string().uuid(),
|
|
101
|
+
cursor: z.string().optional(),
|
|
102
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
103
|
+
order: z.enum(["asc", "desc"]).optional(),
|
|
104
|
+
},
|
|
105
|
+
handler: async ({ sessionId, ...rest }) => call(async () => {
|
|
106
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { query: buildQuery(rest) }, deps);
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "aximo_get_session_screenshots",
|
|
111
|
+
title: "Get session screenshots",
|
|
112
|
+
description: "Get screenshots captured during a session, ordered by capture time. URLs are presigned (S3) or relative.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
sessionId: z.string().uuid(),
|
|
115
|
+
cursor: z.string().optional(),
|
|
116
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
117
|
+
order: z.enum(["asc", "desc"]).optional(),
|
|
118
|
+
},
|
|
119
|
+
handler: async ({ sessionId, ...rest }) => call(async () => {
|
|
120
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/screenshots`, { query: buildQuery(rest) }, deps);
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "aximo_get_session_timeline",
|
|
125
|
+
title: "Get session timeline",
|
|
126
|
+
description: "Get the merged step timeline: each step pairs a message (tool call, assistant turn, user input) with its screenshots. This is the recommended endpoint when generating a Playwright spec from a session.",
|
|
127
|
+
inputSchema: {
|
|
128
|
+
sessionId: z.string().uuid(),
|
|
129
|
+
},
|
|
130
|
+
handler: async ({ sessionId }) => call(async () => {
|
|
131
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/timeline`, {}, deps);
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "aximo_get_session_capture",
|
|
136
|
+
title: "Get session CDP capture",
|
|
137
|
+
description: "Get the CDP-derived capture metadata for a session: ordered timestamped events (actions, navigations, network XHR/fetch, console, exceptions, snapshot triggers), browser context, and post-session storage state. By default snapshots[] contains metadata only (id, url, title, domBytes, axBytes) — call aximo_get_session_snapshot to fetch a single snapshot's DOM/AX content, or pass includeSnapshots: 'full' to inline all of them (can be multi-MB). Cookie values are redacted by default — pass includeCookieValues: true only when you genuinely need them (e.g. to emit a Playwright context.storageState()). Returns 404 if the org doesn't have the CDP capture feature flag enabled or the session ran without capture.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
sessionId: z.string().uuid(),
|
|
140
|
+
includeSnapshots: z
|
|
141
|
+
.enum(["none", "metadata", "full"])
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("Default 'metadata'. Use 'full' only when you need DOM/AX content inline."),
|
|
144
|
+
includeCookieValues: z
|
|
145
|
+
.boolean()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe("Default false. When true, cookie 'value' fields are returned unmasked — needed for Playwright storageState export."),
|
|
148
|
+
},
|
|
149
|
+
handler: async ({ sessionId, includeSnapshots, includeCookieValues }) => call(async () => {
|
|
150
|
+
const query = {};
|
|
151
|
+
if (includeSnapshots)
|
|
152
|
+
query["includeSnapshots"] = includeSnapshots;
|
|
153
|
+
if (includeCookieValues)
|
|
154
|
+
query["includeCookieValues"] = "true";
|
|
155
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/capture`, { query }, deps);
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "aximo_get_session_snapshot",
|
|
160
|
+
title: "Get one DOM+AX snapshot",
|
|
161
|
+
description: "Fetch a single DOM + accessibility-tree snapshot by id (from aximo_get_session_capture's snapshots[] list). Use this instead of pulling the whole capture blob when you only need one page's structure (a single snapshot can be 100 KB – 5 MB).",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
sessionId: z.string().uuid(),
|
|
164
|
+
snapshotId: z.string().uuid(),
|
|
165
|
+
},
|
|
166
|
+
handler: async ({ sessionId, snapshotId }) => call(async () => {
|
|
167
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/snapshots/${encodeURIComponent(snapshotId)}`, {}, deps);
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "aximo_list_cases",
|
|
172
|
+
title: "List test cases",
|
|
173
|
+
description: "List saved test cases for a workspace. Filter by type, search, or directory.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
projectId: z.string().uuid(),
|
|
176
|
+
type: z.enum(["web", "mobile"]).optional(),
|
|
177
|
+
search: z.string().optional(),
|
|
178
|
+
directoryId: z.string().optional(),
|
|
179
|
+
page: z.number().int().positive().optional(),
|
|
180
|
+
pageSize: z.number().int().positive().max(100).optional(),
|
|
181
|
+
},
|
|
182
|
+
handler: async (input) => call(async () => {
|
|
183
|
+
return await request("/api/cases", { query: buildQuery(input) }, deps);
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "aximo_get_case",
|
|
188
|
+
title: "Get test case",
|
|
189
|
+
description: "Get the full scenario text and metadata for a saved test case. Useful for grounding generated Playwright specs.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
caseId: z.string().uuid(),
|
|
192
|
+
},
|
|
193
|
+
handler: async ({ caseId }) => call(async () => {
|
|
194
|
+
return await request(`/api/cases/${encodeURIComponent(caseId)}`, {}, deps);
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { HttpResponse, TEST_BASE_URL, http, server } from "../../test-utils.js";
|
|
3
|
+
import { defineTools } from "./tools.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
|
+
function byName(tools, name) {
|
|
12
|
+
const t = tools.find((x) => x.name === name);
|
|
13
|
+
if (!t)
|
|
14
|
+
throw new Error(`tool ${name} not found`);
|
|
15
|
+
return t;
|
|
16
|
+
}
|
|
17
|
+
function parsePayload(result) {
|
|
18
|
+
return JSON.parse(result.content[0].text);
|
|
19
|
+
}
|
|
20
|
+
describe("defineTools", () => {
|
|
21
|
+
it("registers the expected tool names", () => {
|
|
22
|
+
const names = defineTools().map((t) => t.name);
|
|
23
|
+
expect(names).toEqual([
|
|
24
|
+
"aximo_list_projects",
|
|
25
|
+
"aximo_list_sessions",
|
|
26
|
+
"aximo_get_session",
|
|
27
|
+
"aximo_get_session_messages",
|
|
28
|
+
"aximo_get_session_screenshots",
|
|
29
|
+
"aximo_get_session_timeline",
|
|
30
|
+
"aximo_get_session_capture",
|
|
31
|
+
"aximo_get_session_snapshot",
|
|
32
|
+
"aximo_list_cases",
|
|
33
|
+
"aximo_get_case",
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe("aximo_get_session_timeline", () => {
|
|
38
|
+
it("calls /api/sessions/:id/timeline and returns the JSON payload", async () => {
|
|
39
|
+
const sessionId = "11111111-1111-4111-9111-111111111111";
|
|
40
|
+
const payload = { sessionId, stepCount: 1, steps: [] };
|
|
41
|
+
let captured = null;
|
|
42
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, ({ request: req }) => {
|
|
43
|
+
captured = req.headers.get("x-api-key");
|
|
44
|
+
return HttpResponse.json(payload);
|
|
45
|
+
}));
|
|
46
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
47
|
+
const result = await tool.handler({ sessionId });
|
|
48
|
+
expect(result.isError).toBeUndefined();
|
|
49
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
50
|
+
expect(captured).toBe("test-key");
|
|
51
|
+
});
|
|
52
|
+
it("returns a structured error when the API returns 404", async () => {
|
|
53
|
+
const sessionId = "22222222-2222-4222-9222-222222222222";
|
|
54
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Session not found" }, { status: 404 })));
|
|
55
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
56
|
+
const result = await tool.handler({ sessionId });
|
|
57
|
+
expect(result.isError).toBe(true);
|
|
58
|
+
expect(parsePayload(result)).toMatchObject({ error: "Session not found" });
|
|
59
|
+
});
|
|
60
|
+
it("returns a structured error when the API returns 401", async () => {
|
|
61
|
+
const sessionId = "33333333-3333-4333-9333-333333333333";
|
|
62
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Unauthorized" }, { status: 401 })));
|
|
63
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
64
|
+
const result = await tool.handler({ sessionId });
|
|
65
|
+
expect(result.isError).toBe(true);
|
|
66
|
+
const payload = parsePayload(result);
|
|
67
|
+
expect(payload.error).toMatch(/Unauthorized/);
|
|
68
|
+
expect(payload.hint).toMatch(/configure/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe("aximo_list_sessions", () => {
|
|
72
|
+
it("passes through query parameters", async () => {
|
|
73
|
+
let url = null;
|
|
74
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions`, ({ request: req }) => {
|
|
75
|
+
url = new URL(req.url);
|
|
76
|
+
return HttpResponse.json({
|
|
77
|
+
items: [],
|
|
78
|
+
totalCount: 0,
|
|
79
|
+
page: 1,
|
|
80
|
+
pageSize: 25,
|
|
81
|
+
totalPages: 0,
|
|
82
|
+
});
|
|
83
|
+
}));
|
|
84
|
+
const tool = byName(defineTools(), "aximo_list_sessions");
|
|
85
|
+
await tool.handler({
|
|
86
|
+
projectId: "44444444-4444-4444-8444-444444444444",
|
|
87
|
+
status: "succeeded",
|
|
88
|
+
page: 2,
|
|
89
|
+
});
|
|
90
|
+
expect(url).not.toBeNull();
|
|
91
|
+
expect(url.searchParams.get("projectId")).toBe("44444444-4444-4444-8444-444444444444");
|
|
92
|
+
expect(url.searchParams.get("status")).toBe("succeeded");
|
|
93
|
+
expect(url.searchParams.get("page")).toBe("2");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe("aximo_get_case", () => {
|
|
97
|
+
it("returns the case scenario payload", async () => {
|
|
98
|
+
const caseId = "55555555-5555-4555-9555-555555555555";
|
|
99
|
+
const payload = {
|
|
100
|
+
id: caseId,
|
|
101
|
+
projectId: "p1",
|
|
102
|
+
type: "web",
|
|
103
|
+
title: "Login flow",
|
|
104
|
+
scenario: "Given I navigate to login\nThen I am logged in",
|
|
105
|
+
format: "gherkin",
|
|
106
|
+
};
|
|
107
|
+
server.use(http.get(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json(payload)));
|
|
108
|
+
const tool = byName(defineTools(), "aximo_get_case");
|
|
109
|
+
const result = await tool.handler({ caseId });
|
|
110
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe("aximo_list_cases", () => {
|
|
114
|
+
it("requires projectId at schema time", () => {
|
|
115
|
+
const tool = byName(defineTools(), "aximo_list_cases");
|
|
116
|
+
// biome-ignore lint/suspicious/noExplicitAny: zod runtime check
|
|
117
|
+
const parsed = tool.inputSchema.projectId.safeParse(undefined);
|
|
118
|
+
expect(parsed.success).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe("aximo_list_projects", () => {
|
|
122
|
+
it("calls /api/organizations/projects and returns the payload", async () => {
|
|
123
|
+
const payload = { projects: [{ id: "p1", name: "Default" }] };
|
|
124
|
+
let captured = null;
|
|
125
|
+
server.use(http.get(`${TEST_BASE_URL}/api/organizations/projects`, ({ request: req }) => {
|
|
126
|
+
captured = { method: req.method, url: req.url };
|
|
127
|
+
return HttpResponse.json(payload);
|
|
128
|
+
}));
|
|
129
|
+
const tool = byName(defineTools(), "aximo_list_projects");
|
|
130
|
+
const result = await tool.handler({});
|
|
131
|
+
expect(result.isError).toBeUndefined();
|
|
132
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
133
|
+
expect(captured).not.toBeNull();
|
|
134
|
+
expect(captured.method).toBe("GET");
|
|
135
|
+
expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe("call() error mapping", () => {
|
|
139
|
+
const sessionId = "66666666-6666-4666-9666-666666666666";
|
|
140
|
+
it("maps 403 with the original message and a hint", async () => {
|
|
141
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Forbidden by policy" }, { status: 403 })));
|
|
142
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
143
|
+
const result = await tool.handler({ sessionId });
|
|
144
|
+
expect(result.isError).toBe(true);
|
|
145
|
+
const payload = parsePayload(result);
|
|
146
|
+
expect(payload.error).toMatch(/Forbidden by policy|Forbidden/);
|
|
147
|
+
expect(payload.hint).toMatch(/access/i);
|
|
148
|
+
});
|
|
149
|
+
it("preserves the backend's 404 message instead of squashing to 'Not found'", async () => {
|
|
150
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Session was archived" }, { status: 404 })));
|
|
151
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
152
|
+
const result = await tool.handler({ sessionId });
|
|
153
|
+
expect(result.isError).toBe(true);
|
|
154
|
+
const payload = parsePayload(result);
|
|
155
|
+
expect(payload.error).toBe("Session was archived");
|
|
156
|
+
});
|
|
157
|
+
it("flags 429 with the rate-limit hint", async () => {
|
|
158
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429 })));
|
|
159
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
160
|
+
const result = await tool.handler({ sessionId });
|
|
161
|
+
expect(result.isError).toBe(true);
|
|
162
|
+
const payload = parsePayload(result);
|
|
163
|
+
expect(payload.error).toBe("Rate limited");
|
|
164
|
+
expect(payload.hint).toMatch(/daily/i);
|
|
165
|
+
});
|
|
166
|
+
it("distinguishes a 500 with the HTTP-prefixed message", async () => {
|
|
167
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Internal server error" }, { status: 500 })));
|
|
168
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
169
|
+
const result = await tool.handler({ sessionId });
|
|
170
|
+
expect(result.isError).toBe(true);
|
|
171
|
+
const payload = parsePayload(result);
|
|
172
|
+
expect(payload.error).toMatch(/^HTTP 500/);
|
|
173
|
+
});
|
|
174
|
+
it("returns a config-error hint when the API key is missing mid-session", async () => {
|
|
175
|
+
const prev = process.env["AXIMO_API_KEY"];
|
|
176
|
+
delete process.env["AXIMO_API_KEY"];
|
|
177
|
+
try {
|
|
178
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
179
|
+
const result = await tool.handler({ sessionId });
|
|
180
|
+
expect(result.isError).toBe(true);
|
|
181
|
+
const payload = parsePayload(result);
|
|
182
|
+
expect(payload.error).toBe("API key not configured");
|
|
183
|
+
expect(payload.hint).toMatch(/aximo configure/);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
process.env["AXIMO_API_KEY"] = prev;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it("surfaces network failures with code + remediation hint", async () => {
|
|
190
|
+
// MSW intercepts and reshapes thrown errors, so inject a fetch that throws
|
|
191
|
+
// a fetch-style network error directly via ClientDeps.
|
|
192
|
+
const networkErr = new TypeError("fetch failed");
|
|
193
|
+
networkErr.cause = {
|
|
194
|
+
code: "ENOTFOUND",
|
|
195
|
+
message: "getaddrinfo ENOTFOUND aximo.test",
|
|
196
|
+
};
|
|
197
|
+
const fetchImpl = (() => Promise.reject(networkErr));
|
|
198
|
+
const tool = byName(defineTools({ fetchImpl }), "aximo_get_session_timeline");
|
|
199
|
+
const result = await tool.handler({ sessionId });
|
|
200
|
+
expect(result.isError).toBe(true);
|
|
201
|
+
const payload = parsePayload(result);
|
|
202
|
+
expect(payload.error).toMatch(/Network error \(ENOTFOUND\)/);
|
|
203
|
+
expect(payload.hint).toMatch(/AXIMO_BASE_URL/);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/dist/commands/schema.js
CHANGED
|
@@ -74,8 +74,8 @@ export function getSchema() {
|
|
|
74
74
|
]),
|
|
75
75
|
]),
|
|
76
76
|
]),
|
|
77
|
-
cmd("device", "Device commands", [], [
|
|
78
|
-
cmd("list", "GET /api/devices", [
|
|
77
|
+
cmd("device", "Device Farm mobile device listing (BYOD device commands are not exposed via schema)", [], [
|
|
78
|
+
cmd("list", "GET /api/devices — list Device Farm mobile devices (ANDROID/IOS)", [
|
|
79
79
|
arg("platform", "string", "ANDROID | IOS"),
|
|
80
80
|
arg("available-only", "boolean", ""),
|
|
81
81
|
]),
|
package/dist/types.d.ts
CHANGED
|
@@ -119,3 +119,113 @@ export interface Project {
|
|
|
119
119
|
name?: string | undefined;
|
|
120
120
|
[key: string]: unknown;
|
|
121
121
|
}
|
|
122
|
+
export interface PaginatedList<T> {
|
|
123
|
+
items: T[];
|
|
124
|
+
totalCount: number;
|
|
125
|
+
page: number;
|
|
126
|
+
pageSize: number;
|
|
127
|
+
totalPages: number;
|
|
128
|
+
}
|
|
129
|
+
export interface CursorPage<T> {
|
|
130
|
+
items: T[];
|
|
131
|
+
nextCursor: string | null;
|
|
132
|
+
}
|
|
133
|
+
export interface SessionListItem {
|
|
134
|
+
id: string;
|
|
135
|
+
projectId: string | null;
|
|
136
|
+
caseId: string | null;
|
|
137
|
+
title: string;
|
|
138
|
+
prompt: string;
|
|
139
|
+
platform: string;
|
|
140
|
+
status: string;
|
|
141
|
+
result: string | null;
|
|
142
|
+
startedAt: string | null;
|
|
143
|
+
completedAt: string | null;
|
|
144
|
+
createdAt: string;
|
|
145
|
+
durationMs: number | null;
|
|
146
|
+
}
|
|
147
|
+
export interface SessionMessage {
|
|
148
|
+
id: string;
|
|
149
|
+
role: string;
|
|
150
|
+
content: string;
|
|
151
|
+
metadata?: unknown;
|
|
152
|
+
createdAt: string;
|
|
153
|
+
}
|
|
154
|
+
export interface SessionScreenshot {
|
|
155
|
+
id: string;
|
|
156
|
+
url: string;
|
|
157
|
+
messageId: string | null;
|
|
158
|
+
width: number | null;
|
|
159
|
+
height: number | null;
|
|
160
|
+
resizedWidth: number | null;
|
|
161
|
+
resizedHeight: number | null;
|
|
162
|
+
capturedAt: string;
|
|
163
|
+
}
|
|
164
|
+
export interface TimelineStep {
|
|
165
|
+
index: number;
|
|
166
|
+
messageId: string;
|
|
167
|
+
role: string;
|
|
168
|
+
toolName: string | null;
|
|
169
|
+
toolInput: unknown;
|
|
170
|
+
toolOutput: unknown;
|
|
171
|
+
text: string | null;
|
|
172
|
+
createdAt: string;
|
|
173
|
+
screenshots: SessionScreenshot[];
|
|
174
|
+
}
|
|
175
|
+
export interface SessionTimeline {
|
|
176
|
+
sessionId: string;
|
|
177
|
+
stepCount: number;
|
|
178
|
+
steps: TimelineStep[];
|
|
179
|
+
}
|
|
180
|
+
export interface CaseListItem {
|
|
181
|
+
id: string;
|
|
182
|
+
projectId: string;
|
|
183
|
+
type: string;
|
|
184
|
+
title: string | null;
|
|
185
|
+
description: string | null;
|
|
186
|
+
startUrl: string | null;
|
|
187
|
+
lastRunAt: string | null;
|
|
188
|
+
lastStatus: string | null;
|
|
189
|
+
createdAt: string;
|
|
190
|
+
updatedAt: string;
|
|
191
|
+
}
|
|
192
|
+
export interface CaptureSnapshot {
|
|
193
|
+
id: string;
|
|
194
|
+
ts: number;
|
|
195
|
+
messageId: string | null;
|
|
196
|
+
trigger: "screenshot" | "page_load";
|
|
197
|
+
targetId: string;
|
|
198
|
+
url: string;
|
|
199
|
+
title: string;
|
|
200
|
+
dom: unknown;
|
|
201
|
+
axTree: unknown;
|
|
202
|
+
}
|
|
203
|
+
export interface SessionCapture {
|
|
204
|
+
sessionId: string;
|
|
205
|
+
platform: string;
|
|
206
|
+
startedAt: string | null;
|
|
207
|
+
endedAt: string | null;
|
|
208
|
+
browserContext: Record<string, unknown> | null;
|
|
209
|
+
events: Array<Record<string, unknown>>;
|
|
210
|
+
storageState: Record<string, unknown> | null;
|
|
211
|
+
snapshotCount: number;
|
|
212
|
+
snapshotsPrefix: string | null;
|
|
213
|
+
truncated: boolean;
|
|
214
|
+
createdAt: string;
|
|
215
|
+
updatedAt: string;
|
|
216
|
+
}
|
|
217
|
+
export interface CaseDetail {
|
|
218
|
+
id: string;
|
|
219
|
+
projectId: string;
|
|
220
|
+
type: string;
|
|
221
|
+
title: string | null;
|
|
222
|
+
description: string | null;
|
|
223
|
+
scenario: string;
|
|
224
|
+
format: string;
|
|
225
|
+
startUrl: string | null;
|
|
226
|
+
apiEnabled: boolean;
|
|
227
|
+
lastRunAt: string | null;
|
|
228
|
+
lastStatus: string | null;
|
|
229
|
+
createdAt: string;
|
|
230
|
+
updatedAt: string;
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autifyhq/aximo-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Autify Aximo Command Line Interface (CLI)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"autify",
|
|
@@ -36,8 +36,10 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
40
|
"citty": "0.2.2",
|
|
40
|
-
"ws": "^8.18.0"
|
|
41
|
+
"ws": "^8.18.0",
|
|
42
|
+
"zod": "^3.25.76"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@commitlint/cli": "20.5.0",
|