From 5ce7d0949bc895b8462854df1bbacc7b8afb4542 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 10 Jul 2026 04:21:16 +0530 Subject: [PATCH 1/4] fix: handle export-prefixed lines in env file parsing The parseEnv function only handled KEY=value format. If a user manually edited the .openwiki/env file or copied lines from shell scripts, lines like 'export KEY=value' would be silently skipped. Added export prefix handling: lines starting with 'export ' now have the prefix stripped before parsing, so both formats work correctly. --- src/env.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/env.ts b/src/env.ts index 68a70d48..6b9b9fc3 100644 --- a/src/env.ts +++ b/src/env.ts @@ -346,14 +346,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; From 14c72e59432da77d6bb959746e1589142c4b1bd4 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 13 Jul 2026 23:16:53 +0530 Subject: [PATCH 2/4] test: add export-prefix parsing tests for env file handling --- test/env.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/env.test.ts b/test/env.test.ts index c22b4110..54c5f50a 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", () => { From c552722a58182284c07f8944e151e34f1b6229c9 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 14 Jul 2026 00:14:33 +0530 Subject: [PATCH 3/4] fix: require explicit mode for bare --update command Bare \openwiki --update\ (without specifying personal or code mode) defaulted to personal mode, which targeted ~/.openwiki/wiki instead of the current project's openwiki/ directory. This caused the agent to document a completely different, unrelated project that was previously stored in the global wiki. Make bare --update require an explicit mode (--init already had this requirement). Users must now run either: - \openwiki personal --update\ to update the local brain wiki - \openwiki code --update\ to update repository documentation All CI workflows already use explicit modes (\openwiki code --update --print\), so this is not a breaking change for automation. Fixes #310 --- src/agent/prompt.ts | 2 ++ src/commands.ts | 7 +++---- test/commands.test.ts | 24 +++++++++++++++++++++--- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index ca59e5f6..105a998b 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -111,6 +111,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 e12b4d2f..c1767bcd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -574,7 +574,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 ", @@ -681,12 +680,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/test/commands.test.ts b/test/commands.test.ts index 19a1fc53..7e265298 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -115,6 +115,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", @@ -260,9 +278,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", () => { From 69c6eb5d72c6f6df0d9ffc248e41a5a8b978edbf Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 14 Jul 2026 00:58:39 +0530 Subject: [PATCH 4/4] feat: add ClickUp connector as a knowledge source Add a direct-API ClickUp connector that ingests workspaces, tasks, subtasks, comments, and docs via the ClickUp API v2. Changes: - Add OPENWIKI_CLICKUP_API_TOKEN env constant - Extend ConnectorId type with 'clickup' - Create connector source with workspace/space/list scoping - Register connector in registry - Update agent-facing tool enums - Add onboarding SOURCE_OPTIONS for ClickUp - Add unit tests Closes #301 --- src/connectors/registry.ts | 3 + src/connectors/sources/clickup.ts | 556 ++++++++++++++++++++++++++++++ src/connectors/tools.ts | 3 + src/connectors/types.ts | 1 + src/constants.ts | 1 + src/credentials.tsx | 24 ++ test/clickup.test.ts | 73 ++++ 7 files changed, 661 insertions(+) create mode 100644 src/connectors/sources/clickup.ts create mode 100644 test/clickup.test.ts diff --git a/src/connectors/registry.ts b/src/connectors/registry.ts index 22a9de5a..6673cb28 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 1c1eb2bb..925aafda 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -49,6 +49,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 9b3ae8bf..77036dff 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -19,6 +19,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, @@ -184,6 +185,7 @@ const ONBOARDING_TEMPLATES = [ id: "personal", name: "Personal", sourceIds: [ + "clickup", "git-repo", "google", "notion", @@ -192,6 +194,7 @@ const ONBOARDING_TEMPLATES = [ "x", ], suggestedSources: [ + "ClickUp", "Gmail", "Notion", "Web Search (Tavily)", @@ -312,6 +315,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/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"); + }); + }); +});