@mainahq/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +4 -1
  2. package/src/ai/__tests__/delegation.test.ts +105 -0
  3. package/src/ai/delegation.ts +111 -0
  4. package/src/ai/try-generate.ts +17 -0
  5. package/src/cloud/__tests__/auth.test.ts +164 -0
  6. package/src/cloud/__tests__/client.test.ts +253 -0
  7. package/src/cloud/auth.ts +232 -0
  8. package/src/cloud/client.ts +190 -0
  9. package/src/cloud/types.ts +106 -0
  10. package/src/context/relevance.ts +5 -0
  11. package/src/context/retrieval.ts +3 -0
  12. package/src/context/semantic.ts +3 -0
  13. package/src/feedback/__tests__/trace-analysis.test.ts +98 -0
  14. package/src/feedback/trace-analysis.ts +153 -0
  15. package/src/index.ts +55 -0
  16. package/src/init/__tests__/init.test.ts +51 -0
  17. package/src/init/index.ts +43 -0
  18. package/src/language/__tests__/detect.test.ts +61 -1
  19. package/src/language/__tests__/profile.test.ts +68 -1
  20. package/src/language/detect.ts +33 -3
  21. package/src/language/profile.ts +67 -2
  22. package/src/ticket/index.ts +5 -0
  23. package/src/verify/__tests__/consistency.test.ts +98 -0
  24. package/src/verify/__tests__/lighthouse.test.ts +215 -0
  25. package/src/verify/__tests__/linters/checkstyle.test.ts +23 -0
  26. package/src/verify/__tests__/linters/dotnet-format.test.ts +18 -0
  27. package/src/verify/__tests__/pipeline.test.ts +21 -2
  28. package/src/verify/__tests__/typecheck.test.ts +160 -0
  29. package/src/verify/__tests__/zap.test.ts +188 -0
  30. package/src/verify/consistency.ts +199 -0
  31. package/src/verify/detect.ts +13 -1
  32. package/src/verify/lighthouse.ts +173 -0
  33. package/src/verify/linters/checkstyle.ts +41 -0
  34. package/src/verify/linters/dotnet-format.ts +37 -0
  35. package/src/verify/pipeline.ts +20 -2
  36. package/src/verify/syntax-guard.ts +8 -0
  37. package/src/verify/typecheck.ts +178 -0
  38. package/src/verify/zap.ts +189 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
@@ -33,5 +33,8 @@
33
33
  "@ai-sdk/openai": "^3.0.50",
34
34
  "ai": "^6.0.145",
35
35
  "drizzle-orm": "^0.45.2"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
36
39
  }
37
40
  }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ type DelegationRequest,
