@studio-foundation/cli 0.3.0-beta.1

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 (277) hide show
  1. package/ARCHITECTURE.md +40 -0
  2. package/LICENSE +663 -0
  3. package/README.md +165 -0
  4. package/dist/commands/api.d.ts +16 -0
  5. package/dist/commands/api.d.ts.map +1 -0
  6. package/dist/commands/api.js +118 -0
  7. package/dist/commands/api.js.map +1 -0
  8. package/dist/commands/config.d.ts +32 -0
  9. package/dist/commands/config.d.ts.map +1 -0
  10. package/dist/commands/config.js +390 -0
  11. package/dist/commands/config.js.map +1 -0
  12. package/dist/commands/init.d.ts +93 -0
  13. package/dist/commands/init.d.ts.map +1 -0
  14. package/dist/commands/init.js +728 -0
  15. package/dist/commands/init.js.map +1 -0
  16. package/dist/commands/install.d.ts +2 -0
  17. package/dist/commands/install.d.ts.map +1 -0
  18. package/dist/commands/install.js +22 -0
  19. package/dist/commands/install.js.map +1 -0
  20. package/dist/commands/integrations.d.ts +14 -0
  21. package/dist/commands/integrations.d.ts.map +1 -0
  22. package/dist/commands/integrations.js +282 -0
  23. package/dist/commands/integrations.js.map +1 -0
  24. package/dist/commands/list.d.ts +9 -0
  25. package/dist/commands/list.d.ts.map +1 -0
  26. package/dist/commands/list.js +161 -0
  27. package/dist/commands/list.js.map +1 -0
  28. package/dist/commands/logs.d.ts +7 -0
  29. package/dist/commands/logs.d.ts.map +1 -0
  30. package/dist/commands/logs.js +98 -0
  31. package/dist/commands/logs.js.map +1 -0
  32. package/dist/commands/ollama.d.ts +6 -0
  33. package/dist/commands/ollama.d.ts.map +1 -0
  34. package/dist/commands/ollama.js +161 -0
  35. package/dist/commands/ollama.js.map +1 -0
  36. package/dist/commands/project.d.ts +38 -0
  37. package/dist/commands/project.d.ts.map +1 -0
  38. package/dist/commands/project.js +137 -0
  39. package/dist/commands/project.js.map +1 -0
  40. package/dist/commands/registry/audit.d.ts +13 -0
  41. package/dist/commands/registry/audit.d.ts.map +1 -0
  42. package/dist/commands/registry/audit.js +65 -0
  43. package/dist/commands/registry/audit.js.map +1 -0
  44. package/dist/commands/registry/index.d.ts +3 -0
  45. package/dist/commands/registry/index.d.ts.map +1 -0
  46. package/dist/commands/registry/index.js +53 -0
  47. package/dist/commands/registry/index.js.map +1 -0
  48. package/dist/commands/registry/install.d.ts +17 -0
  49. package/dist/commands/registry/install.d.ts.map +1 -0
  50. package/dist/commands/registry/install.js +153 -0
  51. package/dist/commands/registry/install.js.map +1 -0
  52. package/dist/commands/registry/publish.d.ts +8 -0
  53. package/dist/commands/registry/publish.d.ts.map +1 -0
  54. package/dist/commands/registry/publish.js +94 -0
  55. package/dist/commands/registry/publish.js.map +1 -0
  56. package/dist/commands/registry/remove.d.ts +8 -0
  57. package/dist/commands/registry/remove.d.ts.map +1 -0
  58. package/dist/commands/registry/remove.js +82 -0
  59. package/dist/commands/registry/remove.js.map +1 -0
  60. package/dist/commands/registry/search.d.ts +9 -0
  61. package/dist/commands/registry/search.d.ts.map +1 -0
  62. package/dist/commands/registry/search.js +68 -0
  63. package/dist/commands/registry/search.js.map +1 -0
  64. package/dist/commands/registry/sync.d.ts +8 -0
  65. package/dist/commands/registry/sync.d.ts.map +1 -0
  66. package/dist/commands/registry/sync.js +19 -0
  67. package/dist/commands/registry/sync.js.map +1 -0
  68. package/dist/commands/registry/update.d.ts +15 -0
  69. package/dist/commands/registry/update.d.ts.map +1 -0
  70. package/dist/commands/registry/update.js +48 -0
  71. package/dist/commands/registry/update.js.map +1 -0
  72. package/dist/commands/replay.d.ts +36 -0
  73. package/dist/commands/replay.d.ts.map +1 -0
  74. package/dist/commands/replay.js +453 -0
  75. package/dist/commands/replay.js.map +1 -0
  76. package/dist/commands/run.d.ts +18 -0
  77. package/dist/commands/run.d.ts.map +1 -0
  78. package/dist/commands/run.js +406 -0
  79. package/dist/commands/run.js.map +1 -0
  80. package/dist/commands/status.d.ts +6 -0
  81. package/dist/commands/status.d.ts.map +1 -0
  82. package/dist/commands/status.js +144 -0
  83. package/dist/commands/status.js.map +1 -0
  84. package/dist/commands/template/index.d.ts +2 -0
  85. package/dist/commands/template/index.d.ts.map +1 -0
  86. package/dist/commands/template/index.js +57 -0
  87. package/dist/commands/template/index.js.map +1 -0
  88. package/dist/commands/template/validate.d.ts +8 -0
  89. package/dist/commands/template/validate.d.ts.map +1 -0
  90. package/dist/commands/template/validate.js +135 -0
  91. package/dist/commands/template/validate.js.map +1 -0
  92. package/dist/commands/templates.d.ts +14 -0
  93. package/dist/commands/templates.d.ts.map +1 -0
  94. package/dist/commands/templates.js +86 -0
  95. package/dist/commands/templates.js.map +1 -0
  96. package/dist/commands/tools.d.ts +12 -0
  97. package/dist/commands/tools.d.ts.map +1 -0
  98. package/dist/commands/tools.js +200 -0
  99. package/dist/commands/tools.js.map +1 -0
  100. package/dist/commands/users.d.ts +4 -0
  101. package/dist/commands/users.d.ts.map +1 -0
  102. package/dist/commands/users.js +123 -0
  103. package/dist/commands/users.js.map +1 -0
  104. package/dist/commands/validate.d.ts +2 -0
  105. package/dist/commands/validate.d.ts.map +1 -0
  106. package/dist/commands/validate.js +39 -0
  107. package/dist/commands/validate.js.map +1 -0
  108. package/dist/config.d.ts +36 -0
  109. package/dist/config.d.ts.map +1 -0
  110. package/dist/config.js +64 -0
  111. package/dist/config.js.map +1 -0
  112. package/dist/index.d.ts +3 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.js +178 -0
  115. package/dist/index.js.map +1 -0
  116. package/dist/models-cache.d.ts +13 -0
  117. package/dist/models-cache.d.ts.map +1 -0
  118. package/dist/models-cache.js +106 -0
  119. package/dist/models-cache.js.map +1 -0
  120. package/dist/output/file-changes.d.ts +15 -0
  121. package/dist/output/file-changes.d.ts.map +1 -0
  122. package/dist/output/file-changes.js +77 -0
  123. package/dist/output/file-changes.js.map +1 -0
  124. package/dist/output/formatter.d.ts +6 -0
  125. package/dist/output/formatter.d.ts.map +1 -0
  126. package/dist/output/formatter.js +70 -0
  127. package/dist/output/formatter.js.map +1 -0
  128. package/dist/output/formatters.d.ts +42 -0
  129. package/dist/output/formatters.d.ts.map +1 -0
  130. package/dist/output/formatters.js +280 -0
  131. package/dist/output/formatters.js.map +1 -0
  132. package/dist/output/logger.d.ts +3 -0
  133. package/dist/output/logger.d.ts.map +1 -0
  134. package/dist/output/logger.js +15 -0
  135. package/dist/output/logger.js.map +1 -0
  136. package/dist/output/parallel-progress.d.ts +33 -0
  137. package/dist/output/parallel-progress.d.ts.map +1 -0
  138. package/dist/output/parallel-progress.js +85 -0
  139. package/dist/output/parallel-progress.js.map +1 -0
  140. package/dist/output/progress.d.ts +32 -0
  141. package/dist/output/progress.d.ts.map +1 -0
  142. package/dist/output/progress.js +393 -0
  143. package/dist/output/progress.js.map +1 -0
  144. package/dist/provider-validator.d.ts +15 -0
  145. package/dist/provider-validator.d.ts.map +1 -0
  146. package/dist/provider-validator.js +85 -0
  147. package/dist/provider-validator.js.map +1 -0
  148. package/dist/registry/cache.d.ts +10 -0
  149. package/dist/registry/cache.d.ts.map +1 -0
  150. package/dist/registry/cache.js +36 -0
  151. package/dist/registry/cache.js.map +1 -0
  152. package/dist/registry/client.d.ts +19 -0
  153. package/dist/registry/client.d.ts.map +1 -0
  154. package/dist/registry/client.js +63 -0
  155. package/dist/registry/client.js.map +1 -0
  156. package/dist/registry/lockfile.d.ts +17 -0
  157. package/dist/registry/lockfile.d.ts.map +1 -0
  158. package/dist/registry/lockfile.js +61 -0
  159. package/dist/registry/lockfile.js.map +1 -0
  160. package/dist/registry/resolver.d.ts +13 -0
  161. package/dist/registry/resolver.d.ts.map +1 -0
  162. package/dist/registry/resolver.js +79 -0
  163. package/dist/registry/resolver.js.map +1 -0
  164. package/dist/registry/types.d.ts +59 -0
  165. package/dist/registry/types.d.ts.map +1 -0
  166. package/dist/registry/types.js +14 -0
  167. package/dist/registry/types.js.map +1 -0
  168. package/dist/run-logger.d.ts +8 -0
  169. package/dist/run-logger.d.ts.map +1 -0
  170. package/dist/run-logger.js +53 -0
  171. package/dist/run-logger.js.map +1 -0
  172. package/dist/run-store-factory.d.ts +9 -0
  173. package/dist/run-store-factory.d.ts.map +1 -0
  174. package/dist/run-store-factory.js +26 -0
  175. package/dist/run-store-factory.js.map +1 -0
  176. package/dist/studio-dir.d.ts +7 -0
  177. package/dist/studio-dir.d.ts.map +1 -0
  178. package/dist/studio-dir.js +27 -0
  179. package/dist/studio-dir.js.map +1 -0
  180. package/dist/utils/input-wizard.d.ts +4 -0
  181. package/dist/utils/input-wizard.d.ts.map +1 -0
  182. package/dist/utils/input-wizard.js +56 -0
  183. package/dist/utils/input-wizard.js.map +1 -0
  184. package/dist/utils/placeholders.d.ts +6 -0
  185. package/dist/utils/placeholders.d.ts.map +1 -0
  186. package/dist/utils/placeholders.js +12 -0
  187. package/dist/utils/placeholders.js.map +1 -0
  188. package/package.json +43 -0
  189. package/src/commands/api.ts +126 -0
  190. package/src/commands/config.ts +433 -0
  191. package/src/commands/init.ts +879 -0
  192. package/src/commands/install.ts +23 -0
  193. package/src/commands/integrations.ts +332 -0
  194. package/src/commands/list.ts +197 -0
  195. package/src/commands/logs.ts +119 -0
  196. package/src/commands/ollama.ts +168 -0
  197. package/src/commands/project.ts +167 -0
  198. package/src/commands/registry/audit.ts +87 -0
  199. package/src/commands/registry/index.ts +63 -0
  200. package/src/commands/registry/install.ts +222 -0
  201. package/src/commands/registry/publish.ts +112 -0
  202. package/src/commands/registry/remove.ts +89 -0
  203. package/src/commands/registry/search.ts +93 -0
  204. package/src/commands/registry/sync.ts +24 -0
  205. package/src/commands/registry/update.ts +63 -0
  206. package/src/commands/replay.ts +559 -0
  207. package/src/commands/run.ts +454 -0
  208. package/src/commands/status.ts +163 -0
  209. package/src/commands/template/index.ts +59 -0
  210. package/src/commands/template/validate.ts +175 -0
  211. package/src/commands/templates.ts +99 -0
  212. package/src/commands/tools.ts +227 -0
  213. package/src/commands/users.ts +127 -0
  214. package/src/commands/validate.ts +46 -0
  215. package/src/config.ts +101 -0
  216. package/src/index.ts +201 -0
  217. package/src/models-cache.ts +127 -0
  218. package/src/output/file-changes.ts +97 -0
  219. package/src/output/formatter.ts +85 -0
  220. package/src/output/formatters.ts +303 -0
  221. package/src/output/logger.ts +18 -0
  222. package/src/output/parallel-progress.ts +103 -0
  223. package/src/output/progress.ts +414 -0
  224. package/src/provider-validator.ts +109 -0
  225. package/src/registry/cache.ts +46 -0
  226. package/src/registry/client.ts +84 -0
  227. package/src/registry/lockfile.ts +68 -0
  228. package/src/registry/resolver.ts +113 -0
  229. package/src/registry/types.ts +66 -0
  230. package/src/run-logger.ts +66 -0
  231. package/src/run-store-factory.ts +30 -0
  232. package/src/studio-dir.ts +28 -0
  233. package/src/utils/input-wizard.ts +73 -0
  234. package/src/utils/placeholders.ts +10 -0
  235. package/templates/.studiorc.yaml +16 -0
  236. package/templates/projects/blank/metadata.json +7 -0
  237. package/templates/studio-config.yaml +14 -0
  238. package/tests/__stubs__/studio-runner.ts +104 -0
  239. package/tests/commands/api.test.ts +110 -0
  240. package/tests/commands/config.test.ts +221 -0
  241. package/tests/commands/init.test.ts +919 -0
  242. package/tests/commands/install.test.ts +52 -0
  243. package/tests/commands/integrations.test.ts +158 -0
  244. package/tests/commands/ollama.test.ts +139 -0
  245. package/tests/commands/project.test.ts +179 -0
  246. package/tests/commands/registry/audit.test.ts +56 -0
  247. package/tests/commands/registry/install.test.ts +200 -0
  248. package/tests/commands/registry/publish.test.ts +56 -0
  249. package/tests/commands/registry/remove.test.ts +103 -0
  250. package/tests/commands/registry/search.test.ts +44 -0
  251. package/tests/commands/registry/sync.test.ts +37 -0
  252. package/tests/commands/registry/update.test.ts +62 -0
  253. package/tests/commands/replay.test.ts +283 -0
  254. package/tests/commands/template/validate.test.ts +150 -0
  255. package/tests/commands/templates.test.ts +42 -0
  256. package/tests/commands/tools.test.ts +106 -0
  257. package/tests/config.test.ts +142 -0
  258. package/tests/formatter.test.ts +158 -0
  259. package/tests/integration/sigint.test.ts +188 -0
  260. package/tests/models-cache.test.ts +250 -0
  261. package/tests/output/file-changes.test.ts +178 -0
  262. package/tests/output/formatters.test.ts +448 -0
  263. package/tests/output/progress-spinner.test.ts +232 -0
  264. package/tests/output/progress-timer.test.ts +230 -0
  265. package/tests/provider-validator.test.ts +182 -0
  266. package/tests/registry/cache.test.ts +66 -0
  267. package/tests/registry/client.test.ts +70 -0
  268. package/tests/registry/lockfile.test.ts +87 -0
  269. package/tests/registry/resolver.test.ts +122 -0
  270. package/tests/run-logger-events.test.ts +326 -0
  271. package/tests/run-logger.test.ts +64 -0
  272. package/tests/run-store-factory.test.ts +51 -0
  273. package/tests/studio-dir.test.ts +31 -0
  274. package/tests/utils/input-wizard.test.ts +153 -0
  275. package/tests/utils/placeholders.test.ts +36 -0
  276. package/tsconfig.json +24 -0
  277. package/vitest.config.ts +20 -0
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { writeFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ // Use a unique fake home dir so tests don't collide with other test files using /tmp
7
+ const FAKE_HOME = join(tmpdir(), `.studio-api-test-${process.pid}`);
8
+
9
+ vi.mock('node:os', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('node:os')>();
11
+ return { ...actual, homedir: () => FAKE_HOME };
12
+ });
13
+
14
+ // Import after mocking
15
+ const { apiStopCommand, apiStatusCommand, writePid, readPid, clearPid, isProcessAlive } = await import('../../src/commands/api.js');
16
+
17
+ afterEach(async () => {
18
+ await clearPid();
19
+ // Remove the .studio dir created by writePid
20
+ await rm(join(FAKE_HOME, '.studio'), { recursive: true, force: true });
21
+ });
22
+
23
+ describe('writePid / readPid / clearPid', () => {
24
+ it('writes and reads PID with port', async () => {
25
+ await writePid(3700);
26
+ const entry = await readPid();
27
+ expect(entry).not.toBeNull();
28
+ expect(entry!.pid).toBe(process.pid);
29
+ expect(entry!.port).toBe(3700);
30
+ });
31
+
32
+ it('readPid returns null when no file exists', async () => {
33
+ const entry = await readPid();
34
+ expect(entry).toBeNull();
35
+ });
36
+
37
+ it('clearPid removes the file', async () => {
38
+ await writePid(3700);
39
+ await clearPid();
40
+ const entry = await readPid();
41
+ expect(entry).toBeNull();
42
+ });
43
+ });
44
+
45
+ describe('isProcessAlive', () => {
46
+ it('returns true for the current process', () => {
47
+ expect(isProcessAlive(process.pid)).toBe(true);
48
+ });
49
+
50
+ it('returns false for a non-existent PID', () => {
51
+ // PID 999999999 is very unlikely to exist
52
+ expect(isProcessAlive(999999999)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe('apiStopCommand', () => {
57
+ it('prints "not running" when no PID file', async () => {
58
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
59
+ await apiStopCommand();
60
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
61
+ consoleSpy.mockRestore();
62
+ });
63
+
64
+ it('prints "stale PID" and clears file when process is dead', async () => {
65
+ await writePid(3700);
66
+ // Overwrite with a dead PID
67
+ const pidFile = join(FAKE_HOME, '.studio', 'api.pid');
68
+ await writeFile(pidFile, '999999999:3700', 'utf-8');
69
+
70
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
71
+ await apiStopCommand();
72
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('stale'));
73
+ expect(await readPid()).toBeNull();
74
+ consoleSpy.mockRestore();
75
+ });
76
+
77
+ it('sends SIGTERM and clears PID when process is the current process', async () => {
78
+ await writePid(3700);
79
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
80
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
81
+
82
+ await apiStopCommand();
83
+
84
+ expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');
85
+ expect(await readPid()).toBeNull();
86
+ killSpy.mockRestore();
87
+ consoleSpy.mockRestore();
88
+ });
89
+ });
90
+
91
+ describe('apiStatusCommand', () => {
92
+ it('prints "not running" when no PID file', async () => {
93
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
94
+ await apiStatusCommand({});
95
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
96
+ consoleSpy.mockRestore();
97
+ });
98
+
99
+ it('prints "running" when process is alive and health check succeeds', async () => {
100
+ await writePid(3700);
101
+ // Override with current PID (alive) and mock fetch
102
+ global.fetch = vi.fn().mockResolvedValue({ ok: true }) as unknown as typeof fetch;
103
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
104
+
105
+ await apiStatusCommand({});
106
+
107
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
108
+ consoleSpy.mockRestore();
109
+ });
110
+ });
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, rm, readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import * as yaml from 'js-yaml';
5
+ import {
6
+ getConfigValue,
7
+ setConfigValue,
8
+ maskSecrets,
9
+ PROVIDERS,
10
+ validateApiKeyForProvider,
11
+ addProviderConfig,
12
+ isProviderConfigured,
13
+ } from '../../src/commands/config.js';
14
+
15
+ const TMP = resolve(import.meta.dirname, '.tmp-config-cmd-test');
16
+ const STUDIO_DIR = resolve(TMP, '.studio');
17
+
18
+ beforeEach(async () => { await mkdir(STUDIO_DIR, { recursive: true }); });
19
+ afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
20
+
21
+ describe('getConfigValue', () => {
22
+ it('gets a nested value by dotted path', () => {
23
+ const config = { defaults: { model: 'claude-haiku' } };
24
+ expect(getConfigValue(config, 'defaults.model')).toBe('claude-haiku');
25
+ });
26
+
27
+ it('returns undefined for missing path', () => {
28
+ expect(getConfigValue({}, 'defaults.model')).toBeUndefined();
29
+ });
30
+ });
31
+
32
+ describe('setConfigValue', () => {
33
+ it('sets a nested value by dotted path', () => {
34
+ const config: Record<string, unknown> = {};
35
+ setConfigValue(config, 'defaults.model', 'claude-sonnet');
36
+ expect((config as any).defaults.model).toBe('claude-sonnet');
37
+ });
38
+
39
+ it('merges without destroying sibling keys', () => {
40
+ const config = { defaults: { provider: 'anthropic', model: 'old' } };
41
+ setConfigValue(config, 'defaults.model', 'new');
42
+ expect((config as any).defaults.provider).toBe('anthropic');
43
+ expect((config as any).defaults.model).toBe('new');
44
+ });
45
+ });
46
+
47
+ describe('maskSecrets', () => {
48
+ it('masks apiKey values', () => {
49
+ const config = { providers: { anthropic: { apiKey: 'sk-ant-longkey' } } };
50
+ const masked = maskSecrets(config);
51
+ expect((masked as any).providers.anthropic.apiKey).toMatch(/\*\*\*/);
52
+ expect((masked as any).providers.anthropic.apiKey).not.toContain('longkey');
53
+ });
54
+
55
+ it('preserves non-secret values', () => {
56
+ const config = { defaults: { model: 'claude-haiku' } };
57
+ expect((maskSecrets(config) as any).defaults.model).toBe('claude-haiku');
58
+ });
59
+ });
60
+
61
+ describe('PROVIDERS', () => {
62
+ it('includes anthropic, openai, google, ollama', () => {
63
+ const ids = PROVIDERS.map((p) => p.id);
64
+ expect(ids).toContain('anthropic');
65
+ expect(ids).toContain('openai');
66
+ expect(ids).toContain('google');
67
+ expect(ids).toContain('ollama');
68
+ });
69
+
70
+ it('each provider has id, label, and defaultModel', () => {
71
+ for (const p of PROVIDERS) {
72
+ expect(typeof p.id).toBe('string');
73
+ expect(typeof p.label).toBe('string');
74
+ expect(typeof p.defaultModel).toBe('string');
75
+ }
76
+ });
77
+ });
78
+
79
+ describe('validateApiKeyForProvider', () => {
80
+ it('accepts a valid Anthropic key (sk-ant-...)', () => {
81
+ expect(validateApiKeyForProvider('anthropic', 'sk-ant-api03-abc123')).toBe(true);
82
+ });
83
+
84
+ it('rejects an Anthropic key with wrong prefix', () => {
85
+ const result = validateApiKeyForProvider('anthropic', 'sk-wrong');
86
+ expect(typeof result).toBe('string');
87
+ expect(result).toContain('sk-ant-');
88
+ });
89
+
90
+ it('accepts a valid OpenAI key (sk-...)', () => {
91
+ expect(validateApiKeyForProvider('openai', 'sk-proj-abc123')).toBe(true);
92
+ });
93
+
94
+ it('rejects an OpenAI key with wrong prefix', () => {
95
+ const result = validateApiKeyForProvider('openai', 'wrong-key');
96
+ expect(typeof result).toBe('string');
97
+ expect(result).toContain('sk-');
98
+ });
99
+
100
+ it('rejects an Anthropic-prefixed key when provider is openai', () => {
101
+ const result = validateApiKeyForProvider('openai', 'sk-ant-api03-abc');
102
+ expect(typeof result).toBe('string');
103
+ });
104
+
105
+ it('accepts a valid Google key (AIza...)', () => {
106
+ expect(validateApiKeyForProvider('google', 'AIzaSyABC123')).toBe(true);
107
+ });
108
+
109
+ it('rejects a Google key with wrong prefix', () => {
110
+ const result = validateApiKeyForProvider('google', 'wrong-key');
111
+ expect(typeof result).toBe('string');
112
+ expect(result).toContain('AIza');
113
+ });
114
+
115
+ it('accepts any value for ollama provider (no validation)', () => {
116
+ expect(validateApiKeyForProvider('ollama', 'http://localhost:11434')).toBe(true);
117
+ expect(validateApiKeyForProvider('ollama', '')).toBe(true);
118
+ });
119
+
120
+ it('accepts any value for unknown providers', () => {
121
+ expect(validateApiKeyForProvider('future-provider', 'any-key')).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe('addProviderConfig', () => {
126
+ const CONFIG_FILE = resolve(STUDIO_DIR, 'config.yaml');
127
+
128
+ it('writes provider apiKey to config.yaml', async () => {
129
+ await addProviderConfig(CONFIG_FILE, 'anthropic', 'sk-ant-key', false);
130
+
131
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
132
+ const parsed = yaml.load(raw) as Record<string, unknown>;
133
+ const providers = parsed.providers as Record<string, { apiKey: string }>;
134
+ expect(providers.anthropic.apiKey).toBe('sk-ant-key');
135
+ });
136
+
137
+ it('sets defaults when setDefault=true', async () => {
138
+ await addProviderConfig(CONFIG_FILE, 'anthropic', 'sk-ant-key', true);
139
+
140
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
141
+ const parsed = yaml.load(raw) as Record<string, unknown>;
142
+ const defaults = parsed.defaults as { provider: string; model: string };
143
+ expect(defaults.provider).toBe('anthropic');
144
+ expect(defaults.model).toBe('claude-sonnet-4-20250514');
145
+ });
146
+
147
+ it('does not touch defaults when setDefault=false', async () => {
148
+ await addProviderConfig(CONFIG_FILE, 'anthropic', 'sk-ant-key', false);
149
+
150
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
151
+ const parsed = yaml.load(raw) as Record<string, unknown>;
152
+ expect(parsed.defaults).toBeUndefined();
153
+ });
154
+
155
+ it('preserves existing provider when adding a second', async () => {
156
+ await addProviderConfig(CONFIG_FILE, 'anthropic', 'sk-ant-key', false);
157
+ await addProviderConfig(CONFIG_FILE, 'openai', 'sk-openai-key', false);
158
+
159
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
160
+ const parsed = yaml.load(raw) as Record<string, unknown>;
161
+ const providers = parsed.providers as Record<string, { apiKey: string }>;
162
+ expect(providers.anthropic.apiKey).toBe('sk-ant-key');
163
+ expect(providers.openai.apiKey).toBe('sk-openai-key');
164
+ });
165
+
166
+ it('overwrites existing provider key', async () => {
167
+ await addProviderConfig(CONFIG_FILE, 'anthropic', 'sk-ant-first', false);
168
+ await addProviderConfig(CONFIG_FILE, 'anthropic', 'sk-ant-second', false);
169
+
170
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
171
+ const parsed = yaml.load(raw) as Record<string, unknown>;
172
+ const providers = parsed.providers as Record<string, { apiKey: string }>;
173
+ expect(providers.anthropic.apiKey).toBe('sk-ant-second');
174
+ });
175
+
176
+ it('creates config.yaml if it does not exist', async () => {
177
+ // STUDIO_DIR exists (from beforeEach) but config.yaml does not
178
+ await addProviderConfig(CONFIG_FILE, 'openai', 'sk-new-key', false);
179
+
180
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
181
+ const parsed = yaml.load(raw) as Record<string, unknown>;
182
+ const providers = parsed.providers as Record<string, { apiKey: string }>;
183
+ expect(providers.openai.apiKey).toBe('sk-new-key');
184
+ });
185
+
186
+ it('uses correct defaultModel for openai', async () => {
187
+ await addProviderConfig(CONFIG_FILE, 'openai', 'sk-openai-key', true);
188
+
189
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
190
+ const parsed = yaml.load(raw) as Record<string, unknown>;
191
+ const defaults = parsed.defaults as { model: string };
192
+ expect(defaults.model).toBe('gpt-4o');
193
+ });
194
+
195
+ it('uses claude-sonnet fallback for unknown provider', async () => {
196
+ await addProviderConfig(CONFIG_FILE, 'unknown-provider', 'some-key', true);
197
+
198
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
199
+ const parsed = yaml.load(raw) as Record<string, unknown>;
200
+ const defaults = parsed.defaults as { model: string };
201
+ expect(defaults.model).toBe('claude-sonnet-4-20250514');
202
+ });
203
+ });
204
+
205
+ describe('isProviderConfigured', () => {
206
+ const CONFIG_FILE = resolve(STUDIO_DIR, 'config.yaml');
207
+
208
+ it('returns false when config.yaml does not exist', async () => {
209
+ expect(await isProviderConfigured(CONFIG_FILE, 'anthropic')).toBe(false);
210
+ });
211
+
212
+ it('returns false when provider not in config', async () => {
213
+ await addProviderConfig(CONFIG_FILE, 'openai', 'sk-key', false);
214
+ expect(await isProviderConfigured(CONFIG_FILE, 'anthropic')).toBe(false);
215
+ });
216
+
217
+ it('returns true when provider is in config', async () => {
218
+ await addProviderConfig(CONFIG_FILE, 'anthropic', 'sk-ant-key', false);
219
+ expect(await isProviderConfigured(CONFIG_FILE, 'anthropic')).toBe(true);
220
+ });
221
+ });