@mainahq/core 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.
Files changed (156) hide show
  1. package/README.md +31 -0
  2. package/package.json +37 -0
  3. package/src/ai/__tests__/ai.test.ts +207 -0
  4. package/src/ai/__tests__/design-approaches.test.ts +192 -0
  5. package/src/ai/__tests__/spec-questions.test.ts +191 -0
  6. package/src/ai/__tests__/tiers.test.ts +110 -0
  7. package/src/ai/commit-msg.ts +28 -0
  8. package/src/ai/design-approaches.ts +76 -0
  9. package/src/ai/index.ts +205 -0
  10. package/src/ai/pr-summary.ts +60 -0
  11. package/src/ai/spec-questions.ts +74 -0
  12. package/src/ai/tiers.ts +52 -0
  13. package/src/ai/try-generate.ts +89 -0
  14. package/src/ai/validate.ts +66 -0
  15. package/src/benchmark/__tests__/reporter.test.ts +525 -0
  16. package/src/benchmark/__tests__/runner.test.ts +113 -0
  17. package/src/benchmark/__tests__/story-loader.test.ts +152 -0
  18. package/src/benchmark/reporter.ts +332 -0
  19. package/src/benchmark/runner.ts +91 -0
  20. package/src/benchmark/story-loader.ts +88 -0
  21. package/src/benchmark/types.ts +95 -0
  22. package/src/cache/__tests__/keys.test.ts +97 -0
  23. package/src/cache/__tests__/manager.test.ts +312 -0
  24. package/src/cache/__tests__/ttl.test.ts +94 -0
  25. package/src/cache/keys.ts +44 -0
  26. package/src/cache/manager.ts +231 -0
  27. package/src/cache/ttl.ts +77 -0
  28. package/src/config/__tests__/config.test.ts +376 -0
  29. package/src/config/index.ts +198 -0
  30. package/src/context/__tests__/budget.test.ts +179 -0
  31. package/src/context/__tests__/engine.test.ts +163 -0
  32. package/src/context/__tests__/episodic.test.ts +291 -0
  33. package/src/context/__tests__/relevance.test.ts +323 -0
  34. package/src/context/__tests__/retrieval.test.ts +143 -0
  35. package/src/context/__tests__/selector.test.ts +174 -0
  36. package/src/context/__tests__/semantic.test.ts +252 -0
  37. package/src/context/__tests__/treesitter.test.ts +229 -0
  38. package/src/context/__tests__/working.test.ts +236 -0
  39. package/src/context/budget.ts +130 -0
  40. package/src/context/engine.ts +394 -0
  41. package/src/context/episodic.ts +251 -0
  42. package/src/context/relevance.ts +325 -0
  43. package/src/context/retrieval.ts +325 -0
  44. package/src/context/selector.ts +93 -0
  45. package/src/context/semantic.ts +331 -0
  46. package/src/context/treesitter.ts +216 -0
  47. package/src/context/working.ts +192 -0
  48. package/src/db/__tests__/db.test.ts +151 -0
  49. package/src/db/index.ts +211 -0
  50. package/src/db/schema.ts +84 -0
  51. package/src/design/__tests__/design.test.ts +310 -0
  52. package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
  53. package/src/design/__tests__/review.test.ts +561 -0
  54. package/src/design/index.ts +297 -0
  55. package/src/design/review.ts +327 -0
  56. package/src/explain/__tests__/explain.test.ts +173 -0
  57. package/src/explain/index.ts +181 -0
  58. package/src/features/__tests__/analyzer.test.ts +358 -0
  59. package/src/features/__tests__/checklist.test.ts +454 -0
  60. package/src/features/__tests__/numbering.test.ts +319 -0
  61. package/src/features/__tests__/quality.test.ts +295 -0
  62. package/src/features/__tests__/traceability.test.ts +147 -0
  63. package/src/features/analyzer.ts +445 -0
  64. package/src/features/checklist.ts +366 -0
  65. package/src/features/index.ts +18 -0
  66. package/src/features/numbering.ts +404 -0
  67. package/src/features/quality.ts +349 -0
  68. package/src/features/test-stubs.ts +157 -0
  69. package/src/features/traceability.ts +260 -0
  70. package/src/feedback/__tests__/async-feedback.test.ts +52 -0
  71. package/src/feedback/__tests__/collector.test.ts +219 -0
  72. package/src/feedback/__tests__/compress.test.ts +150 -0
  73. package/src/feedback/__tests__/preferences.test.ts +169 -0
  74. package/src/feedback/collector.ts +135 -0
  75. package/src/feedback/compress.ts +92 -0
  76. package/src/feedback/preferences.ts +108 -0
  77. package/src/git/__tests__/git.test.ts +62 -0
  78. package/src/git/index.ts +110 -0
  79. package/src/hooks/__tests__/runner.test.ts +266 -0
  80. package/src/hooks/index.ts +8 -0
  81. package/src/hooks/runner.ts +130 -0
  82. package/src/index.ts +356 -0
  83. package/src/init/__tests__/init.test.ts +228 -0
  84. package/src/init/index.ts +364 -0
  85. package/src/language/__tests__/detect.test.ts +77 -0
  86. package/src/language/__tests__/profile.test.ts +51 -0
  87. package/src/language/detect.ts +70 -0
  88. package/src/language/profile.ts +110 -0
  89. package/src/prompts/__tests__/defaults.test.ts +52 -0
  90. package/src/prompts/__tests__/engine.test.ts +183 -0
  91. package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
  92. package/src/prompts/__tests__/evolution.test.ts +187 -0
  93. package/src/prompts/__tests__/loader.test.ts +105 -0
  94. package/src/prompts/candidates/review-v2.md +55 -0
  95. package/src/prompts/defaults/ai-review.md +49 -0
  96. package/src/prompts/defaults/commit.md +30 -0
  97. package/src/prompts/defaults/context.md +26 -0
  98. package/src/prompts/defaults/design-approaches.md +57 -0
  99. package/src/prompts/defaults/design-hld-lld.md +55 -0
  100. package/src/prompts/defaults/design.md +53 -0
  101. package/src/prompts/defaults/explain.md +31 -0
  102. package/src/prompts/defaults/fix.md +32 -0
  103. package/src/prompts/defaults/index.ts +38 -0
  104. package/src/prompts/defaults/review.md +41 -0
  105. package/src/prompts/defaults/spec-questions.md +59 -0
  106. package/src/prompts/defaults/tests.md +72 -0
  107. package/src/prompts/engine.ts +137 -0
  108. package/src/prompts/evolution.ts +409 -0
  109. package/src/prompts/loader.ts +71 -0
  110. package/src/review/__tests__/review.test.ts +288 -0
  111. package/src/review/comprehensive.ts +362 -0
  112. package/src/review/index.ts +417 -0
  113. package/src/stats/__tests__/tracker.test.ts +323 -0
  114. package/src/stats/index.ts +11 -0
  115. package/src/stats/tracker.ts +492 -0
  116. package/src/ticket/__tests__/ticket.test.ts +273 -0
  117. package/src/ticket/index.ts +185 -0
  118. package/src/utils.ts +87 -0
  119. package/src/verify/__tests__/ai-review.test.ts +242 -0
  120. package/src/verify/__tests__/coverage.test.ts +83 -0
  121. package/src/verify/__tests__/detect.test.ts +175 -0
  122. package/src/verify/__tests__/diff-filter.test.ts +338 -0
  123. package/src/verify/__tests__/fix.test.ts +478 -0
  124. package/src/verify/__tests__/linters/clippy.test.ts +45 -0
  125. package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
  126. package/src/verify/__tests__/linters/ruff.test.ts +64 -0
  127. package/src/verify/__tests__/mutation.test.ts +141 -0
  128. package/src/verify/__tests__/pipeline.test.ts +553 -0
  129. package/src/verify/__tests__/proof.test.ts +97 -0
  130. package/src/verify/__tests__/secretlint.test.ts +190 -0
  131. package/src/verify/__tests__/semgrep.test.ts +217 -0
  132. package/src/verify/__tests__/slop.test.ts +366 -0
  133. package/src/verify/__tests__/sonar.test.ts +113 -0
  134. package/src/verify/__tests__/syntax-guard.test.ts +227 -0
  135. package/src/verify/__tests__/trivy.test.ts +191 -0
  136. package/src/verify/__tests__/visual.test.ts +139 -0
  137. package/src/verify/ai-review.ts +276 -0
  138. package/src/verify/coverage.ts +134 -0
  139. package/src/verify/detect.ts +171 -0
  140. package/src/verify/diff-filter.ts +183 -0
  141. package/src/verify/fix.ts +317 -0
  142. package/src/verify/linters/clippy.ts +52 -0
  143. package/src/verify/linters/go-vet.ts +32 -0
  144. package/src/verify/linters/ruff.ts +47 -0
  145. package/src/verify/mutation.ts +143 -0
  146. package/src/verify/pipeline.ts +328 -0
  147. package/src/verify/proof.ts +277 -0
  148. package/src/verify/secretlint.ts +168 -0
  149. package/src/verify/semgrep.ts +170 -0
  150. package/src/verify/slop.ts +493 -0
  151. package/src/verify/sonar.ts +146 -0
  152. package/src/verify/syntax-guard.ts +251 -0
  153. package/src/verify/trivy.ts +161 -0
  154. package/src/verify/visual.ts +460 -0
  155. package/src/workflow/__tests__/context.test.ts +110 -0
  156. package/src/workflow/context.ts +81 -0
