@meshxdata/fops 0.0.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.

Potentially problematic release.


This version of @meshxdata/fops might be problematic. Click here for more details.

Files changed (57) hide show
  1. package/README.md +98 -0
  2. package/STRUCTURE.md +43 -0
  3. package/foundation.mjs +16 -0
  4. package/package.json +52 -0
  5. package/src/agent/agent.js +367 -0
  6. package/src/agent/agent.test.js +233 -0
  7. package/src/agent/context.js +143 -0
  8. package/src/agent/context.test.js +81 -0
  9. package/src/agent/index.js +2 -0
  10. package/src/agent/llm.js +127 -0
  11. package/src/agent/llm.test.js +139 -0
  12. package/src/auth/index.js +4 -0
  13. package/src/auth/keychain.js +58 -0
  14. package/src/auth/keychain.test.js +185 -0
  15. package/src/auth/login.js +421 -0
  16. package/src/auth/login.test.js +192 -0
  17. package/src/auth/oauth.js +203 -0
  18. package/src/auth/oauth.test.js +118 -0
  19. package/src/auth/resolve.js +78 -0
  20. package/src/auth/resolve.test.js +153 -0
  21. package/src/commands/index.js +268 -0
  22. package/src/config.js +24 -0
  23. package/src/config.test.js +70 -0
  24. package/src/doctor.js +487 -0
  25. package/src/doctor.test.js +134 -0
  26. package/src/plugins/api.js +37 -0
  27. package/src/plugins/api.test.js +95 -0
  28. package/src/plugins/discovery.js +78 -0
  29. package/src/plugins/discovery.test.js +92 -0
  30. package/src/plugins/hooks.js +13 -0
  31. package/src/plugins/hooks.test.js +118 -0
  32. package/src/plugins/index.js +3 -0
  33. package/src/plugins/loader.js +110 -0
  34. package/src/plugins/manifest.js +26 -0
  35. package/src/plugins/manifest.test.js +106 -0
  36. package/src/plugins/registry.js +14 -0
  37. package/src/plugins/registry.test.js +43 -0
  38. package/src/plugins/skills.js +126 -0
  39. package/src/plugins/skills.test.js +173 -0
  40. package/src/project.js +61 -0
  41. package/src/project.test.js +196 -0
  42. package/src/setup/aws.js +369 -0
  43. package/src/setup/aws.test.js +280 -0
  44. package/src/setup/index.js +3 -0
  45. package/src/setup/setup.js +161 -0
  46. package/src/setup/wizard.js +119 -0
  47. package/src/shell.js +9 -0
  48. package/src/shell.test.js +72 -0
  49. package/src/skills/foundation/SKILL.md +107 -0
  50. package/src/ui/banner.js +56 -0
  51. package/src/ui/banner.test.js +97 -0
  52. package/src/ui/confirm.js +97 -0
  53. package/src/ui/index.js +5 -0
  54. package/src/ui/input.js +199 -0
  55. package/src/ui/spinner.js +170 -0
  56. package/src/ui/spinner.test.js +29 -0
  57. package/src/ui/streaming.js +106 -0
