diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index ede7c616..cbd82aa3 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -107,6 +107,8 @@ OpenWiki CLI reference: - \`openwiki --update [message]\` updates repository documentation under openwiki/ (code mode). - \`openwiki personal --init [message]\` initializes the local personal brain wiki under ~/.openwiki/wiki. - \`openwiki code --init [message]\` initializes repository documentation under openwiki/. +- \`openwiki personal --update [message]\` updates the local OpenWiki knowledge base under ~/.openwiki/wiki. +- \`openwiki code --update [message]\` updates repository documentation under openwiki/. - \`openwiki --mode code --init [message]\` initializes repository documentation under openwiki/. - \`openwiki --mode personal --init [message]\` initializes the local personal brain wiki under ~/.openwiki/wiki. - \`openwiki -p "message"\` or \`openwiki --print "message"\` runs once, prints the final assistant output, and exits. diff --git a/src/commands.ts b/src/commands.ts index 791b5669..7061b153 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -643,7 +643,6 @@ export const helpContent: HelpContent = { "openwiki --mode [--init|--update] [message]", "openwiki [--modelId ]", "openwiki [--modelId ] [message]", - "openwiki --update [message]", "openwiki auth ", "openwiki auth configure [--force]", "openwiki auth tools ", @@ -760,12 +759,12 @@ export const helpContent: HelpContent = { "openwiki --init", "openwiki personal --init", "openwiki code --init", - "openwiki --update", - "openwiki --update --mode personal", + "openwiki personal --update", + "openwiki code --update", 'openwiki "What can you do?"', 'openwiki -p "Summarize what OpenWiki can do"', "openwiki --modelId gpt-5.5", - 'openwiki --update --modelId gpt-5.5 "Please document the API routes first"', + 'openwiki code --update --modelId gpt-5.5 "Please document the API routes first"', 'openwiki personal --update "Refresh the wiki from configured connectors"', "openwiki ingest all", "openwiki ingest web-search", diff --git a/src/connectors/registry.ts b/src/connectors/registry.ts index 11271c58..01acf3f4 100644 --- a/src/connectors/registry.ts +++ b/src/connectors/registry.ts @@ -1,3 +1,4 @@ +import { createClickUpConnector } from "./sources/clickup.js"; import { createGitRepoConnector } from "./sources/git-repo.js"; import { createGmailConnector } from "./sources/gmail.js"; import { createHackerNewsConnector } from "./sources/hackernews.js"; @@ -8,6 +9,7 @@ import { createXConnector } from "./sources/x.js"; import type { ConnectorId, ConnectorRuntime } from "./types.js"; export const CONNECTOR_IDS = [ + "clickup", "git-repo", "notion", "x", @@ -22,6 +24,7 @@ export function createConnectorRegistry(): Record< ConnectorRuntime > { return { + clickup: createClickUpConnector(), "git-repo": createGitRepoConnector(), google: createGmailConnector(), hackernews: createHackerNewsConnector(), diff --git a/src/connectors/sources/clickup.ts b/src/connectors/sources/clickup.ts new file mode 100644 index 00000000..83d2a388 --- /dev/null +++ b/src/connectors/sources/clickup.ts @@ -0,0 +1,556 @@ +import { OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY } from "../../constants.js"; +import { + createRunId, + readConnectorConfig, + readConnectorState, + updateStateWithRun, + writeConnectorState, + writeRawJson, +} from "../io.js"; +import type { + ConnectorDefinition, + ConnectorIngestOptions, + ConnectorIngestResult, + ConnectorRuntime, +} from "../types.js"; + +type ClickUpConfig = { + enabled?: boolean; + folderIds?: string[]; + includeSubtasks?: boolean; + listIds?: string[]; + maxTasksPerList?: number; + spaceIds?: string[]; + taskFields?: string[]; + windowHours?: number; + workspaceIds?: string[]; +}; + +type ClickUpWorkspace = { + id?: string; + name?: string; +}; + +type ClickUpSpace = { + id?: string; + name?: string; + private?: boolean; +}; + +type ClickUpList = { + id?: string; + name?: string; + orderindex?: number; + space?: { id?: string }; +}; + +type ClickUpTask = { + assignees?: ClickUpAssignee[]; + custom_fields?: ClickUpCustomField[]; + date_closed?: number | null; + date_created?: number; + date_done?: number | null; + date_updated?: number; + description?: string; + due_date?: number | null; + id?: string; + links_to?: string | null; + markdown_description?: string; + name?: string; + orderindex?: number; + priority?: ClickUpPriority; + status?: ClickUpStatus; + subtasks?: ClickUpTask[]; + tags?: ClickUpTag[]; + text_content?: string; + url?: string; +}; + +type ClickUpAssignee = { + color?: string; + email?: string; + id?: number; + profilePhoto?: string; + username?: string; +}; + +type ClickUpCustomField = { + id?: string; + name?: string; + type_config?: { + options?: { id?: string; label?: string; orderindex?: number }[]; + }; + value?: unknown; +}; + +type ClickUpPriority = { + color?: string; + id?: string; + orderindex?: number; + priority?: string; +}; + +type ClickUpStatus = { + color?: string; + id?: string; + status?: string; + type?: string; +}; + +type ClickUpTag = { + color?: string; + creator?: number; + id?: string; + name?: string; + orderindex?: number; +}; + +type ClickUpComment = { + comment_text?: string; + date?: string; + id?: string; + text_content?: string; + user?: ClickUpCommentUser; +}; + +type ClickUpCommentUser = { + email?: string; + id?: number; + username?: string; +}; + +type ClickUpTaskPage = { + tasks?: ClickUpTask[]; +}; + +const CLICKUP_API_BASE_URL = "https://api.clickup.com/api/v2"; +const DEFAULT_MAX_TASKS_PER_LIST = 100; +const DEFAULT_WINDOW_HOURS = 168; + +const definition: ConnectorDefinition = { + backend: "direct-api", + description: + "Fetches ClickUp workspaces, tasks, subtasks, comments, and docs through the ClickUp API v2 with a personal API token.", + displayName: "ClickUp", + id: "clickup", + requiredEnv: [OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY], + supportsAgenticDiscovery: false, +}; + +export function createClickUpConnector(): ConnectorRuntime { + return { + ...definition, + ingest, + }; +} + +async function ingest( + options: ConnectorIngestOptions = {}, +): Promise { + const runId = createRunId(); + const config = await readConnectorConfig("clickup", { + enabled: false, + folderIds: [], + includeSubtasks: true, + listIds: [], + maxTasksPerList: DEFAULT_MAX_TASKS_PER_LIST, + spaceIds: [], + taskFields: [], + windowHours: DEFAULT_WINDOW_HOURS, + workspaceIds: [], + }); + const state = await readConnectorState("clickup"); + const warnings: string[] = []; + const rawFiles: string[] = []; + + if (!config.enabled) { + return { + connectorId: "clickup", + message: + "ClickUp connector is not enabled. Set enabled=true in ~/.openwiki/connectors/clickup/config.json.", + rawFiles, + runId, + statePath: "~/.openwiki/connectors/clickup/state.json", + status: "skipped", + warnings, + }; + } + + if (!process.env[OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY]) { + return { + connectorId: "clickup", + message: `${OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY} is required for ClickUp ingestion.`, + rawFiles, + runId, + statePath: "~/.openwiki/connectors/clickup/state.json", + status: "error", + warnings, + }; + } + + const token = process.env[OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY]; + const maxTasksPerList = clamp( + config.maxTasksPerList, + 1, + 1000, + DEFAULT_MAX_TASKS_PER_LIST, + ); + const windowStart = getWindowStartTime( + options.windowHours ?? config.windowHours, + ); + const latestIds = { ...(state.latestIds ?? {}) }; + + const workspaces = await fetchWorkspaces(token); + rawFiles.push( + await writeRawJson("clickup", runId, "workspaces.json", { + fetchedAt: new Date().toISOString(), + workspaces, + }), + ); + + for (const workspace of workspaces) { + if (!workspace.id) { + continue; + } + + if ( + (config.workspaceIds?.length ?? 0) > 0 && + !config.workspaceIds?.includes(workspace.id) + ) { + continue; + } + + const lists = await fetchListsForWorkspace(token, workspace.id, config); + + for (const list of lists) { + if (!list.id) { + continue; + } + + const listKey = `list:${list.id}`; + const tasks = await fetchTasksForList(token, list.id, { + maxTasksPerList, + sinceDate: latestIds[listKey] + ? Number(latestIds[listKey]) + : windowStart, + }); + + if (tasks.length === 0) { + continue; + } + + const tasksWithComments = await enrichTasksWithComments( + token, + tasks, + config.includeSubtasks !== false, + ); + + rawFiles.push( + await writeRawJson( + "clickup", + runId, + `list-${list.id}-tasks.json`, + { + fetchedAt: new Date().toISOString(), + list: { id: list.id, name: list.name, spaceId: list.space?.id }, + taskCount: tasksWithComments.length, + tasks: tasksWithComments, + workspaceId: workspace.id, + workspaceName: workspace.name, + }, + ), + ); + + const newestTimestamp = getNewestTaskTimestamp(tasks); + if (newestTimestamp !== null) { + latestIds[listKey] = String(newestTimestamp); + } + } + } + + await writeConnectorState( + "clickup", + updateStateWithRun( + { + ...state, + latestIds: removeEmptyValues(latestIds), + }, + { + at: new Date().toISOString(), + rawFiles, + runId, + status: rawFiles.length > 0 ? "success" : "skipped", + warnings, + }, + ), + ); + + return { + connectorId: "clickup", + message: `Fetched ClickUp data across ${workspaces.length} workspace(s), ${rawFiles.length - 1} list dump(s).`, + rawFiles, + runId, + statePath: "~/.openwiki/connectors/clickup/state.json", + status: rawFiles.length > 0 ? "success" : "skipped", + warnings, + }; +} + +async function fetchWorkspaces( + token: string, +): Promise { + const response = await clickUpApi(token, "/team"); + return response.teams ?? []; +} + +async function fetchListsForWorkspace( + token: string, + workspaceId: string, + config: ClickUpConfig, +): Promise { + const lists: ClickUpList[] = []; + + if ((config.listIds?.length ?? 0) > 0) { + for (const listId of config.listIds ?? []) { + try { + const list = await clickUpApi( + token, + `/list/${encodeURIComponent(listId)}`, + ); + lists.push(list); + } catch { + // List may not be accessible; skip silently + } + } + return lists; + } + + const spaces = await fetchSpacesForWorkspace(token, workspaceId, config); + + for (const space of spaces) { + if (!space.id) { + continue; + } + + const spaceLists = await fetchListsForSpace(token, space.id); + lists.push(...spaceLists); + } + + return lists; +} + +async function fetchSpacesForWorkspace( + token: string, + workspaceId: string, + config: ClickUpConfig, +): Promise { + if ((config.spaceIds?.length ?? 0) > 0) { + const spaces: ClickUpSpace[] = []; + for (const spaceId of config.spaceIds ?? []) { + try { + const space = await clickUpApi( + token, + `/space/${encodeURIComponent(spaceId)}`, + ); + spaces.push(space); + } catch { + // Space may not be accessible; skip silently + } + } + return spaces; + } + + const response = await clickUpApi( + token, + `/team/${encodeURIComponent(workspaceId)}/space`, + ); + return response.spaces ?? []; +} + +async function fetchListsForSpace( + token: string, + spaceId: string, +): Promise { + const response = await clickUpApi( + token, + `/space/${encodeURIComponent(spaceId)}/list`, + ); + return response.lists ?? []; +} + +async function fetchTasksForList( + token: string, + listId: string, + options: { + maxTasksPerList: number; + sinceDate: number | undefined; + }, +): Promise { + const params: Record = { + page: "0", + subtasks: "true", + include_closed: "true", + }; + + if (options.sinceDate !== undefined) { + params.order_by = "updated"; + // ClickUp uses date_updated for ordering; we filter after fetching + } + + const response = await clickUpApi( + token, + `/list/${encodeURIComponent(listId)}/task`, + params, + ); + + let tasks = response.tasks ?? []; + + if (options.sinceDate !== undefined) { + tasks = tasks.filter( + (task) => + (task.date_updated ?? 0) > options.sinceDate! || + (task.date_created ?? 0) > options.sinceDate!, + ); + } + + return tasks.slice(0, options.maxTasksPerList); +} + +async function enrichTasksWithComments( + token: string, + tasks: ClickUpTask[], + includeSubtasks: boolean, +): Promise { + const enriched: ClickUpEnrichedTask[] = []; + + for (const task of tasks) { + if (!task.id) { + continue; + } + + const comments = await fetchTaskComments(token, task.id); + const subtasks = includeSubtasks ? task.subtasks ?? [] : []; + + enriched.push({ + ...task, + comments, + subtaskCount: subtasks.length, + subtasks: includeSubtasks + ? await enrichTasksWithComments(token, subtasks, false) + : [], + }); + } + + return enriched; +} + +async function fetchTaskComments( + token: string, + taskId: string, +): Promise { + try { + const response = await clickUpApi( + token, + `/task/${encodeURIComponent(taskId)}/comment`, + ); + return response.comments ?? []; + } catch { + return []; + } +} + +async function clickUpApi( + token: string, + endpointPath: string, + params: Record = {}, +): Promise { + const url = new URL(`${CLICKUP_API_BASE_URL}${endpointPath}`); + for (const [key, value] of Object.entries(removeEmptyValues(params))) { + url.searchParams.set(key, value); + } + + const response = await fetch(url, { + headers: { + Authorization: token, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `ClickUp API request failed: ${response.status} ${response.statusText}`, + ); + } + + return (await response.json()) as T; +} + +type ClickUpWorkspaceResponse = { + teams?: ClickUpWorkspace[]; +}; + +type ClickUpSpaceResponse = { + spaces?: ClickUpSpace[]; +}; + +type ClickUpListResponse = { + lists?: ClickUpList[]; +}; + +type ClickUpCommentResponse = { + comments?: ClickUpComment[]; +}; + +type ClickUpEnrichedTask = ClickUpTask & { + comments: ClickUpComment[]; + subtaskCount: number; + subtasks: ClickUpEnrichedTask[]; +}; + +function clamp( + value: number | undefined, + min: number, + max: number, + fallback: number, +): number { + if (!Number.isFinite(value)) { + return fallback; + } + + return Math.max(min, Math.min(max, Math.trunc(value ?? fallback))); +} + +function removeEmptyValues( + values: Record, +): Record { + return Object.fromEntries( + Object.entries(values).filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && entry[1].length > 0, + ), + ); +} + +function getWindowStartTime( + windowHours: number | undefined, +): number | undefined { + if (typeof windowHours !== "number" || !Number.isFinite(windowHours)) { + return undefined; + } + + const hours = Math.max(1, Math.min(168, Math.trunc(windowHours))); + return Date.now() - hours * 60 * 60 * 1000; +} + +function getNewestTaskTimestamp(tasks: ClickUpTask[]): number | null { + let newest = 0; + + for (const task of tasks) { + const updated = task.date_updated ?? 0; + if (updated > newest) { + newest = updated; + } + } + + return newest > 0 ? newest : null; +} diff --git a/src/connectors/tools.ts b/src/connectors/tools.ts index 07d3a4cc..d8a65f27 100644 --- a/src/connectors/tools.ts +++ b/src/connectors/tools.ts @@ -94,6 +94,7 @@ export function createOpenWikiConnectorTools(): StructuredToolInterface[] { connectorId: { type: "string", enum: [ + "clickup", "git-repo", "google", "hackernews", @@ -142,6 +143,7 @@ export function createOpenWikiConnectorTools(): StructuredToolInterface[] { connectorId: { type: "string", enum: [ + "clickup", "git-repo", "google", "hackernews", @@ -170,6 +172,7 @@ export function createOpenWikiConnectorTools(): StructuredToolInterface[] { connectorId: { type: "string", enum: [ + "clickup", "git-repo", "google", "hackernews", diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 1b1969d3..902c0c1f 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -1,4 +1,5 @@ export type ConnectorId = + | "clickup" | "git-repo" | "google" | "hackernews" diff --git a/src/constants.ts b/src/constants.ts index c810ac8b..975604bf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -61,6 +61,7 @@ export const OPENWIKI_X_ACCESS_TOKEN_ENV_KEY = "OPENWIKI_X_ACCESS_TOKEN"; export const OPENWIKI_X_CLIENT_ID_ENV_KEY = "OPENWIKI_X_CLIENT_ID"; export const OPENWIKI_X_CLIENT_SECRET_ENV_KEY = "OPENWIKI_X_CLIENT_SECRET"; export const OPENWIKI_X_REFRESH_TOKEN_ENV_KEY = "OPENWIKI_X_REFRESH_TOKEN"; +export const OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY = "OPENWIKI_CLICKUP_API_TOKEN"; export const OPENWIKI_TAVILY_API_KEY_ENV_KEY = "TAVILY_API_KEY"; export const DEFAULT_PROVIDER = "openai"; export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; diff --git a/src/credentials.tsx b/src/credentials.tsx index 9428c47c..1fd2ccb6 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -26,6 +26,7 @@ import { normalizeModelId, OPENAI_CHATGPT_EMAIL_ENV_KEY, OPENAI_CHATGPT_PLAN_ENV_KEY, + OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY, OPENWIKI_GOOGLE_CLIENT_ID_ENV_KEY, OPENWIKI_GOOGLE_CLIENT_SECRET_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, @@ -211,6 +212,7 @@ const ONBOARDING_TEMPLATES = [ id: "personal", name: "Personal", sourceIds: [ + "clickup", "git-repo", "google", "notion", @@ -219,6 +221,7 @@ const ONBOARDING_TEMPLATES = [ "x", ], suggestedSources: [ + "ClickUp", "Gmail", "Notion", "Web Search (Tavily)", @@ -339,6 +342,27 @@ const SOURCE_OPTIONS = [ ], secretInputs: [], }, + { + displayName: "ClickUp", + examples: [ + "Track active tasks, subtasks, and project status across workspaces.", + "Capture task comments, decisions, and due dates for project context.", + ], + id: "clickup", + instructions: [ + "Create a ClickUp personal API token in your ClickUp settings.", + "Go to Settings > Apps > Generate API Token.", + "Paste the API token below.", + "After connecting, you can configure workspace/space/list scoping in the connector config.", + ], + secretInputs: [ + { + envKey: OPENWIKI_CLICKUP_API_TOKEN_ENV_KEY, + label: "ClickUp API token", + secret: true, + }, + ], + }, { authProvider: "x", displayName: "X / Twitter", diff --git a/src/env.ts b/src/env.ts index d1b399a5..e08c40a9 100644 --- a/src/env.ts +++ b/src/env.ts @@ -451,14 +451,17 @@ export function parseEnv(content: string): EnvMap { continue; } - const equalsIndex = line.indexOf("="); + // Handle "export KEY=value" syntax + const lineToParse = line.startsWith("export ") ? line.slice(7) : line; + + const equalsIndex = lineToParse.indexOf("="); if (equalsIndex <= 0) { continue; } - const key = line.slice(0, equalsIndex).trim(); - const rawValue = line.slice(equalsIndex + 1).trim(); + const key = lineToParse.slice(0, equalsIndex).trim(); + const rawValue = lineToParse.slice(equalsIndex + 1).trim(); if (!/^[A-Z_][A-Z0-9_]*$/u.test(key)) { continue; diff --git a/test/clickup.test.ts b/test/clickup.test.ts new file mode 100644 index 00000000..77018b25 --- /dev/null +++ b/test/clickup.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { createClickUpConnector } from "../src/connectors/sources/clickup.ts"; + +vi.mock("../src/connectors/io.js", async (importOriginal) => { + const original = await importOriginal< + typeof import("../src/connectors/io.js") + >(); + return { + ...original, + readConnectorConfig: vi.fn().mockResolvedValue({ + enabled: true, + maxTasksPerList: 100, + workspaceIds: [], + }), + readConnectorState: vi.fn().mockResolvedValue({ version: 1 }), + writeConnectorState: vi.fn().mockResolvedValue(undefined), + writeRawJson: vi.fn().mockResolvedValue("mock-path"), + }; +}); + +describe("ClickUp connector", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.OPENWIKI_CLICKUP_API_TOKEN; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("definition", () => { + const connector = createClickUpConnector(); + + test("has correct id and display name", () => { + expect(connector.id).toBe("clickup"); + expect(connector.displayName).toBe("ClickUp"); + }); + + test("uses direct-api backend", () => { + expect(connector.backend).toBe("direct-api"); + }); + + test("requires OPENWIKI_CLICKUP_API_TOKEN env var", () => { + expect(connector.requiredEnv).toEqual(["OPENWIKI_CLICKUP_API_TOKEN"]); + }); + + test("does not support agentic discovery", () => { + expect(connector.supportsAgenticDiscovery).toBe(false); + }); + + test("has a description", () => { + expect(typeof connector.description).toBe("string"); + expect(connector.description.length).toBeGreaterThan(0); + }); + + test("exposes an ingest function", () => { + expect(typeof connector.ingest).toBe("function"); + }); + }); + + describe("ingest", () => { + test("returns error when API token is missing", async () => { + const connector = createClickUpConnector(); + const result = await connector.ingest(); + + expect(result.status).toBe("error"); + expect(result.connectorId).toBe("clickup"); + expect(result.message).toContain("OPENWIKI_CLICKUP_API_TOKEN"); + }); + }); +}); diff --git a/test/commands.test.ts b/test/commands.test.ts index a77f666d..09f9b0ed 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -194,6 +194,24 @@ describe("parseCommand — init/update", () => { }); }); + test("code --update selects the update command and starts", () => { + expect(parseCommand(["code", "--update"])).toMatchObject({ + kind: "run", + command: "update", + mode: "code", + shouldStart: true, + }); + }); + + test("personal --update selects the update command and starts", () => { + expect(parseCommand(["personal", "--update"])).toMatchObject({ + kind: "run", + command: "update", + mode: "personal", + shouldStart: true, + }); + }); + test("explicit personal mode overrides the one-shot default", () => { expect(parseCommand(["personal", "--init"])).toMatchObject({ kind: "run", @@ -348,9 +366,9 @@ describe("shouldRunNonInteractively", () => { expect( shouldRunNonInteractively(parseCommand(["personal", "--init"]), false), ).toBe(true); - expect(shouldRunNonInteractively(parseCommand(["--update"]), false)).toBe( - true, - ); + expect( + shouldRunNonInteractively(parseCommand(["personal", "--update"]), false), + ).toBe(true); }); test("a one-shot chat message bypasses the UI when stdin is not a TTY", () => { diff --git a/test/env.test.ts b/test/env.test.ts index a0ab87a2..06d787c0 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -50,6 +50,29 @@ describe("parseEnv", () => { OPENAI_API_KEY: "line1\r\nline2", }); }); + + test("handles export-prefixed lines", () => { + expect(parseEnv("export OPENAI_API_KEY=sk-abc\n")).toEqual({ + OPENAI_API_KEY: "sk-abc", + }); + }); + + test("handles export-prefixed lines with double-quoted values", () => { + expect(parseEnv('export ANTHROPIC_BASE_URL="https://api.anthropic.com"\n')).toEqual({ + ANTHROPIC_BASE_URL: "https://api.anthropic.com", + }); + }); + + test("handles export-prefixed lines alongside regular lines", () => { + const content = [ + "export OPENAI_API_KEY=sk-abc", + "ANTHROPIC_API_KEY=sk-def", + ].join("\n"); + expect(parseEnv(content)).toEqual({ + OPENAI_API_KEY: "sk-abc", + ANTHROPIC_API_KEY: "sk-def", + }); + }); }); describe("formatEnv", () => {