@@ -0,0 +1,231 @@
1
+ import { getCacheDb } from "../db/index";
2
+
3
+ export interface CacheEntry {
4
+ key: string;
5
+ value: string;
6
+ createdAt: number; // Unix timestamp ms
7
+ ttl: number; // seconds, 0 = forever
8
+ promptVersion?: string;
9
+ contextHash?: string;
10
+ model?: string;
11
+ }
12
+
13
+ export interface CacheStats {
14
+ l1Hits: number;
15
+ l2Hits: number;
16
+ misses: number;
17
+ totalQueries: number;
18
+ entriesL1: number;
19
+ entriesL2: number;
20
+ }
21
+
22
+ interface CacheSetOptions {
23
+ ttl?: number;
24
+ promptVersion?: string;
25
+ contextHash?: string;
26
+ model?: string;
27
+ }
28
+
29
+ /** Returns true when the entry has exceeded its TTL. */
30
+ function isExpired(entry: CacheEntry): boolean {
31
+ if (entry.ttl <= 0) return false;
32
+ return Date.now() - entry.createdAt > entry.ttl * 1000;
33
+ }
34
+
35
+ /** Raw row shape returned by SQLite queries. */
36
+ interface RawRow {
37
+ key: string;
38
+ value: string;
39
+ created_at: string;
40
+ ttl: number | null;
41
+ prompt_version: string | null;
42
+ context_hash: string | null;
43
+ model: string | null;
44
+ }
45
+
46
+ function rowToEntry(row: RawRow): CacheEntry {
47
+ return {
48
+ key: row.key,
49
+ value: row.value,
50
+ createdAt: Number(row.created_at),
51
+ ttl: row.ttl ?? 0,
52
+ promptVersion: row.prompt_version ?? undefined,
53
+ contextHash: row.context_hash ?? undefined,
54
+ model: row.model ?? undefined,
55
+ };
56
+ }
57
+
58
+ const L1_MAX = 100;
59
+
60
+ export interface CacheManager {
61
+ get(key: string): CacheEntry | null;
62
+ set(key: string, value: string, options?: CacheSetOptions): void;
63
+ has(key: string): boolean;
64
+ invalidate(key: string): void;
65
+ clear(): void;
66
+ stats(): CacheStats;
67
+ }
68
+
69
+ export function createCacheManager(mainaDir: string): CacheManager {
70
+ // Initialise L2 (SQLite)
71
+ const dbResult = getCacheDb(mainaDir);
72
+ if (!dbResult.ok) {
73
+ // Return a no-op manager that always misses — never throw
74
+ const noop: CacheManager = {
75
+ get: () => null,
76
+ set: () => {
77
+ /* no-op */
78
+ },
79
+ has: () => false,
80
+ invalidate: () => {
81
+ /* no-op */
82
+ },
83
+ clear: () => {
84
+ /* no-op */
85
+ },
86
+ stats: () => ({
87
+ l1Hits: 0,
88
+ l2Hits: 0,
89
+ misses: 0,
90
+ totalQueries: 0,
91
+ entriesL1: 0,
92
+ entriesL2: 0,
93
+ }),
94
+ };
95
+ return noop;
96
+ }
97
+
98
+ const { db } = dbResult.value;
99
+
100
+ // Prepared statements
101
+ const stmtGet = db.prepare<RawRow, [string]>(
102
+ `SELECT key, value, created_at, ttl, prompt_version, context_hash, model
103
+ FROM cache_entries WHERE key = ?`,
104
+ );
105
+ const stmtInsert = db.prepare(
106
+ `INSERT OR REPLACE INTO cache_entries
107
+ (id, key, value, created_at, ttl, prompt_version, context_hash, model)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
109
+ );
110
+ const stmtDelete = db.prepare(`DELETE FROM cache_entries WHERE key = ?`);
111
+ const stmtClear = db.prepare(`DELETE FROM cache_entries`);
112
+ const stmtCount = db.prepare<{ cnt: number }, []>(
113
+ `SELECT COUNT(*) as cnt FROM cache_entries`,
114
+ );
115
+
116
+ // L1 in-memory map (maintains insertion order for eviction)
117
+ const l1 = new Map<string, CacheEntry>();
118
+
119
+ // Stats counters
120
+ let l1Hits = 0;
121
+ let l2Hits = 0;
122
+ let misses = 0;
123
+
124
+ function evictIfNeeded(): void {
125
+ if (l1.size >= L1_MAX) {
126
+ // Delete the first (oldest) key
127
+ const firstKey = l1.keys().next().value;
128
+ if (firstKey !== undefined) {
129
+ l1.delete(firstKey);
130
+ }
131
+ }
132
+ }
133
+
134
+ function get(key: string): CacheEntry | null {
135
+ // Check L1
136
+ const l1Entry = l1.get(key);
137
+ if (l1Entry !== undefined) {
138
+ if (isExpired(l1Entry)) {
139
+ l1.delete(key);
140
+ // Fall through to check L2 (it will also be expired, but be consistent)
141
+ } else {
142
+ l1Hits++;
143
+ return l1Entry;
144
+ }
145
+ }
146
+
147
+ // Check L2
148
+ const row = stmtGet.get(key);
149
+ if (row == null) {
150
+ misses++;
151
+ return null;
152
+ }
153
+
154
+ const entry = rowToEntry(row);
155
+ if (isExpired(entry)) {
156
+ misses++;
157
+ return null;
158
+ }
159
+
160
+ // Promote to L1
161
+ l2Hits++;
162
+ evictIfNeeded();
163
+ l1.set(key, entry);
164
+ return entry;
165
+ }
166
+
167
+ function set(key: string, value: string, options?: CacheSetOptions): void {
168
+ const now = Date.now();
169
+ const ttl = options?.ttl ?? 0;
170
+ const entry: CacheEntry = {
171
+ key,
172
+ value,
173
+ createdAt: now,
174
+ ttl,
175
+ promptVersion: options?.promptVersion,
176
+ contextHash: options?.contextHash,
177
+ model: options?.model,
178
+ };
179
+
180
+ // Write to L2 first
181
+ const id = `${key}-${now}`;
182
+ stmtInsert.run(
183
+ id,
184
+ key,
185
+ value,
186
+ String(now),
187
+ ttl,
188
+ options?.promptVersion ?? null,
189
+ options?.contextHash ?? null,
190
+ options?.model ?? null,
191
+ );
192
+
193
+ // Write to L1 (evict oldest if needed)
194
+ if (l1.has(key)) {
195
+ // Update in-place without changing insertion order (delete + re-insert)
196
+ l1.delete(key);
197
+ } else {
198
+ evictIfNeeded();
199
+ }
200
+ l1.set(key, entry);
201
+ }
202
+
203
+ function has(key: string): boolean {
204
+ return get(key) !== null;
205
+ }
206
+
207
+ function invalidate(key: string): void {
208
+ l1.delete(key);
209
+ stmtDelete.run(key);
210
+ }
211
+
212
+ function clear(): void {
213
+ l1.clear();
214
+ stmtClear.run();
215
+ }
216
+
217
+ function stats(): CacheStats {
218
+ const countRow = stmtCount.get();
219
+ const entriesL2 = countRow?.cnt ?? 0;
220
+ return {
221
+ l1Hits,
222
+ l2Hits,
223
+ misses,
224
+ totalQueries: l1Hits + l2Hits + misses,
225
+ entriesL1: l1.size,
226
+ entriesL2,
227
+ };
228
+ }
229
+
230
+ return { get, set, has, invalidate, clear, stats };
231
+ }
@@ -0,0 +1,77 @@
1
+ export type TaskType =
2
+ | "review"
3
+ | "tests"
4
+ | "fix"
5
+ | "commit"
6
+ | "context"
7
+ | "explain"
8
+ | "design"
9
+ | "plan";
10
+
11
+ export interface TtlRule {
12
+ task: TaskType;
13
+ ttl: number;
14
+ description: string;
15
+ }
16
+
17
+ const TTL_RULES: TtlRule[] = [
18
+ {
19
+ task: "review",
20
+ ttl: 0,
21
+ description: "Code review — cached forever, keyed by content hash",
22
+ },
23
+ {
24
+ task: "tests",
25
+ ttl: 0,
26
+ description: "Test generation — cached forever, keyed by content hash",
27
+ },
28
+ {
29
+ task: "fix",
30
+ ttl: 0,
31
+ description: "Bug fix — cached forever, keyed by content hash",
32
+ },
33
+ {
34
+ task: "commit",
35
+ ttl: 0,
36
+ description: "Commit message — cached forever, keyed by content hash",
37
+ },
38
+ {
39
+ task: "context",
40
+ ttl: 3600,
41
+ description: "Context assembly — cached for 1 hour",
42
+ },
43
+ {
44
+ task: "explain",
45
+ ttl: 86400,
46
+ description: "Code explanation — cached for 24 hours",
47
+ },
48
+ {
49
+ task: "design",
50
+ ttl: 86400,
51
+ description: "Design advice — cached for 24 hours",
52
+ },
53
+ {
54
+ task: "plan",
55
+ ttl: 86400,
56
+ description: "Planning — cached for 24 hours",
57
+ },
58
+ ];
59
+
60
+ const TTL_MAP: Record<TaskType, number> = Object.fromEntries(
61
+ TTL_RULES.map((r) => [r.task, r.ttl]),
62
+ ) as Record<TaskType, number>;
63
+
64
+ export function getTtl(task: TaskType): number {
65
+ return TTL_MAP[task] ?? 0;
66
+ }
67
+
68
+ export function isExpired(createdAt: number, ttl: number): boolean {
69
+ if (ttl === 0) {
70
+ return false;
71
+ }
72
+ return Date.now() - createdAt > ttl * 1000;
73
+ }
74
+
75
+ export function getAllRules(): TtlRule[] {
76
+ return TTL_RULES;
77
+ }
@@ -0,0 +1,376 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ findConfigFile,
7
+ getApiKey,
8
+ getDefaultConfig,
9
+ isHostMode,
10
+ loadConfig,
11
+ resolveProvider,
12
+ } from "../index";
13
+
14
+ // ─── getDefaultConfig ────────────────────────────────────────────────────────
15
+
16
+ describe("getDefaultConfig", () => {
17
+ test("returns a config with all required top-level fields", () => {
18
+ const config = getDefaultConfig();
19
+ expect(config).toHaveProperty("models");
20
+ expect(config).toHaveProperty("provider");
21
+ expect(config).toHaveProperty("budget");
22
+ });
23
+
24
+ test("models has all four required keys", () => {
25
+ const { models } = getDefaultConfig();
26
+ expect(models).toHaveProperty("mechanical");
27
+ expect(models).toHaveProperty("standard");
28
+ expect(models).toHaveProperty("architectural");
29
+ expect(models).toHaveProperty("local");
30
+ });
31
+
32
+ test("budget has daily, perTask, and alertAt", () => {
33
+ const { budget } = getDefaultConfig();
34
+ expect(budget).toHaveProperty("daily");
35
+ expect(budget).toHaveProperty("perTask");
36
+ expect(budget).toHaveProperty("alertAt");
37
+ });
38
+
39
+ test("returns a copy — mutations do not affect subsequent calls", () => {
40
+ const first = getDefaultConfig();
41
+ first.provider = "mutated";
42
+ const second = getDefaultConfig();
43
+ expect(second.provider).toBe("openrouter");
44
+ });
45
+
46
+ test("default provider is openrouter", () => {
47
+ expect(getDefaultConfig().provider).toBe("openrouter");
48
+ });
49
+
50
+ test("default daily budget is 5.0", () => {
51
+ expect(getDefaultConfig().budget.daily).toBe(5.0);
52
+ });
53
+ });
54
+
55
+ // ─── findConfigFile ───────────────────────────────────────────────────────────
56
+
57
+ describe("findConfigFile", () => {
58
+ let tmpDir: string;
59
+
60
+ beforeEach(() => {
61
+ tmpDir = join(
62
+ tmpdir(),
63
+ `maina-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
64
+ );
65
+ mkdirSync(tmpDir, { recursive: true });
66
+ });
67
+
68
+ afterEach(() => {
69
+ rmSync(tmpDir, { recursive: true, force: true });
70
+ });
71
+
72
+ test("returns null when no config file exists", () => {
73
+ const result = findConfigFile(tmpDir);
74
+ expect(result).toBeNull();
75
+ });
76
+
77
+ test("finds maina.config.ts in the start directory", () => {
78
+ const configPath = join(tmpDir, "maina.config.ts");
79
+ writeFileSync(configPath, "export default {};");
80
+ const result = findConfigFile(tmpDir);
81
+ expect(result).toBe(configPath);
82
+ });
83
+
84
+ test("finds maina.config.js in the start directory", () => {
85
+ const configPath = join(tmpDir, "maina.config.js");
86
+ writeFileSync(configPath, "module.exports = {};");
87
+ const result = findConfigFile(tmpDir);
88
+ expect(result).toBe(configPath);
89
+ });
90
+
91
+ test("finds config in a parent directory", () => {
92
+ const subDir = join(tmpDir, "nested", "deep");
93
+ mkdirSync(subDir, { recursive: true });
94
+ const configPath = join(tmpDir, "maina.config.ts");
95
+ writeFileSync(configPath, "export default {};");
96
+ const result = findConfigFile(subDir);
97
+ expect(result).toBe(configPath);
98
+ });
99
+
100
+ test("prefers maina.config.ts over maina.config.js when both exist", () => {
101
+ const tsPath = join(tmpDir, "maina.config.ts");
102
+ const jsPath = join(tmpDir, "maina.config.js");
103
+ writeFileSync(tsPath, "export default {};");
104
+ writeFileSync(jsPath, "module.exports = {};");
105
+ const result = findConfigFile(tmpDir);
106
+ expect(result).toBe(tsPath);
107
+ });
108
+ });
109
+
110
+ // ─── loadConfig ──────────────────────────────────────────────────────────────
111
+
112
+ describe("loadConfig", () => {
113
+ let tmpDir: string;
114
+
115
+ beforeEach(() => {
116
+ tmpDir = join(
117
+ tmpdir(),
118
+ `maina-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
119
+ );
120
+ mkdirSync(tmpDir, { recursive: true });
121
+ });
122
+
123
+ afterEach(() => {
124
+ rmSync(tmpDir, { recursive: true, force: true });
125
+ });
126
+
127
+ test("returns defaults when no config file is found", async () => {
128
+ const config = await loadConfig(tmpDir);
129
+ const defaults = getDefaultConfig();
130
+ expect(config.provider).toBe(defaults.provider);
131
+ expect(config.budget.daily).toBe(defaults.budget.daily);
132
+ expect(config.models.standard).toBe(defaults.models.standard);
133
+ });
134
+
135
+ test("merges a partial config with defaults", async () => {
136
+ const configPath = join(tmpDir, "maina.config.js");
137
+ // CJS: module.exports becomes mod.default when dynamically imported
138
+ writeFileSync(
139
+ configPath,
140
+ `module.exports = { provider: "custom-provider" };`,
141
+ );
142
+ const config = await loadConfig(tmpDir);
143
+ expect(config.provider).toBe("custom-provider");
144
+ // Defaults are preserved for unspecified fields
145
+ expect(config.budget.daily).toBe(5.0);
146
+ expect(config.models.standard).toBe("anthropic/claude-sonnet-4");
147
+ });
148
+
149
+ test("never throws — returns defaults on any import error", async () => {
150
+ // Point at an empty temp dir where no config exists
151
+ const emptyDir = join(tmpDir, "empty");
152
+ mkdirSync(emptyDir, { recursive: true });
153
+ const config = await loadConfig(emptyDir);
154
+ expect(config).toBeDefined();
155
+ expect(config.provider).toBe("openrouter");
156
+ });
157
+ });
158
+
159
+ // ─── getApiKey ────────────────────────────────────────────────────────────────
160
+
161
+ describe("getApiKey", () => {
162
+ test("returns null when neither env var is set", () => {
163
+ // Temporarily unset both vars for isolation
164
+ const saved1 = process.env.MAINA_API_KEY;
165
+ const saved2 = process.env.OPENROUTER_API_KEY;
166
+ delete process.env.MAINA_API_KEY;
167
+ delete process.env.OPENROUTER_API_KEY;
168
+
169
+ const result = getApiKey();
170
+
171
+ // Restore
172
+ if (saved1 !== undefined) process.env.MAINA_API_KEY = saved1;
173
+ if (saved2 !== undefined) process.env.OPENROUTER_API_KEY = saved2;
174
+
175
+ expect(result).toBeNull();
176
+ });
177
+
178
+ test("returns MAINA_API_KEY when set", () => {
179
+ const saved1 = process.env.MAINA_API_KEY;
180
+ const saved2 = process.env.OPENROUTER_API_KEY;
181
+ process.env.MAINA_API_KEY = "test-maina-key";
182
+ delete process.env.OPENROUTER_API_KEY;
183
+
184
+ const result = getApiKey();
185
+
186
+ if (saved1 !== undefined) process.env.MAINA_API_KEY = saved1;
187
+ else delete process.env.MAINA_API_KEY;
188
+ if (saved2 !== undefined) process.env.OPENROUTER_API_KEY = saved2;
189
+
190
+ expect(result).toBe("test-maina-key");
191
+ });
192
+
193
+ test("returns OPENROUTER_API_KEY when MAINA_API_KEY is not set", () => {
194
+ const saved1 = process.env.MAINA_API_KEY;
195
+ const saved2 = process.env.OPENROUTER_API_KEY;
196
+ delete process.env.MAINA_API_KEY;
197
+ process.env.OPENROUTER_API_KEY = "test-openrouter-key";
198
+
199
+ const result = getApiKey();
200
+
201
+ if (saved1 !== undefined) process.env.MAINA_API_KEY = saved1;
202
+ if (saved2 !== undefined) process.env.OPENROUTER_API_KEY = saved2;
203
+ else delete process.env.OPENROUTER_API_KEY;
204
+
205
+ expect(result).toBe("test-openrouter-key");
206
+ });
207
+
208
+ test("MAINA_API_KEY takes precedence over OPENROUTER_API_KEY", () => {
209
+ const saved1 = process.env.MAINA_API_KEY;
210
+ const saved2 = process.env.OPENROUTER_API_KEY;
211
+ process.env.MAINA_API_KEY = "maina-wins";
212
+ process.env.OPENROUTER_API_KEY = "openrouter-loses";
213
+
214
+ const result = getApiKey();
215
+
216
+ if (saved1 !== undefined) process.env.MAINA_API_KEY = saved1;
217
+ else delete process.env.MAINA_API_KEY;
218
+ if (saved2 !== undefined) process.env.OPENROUTER_API_KEY = saved2;
219
+ else delete process.env.OPENROUTER_API_KEY;
220
+
221
+ expect(result).toBe("maina-wins");
222
+ });
223
+ });
224
+
225
+ // ─── resolveProvider ─────────────────────────────────────────────────────────
226
+
227
+ describe("resolveProvider", () => {
228
+ test("returns config provider by default", () => {
229
+ const config = getDefaultConfig();
230
+ const saved = process.env.MAINA_PROVIDER;
231
+ delete process.env.MAINA_PROVIDER;
232
+
233
+ const result = resolveProvider(config);
234
+
235
+ if (saved !== undefined) process.env.MAINA_PROVIDER = saved;
236
+
237
+ expect(result).toBe("openrouter");
238
+ });
239
+
240
+ test("MAINA_PROVIDER env var overrides config provider", () => {
241
+ const config = getDefaultConfig();
242
+ const saved = process.env.MAINA_PROVIDER;
243
+ process.env.MAINA_PROVIDER = "env-provider";
244
+
245
+ const result = resolveProvider(config);
246
+
247
+ if (saved !== undefined) process.env.MAINA_PROVIDER = saved;
248
+ else delete process.env.MAINA_PROVIDER;
249
+
250
+ expect(result).toBe("env-provider");
251
+ });
252
+
253
+ test("returns custom config provider when no env var", () => {
254
+ const config = { ...getDefaultConfig(), provider: "my-custom-provider" };
255
+ const saved = process.env.MAINA_PROVIDER;
256
+ delete process.env.MAINA_PROVIDER;
257
+
258
+ const result = resolveProvider(config);
259
+
260
+ if (saved !== undefined) process.env.MAINA_PROVIDER = saved;
261
+
262
+ expect(result).toBe("my-custom-provider");
263
+ });
264
+
265
+ test("auto-detects anthropic provider in host mode", () => {
266
+ const saved = {
267
+ provider: process.env.MAINA_PROVIDER,
268
+ maina: process.env.MAINA_API_KEY,
269
+ openrouter: process.env.OPENROUTER_API_KEY,
270
+ anthropic: process.env.ANTHROPIC_API_KEY,
271
+ hostMode: process.env.MAINA_HOST_MODE,
272
+ };
273
+ delete process.env.MAINA_PROVIDER;
274
+ delete process.env.MAINA_API_KEY;
275
+ delete process.env.OPENROUTER_API_KEY;
276
+ process.env.ANTHROPIC_API_KEY = "sk-ant-test";
277
+ process.env.MAINA_HOST_MODE = "true";
278
+
279
+ const config = getDefaultConfig();
280
+ const result = resolveProvider(config);
281
+
282
+ // Restore
283
+ if (saved.provider !== undefined)
284
+ process.env.MAINA_PROVIDER = saved.provider;
285
+ if (saved.maina !== undefined) process.env.MAINA_API_KEY = saved.maina;
286
+ if (saved.openrouter !== undefined)
287
+ process.env.OPENROUTER_API_KEY = saved.openrouter;
288
+ if (saved.anthropic !== undefined)
289
+ process.env.ANTHROPIC_API_KEY = saved.anthropic;
290
+ else delete process.env.ANTHROPIC_API_KEY;
291
+ if (saved.hostMode !== undefined)
292
+ process.env.MAINA_HOST_MODE = saved.hostMode;
293
+ else delete process.env.MAINA_HOST_MODE;
294
+
295
+ expect(result).toBe("anthropic");
296
+ });
297
+ });
298
+
299
+ // ─── isHostMode ─────────────────────────────────────────────────────────────
300
+
301
+ describe("isHostMode", () => {
302
+ test("returns true when MAINA_HOST_MODE=true", () => {
303
+ const saved = process.env.MAINA_HOST_MODE;
304
+ process.env.MAINA_HOST_MODE = "true";
305
+
306
+ const result = isHostMode();
307
+
308
+ if (saved !== undefined) process.env.MAINA_HOST_MODE = saved;
309
+ else delete process.env.MAINA_HOST_MODE;
310
+
311
+ expect(result).toBe(true);
312
+ });
313
+
314
+ test("returns true when ANTHROPIC_API_KEY set without Maina keys", () => {
315
+ const saved = {
316
+ maina: process.env.MAINA_API_KEY,
317
+ openrouter: process.env.OPENROUTER_API_KEY,
318
+ anthropic: process.env.ANTHROPIC_API_KEY,
319
+ hostMode: process.env.MAINA_HOST_MODE,
320
+ };
321
+ delete process.env.MAINA_API_KEY;
322
+ delete process.env.OPENROUTER_API_KEY;
323
+ delete process.env.MAINA_HOST_MODE;
324
+ process.env.ANTHROPIC_API_KEY = "sk-ant-test";
325
+
326
+ const result = isHostMode();
327
+
328
+ if (saved.maina !== undefined) process.env.MAINA_API_KEY = saved.maina;
329
+ if (saved.openrouter !== undefined)
330
+ process.env.OPENROUTER_API_KEY = saved.openrouter;
331
+ if (saved.anthropic !== undefined)
332
+ process.env.ANTHROPIC_API_KEY = saved.anthropic;
333
+ else delete process.env.ANTHROPIC_API_KEY;
334
+ if (saved.hostMode !== undefined)
335
+ process.env.MAINA_HOST_MODE = saved.hostMode;
336
+
337
+ expect(result).toBe(true);
338
+ });
339
+
340
+ test("returns false when no host indicators present", () => {
341
+ const saved = {
342
+ maina: process.env.MAINA_API_KEY,
343
+ openrouter: process.env.OPENROUTER_API_KEY,
344
+ anthropic: process.env.ANTHROPIC_API_KEY,
345
+ hostMode: process.env.MAINA_HOST_MODE,
346
+ claude: process.env.CLAUDECODE,
347
+ claudeEntrypoint: process.env.CLAUDE_CODE_ENTRYPOINT,
348
+ cursor: process.env.CURSOR,
349
+ };
350
+ delete process.env.MAINA_HOST_MODE;
351
+ delete process.env.ANTHROPIC_API_KEY;
352
+ delete process.env.CLAUDECODE;
353
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
354
+ delete process.env.CURSOR;
355
+ process.env.MAINA_API_KEY = "test";
356
+
357
+ const result = isHostMode();
358
+
359
+ // Restore
360
+ if (saved.maina !== undefined) process.env.MAINA_API_KEY = saved.maina;
361
+ else delete process.env.MAINA_API_KEY;
362
+ if (saved.openrouter !== undefined)
363
+ process.env.OPENROUTER_API_KEY = saved.openrouter;
364
+ if (saved.anthropic !== undefined)
365
+ process.env.ANTHROPIC_API_KEY = saved.anthropic;
366
+ if (saved.hostMode !== undefined)
367
+ process.env.MAINA_HOST_MODE = saved.hostMode;
368
+ if (saved.claude !== undefined) process.env.CLAUDECODE = saved.claude;
369
+ if (saved.claudeEntrypoint !== undefined)
370
+ process.env.CLAUDE_CODE_ENTRYPOINT = saved.claudeEntrypoint;
371
+ else delete process.env.CLAUDE_CODE_ENTRYPOINT;
372
+ if (saved.cursor !== undefined) process.env.CURSOR = saved.cursor;
373
+
374
+ expect(result).toBe(false);
375
+ });
376
+ });