4
+ formatDelegationRequest,
5
+ parseDelegationRequest,
6
+ } from "../delegation";
7
+
8
+ describe("formatDelegationRequest", () => {
9
+ it("should format a request with all fields", () => {
10
+ const req: DelegationRequest = {
11
+ task: "ai-review",
12
+ context: "Reviewing diff for cross-function consistency",
13
+ prompt: "Review this diff:\n+const x = 1;",
14
+ expectedFormat: "json",
15
+ schema: '{"findings":[]}',
16
+ };
17
+
18
+ const formatted = formatDelegationRequest(req);
19
+
20
+ expect(formatted).toContain("---MAINA_AI_REQUEST---");
21
+ expect(formatted).toContain("---END_MAINA_AI_REQUEST---");
22
+ expect(formatted).toContain("task: ai-review");
23
+ expect(formatted).toContain("context: Reviewing diff");
24
+ expect(formatted).toContain("expected_format: json");
25
+ expect(formatted).toContain("schema: ");
26
+ expect(formatted).toContain("prompt: |");
27
+ expect(formatted).toContain(" Review this diff:");
28
+ });
29
+
30
+ it("should format without optional schema", () => {
31
+ const req: DelegationRequest = {
32
+ task: "commit-msg",
33
+ context: "Generate commit message",
34
+ prompt: "Generate a commit message for these changes",
35
+ expectedFormat: "text",
36
+ };
37
+
38
+ const formatted = formatDelegationRequest(req);
39
+ expect(formatted).not.toContain("schema:");
40
+ });
41
+ });
42
+
43
+ describe("parseDelegationRequest", () => {
44
+ it("should parse a formatted request back", () => {
45
+ const original: DelegationRequest = {
46
+ task: "ai-review",
47
+ context: "Reviewing diff",
48
+ prompt: "Review this diff:\n+const x = 1;",
49
+ expectedFormat: "json",
50
+ schema: '{"findings":[]}',
51
+ };
52
+
53
+ const formatted = formatDelegationRequest(original);
54
+ const parsed = parseDelegationRequest(formatted);
55
+
56
+ expect(parsed).not.toBeNull();
57
+ expect(parsed?.task).toBe("ai-review");
58
+ expect(parsed?.context).toBe("Reviewing diff");
59
+ expect(parsed?.expectedFormat).toBe("json");
60
+ expect(parsed?.schema).toBe('{"findings":[]}');
61
+ expect(parsed?.prompt).toContain("Review this diff:");
62
+ expect(parsed?.prompt).toContain("+const x = 1;");
63
+ });
64
+
65
+ it("should return null for text without markers", () => {
66
+ expect(parseDelegationRequest("no markers here")).toBeNull();
67
+ });
68
+
69
+ it("should return null for empty task", () => {
70
+ const text =
71
+ "---MAINA_AI_REQUEST---\ncontext: test\n---END_MAINA_AI_REQUEST---";
72
+ expect(parseDelegationRequest(text)).toBeNull();
73
+ });
74
+
75
+ it("should handle multiline prompts", () => {
76
+ const req: DelegationRequest = {
77
+ task: "review",
78
+ context: "Code review",
79
+ prompt: "Line 1\nLine 2\nLine 3",
80
+ expectedFormat: "markdown",
81
+ };
82
+
83
+ const formatted = formatDelegationRequest(req);
84
+ const parsed = parseDelegationRequest(formatted);
85
+
86
+ expect(parsed?.prompt).toContain("Line 1");
87
+ expect(parsed?.prompt).toContain("Line 2");
88
+ expect(parsed?.prompt).toContain("Line 3");
89
+ });
90
+
91
+ it("should parse request embedded in other text", () => {
92
+ const text = `Some output before
93
+ ---MAINA_AI_REQUEST---
94
+ task: test
95
+ context: testing
96
+ prompt: |
97
+ hello
98
+ ---END_MAINA_AI_REQUEST---
99
+ Some output after`;
100
+
101
+ const parsed = parseDelegationRequest(text);
102
+ expect(parsed?.task).toBe("test");
103
+ expect(parsed?.prompt).toBe("hello");
104
+ });
105
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * AI Delegation Protocol — structured stdout protocol for host agents.
3
+ *
4
+ * When maina runs inside Claude Code/Codex/OpenCode without an API key,
5
+ * AI-dependent steps output request blocks that the host agent can
6
+ * parse and process with its own AI.
7
+ */
8
+
9
+ // ─── Types ────────────────────────────────────────────────────────────────
10
+
11
+ export interface DelegationRequest {
12
+ task: string;
13
+ context: string;
14
+ prompt: string;
15
+ expectedFormat: "json" | "markdown" | "text";
16
+ schema?: string;
17
+ }
18
+
19
+ // ─── Constants ────────────────────────────────────────────────────────────
20
+
21
+ const START_MARKER = "---MAINA_AI_REQUEST---";
22
+ const END_MARKER = "---END_MAINA_AI_REQUEST---";
23
+
24
+ // ─── Format ───────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Format a delegation request as a structured text block.
28
+ * This block is output to stdout for host agents to parse.
29
+ */
30
+ export function formatDelegationRequest(req: DelegationRequest): string {
31
+ const lines: string[] = [START_MARKER];
32
+ lines.push(`task: ${req.task}`);
33
+ lines.push(`context: ${req.context}`);
34
+ lines.push(`expected_format: ${req.expectedFormat}`);
35
+ if (req.schema) {
36
+ lines.push(`schema: ${req.schema}`);
37
+ }
38
+ lines.push("prompt: |");
39
+ // Indent prompt lines by 2 spaces (YAML-style block scalar)
40
+ for (const line of req.prompt.split("\n")) {
41
+ lines.push(` ${line}`);
42
+ }
43
+ lines.push(END_MARKER);
44
+ return lines.join("\n");
45
+ }
46
+
47
+ // ─── Parse ────────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Parse a delegation request block from text.
51
+ * Returns null if the text doesn't contain a valid request block.
52
+ */
53
+ export function parseDelegationRequest(text: string): DelegationRequest | null {
54
+ const startIdx = text.indexOf(START_MARKER);
55
+ const endIdx = text.indexOf(END_MARKER);
56
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
57
+ return null;
58
+ }
59
+
60
+ const block = text.slice(startIdx + START_MARKER.length, endIdx).trim();
61
+ const lines = block.split("\n");
62
+
63
+ let task = "";
64
+ let context = "";
65
+ let expectedFormat: DelegationRequest["expectedFormat"] = "text";
66
+ let schema: string | undefined;
67
+ const promptLines: string[] = [];
68
+ let inPrompt = false;
69
+
70
+ for (const line of lines) {
71
+ if (inPrompt) {
72
+ // Prompt lines are indented by 2 spaces
73
+ promptLines.push(line.startsWith(" ") ? line.slice(2) : line);
74
+ continue;
75
+ }
76
+
77
+ if (line.startsWith("task: ")) {
78
+ task = line.slice(6).trim();
79
+ } else if (line.startsWith("context: ")) {
80
+ context = line.slice(9).trim();
81
+ } else if (line.startsWith("expected_format: ")) {
82
+ const fmt = line.slice(17).trim();
83
+ if (fmt === "json" || fmt === "markdown" || fmt === "text") {
84
+ expectedFormat = fmt;
85
+ }
86
+ } else if (line.startsWith("schema: ")) {
87
+ schema = line.slice(8).trim();
88
+ } else if (line.startsWith("prompt: |")) {
89
+ inPrompt = true;
90
+ }
91
+ }
92
+
93
+ if (!task) return null;
94
+
95
+ return {
96
+ task,
97
+ context,
98
+ prompt: promptLines.join("\n").trim(),
99
+ expectedFormat,
100
+ schema,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Output a delegation request to stdout.
106
+ * Used by tryAIGenerate when in host mode.
107
+ */
108
+ export function outputDelegationRequest(req: DelegationRequest): void {
109
+ const formatted = formatDelegationRequest(req);
110
+ process.stdout.write(`\n${formatted}\n`);
111
+ }
@@ -1,4 +1,5 @@
1
1
  import { getApiKey, isHostMode } from "../config/index";
2
+ import { outputDelegationRequest } from "./delegation";
2
3
 
3
4
  export interface DelegationPrompt {
4
5
  task: string;
@@ -69,6 +70,22 @@ export async function tryAIGenerate(
69
70
  }
70
71
  }
71
72
 
73
+ // Output structured request for host agent to process
74
+ outputDelegationRequest({
75
+ task,
76
+ context: `AI ${task} requested — process with host AI`,
77
+ prompt: userPrompt,
78
+ expectedFormat:
79
+ task === "commit"
80
+ ? "text"
81
+ : task.includes("review")
82
+ ? "json"
83
+ : "markdown",
84
+ schema: task.includes("review")
85
+ ? '{"findings":[{"file":"path","line":42,"message":"desc","severity":"warning"}]}'
86
+ : undefined,
87
+ });
88
+
72
89
  // Host mode — return structured delegation for host agent to process
73
90
  return {
74
91
  text: null,
@@ -0,0 +1,164 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ clearAuthConfig,
6
+ getAuthConfigPath,
7
+ loadAuthConfig,
8
+ saveAuthConfig,
9
+ startDeviceFlow,
10
+ } from "../auth";
11
+
12
+ // ── Helpers ─────────────────────────────────────────────────────────────────
13
+
14
+ const originalFetch = globalThis.fetch;
15
+
16
+ let tmpDir: string;
17
+ let mockFetch: ReturnType<typeof mock>;
18
+
19
+ function jsonResponse(body: unknown, status = 200): Response {
20
+ return new Response(JSON.stringify(body), {
21
+ status,
22
+ headers: { "Content-Type": "application/json" },
23
+ });
24
+ }
25
+
26
+ // ── Setup / Teardown ────────────────────────────────────────────────────────
27
+
28
+ beforeEach(() => {
29
+ tmpDir = join(
30
+ import.meta.dir,
31
+ `tmp-auth-${Date.now()}-${Math.random().toString(36).slice(2)}`,
32
+ );
33
+ mkdirSync(tmpDir, { recursive: true });
34
+
35
+ mockFetch = mock(() =>
36
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
37
+ );
38
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
39
+ });
40
+
41
+ afterEach(() => {
42
+ globalThis.fetch = originalFetch;
43
+ try {
44
+ rmSync(tmpDir, { recursive: true, force: true });
45
+ } catch {
46
+ // ignore
47
+ }
48
+ });
49
+
50
+ // ── Tests ───────────────────────────────────────────────────────────────────
51
+
52
+ describe("getAuthConfigPath", () => {
53
+ test("returns path inside custom config dir", () => {
54
+ const path = getAuthConfigPath("/custom/dir");
55
+ expect(path).toBe("/custom/dir/auth.json");
56
+ });
57
+
58
+ test("defaults to home directory", () => {
59
+ const path = getAuthConfigPath();
60
+ expect(path).toContain(".maina");
61
+ expect(path).toEndWith("auth.json");
62
+ });
63
+ });
64
+
65
+ describe("saveAuthConfig / loadAuthConfig", () => {
66
+ test("round-trips auth config", () => {
67
+ const config = {
68
+ accessToken: "tok-abc-123",
69
+ refreshToken: "ref-xyz",
70
+ expiresAt: "2026-12-31T23:59:59Z",
71
+ };
72
+
73
+ const saveResult = saveAuthConfig(config, tmpDir);
74
+ expect(saveResult.ok).toBe(true);
75
+
76
+ const loadResult = loadAuthConfig(tmpDir);
77
+ expect(loadResult.ok).toBe(true);
78
+ if (loadResult.ok) {
79
+ expect(loadResult.value.accessToken).toBe("tok-abc-123");
80
+ expect(loadResult.value.refreshToken).toBe("ref-xyz");
81
+ expect(loadResult.value.expiresAt).toBe("2026-12-31T23:59:59Z");
82
+ }
83
+ });
84
+
85
+ test("creates parent directories", () => {
86
+ const nested = join(tmpDir, "a", "b", "c");
87
+ const result = saveAuthConfig({ accessToken: "t" }, nested);
88
+ expect(result.ok).toBe(true);
89
+ expect(existsSync(join(nested, "auth.json"))).toBe(true);
90
+ });
91
+
92
+ test("returns error when not logged in", () => {
93
+ const result = loadAuthConfig(join(tmpDir, "nonexistent"));
94
+ expect(result.ok).toBe(false);
95
+ if (!result.ok) {
96
+ expect(result.error).toContain("Not logged in");
97
+ }
98
+ });
99
+
100
+ test("returns error for malformed config", () => {
101
+ const authPath = join(tmpDir, "auth.json");
102
+ writeFileSync(authPath, '{"no_token": true}', "utf-8");
103
+
104
+ const result = loadAuthConfig(tmpDir);
105
+ expect(result.ok).toBe(false);
106
+ if (!result.ok) {
107
+ expect(result.error).toContain("missing accessToken");
108
+ }
109
+ });
110
+ });
111
+
112
+ describe("clearAuthConfig", () => {
113
+ test("removes auth file", () => {
114
+ saveAuthConfig({ accessToken: "t" }, tmpDir);
115
+ const authPath = getAuthConfigPath(tmpDir);
116
+ expect(existsSync(authPath)).toBe(true);
117
+
118
+ const result = clearAuthConfig(tmpDir);
119
+ expect(result.ok).toBe(true);
120
+ expect(existsSync(authPath)).toBe(false);
121
+ });
122
+
123
+ test("succeeds when no file exists", () => {
124
+ const result = clearAuthConfig(join(tmpDir, "nonexistent"));
125
+ expect(result.ok).toBe(true);
126
+ });
127
+ });
128
+
129
+ describe("startDeviceFlow", () => {
130
+ test("returns device code response on success", async () => {
131
+ const deviceData = {
132
+ userCode: "ABCD-1234",
133
+ deviceCode: "dev-code-xyz",
134
+ verificationUri: "https://maina.dev/device",
135
+ interval: 5,
136
+ expiresIn: 900,
137
+ };
138
+ mockFetch.mockImplementation(() =>
139
+ Promise.resolve(jsonResponse({ data: deviceData })),
140
+ );
141
+
142
+ const result = await startDeviceFlow("https://api.maina.dev");
143
+
144
+ expect(result.ok).toBe(true);
145
+ if (result.ok) {
146
+ expect(result.value.userCode).toBe("ABCD-1234");
147
+ expect(result.value.deviceCode).toBe("dev-code-xyz");
148
+ expect(result.value.verificationUri).toBe("https://maina.dev/device");
149
+ }
150
+ });
151
+
152
+ test("returns error on API failure", async () => {
153
+ mockFetch.mockImplementation(() =>
154
+ Promise.resolve(jsonResponse({ error: "Service unavailable" }, 503)),
155
+ );
156
+
157
+ const result = await startDeviceFlow("https://api.maina.dev");
158
+
159
+ expect(result.ok).toBe(false);
160
+ if (!result.ok) {
161
+ expect(result.error).toContain("503");
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,253 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { type CloudClient, createCloudClient } from "../client";
3
+ import type { CloudConfig } from "../types";
4
+
5
+ // ── Helpers ─────────────────────────────────────────────────────────────────
6
+
7
+ const originalFetch = globalThis.fetch;
8
+
9
+ let mockFetch: ReturnType<typeof mock>;
10
+
11
+ function jsonResponse(body: unknown, status = 200): Response {
12
+ return new Response(JSON.stringify(body), {
13
+ status,
14
+ headers: { "Content-Type": "application/json" },
15
+ });
16
+ }
17
+
18
+ function setupClient(overrides?: Partial<CloudConfig>): CloudClient {
19
+ return createCloudClient({
20
+ baseUrl: "https://api.test.maina.dev",
21
+ token: "test-token-abc",
22
+ timeoutMs: 5_000,
23
+ maxRetries: 2,
24
+ ...overrides,
25
+ });
26
+ }
27
+
28
+ // ── Setup / Teardown ────────────────────────────────────────────────────────
29
+
30
+ beforeEach(() => {
31
+ mockFetch = mock(() =>
32
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
33
+ );
34
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
35
+ });
36
+
37
+ afterEach(() => {
38
+ globalThis.fetch = originalFetch;
39
+ });
40
+
41
+ // ── Tests ───────────────────────────────────────────────────────────────────
42
+
43
+ describe("createCloudClient", () => {
44
+ test("health returns ok status", async () => {
45
+ mockFetch.mockImplementation(() =>
46
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
47
+ );
48
+
49
+ const client = setupClient();
50
+ const result = await client.health();
51
+
52
+ expect(result.ok).toBe(true);
53
+ if (result.ok) {
54
+ expect(result.value.status).toBe("ok");
55
+ }
56
+ });
57
+
58
+ test("attaches Authorization header when token is provided", async () => {
59
+ mockFetch.mockImplementation(() =>
60
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
61
+ );
62
+
63
+ const client = setupClient({ token: "my-secret-token" });
64
+ await client.health();
65
+
66
+ expect(mockFetch).toHaveBeenCalledTimes(1);
67
+ const call = mockFetch.mock.calls[0] as unknown[];
68
+ const requestInit = call[1] as RequestInit;
69
+ const authHeader = (requestInit.headers as Record<string, string>)
70
+ .Authorization;
71
+ expect(authHeader).toBe("Bearer my-secret-token");
72
+ });
73
+
74
+ test("omits Authorization header when no token", async () => {
75
+ mockFetch.mockImplementation(() =>
76
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
77
+ );
78
+
79
+ const client = setupClient({ token: undefined });
80
+ await client.health();
81
+
82
+ const call = mockFetch.mock.calls[0] as unknown[];
83
+ const requestInit = call[1] as RequestInit;
84
+ const h = requestInit.headers as Record<string, string>;
85
+ expect(h.Authorization).toBeUndefined();
86
+ });
87
+
88
+ test("retries on 500 errors", async () => {
89
+ let attempts = 0;
90
+ mockFetch.mockImplementation(() => {
91
+ attempts++;
92
+ if (attempts < 3) {
93
+ return Promise.resolve(jsonResponse({ error: "Internal error" }, 500));
94
+ }
95
+ return Promise.resolve(jsonResponse({ data: { status: "ok" } }));
96
+ });
97
+
98
+ const client = setupClient({ maxRetries: 2 });
99
+ const result = await client.health();
100
+
101
+ expect(result.ok).toBe(true);
102
+ expect(attempts).toBe(3);
103
+ });
104
+
105
+ test("retries on 429 rate limit", async () => {
106
+ let attempts = 0;
107
+ mockFetch.mockImplementation(() => {
108
+ attempts++;
109
+ if (attempts === 1) {
110
+ return Promise.resolve(
111
+ jsonResponse({ error: "Too many requests" }, 429),
112
+ );
113
+ }
114
+ return Promise.resolve(jsonResponse({ data: { status: "ok" } }));
115
+ });
116
+
117
+ const client = setupClient({ maxRetries: 2 });
118
+ const result = await client.health();
119
+
120
+ expect(result.ok).toBe(true);
121
+ expect(attempts).toBe(2);
122
+ });
123
+
124
+ test("returns error after exhausting retries", async () => {
125
+ mockFetch.mockImplementation(() =>
126
+ Promise.resolve(jsonResponse({ error: "Server down" }, 503)),
127
+ );
128
+
129
+ const client = setupClient({ maxRetries: 1 });
130
+ const result = await client.health();
131
+
132
+ expect(result.ok).toBe(false);
133
+ if (!result.ok) {
134
+ expect(result.error).toContain("Server down");
135
+ }
136
+ });
137
+
138
+ test("returns error on non-retryable 4xx", async () => {
139
+ mockFetch.mockImplementation(() =>
140
+ Promise.resolve(jsonResponse({ error: "Forbidden" }, 403)),
141
+ );
142
+
143
+ const client = setupClient();
144
+ const result = await client.health();
145
+
146
+ expect(result.ok).toBe(false);
147
+ if (!result.ok) {
148
+ expect(result.error).toBe("Forbidden");
149
+ }
150
+ });
151
+
152
+ test("getPrompts returns prompt records", async () => {
153
+ const prompts = [
154
+ {
155
+ id: "p1",
156
+ path: "commit.md",
157
+ content: "# Commit",
158
+ hash: "abc",
159
+ updatedAt: "2026-01-01T00:00:00Z",
160
+ },
161
+ ];
162
+ mockFetch.mockImplementation(() =>
163
+ Promise.resolve(jsonResponse({ data: prompts })),
164
+ );
165
+
166
+ const client = setupClient();
167
+ const result = await client.getPrompts();
168
+
169
+ expect(result.ok).toBe(true);
170
+ if (result.ok) {
171
+ expect(result.value).toHaveLength(1);
172
+ expect(result.value[0]?.path).toBe("commit.md");
173
+ }
174
+ });
175
+
176
+ test("putPrompts sends prompts array", async () => {
177
+ mockFetch.mockImplementation(() =>
178
+ Promise.resolve(new Response(null, { status: 204 })),
179
+ );
180
+
181
+ const client = setupClient();
182
+ const result = await client.putPrompts([
183
+ {
184
+ id: "p1",
185
+ path: "commit.md",
186
+ content: "# Commit",
187
+ hash: "abc",
188
+ updatedAt: "2026-01-01T00:00:00Z",
189
+ },
190
+ ]);
191
+
192
+ expect(result.ok).toBe(true);
193
+
194
+ const call = mockFetch.mock.calls[0] as unknown[];
195
+ const requestInit = call[1] as RequestInit;
196
+ expect(requestInit.method).toBe("PUT");
197
+ const body = JSON.parse(requestInit.body as string);
198
+ expect(body.prompts).toHaveLength(1);
199
+ });
200
+
201
+ test("inviteTeamMember sends email and role", async () => {
202
+ mockFetch.mockImplementation(() =>
203
+ Promise.resolve(jsonResponse({ data: { invited: true } })),
204
+ );
205
+
206
+ const client = setupClient();
207
+ const result = await client.inviteTeamMember("new@example.com", "admin");
208
+
209
+ expect(result.ok).toBe(true);
210
+ if (result.ok) {
211
+ expect(result.value.invited).toBe(true);
212
+ }
213
+
214
+ const call = mockFetch.mock.calls[0] as unknown[];
215
+ const requestInit = call[1] as RequestInit;
216
+ const body = JSON.parse(requestInit.body as string);
217
+ expect(body.email).toBe("new@example.com");
218
+ expect(body.role).toBe("admin");
219
+ });
220
+
221
+ test("postFeedback sends payload", async () => {
222
+ mockFetch.mockImplementation(() =>
223
+ Promise.resolve(jsonResponse({ data: { recorded: true } })),
224
+ );
225
+
226
+ const client = setupClient();
227
+ const result = await client.postFeedback({
228
+ promptHash: "hash-123",
229
+ command: "commit",
230
+ accepted: true,
231
+ timestamp: "2026-01-01T00:00:00Z",
232
+ });
233
+
234
+ expect(result.ok).toBe(true);
235
+ if (result.ok) {
236
+ expect(result.value.recorded).toBe(true);
237
+ }
238
+ });
239
+
240
+ test("handles network errors gracefully", async () => {
241
+ mockFetch.mockImplementation(() =>
242
+ Promise.reject(new Error("Network unreachable")),
243
+ );
244
+
245
+ const client = setupClient({ maxRetries: 0 });
246
+ const result = await client.health();
247
+
248
+ expect(result.ok).toBe(false);
249
+ if (!result.ok) {
250
+ expect(result.error).toContain("Network unreachable");
251
+ }
252
+ });
253
+ });