@@ -0,0 +1,203 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import { URL, URLSearchParams } from "node:url";
5
+ import chalk from "chalk";
6
+ import { saveClaudeCodeKeychain } from "./keychain.js";
7
+ import { saveApiKey, openBrowser } from "./login.js";
8
+
9
+ // OAuth configuration (same as Claude Code uses)
10
+ export const OAUTH_CONFIG = {
11
+ authorizationEndpoint: "https://claude.ai/oauth/authorize",
12
+ tokenEndpoint: "https://claude.ai/api/oauth/token",
13
+ clientId: "9d1c250a-e61b-44d0-8e2d-42452f11526c", // Claude Code's public client ID
14
+ scope: "user:inference",
15
+ };
16
+
17
+ /**
18
+ * Generate PKCE code verifier and challenge
19
+ */
20
+ export function generatePKCE() {
21
+ const verifier = crypto.randomBytes(32).toString("base64url");
22
+ const challenge = crypto
23
+ .createHash("sha256")
24
+ .update(verifier)
25
+ .digest("base64url");
26
+ return { verifier, challenge };
27
+ }
28
+
29
+ /**
30
+ * Make HTTPS POST request and return JSON
31
+ */
32
+ export function httpsPost(url, data) {
33
+ return new Promise((resolve, reject) => {
34
+ const parsed = new URL(url);
35
+ const body = new URLSearchParams(data).toString();
36
+ const req = https.request({
37
+ hostname: parsed.hostname,
38
+ port: 443,
39
+ path: parsed.pathname,
40
+ method: "POST",
41
+ headers: {
42
+ "Content-Type": "application/x-www-form-urlencoded",
43
+ "Content-Length": Buffer.byteLength(body),
44
+ },
45
+ }, (res) => {
46
+ let responseBody = "";
47
+ res.on("data", (chunk) => { responseBody += chunk; });
48
+ res.on("end", () => {
49
+ try {
50
+ resolve({ status: res.statusCode, data: JSON.parse(responseBody) });
51
+ } catch {
52
+ reject(new Error(`Invalid JSON response: ${responseBody}`));
53
+ }
54
+ });
55
+ });
56
+ req.on("error", reject);
57
+ req.write(body);
58
+ req.end();
59
+ });
60
+ }
61
+
62
+ export function getResultHTML(success, message) {
63
+ const color = success ? "#4ade80" : "#f87171";
64
+ const icon = success ? "✓" : "✗";
65
+ return `<!DOCTYPE html>
66
+ <html>
67
+ <head><title>Foundation CLI</title>
68
+ <style>
69
+ body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #e4e4e7;
70
+ min-height: 100vh; margin: 0; display: flex; align-items: center; justify-content: center; }
71
+ .container { text-align: center; }
72
+ .icon { font-size: 64px; color: ${color}; }
73
+ h1 { color: ${color}; margin: 16px 0 8px; }
74
+ p { color: #a1a1aa; }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <div class="container">
79
+ <div class="icon">${icon}</div>
80
+ <h1>${success ? "Success" : "Error"}</h1>
81
+ <p>${message}</p>
82
+ </div>
83
+ </body>
84
+ </html>`;
85
+ }
86
+
87
+ /**
88
+ * OAuth login flow - opens browser and receives token via callback
89
+ */
90
+ export async function runOAuthLogin() {
91
+ return new Promise((resolve) => {
92
+ const { verifier, challenge } = generatePKCE();
93
+ const state = crypto.randomBytes(16).toString("hex");
94
+
95
+ const server = http.createServer(async (req, res) => {
96
+ const url = new URL(req.url, `http://127.0.0.1`);
97
+
98
+ if (url.pathname === "/callback") {
99
+ const code = url.searchParams.get("code");
100
+ const returnedState = url.searchParams.get("state");
101
+ const error = url.searchParams.get("error");
102
+
103
+ if (error) {
104
+ res.writeHead(200, { "Content-Type": "text/html" });
105
+ res.end(getResultHTML(false, `Authorization failed: ${error}`));
106
+ server.close();
107
+ resolve(false);
108
+ return;
109
+ }
110
+
111
+ if (returnedState !== state) {
112
+ res.writeHead(200, { "Content-Type": "text/html" });
113
+ res.end(getResultHTML(false, "Invalid state parameter"));
114
+ server.close();
115
+ resolve(false);
116
+ return;
117
+ }
118
+
119
+ if (!code) {
120
+ res.writeHead(200, { "Content-Type": "text/html" });
121
+ res.end(getResultHTML(false, "No authorization code received"));
122
+ server.close();
123
+ resolve(false);
124
+ return;
125
+ }
126
+
127
+ // Exchange code for token
128
+ try {
129
+ const { port } = server.address();
130
+ const tokenResponse = await httpsPost(OAUTH_CONFIG.tokenEndpoint, {
131
+ grant_type: "authorization_code",
132
+ client_id: OAUTH_CONFIG.clientId,
133
+ code,
134
+ redirect_uri: `http://127.0.0.1:${port}/callback`,
135
+ code_verifier: verifier,
136
+ });
137
+
138
+ if (tokenResponse.status !== 200 || !tokenResponse.data.access_token) {
139
+ throw new Error(tokenResponse.data.error || "Token exchange failed");
140
+ }
141
+
142
+ const { access_token, refresh_token } = tokenResponse.data;
143
+
144
+ // Save to keychain (macOS) or credentials file
145
+ if (process.platform === "darwin") {
146
+ saveClaudeCodeKeychain(access_token, refresh_token);
147
+ } else {
148
+ saveApiKey(access_token);
149
+ }
150
+
151
+ res.writeHead(200, { "Content-Type": "text/html" });
152
+ res.end(getResultHTML(true, "Login successful! You can close this window."));
153
+
154
+ console.log(chalk.green("\nLogin successful"));
155
+ if (process.platform === "darwin") {
156
+ console.log(chalk.gray("Token saved to macOS Keychain\n"));
157
+ } else {
158
+ console.log(chalk.gray("Token saved to ~/.claude/.credentials.json\n"));
159
+ }
160
+
161
+ server.close();
162
+ resolve(true);
163
+ } catch (err) {
164
+ res.writeHead(200, { "Content-Type": "text/html" });
165
+ res.end(getResultHTML(false, `Token exchange failed: ${err.message}`));
166
+ server.close();
167
+ resolve(false);
168
+ }
169
+ return;
170
+ }
171
+
172
+ res.writeHead(404);
173
+ res.end();
174
+ });
175
+
176
+ server.listen(0, "127.0.0.1", () => {
177
+ const { port } = server.address();
178
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
179
+
180
+ const authUrl = new URL(OAUTH_CONFIG.authorizationEndpoint);
181
+ authUrl.searchParams.set("client_id", OAUTH_CONFIG.clientId);
182
+ authUrl.searchParams.set("redirect_uri", redirectUri);
183
+ authUrl.searchParams.set("response_type", "code");
184
+ authUrl.searchParams.set("scope", OAUTH_CONFIG.scope);
185
+ authUrl.searchParams.set("state", state);
186
+ authUrl.searchParams.set("code_challenge", challenge);
187
+ authUrl.searchParams.set("code_challenge_method", "S256");
188
+
189
+ console.log(chalk.blue("\nOpening browser for Claude login...\n"));
190
+ console.log(chalk.gray(` If browser doesn't open, visit:\n ${chalk.cyan(authUrl.toString())}\n`));
191
+ console.log(chalk.gray(" Waiting for authentication..."));
192
+
193
+ openBrowser(authUrl.toString());
194
+ });
195
+
196
+ // Timeout after 5 minutes
197
+ setTimeout(() => {
198
+ console.log(chalk.yellow("\n Login timed out. Run foundation login again.\n"));
199
+ server.close();
200
+ resolve(false);
201
+ }, 5 * 60 * 1000);
202
+ });
203
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import crypto from "node:crypto";
3
+ import { OAUTH_CONFIG, generatePKCE, getResultHTML } from "./oauth.js";
4
+
5
+ describe("auth/oauth", () => {
6
+ describe("OAUTH_CONFIG", () => {
7
+ it("has required OAuth fields", () => {
8
+ expect(OAUTH_CONFIG.authorizationEndpoint).toContain("claude.ai");
9
+ expect(OAUTH_CONFIG.tokenEndpoint).toContain("claude.ai");
10
+ expect(typeof OAUTH_CONFIG.clientId).toBe("string");
11
+ expect(OAUTH_CONFIG.clientId.length).toBeGreaterThan(0);
12
+ expect(OAUTH_CONFIG.scope).toBe("user:inference");
13
+ });
14
+
15
+ it("authorization endpoint is a valid URL", () => {
16
+ expect(() => new URL(OAUTH_CONFIG.authorizationEndpoint)).not.toThrow();
17
+ });
18
+
19
+ it("token endpoint is a valid URL", () => {
20
+ expect(() => new URL(OAUTH_CONFIG.tokenEndpoint)).not.toThrow();
21
+ });
22
+
23
+ it("clientId is a UUID format", () => {
24
+ expect(OAUTH_CONFIG.clientId).toMatch(
25
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
26
+ );
27
+ });
28
+
29
+ it("endpoints use HTTPS", () => {
30
+ expect(OAUTH_CONFIG.authorizationEndpoint).toMatch(/^https:/);
31
+ expect(OAUTH_CONFIG.tokenEndpoint).toMatch(/^https:/);
32
+ });
33
+ });
34
+
35
+ describe("generatePKCE", () => {
36
+ it("returns verifier and challenge", () => {
37
+ const { verifier, challenge } = generatePKCE();
38
+ expect(typeof verifier).toBe("string");
39
+ expect(typeof challenge).toBe("string");
40
+ expect(verifier.length).toBeGreaterThan(0);
41
+ expect(challenge.length).toBeGreaterThan(0);
42
+ });
43
+
44
+ it("generates base64url strings (no +, /, = chars)", () => {
45
+ const { verifier, challenge } = generatePKCE();
46
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
47
+ expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
48
+ });
49
+
50
+ it("generates unique values each time", () => {
51
+ const a = generatePKCE();
52
+ const b = generatePKCE();
53
+ expect(a.verifier).not.toBe(b.verifier);
54
+ expect(a.challenge).not.toBe(b.challenge);
55
+ });
56
+
57
+ it("challenge is SHA-256 of verifier", () => {
58
+ const { verifier, challenge } = generatePKCE();
59
+ const expected = crypto.createHash("sha256").update(verifier).digest("base64url");
60
+ expect(challenge).toBe(expected);
61
+ });
62
+
63
+ it("verifier is 43 chars (32 bytes base64url)", () => {
64
+ const { verifier } = generatePKCE();
65
+ // 32 bytes in base64url = ceil(32 * 4/3) = 43 chars
66
+ expect(verifier.length).toBe(43);
67
+ });
68
+
69
+ it("challenge is 43 chars (SHA-256 = 32 bytes base64url)", () => {
70
+ const { challenge } = generatePKCE();
71
+ expect(challenge.length).toBe(43);
72
+ });
73
+ });
74
+
75
+ describe("getResultHTML", () => {
76
+ it("returns success HTML", () => {
77
+ const html = getResultHTML(true, "All good");
78
+ expect(html).toContain("<!DOCTYPE html>");
79
+ expect(html).toContain("Success");
80
+ expect(html).toContain("All good");
81
+ expect(html).toContain("#4ade80");
82
+ });
83
+
84
+ it("returns error HTML", () => {
85
+ const html = getResultHTML(false, "Something failed");
86
+ expect(html).toContain("Error");
87
+ expect(html).toContain("Something failed");
88
+ expect(html).toContain("#f87171");
89
+ });
90
+
91
+ it("success uses checkmark icon", () => {
92
+ const html = getResultHTML(true, "ok");
93
+ expect(html).toContain("✓");
94
+ });
95
+
96
+ it("error uses cross icon", () => {
97
+ const html = getResultHTML(false, "fail");
98
+ expect(html).toContain("✗");
99
+ });
100
+
101
+ it("escapes message into HTML body", () => {
102
+ const html = getResultHTML(true, "Token saved");
103
+ expect(html).toContain("Token saved");
104
+ });
105
+
106
+ it("returns valid HTML with head and body", () => {
107
+ const html = getResultHTML(true, "test");
108
+ expect(html).toContain("<head>");
109
+ expect(html).toContain("<body>");
110
+ expect(html).toContain("</html>");
111
+ });
112
+
113
+ it("includes Foundation CLI title", () => {
114
+ const html = getResultHTML(true, "test");
115
+ expect(html).toContain("Foundation CLI");
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execaSync } from "execa";
5
+
6
+ export const CLAUDE_DIR = path.join(os.homedir(), ".claude");
7
+ export const CLAUDE_JSON = path.join(os.homedir(), ".claude.json");
8
+ export const CLAUDE_CREDENTIALS = path.join(CLAUDE_DIR, ".credentials.json");
9
+
10
+ export function readJsonKey(obj, keys) {
11
+ if (!obj || typeof obj !== "object") return null;
12
+ for (const k of keys) {
13
+ const v = obj[k];
14
+ if (typeof v === "string" && v.trim()) return v.trim();
15
+ }
16
+ return null;
17
+ }
18
+
19
+ export function resolveAnthropicApiKey() {
20
+ const envKey = process.env.ANTHROPIC_API_KEY?.trim();
21
+ if (envKey) return envKey;
22
+ try {
23
+ const settingsPath = path.join(CLAUDE_DIR, "settings.json");
24
+ if (fs.existsSync(settingsPath)) {
25
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
26
+ const helper = settings?.apiKeyHelper;
27
+ if (typeof helper === "string" && helper.trim()) {
28
+ const { stdout } = execaSync(helper.trim(), [], { shell: true, encoding: "utf8" });
29
+ const key = stdout?.trim();
30
+ if (key) return key;
31
+ }
32
+ }
33
+ } catch {
34
+ // ignore
35
+ }
36
+ const credKeys = ["anthropic_api_key", "ANTHROPIC_API_KEY", "apiKey", "api_key"];
37
+ try {
38
+ if (fs.existsSync(CLAUDE_CREDENTIALS)) {
39
+ const cred = JSON.parse(fs.readFileSync(CLAUDE_CREDENTIALS, "utf8"));
40
+ const key = readJsonKey(cred, credKeys);
41
+ if (key) return key;
42
+ }
43
+ } catch {
44
+ // ignore
45
+ }
46
+ try {
47
+ if (fs.existsSync(CLAUDE_JSON)) {
48
+ const data = JSON.parse(fs.readFileSync(CLAUDE_JSON, "utf8"));
49
+ const key = readJsonKey(data, credKeys);
50
+ if (key) return key;
51
+ }
52
+ } catch {
53
+ // ignore
54
+ }
55
+ // Note: Claude Code's OAuth tokens (sk-ant-oat01-...) don't work directly with the API.
56
+ // They're for Claude Code's proxy service. Users need actual API keys.
57
+ return null;
58
+ }
59
+
60
+ export function resolveOpenAiApiKey() {
61
+ const envKey = process.env.OPENAI_API_KEY?.trim();
62
+ if (envKey) return envKey;
63
+ try {
64
+ if (fs.existsSync(CLAUDE_CREDENTIALS)) {
65
+ const cred = JSON.parse(fs.readFileSync(CLAUDE_CREDENTIALS, "utf8"));
66
+ const key = readJsonKey(cred, ["openai_api_key", "OPENAI_API_KEY", "apiKey"]);
67
+ if (key) return key;
68
+ }
69
+ } catch {}
70
+ try {
71
+ if (fs.existsSync(CLAUDE_JSON)) {
72
+ const data = JSON.parse(fs.readFileSync(CLAUDE_JSON, "utf8"));
73
+ const key = readJsonKey(data, ["openai_api_key", "OPENAI_API_KEY"]);
74
+ if (key) return key;
75
+ }
76
+ } catch {}
77
+ return null;
78
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ vi.mock("execa", () => ({
7
+ execaSync: vi.fn(),
8
+ }));
9
+
10
+ describe("auth/resolve", () => {
11
+ describe("readJsonKey", () => {
12
+ let readJsonKey;
13
+
14
+ beforeEach(async () => {
15
+ ({ readJsonKey } = await import("./resolve.js"));
16
+ });
17
+
18
+ it("returns first matching string key", () => {
19
+ expect(readJsonKey({ apiKey: "sk-123" }, ["apiKey"])).toBe("sk-123");
20
+ });
21
+
22
+ it("tries multiple keys in order", () => {
23
+ expect(readJsonKey({ api_key: "sk-456" }, ["apiKey", "api_key"])).toBe("sk-456");
24
+ });
25
+
26
+ it("returns first match when multiple keys exist", () => {
27
+ expect(readJsonKey({ apiKey: "first", api_key: "second" }, ["apiKey", "api_key"])).toBe("first");
28
+ });
29
+
30
+ it("trims whitespace", () => {
31
+ expect(readJsonKey({ apiKey: " sk-789 " }, ["apiKey"])).toBe("sk-789");
32
+ });
33
+
34
+ it("returns null for empty string", () => {
35
+ expect(readJsonKey({ apiKey: " " }, ["apiKey"])).toBe(null);
36
+ });
37
+
38
+ it("returns null for non-string values", () => {
39
+ expect(readJsonKey({ apiKey: 123 }, ["apiKey"])).toBe(null);
40
+ expect(readJsonKey({ apiKey: true }, ["apiKey"])).toBe(null);
41
+ expect(readJsonKey({ apiKey: null }, ["apiKey"])).toBe(null);
42
+ expect(readJsonKey({ apiKey: [] }, ["apiKey"])).toBe(null);
43
+ expect(readJsonKey({ apiKey: {} }, ["apiKey"])).toBe(null);
44
+ });
45
+
46
+ it("returns null for null/undefined input", () => {
47
+ expect(readJsonKey(null, ["apiKey"])).toBe(null);
48
+ expect(readJsonKey(undefined, ["apiKey"])).toBe(null);
49
+ });
50
+
51
+ it("returns null for non-object input", () => {
52
+ expect(readJsonKey("string", ["apiKey"])).toBe(null);
53
+ expect(readJsonKey(42, ["apiKey"])).toBe(null);
54
+ expect(readJsonKey(true, ["apiKey"])).toBe(null);
55
+ });
56
+
57
+ it("returns null when key is not found", () => {
58
+ expect(readJsonKey({ other: "value" }, ["apiKey"])).toBe(null);
59
+ });
60
+
61
+ it("skips empty keys and continues", () => {
62
+ expect(readJsonKey({ first: "", second: "found" }, ["first", "second"])).toBe("found");
63
+ });
64
+ });
65
+
66
+ describe("resolveAnthropicApiKey", () => {
67
+ let resolveAnthropicApiKey;
68
+ const originalEnv = { ...process.env };
69
+
70
+ beforeEach(async () => {
71
+ vi.resetModules();
72
+ vi.mock("execa", () => ({ execaSync: vi.fn() }));
73
+ ({ resolveAnthropicApiKey } = await import("./resolve.js"));
74
+ delete process.env.ANTHROPIC_API_KEY;
75
+ });
76
+
77
+ afterEach(() => {
78
+ process.env = { ...originalEnv };
79
+ });
80
+
81
+ it("returns env var when set", () => {
82
+ process.env.ANTHROPIC_API_KEY = "sk-ant-api03-env";
83
+ expect(resolveAnthropicApiKey()).toBe("sk-ant-api03-env");
84
+ });
85
+
86
+ it("trims env var whitespace", () => {
87
+ process.env.ANTHROPIC_API_KEY = " sk-ant-api03-padded ";
88
+ expect(resolveAnthropicApiKey()).toBe("sk-ant-api03-padded");
89
+ });
90
+
91
+ it("returns null when nothing is configured", () => {
92
+ const result = resolveAnthropicApiKey();
93
+ expect(result === null || typeof result === "string").toBe(true);
94
+ });
95
+
96
+ it("prioritizes env var over files", () => {
97
+ process.env.ANTHROPIC_API_KEY = "sk-ant-api03-env-wins";
98
+ // Even if files exist, env should be returned
99
+ expect(resolveAnthropicApiKey()).toBe("sk-ant-api03-env-wins");
100
+ });
101
+ });
102
+
103
+ describe("resolveOpenAiApiKey", () => {
104
+ let resolveOpenAiApiKey;
105
+ const originalEnv = { ...process.env };
106
+
107
+ beforeEach(async () => {
108
+ vi.resetModules();
109
+ vi.mock("execa", () => ({ execaSync: vi.fn() }));
110
+ ({ resolveOpenAiApiKey } = await import("./resolve.js"));
111
+ delete process.env.OPENAI_API_KEY;
112
+ });
113
+
114
+ afterEach(() => {
115
+ process.env = { ...originalEnv };
116
+ });
117
+
118
+ it("returns env var when set", () => {
119
+ process.env.OPENAI_API_KEY = "sk-openai-env";
120
+ expect(resolveOpenAiApiKey()).toBe("sk-openai-env");
121
+ });
122
+
123
+ it("trims env var whitespace", () => {
124
+ process.env.OPENAI_API_KEY = " sk-openai-padded ";
125
+ expect(resolveOpenAiApiKey()).toBe("sk-openai-padded");
126
+ });
127
+
128
+ it("returns null when nothing configured", () => {
129
+ const result = resolveOpenAiApiKey();
130
+ expect(result === null || typeof result === "string").toBe(true);
131
+ });
132
+ });
133
+
134
+ describe("CLAUDE_DIR / CLAUDE_JSON / CLAUDE_CREDENTIALS exports", () => {
135
+ let CLAUDE_DIR, CLAUDE_JSON, CLAUDE_CREDENTIALS;
136
+
137
+ beforeEach(async () => {
138
+ ({ CLAUDE_DIR, CLAUDE_JSON, CLAUDE_CREDENTIALS } = await import("./resolve.js"));
139
+ });
140
+
141
+ it("CLAUDE_DIR points to ~/.claude", () => {
142
+ expect(CLAUDE_DIR).toBe(path.join(os.homedir(), ".claude"));
143
+ });
144
+
145
+ it("CLAUDE_JSON points to ~/.claude.json", () => {
146
+ expect(CLAUDE_JSON).toBe(path.join(os.homedir(), ".claude.json"));
147
+ });
148
+
149
+ it("CLAUDE_CREDENTIALS points to ~/.claude/.credentials.json", () => {
150
+ expect(CLAUDE_CREDENTIALS).toBe(path.join(os.homedir(), ".claude", ".credentials.json"));
151
+ });
152
+ });
153
+ });