veryfront 0.1.556 → 0.1.557

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.
@@ -142,11 +142,16 @@ export default {
142
142
  ".env.example": "# Asana OAuth Configuration\n# Get your credentials from https://app.asana.com/0/developer-console\nASANA_CLIENT_ID=your-client-id\nASANA_CLIENT_SECRET=your-client-secret\n",
143
143
  "app/api/auth/asana/callback/route.ts": "import { asanaConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(asanaConfig, { tokenStore: hybridTokenStore });\n",
144
144
  "app/api/auth/asana/route.ts": "import { asanaConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(asanaConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
145
- "lib/asana-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst ASANA_BASE_URL = \"https://app.asana.com/api/1.0\";\n\ninterface AsanaResponse<T> {\n data: T;\n next_page?: { offset: string } | null;\n}\n\ninterface AsanaTask {\n gid: string;\n name: string;\n notes: string;\n completed: boolean;\n due_on: string | null;\n assignee: { gid: string; name: string } | null;\n projects: Array<{ gid: string; name: string }>;\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaProject {\n gid: string;\n name: string;\n notes: string;\n workspace: { gid: string; name: string };\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaWorkspace {\n gid: string;\n name: string;\n}\n\nasync function asanaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Asana. Please connect your account.\");\n }\n\n const response = await fetch(`${ASANA_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) {\n return response.json();\n }\n\n let error: unknown = {};\n try {\n error = await response.json();\n } catch {\n // ignore JSON parse errors\n }\n\n const message =\n (error as { errors?: Array<{ message?: string }> })?.errors?.[0]?.message ?? response.statusText;\n\n throw new Error(`Asana API error: ${response.status} ${message}`);\n}\n\nexport async function listWorkspaces(): Promise<AsanaWorkspace[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaWorkspace[]>>(\"/workspaces\");\n return data;\n}\n\nexport async function listProjects(workspaceGid: string): Promise<AsanaProject[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaProject[]>>(\n `/workspaces/${workspaceGid}/projects?opt_fields=name,notes,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function listTasks(options: {\n projectGid?: string;\n assigneeGid?: string;\n workspaceGid?: string;\n completedSince?: string;\n}): Promise<AsanaTask[]> {\n const params = new URLSearchParams({\n opt_fields: \"name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at\",\n });\n\n if (options.completedSince) {\n params.set(\"completed_since\", options.completedSince);\n }\n\n let endpoint = \"/tasks\";\n if (options.projectGid) {\n endpoint = `/projects/${options.projectGid}/tasks`;\n } else if (options.assigneeGid && options.workspaceGid) {\n params.set(\"assignee\", options.assigneeGid);\n params.set(\"workspace\", options.workspaceGid);\n }\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask[]>>(`${endpoint}?${params}`);\n return data;\n}\n\nexport async function getTask(taskGid: string): Promise<AsanaTask> {\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\n `/tasks/${taskGid}?opt_fields=name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function createTask(options: {\n projectGid: string;\n name: string;\n notes?: string;\n dueOn?: string;\n assigneeGid?: string;\n}): Promise<AsanaTask> {\n const body: Record<string, unknown> = {\n name: options.name,\n projects: [options.projectGid],\n };\n\n if (options.notes) body.notes = options.notes;\n if (options.dueOn) body.due_on = options.dueOn;\n if (options.assigneeGid) body.assignee = options.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\"/tasks\", {\n method: \"POST\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function updateTask(\n taskGid: string,\n updates: {\n name?: string;\n notes?: string;\n completed?: boolean;\n dueOn?: string;\n assigneeGid?: string;\n },\n): Promise<AsanaTask> {\n const body: Record<string, unknown> = {};\n\n if (updates.name !== undefined) body.name = updates.name;\n if (updates.notes !== undefined) body.notes = updates.notes;\n if (updates.completed !== undefined) body.completed = updates.completed;\n if (updates.dueOn !== undefined) body.due_on = updates.dueOn;\n if (updates.assigneeGid !== undefined) body.assignee = updates.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(`/tasks/${taskGid}`, {\n method: \"PUT\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function getMe(): Promise<{ gid: string; name: string; email: string }> {\n const { data } = await asanaFetch<AsanaResponse<{ gid: string; name: string; email: string }>>(\n \"/users/me\",\n );\n return data;\n}\n",
145
+ "lib/asana-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst ASANA_BASE_URL = \"https://app.asana.com/api/1.0\";\n\ninterface AsanaResponse<T> {\n data: T;\n next_page?: { offset: string } | null;\n}\n\ninterface AsanaTask {\n gid: string;\n name: string;\n notes: string;\n completed: boolean;\n due_on: string | null;\n assignee: { gid: string; name: string } | null;\n projects: Array<{ gid: string; name: string }>;\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaProject {\n gid: string;\n name: string;\n notes: string;\n workspace: { gid: string; name: string };\n created_at: string;\n modified_at: string;\n}\n\ninterface AsanaWorkspace {\n gid: string;\n name: string;\n}\n\nasync function asanaFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Asana. Please connect your account.\");\n }\n\n const response = await fetch(`${ASANA_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (response.ok) {\n return response.json();\n }\n\n let error: unknown = {};\n try {\n error = await response.json();\n } catch {\n // ignore JSON parse errors\n }\n\n const message =\n (error as { errors?: Array<{ message?: string }> })?.errors?.[0]?.message ?? response.statusText;\n\n throw new Error(`Asana API error: ${response.status} ${message}`);\n}\n\nexport async function listWorkspaces(): Promise<AsanaWorkspace[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaWorkspace[]>>(\"/workspaces\");\n return data;\n}\n\nexport async function listProjects(workspaceGid: string): Promise<AsanaProject[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaProject[]>>(\n `/workspaces/${workspaceGid}/projects?opt_fields=name,notes,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function listTasks(options: {\n projectGid?: string;\n assigneeGid?: string;\n workspaceGid?: string;\n completedSince?: string;\n}): Promise<AsanaTask[]> {\n const params = new URLSearchParams({\n opt_fields: \"name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at\",\n });\n\n if (options.completedSince) {\n params.set(\"completed_since\", options.completedSince);\n }\n\n let endpoint = \"/tasks\";\n if (options.projectGid) {\n endpoint = `/projects/${options.projectGid}/tasks`;\n } else if (options.assigneeGid && options.workspaceGid) {\n params.set(\"assignee\", options.assigneeGid);\n params.set(\"workspace\", options.workspaceGid);\n }\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask[]>>(`${endpoint}?${params}`);\n return data;\n}\n\nexport async function getTask(taskGid: string): Promise<AsanaTask> {\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\n `/tasks/${taskGid}?opt_fields=name,notes,completed,due_on,assignee.name,projects.name,created_at,modified_at`,\n );\n return data;\n}\n\nexport async function createTask(options: {\n projectGid: string;\n name: string;\n notes?: string;\n dueOn?: string;\n assigneeGid?: string;\n}): Promise<AsanaTask> {\n const body: Record<string, unknown> = {\n name: options.name,\n projects: [options.projectGid],\n };\n\n if (options.notes) body.notes = options.notes;\n if (options.dueOn) body.due_on = options.dueOn;\n if (options.assigneeGid) body.assignee = options.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(\"/tasks\", {\n method: \"POST\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function updateTask(\n taskGid: string,\n updates: {\n name?: string;\n notes?: string;\n completed?: boolean;\n dueOn?: string;\n assigneeGid?: string;\n },\n): Promise<AsanaTask> {\n const body: Record<string, unknown> = {};\n\n if (updates.name !== undefined) body.name = updates.name;\n if (updates.notes !== undefined) body.notes = updates.notes;\n if (updates.completed !== undefined) body.completed = updates.completed;\n if (updates.dueOn !== undefined) body.due_on = updates.dueOn;\n if (updates.assigneeGid !== undefined) body.assignee = updates.assigneeGid;\n\n const { data } = await asanaFetch<AsanaResponse<AsanaTask>>(`/tasks/${taskGid}`, {\n method: \"PUT\",\n body: JSON.stringify({ data: body }),\n });\n\n return data;\n}\n\nexport async function getMe(): Promise<{ gid: string; name: string; email: string }> {\n const { data } = await asanaFetch<AsanaResponse<{ gid: string; name: string; email: string }>>(\n \"/users/me\",\n );\n return data;\n}\n\ninterface AsanaUser {\n gid: string;\n name: string;\n email?: string;\n}\n\ninterface AsanaTeam {\n gid: string;\n name: string;\n description?: string;\n}\n\ninterface AsanaStory {\n gid: string;\n type: string;\n text?: string;\n created_at: string;\n created_by?: { gid: string; name: string };\n}\n\nexport async function listUsers(options: {\n workspaceGid: string;\n teamGid?: string;\n}): Promise<AsanaUser[]> {\n const params = new URLSearchParams({\n workspace: options.workspaceGid,\n opt_fields: \"gid,name,email\",\n });\n\n if (options.teamGid) params.set(\"team\", options.teamGid);\n\n const { data } = await asanaFetch<AsanaResponse<AsanaUser[]>>(`/users?${params}`);\n return data;\n}\n\nexport async function listTeams(workspaceGid: string): Promise<AsanaTeam[]> {\n const { data } = await asanaFetch<AsanaResponse<AsanaTeam[]>>(\n `/workspaces/${workspaceGid}/teams?opt_fields=gid,name,description`,\n );\n return data;\n}\n\nexport async function addTaskComment(options: {\n taskGid: string;\n text: string;\n}): Promise<AsanaStory> {\n const { data } = await asanaFetch<AsanaResponse<AsanaStory>>(\n `/tasks/${options.taskGid}/stories`,\n {\n method: \"POST\",\n body: JSON.stringify({ data: { text: options.text } }),\n },\n );\n return data;\n}\n\nexport async function listTaskComments(taskGid: string): Promise<AsanaStory[]> {\n const params = new URLSearchParams({\n opt_fields: \"gid,type,text,created_at,created_by.name\",\n });\n const { data } = await asanaFetch<AsanaResponse<AsanaStory[]>>(\n `/tasks/${taskGid}/stories?${params}`,\n );\n return data.filter((story) => story.type === \"comment\");\n}\n",
146
+ "tools/add-task-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addTaskComment } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"add-task-comment\",\n description: \"Add a comment to an Asana task.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"Asana task GID\"),\n text: v.string().min(1).describe(\"Comment text\"),\n }))(),\n async execute({ taskGid, text }) {\n const story = await addTaskComment({ taskGid, text });\n return {\n gid: story.gid,\n type: story.type,\n text: story.text,\n createdAt: story.created_at,\n createdBy: story.created_by?.name,\n };\n },\n});\n",
146
147
  "tools/create-task.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createTask } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"create-task\",\n description: \"Create a new task in an Asana project.\",\n inputSchema: defineSchema((v) => v.object({\n projectGid: v.string().describe(\"The GID of the project to create the task in\"),\n name: v.string().describe(\"The name/title of the task\"),\n notes: v.string().optional().describe(\"Description or notes for the task\"),\n dueOn: v.string().optional().describe(\"Due date in YYYY-MM-DD format\"),\n assigneeGid: v.string().optional().describe(\"GID of the user to assign the task to\"),\n }))(),\n async execute({ projectGid, name, notes, dueOn, assigneeGid }) {\n const task = await createTask({\n projectGid,\n name,\n notes,\n dueOn,\n assigneeGid,\n });\n\n return {\n success: true,\n task: {\n gid: task.gid,\n name: task.name,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n },\n };\n },\n});\n",
147
148
  "tools/get-task.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getTask } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"get-task\",\n description: \"Get details of a specific Asana task by its GID.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"The GID of the task to retrieve\"),\n }))(),\n async execute({ taskGid }) {\n const task = await getTask(taskGid);\n\n return {\n gid: task.gid,\n name: task.name,\n notes: task.notes,\n completed: task.completed,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n projects: task.projects.map(({ gid, name }) => ({ gid, name })),\n createdAt: task.created_at,\n modifiedAt: task.modified_at,\n };\n },\n});\n",
148
149
  "tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects, listWorkspaces } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description: \"List all projects in the Asana workspace.\",\n inputSchema: defineSchema((v) => v.object({\n limit: v\n .number()\n .min(1)\n .max(50)\n .default(20)\n .describe(\"Maximum number of projects to return\"),\n }))(),\n async execute({ limit }) {\n const [workspace] = await listWorkspaces();\n\n if (!workspace) {\n return { projects: [], message: \"No workspaces found\" };\n }\n\n const projects = await listProjects(workspace.gid);\n\n return projects.slice(0, limit).map(({ gid, name, notes, created_at }) => ({\n gid,\n name,\n notes,\n createdAt: created_at,\n }));\n },\n});\n",
150
+ "tools/list-task-comments.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listTaskComments } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-task-comments\",\n description: \"List comment stories for an Asana task.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"Asana task GID\"),\n }))(),\n async execute({ taskGid }) {\n const comments = await listTaskComments(taskGid);\n return comments.map((story) => ({\n gid: story.gid,\n text: story.text,\n createdAt: story.created_at,\n createdBy: story.created_by?.name,\n }));\n },\n});\n",
149
151
  "tools/list-tasks.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getMe, listTasks, listWorkspaces } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-tasks\",\n description:\n \"List tasks from Asana. Can filter by project or get tasks assigned to the current user.\",\n inputSchema: defineSchema((v) => v.object({\n projectGid: v.string().optional().describe(\"Project GID to list tasks from\"),\n assignedToMe: v\n .boolean()\n .default(false)\n .describe(\"List tasks assigned to the current user\"),\n includeCompleted: v.boolean().default(false).describe(\"Include completed tasks\"),\n limit: v.number().min(1).max(50).default(20).describe(\"Maximum number of tasks to return\"),\n }))(),\n async execute({ projectGid, assignedToMe, includeCompleted, limit }) {\n const completedSince = includeCompleted ? undefined : \"now\";\n\n if (!assignedToMe && !projectGid) {\n return {\n tasks: [],\n message: \"Please specify either a projectGid or set assignedToMe to true\",\n };\n }\n\n let tasks;\n\n if (assignedToMe) {\n const me = await getMe();\n const workspaces = await listWorkspaces();\n const workspaceGid = workspaces[0]?.gid;\n\n if (!workspaceGid) {\n return { tasks: [], message: \"No workspaces found\" };\n }\n\n tasks = await listTasks({\n assigneeGid: me.gid,\n workspaceGid,\n completedSince,\n });\n } else {\n tasks = await listTasks({\n projectGid,\n completedSince,\n });\n }\n\n return tasks.slice(0, limit).map((task) => ({\n gid: task.gid,\n name: task.name,\n completed: task.completed,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n projects: task.projects.map((p) => p.name),\n }));\n },\n});\n",
152
+ "tools/list-teams.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listTeams } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-teams\",\n description: \"List teams in an Asana workspace.\",\n inputSchema: defineSchema((v) => v.object({\n workspaceGid: v.string().describe(\"Asana workspace GID\"),\n }))(),\n async execute({ workspaceGid }) {\n const teams = await listTeams(workspaceGid);\n return teams.map(({ gid, name, description }) => ({ gid, name, description }));\n },\n});\n",
153
+ "tools/list-users.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listUsers } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-users\",\n description: \"List users in an Asana workspace.\",\n inputSchema: defineSchema((v) => v.object({\n workspaceGid: v.string().describe(\"Asana workspace GID\"),\n teamGid: v.string().optional().describe(\"Optional Asana team GID\"),\n }))(),\n async execute({ workspaceGid, teamGid }) {\n const users = await listUsers({ workspaceGid, teamGid });\n return users.map(({ gid, name, email }) => ({ gid, name, email }));\n },\n});\n",
154
+ "tools/list-workspaces.ts": "import { tool } from \"veryfront/tool\";\nimport { listWorkspaces } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"list-workspaces\",\n description: \"List Asana workspaces accessible to the authenticated user.\",\n async execute() {\n const workspaces = await listWorkspaces();\n return workspaces.map(({ gid, name }) => ({ gid, name }));\n },\n});\n",
150
155
  "tools/update-task.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateTask } from \"../../lib/asana-client.ts\";\n\nexport default tool({\n id: \"update-task\",\n description: \"Update an existing Asana task.\",\n inputSchema: defineSchema((v) => v.object({\n taskGid: v.string().describe(\"The GID of the task to update\"),\n name: v.string().optional().describe(\"New name/title for the task\"),\n notes: v.string().optional().describe(\"New description or notes\"),\n completed: v.boolean().optional().describe(\"Mark the task as completed or not\"),\n dueOn: v.string().optional().describe(\"New due date in YYYY-MM-DD format\"),\n assigneeGid: v.string().optional().describe(\"GID of the user to reassign the task to\"),\n }))(),\n async execute({ taskGid, ...updates }) {\n const task = await updateTask(taskGid, updates);\n\n return {\n success: true,\n task: {\n gid: task.gid,\n name: task.name,\n completed: task.completed,\n dueOn: task.due_on,\n assignee: task.assignee?.name,\n },\n };\n },\n});\n"
151
156
  }
152
157
  },
@@ -178,10 +183,13 @@ export default {
178
183
  ".env.example": "# =============================================================================\n# Google Calendar Integration Setup\n# =============================================================================\n#\n# STEP 1: Create a Google Cloud Project\n# Visit: https://console.cloud.google.com/projectcreate\n#\n# STEP 2: Enable the Google Calendar API\n# Visit: https://console.cloud.google.com/apis/library/calendar-json.googleapis.com\n# Click \"Enable\" to activate the Calendar API for your project\n#\n# STEP 3: Configure OAuth Consent Screen\n# Visit: https://console.cloud.google.com/apis/credentials/consent\n# - Choose \"External\" user type (or \"Internal\" for Workspace)\n# - Fill in app name, support email\n# - Add scopes: calendar.readonly, calendar.events\n# - Add your email as a test user (required for development)\n#\n# STEP 4: Create OAuth Credentials\n# Visit: https://console.cloud.google.com/apis/credentials\n# - Click \"Create Credentials\" > \"OAuth client ID\"\n# - Application type: \"Web application\"\n# - Add Authorized redirect URI: http://localhost:3000/api/auth/calendar/callback\n# - Copy the Client ID and Client Secret below\n#\n# =============================================================================\n\nGOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=your-client-secret\n",
179
184
  "app/api/auth/calendar/callback/route.ts": "/**\n * Calendar OAuth Callback\n *\n * Handles the OAuth callback from Google and stores the tokens.\n */\n\nimport { calendarConfig, createOAuthCallbackHandler } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(calendarConfig, { tokenStore: hybridTokenStore });\n",
180
185
  "app/api/auth/calendar/route.ts": "import { calendarConfig, createOAuthInitHandler } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(calendarConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
181
- "lib/calendar-client.ts": "/**\n * Google Calendar API Client\n *\n * Provides a type-safe interface to Google Calendar API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst CALENDAR_API_BASE = \"https://www.googleapis.com/calendar/v3\";\n\nexport interface CalendarEvent {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n end: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n attendees?: Array<{\n email: string;\n responseStatus: \"needsAction\" | \"declined\" | \"tentative\" | \"accepted\";\n displayName?: string;\n }>;\n htmlLink: string;\n status: \"confirmed\" | \"tentative\" | \"cancelled\";\n organizer?: { email: string; displayName?: string };\n}\n\nexport interface CreateEventOptions {\n summary: string;\n description?: string;\n location?: string;\n start: Date | string;\n end: Date | string;\n attendees?: string[];\n timeZone?: string;\n}\n\nexport interface FreeBusySlot {\n start: string;\n end: string;\n}\n\n/**\n * Google Calendar OAuth provider configuration\n */\nexport const calendarOAuthProvider = {\n name: \"calendar\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/calendar.readonly\",\n \"https://www.googleapis.com/auth/calendar.events\",\n ],\n callbackPath: \"/api/auth/calendar/callback\",\n};\n\ntype ListEventsOptions = {\n maxResults?: number;\n timeMin?: Date | string;\n timeMax?: Date | string;\n calendarId?: string;\n};\n\ntype FreeBusyOptions = {\n timeMin: Date | string;\n timeMax: Date | string;\n calendarId?: string;\n};\n\ntype FindFreeSlotsOptions = FreeBusyOptions & {\n durationMinutes: number;\n};\n\ntype CalendarClientShape = {\n listEvents(options?: ListEventsOptions): Promise<CalendarEvent[]>;\n getTodayEvents(): Promise<CalendarEvent[]>;\n createEvent(options: CreateEventOptions, calendarId?: string): Promise<CalendarEvent>;\n getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]>;\n findFreeSlots(options: FindFreeSlotsOptions): Promise<Array<{ start: Date; end: Date }>>;\n deleteEvent(eventId: string, calendarId?: string): Promise<void>;\n};\n\n/**\n * Create a Calendar client for a specific user\n */\nexport function createCalendarClient(userId: string): CalendarClientShape {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(calendarOAuthProvider, userId, \"calendar\");\n if (!token) {\n throw new Error(\"Calendar not connected. Please connect your Google Calendar first.\");\n }\n return token;\n }\n\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${CALENDAR_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Calendar API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n async function listEvents(options: ListEventsOptions = {}): Promise<CalendarEvent[]> {\n const params = new URLSearchParams();\n\n const timeMin = options.timeMin ? new Date(options.timeMin) : new Date();\n params.set(\"timeMin\", timeMin.toISOString());\n\n if (options.timeMax) {\n params.set(\"timeMax\", new Date(options.timeMax).toISOString());\n }\n\n params.set(\"maxResults\", String(options.maxResults ?? 10));\n params.set(\"singleEvents\", \"true\");\n params.set(\"orderBy\", \"startTime\");\n\n const calendarId = encodeURIComponent(options.calendarId ?? \"primary\");\n const result = await apiRequest<{ items: CalendarEvent[] }>(\n `/calendars/${calendarId}/events?${params.toString()}`,\n );\n\n return result.items ?? [];\n }\n\n function getTodayEvents(): Promise<CalendarEvent[]> {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n\n const tomorrow = new Date(today);\n tomorrow.setDate(tomorrow.getDate() + 1);\n\n return listEvents({ timeMin: today, timeMax: tomorrow, maxResults: 50 });\n }\n\n function createEvent(options: CreateEventOptions, calendarId = \"primary\"): Promise<CalendarEvent> {\n const startDate = typeof options.start === \"string\" ? options.start : options.start.toISOString();\n const endDate = typeof options.end === \"string\" ? options.end : options.end.toISOString();\n const timeZone = options.timeZone ?? \"UTC\";\n\n const event = {\n summary: options.summary,\n description: options.description,\n location: options.location,\n start: { dateTime: startDate, timeZone },\n end: { dateTime: endDate, timeZone },\n attendees: options.attendees?.map((email) => ({ email })),\n };\n\n return apiRequest<CalendarEvent>(`/calendars/${encodeURIComponent(calendarId)}/events`, {\n method: \"POST\",\n body: JSON.stringify(event),\n });\n }\n\n async function getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]> {\n const calendarId = options.calendarId ?? \"primary\";\n\n const result = await apiRequest<{\n calendars: Record<string, { busy: FreeBusySlot[] }>;\n }>(\"/freeBusy\", {\n method: \"POST\",\n body: JSON.stringify({\n timeMin: new Date(options.timeMin).toISOString(),\n timeMax: new Date(options.timeMax).toISOString(),\n items: [{ id: calendarId }],\n }),\n });\n\n return result.calendars[calendarId]?.busy ?? [];\n }\n\n async function findFreeSlots(\n options: FindFreeSlotsOptions,\n ): Promise<Array<{ start: Date; end: Date }>> {\n const busySlots = await getFreeBusy(options);\n\n const freeSlots: Array<{ start: Date; end: Date }> = [];\n const rangeStart = new Date(options.timeMin);\n const rangeEnd = new Date(options.timeMax);\n const durationMs = options.durationMinutes * 60 * 1000;\n\n let currentStart = rangeStart;\n\n const sortedBusy = busySlots\n .map((s) => ({ start: new Date(s.start), end: new Date(s.end) }))\n .sort((a, b) => a.start.getTime() - b.start.getTime());\n\n for (const busy of sortedBusy) {\n if (busy.start.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: new Date(busy.start) });\n }\n\n if (busy.end > currentStart) {\n currentStart = busy.end;\n }\n }\n\n if (rangeEnd.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: rangeEnd });\n }\n\n return freeSlots;\n }\n\n async function deleteEvent(eventId: string, calendarId = \"primary\"): Promise<void> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(\n `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,\n {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${accessToken}` },\n },\n );\n\n if (!response.ok && response.status !== 204) {\n throw new Error(`Failed to delete event: ${response.status}`);\n }\n }\n\n return {\n listEvents,\n getTodayEvents,\n createEvent,\n getFreeBusy,\n findFreeSlots,\n deleteEvent,\n };\n}\n\nexport type CalendarClient = ReturnType<typeof createCalendarClient>;\n",
186
+ "lib/calendar-client.ts": "/**\n * Google Calendar API Client\n *\n * Provides a type-safe interface to Google Calendar API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") {\n // @ts-ignore - Deno global\n return Deno.env.get(key);\n }\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) {\n // @ts-ignore - process global\n return process.env[key];\n }\n\n return undefined;\n}\n\nconst CALENDAR_API_BASE = \"https://www.googleapis.com/calendar/v3\";\n\nexport interface CalendarEvent {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n end: {\n dateTime?: string;\n date?: string;\n timeZone?: string;\n };\n attendees?: Array<{\n email: string;\n responseStatus: \"needsAction\" | \"declined\" | \"tentative\" | \"accepted\";\n displayName?: string;\n }>;\n htmlLink: string;\n status: \"confirmed\" | \"tentative\" | \"cancelled\";\n organizer?: { email: string; displayName?: string };\n}\n\nexport interface CreateEventOptions {\n summary: string;\n description?: string;\n location?: string;\n start: Date | string;\n end: Date | string;\n attendees?: string[];\n timeZone?: string;\n}\n\nexport interface UpdateEventOptions {\n summary?: string;\n description?: string;\n location?: string;\n start?: Date | string;\n end?: Date | string;\n attendees?: string[];\n timeZone?: string;\n}\n\nexport interface FreeBusySlot {\n start: string;\n end: string;\n}\n\n/**\n * Google Calendar OAuth provider configuration\n */\nexport const calendarOAuthProvider = {\n name: \"calendar\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/calendar.readonly\",\n \"https://www.googleapis.com/auth/calendar.events\",\n ],\n callbackPath: \"/api/auth/calendar/callback\",\n};\n\ntype ListEventsOptions = {\n maxResults?: number;\n timeMin?: Date | string;\n timeMax?: Date | string;\n calendarId?: string;\n};\n\ntype FreeBusyOptions = {\n timeMin: Date | string;\n timeMax: Date | string;\n calendarId?: string;\n};\n\ntype FindFreeSlotsOptions = FreeBusyOptions & {\n durationMinutes: number;\n};\n\ntype CalendarClientShape = {\n listEvents(options?: ListEventsOptions): Promise<CalendarEvent[]>;\n getTodayEvents(): Promise<CalendarEvent[]>;\n createEvent(\n options: CreateEventOptions,\n calendarId?: string,\n ): Promise<CalendarEvent>;\n updateEvent(\n eventId: string,\n options: UpdateEventOptions,\n calendarId?: string,\n ): Promise<CalendarEvent>;\n getFreeBusy(options: FreeBusyOptions): Promise<FreeBusySlot[]>;\n findFreeSlots(\n options: FindFreeSlotsOptions,\n ): Promise<Array<{ start: Date; end: Date }>>;\n deleteEvent(eventId: string, calendarId?: string): Promise<void>;\n};\n\n/**\n * Create a Calendar client for a specific user\n */\nexport function createCalendarClient(userId: string): CalendarClientShape {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(\n calendarOAuthProvider,\n userId,\n \"calendar\",\n );\n if (!token) {\n throw new Error(\n \"Calendar not connected. Please connect your Google Calendar first.\",\n );\n }\n return token;\n }\n\n async function apiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${CALENDAR_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Calendar API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n async function listEvents(\n options: ListEventsOptions = {},\n ): Promise<CalendarEvent[]> {\n const params = new URLSearchParams();\n\n const timeMin = options.timeMin ? new Date(options.timeMin) : new Date();\n params.set(\"timeMin\", timeMin.toISOString());\n\n if (options.timeMax) {\n params.set(\"timeMax\", new Date(options.timeMax).toISOString());\n }\n\n params.set(\"maxResults\", String(options.maxResults ?? 10));\n params.set(\"singleEvents\", \"true\");\n params.set(\"orderBy\", \"startTime\");\n\n const calendarId = encodeURIComponent(options.calendarId ?? \"primary\");\n const result = await apiRequest<{ items: CalendarEvent[] }>(\n `/calendars/${calendarId}/events?${params.toString()}`,\n );\n\n return result.items ?? [];\n }\n\n function getTodayEvents(): Promise<CalendarEvent[]> {\n const today = new Date();\n today.setHours(0, 0, 0, 0);\n\n const tomorrow = new Date(today);\n tomorrow.setDate(tomorrow.getDate() + 1);\n\n return listEvents({ timeMin: today, timeMax: tomorrow, maxResults: 50 });\n }\n\n function createEvent(\n options: CreateEventOptions,\n calendarId = \"primary\",\n ): Promise<CalendarEvent> {\n const startDate = typeof options.start === \"string\"\n ? options.start\n : options.start.toISOString();\n const endDate = typeof options.end === \"string\"\n ? options.end\n : options.end.toISOString();\n const timeZone = options.timeZone ?? \"UTC\";\n\n const event = {\n summary: options.summary,\n description: options.description,\n location: options.location,\n start: { dateTime: startDate, timeZone },\n end: { dateTime: endDate, timeZone },\n attendees: options.attendees?.map((email) => ({ email })),\n };\n\n return apiRequest<CalendarEvent>(\n `/calendars/${encodeURIComponent(calendarId)}/events`,\n {\n method: \"POST\",\n body: JSON.stringify(event),\n },\n );\n }\n\n function updateEvent(\n eventId: string,\n options: UpdateEventOptions,\n calendarId = \"primary\",\n ): Promise<CalendarEvent> {\n const timeZone = options.timeZone ?? \"UTC\";\n const event: Record<string, unknown> = {};\n\n if (options.summary !== undefined) event.summary = options.summary;\n if (options.description !== undefined) {\n event.description = options.description;\n }\n if (options.location !== undefined) event.location = options.location;\n if (options.start !== undefined) {\n const startDate = typeof options.start === \"string\"\n ? options.start\n : options.start.toISOString();\n event.start = { dateTime: startDate, timeZone };\n }\n if (options.end !== undefined) {\n const endDate = typeof options.end === \"string\"\n ? options.end\n : options.end.toISOString();\n event.end = { dateTime: endDate, timeZone };\n }\n if (options.attendees !== undefined) {\n event.attendees = options.attendees.map((email) => ({ email }));\n }\n\n return apiRequest<CalendarEvent>(\n `/calendars/${encodeURIComponent(calendarId)}/events/${\n encodeURIComponent(eventId)\n }?sendUpdates=none`,\n {\n method: \"PATCH\",\n body: JSON.stringify(event),\n },\n );\n }\n\n async function getFreeBusy(\n options: FreeBusyOptions,\n ): Promise<FreeBusySlot[]> {\n const calendarId = options.calendarId ?? \"primary\";\n\n const result = await apiRequest<{\n calendars: Record<string, { busy: FreeBusySlot[] }>;\n }>(\"/freeBusy\", {\n method: \"POST\",\n body: JSON.stringify({\n timeMin: new Date(options.timeMin).toISOString(),\n timeMax: new Date(options.timeMax).toISOString(),\n items: [{ id: calendarId }],\n }),\n });\n\n return result.calendars[calendarId]?.busy ?? [];\n }\n\n async function findFreeSlots(\n options: FindFreeSlotsOptions,\n ): Promise<Array<{ start: Date; end: Date }>> {\n const busySlots = await getFreeBusy(options);\n\n const freeSlots: Array<{ start: Date; end: Date }> = [];\n const rangeStart = new Date(options.timeMin);\n const rangeEnd = new Date(options.timeMax);\n const durationMs = options.durationMinutes * 60 * 1000;\n\n let currentStart = rangeStart;\n\n const sortedBusy = busySlots\n .map((s) => ({ start: new Date(s.start), end: new Date(s.end) }))\n .sort((a, b) => a.start.getTime() - b.start.getTime());\n\n for (const busy of sortedBusy) {\n if (busy.start.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({\n start: new Date(currentStart),\n end: new Date(busy.start),\n });\n }\n\n if (busy.end > currentStart) {\n currentStart = busy.end;\n }\n }\n\n if (rangeEnd.getTime() - currentStart.getTime() >= durationMs) {\n freeSlots.push({ start: new Date(currentStart), end: rangeEnd });\n }\n\n return freeSlots;\n }\n\n async function deleteEvent(\n eventId: string,\n calendarId = \"primary\",\n ): Promise<void> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(\n `${CALENDAR_API_BASE}/calendars/${\n encodeURIComponent(calendarId)\n }/events/${eventId}`,\n {\n method: \"DELETE\",\n headers: { Authorization: `Bearer ${accessToken}` },\n },\n );\n\n if (!response.ok && response.status !== 204) {\n throw new Error(`Failed to delete event: ${response.status}`);\n }\n }\n\n return {\n listEvents,\n getTodayEvents,\n createEvent,\n updateEvent,\n getFreeBusy,\n findFreeSlots,\n deleteEvent,\n };\n}\n\nexport type CalendarClient = ReturnType<typeof createCalendarClient>;\n",
187
+ "lib/user-id.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\n\nexport function requireUserIdFromContext(\n context?: ToolExecutionContext,\n): string {\n const userId = context?.userId;\n if (!userId) {\n throw new Error(\"Calendar tool execution requires an authenticated user.\");\n }\n return userId;\n}\n",
182
188
  "tools/create-event.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"create-event\",\n description: \"Create a new event in Google Calendar\",\n inputSchema: defineSchema((v) => v.object({\n title: v.string().min(1).describe(\"Event title\"),\n startTime: v\n .string()\n .describe(\"Start time in ISO 8601 format (e.g., '2024-01-15T09:00:00')\"),\n endTime: v\n .string()\n .describe(\"End time in ISO 8601 format (e.g., '2024-01-15T10:00:00')\"),\n description: v.string().optional().describe(\"Event description\"),\n location: v.string().optional().describe(\"Event location\"),\n attendees: v\n .array(v.string().email())\n .optional()\n .describe(\"Email addresses of attendees to invite\"),\n timeZone: v\n .string()\n .default(\"UTC\")\n .describe(\"Time zone for the event (e.g., 'America/New_York')\"),\n }))(),\n execute: async (\n { title, startTime, endTime, description, location, attendees, timeZone },\n context,\n ) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n const event = await calendar.createEvent({\n summary: title,\n start: startTime,\n end: endTime,\n description,\n location,\n attendees,\n timeZone,\n });\n\n return {\n success: true,\n event: {\n id: event.id,\n title: event.summary,\n start: event.start.dateTime ?? event.start.date,\n end: event.end.dateTime ?? event.end.date,\n url: event.htmlLink,\n location: event.location,\n attendees: event.attendees?.map((a: { email: string }) => a.email) ?? [],\n },\n message: `Event \"${title}\" created successfully.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n",
189
+ "tools/delete-event.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"delete-event\",\n description: \"Delete a Google Calendar event by ID\",\n inputSchema: defineSchema((v) =>\n v.object({\n eventId: v.string().min(1).describe(\"Event ID to delete\"),\n calendarId: v.string().default(\"primary\").describe(\"Calendar ID\"),\n })\n )(),\n execute: async ({ eventId, calendarId }, context) => {\n const userId = requireUserIdFromContext(context);\n const calendar = createCalendarClient(userId);\n await calendar.deleteEvent(eventId, calendarId);\n\n return {\n success: true,\n eventId,\n message: `Event ${eventId} deleted successfully.`,\n };\n },\n});\n",
183
190
  "tools/find-free-time.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype FreeSlot = { start: Date; end: Date };\n\nexport default tool({\n id: \"find-free-time\",\n description: \"Find available time slots in the calendar for scheduling\",\n inputSchema: defineSchema((v) => v.object({\n durationMinutes: v\n .number()\n .min(15)\n .max(480)\n .default(60)\n .describe(\"Duration needed in minutes\"),\n daysToSearch: v\n .number()\n .min(1)\n .max(14)\n .default(7)\n .describe(\"Number of days to search ahead\"),\n workingHoursOnly: v\n .boolean()\n .default(true)\n .describe(\"Only show slots during working hours (9 AM - 6 PM)\"),\n }))(),\n execute: async (\n { durationMinutes, daysToSearch, workingHoursOnly },\n context,\n ): Promise<unknown> => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n\n const now = new Date();\n const searchEnd = new Date();\n searchEnd.setDate(searchEnd.getDate() + daysToSearch);\n\n const freeSlots = (await calendar.findFreeSlots({\n timeMin: now,\n timeMax: searchEnd,\n durationMinutes,\n })) as FreeSlot[];\n\n const slots = workingHoursOnly\n ? freeSlots.filter(({ start, end }) => {\n const startHour = start.getHours();\n const endHour = end.getHours();\n return startHour >= 9 && endHour <= 18;\n })\n : freeSlots;\n\n const formattedSlots = slots.slice(0, 10).map(({ start, end }) => {\n const duration = Math.round((end.getTime() - start.getTime()) / 60000);\n\n return {\n start: start.toISOString(),\n end: end.toISOString(),\n durationMinutes: duration,\n date: start.toLocaleDateString(\"en-US\", {\n weekday: \"long\",\n month: \"short\",\n day: \"numeric\",\n }),\n timeRange: `${start.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n })} - ${end.toLocaleTimeString(\"en-US\", {\n hour: \"numeric\",\n minute: \"2-digit\",\n })}`,\n };\n });\n\n const count = formattedSlots.length;\n\n return {\n freeSlots: formattedSlots,\n count,\n searchCriteria: {\n durationMinutes,\n daysToSearch,\n workingHoursOnly,\n },\n message:\n count > 0\n ? `Found ${count} available slot(s) of ${durationMinutes} minutes or more.`\n : `No free slots of ${durationMinutes} minutes found in the next ${daysToSearch} days.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n",
184
- "tools/list-events.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype CalendarEvent = {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: { dateTime?: string; date?: string };\n end: { dateTime?: string; date?: string };\n status: string;\n htmlLink: string;\n attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;\n};\n\nexport default tool({\n id: \"list-events\",\n description: \"List upcoming calendar events. By default shows events from now onwards.\",\n inputSchema: defineSchema((v) => v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of events to return\"),\n daysAhead: v.number().min(1).max(30).default(7).describe(\"Number of days to look ahead\"),\n todayOnly: v.boolean().default(false).describe(\"Only show events for today\"),\n }))(),\n execute: async ({ maxResults, daysAhead, todayOnly }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n\n const events = todayOnly\n ? ((await calendar.getTodayEvents()) as CalendarEvent[])\n : ((await calendar.listEvents({\n maxResults,\n timeMin: new Date(),\n timeMax: new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000),\n })) as CalendarEvent[]);\n\n return {\n events: events.map((event) => ({\n id: event.id,\n title: event.summary,\n description: event.description ?? null,\n location: event.location ?? null,\n start: event.start.dateTime || event.start.date,\n end: event.end.dateTime || event.end.date,\n isAllDay: !event.start.dateTime,\n status: event.status,\n url: event.htmlLink,\n attendees:\n event.attendees?.map((a) => ({\n email: a.email,\n name: a.displayName,\n status: a.responseStatus,\n })) ?? [],\n })),\n count: events.length,\n message: todayOnly\n ? `Found ${events.length} event(s) for today.`\n : `Found ${events.length} event(s) in the next ${daysAhead} days.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n"
191
+ "tools/list-events.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype CalendarEvent = {\n id: string;\n summary: string;\n description?: string;\n location?: string;\n start: { dateTime?: string; date?: string };\n end: { dateTime?: string; date?: string };\n status: string;\n htmlLink: string;\n attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>;\n};\n\nexport default tool({\n id: \"list-events\",\n description: \"List upcoming calendar events. By default shows events from now onwards.\",\n inputSchema: defineSchema((v) => v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of events to return\"),\n daysAhead: v.number().min(1).max(30).default(7).describe(\"Number of days to look ahead\"),\n todayOnly: v.boolean().default(false).describe(\"Only show events for today\"),\n }))(),\n execute: async ({ maxResults, daysAhead, todayOnly }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const calendar = createCalendarClient(userId);\n\n const events = todayOnly\n ? ((await calendar.getTodayEvents()) as CalendarEvent[])\n : ((await calendar.listEvents({\n maxResults,\n timeMin: new Date(),\n timeMax: new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000),\n })) as CalendarEvent[]);\n\n return {\n events: events.map((event) => ({\n id: event.id,\n title: event.summary,\n description: event.description ?? null,\n location: event.location ?? null,\n start: event.start.dateTime || event.start.date,\n end: event.end.dateTime || event.end.date,\n isAllDay: !event.start.dateTime,\n status: event.status,\n url: event.htmlLink,\n attendees:\n event.attendees?.map((a) => ({\n email: a.email,\n name: a.displayName,\n status: a.responseStatus,\n })) ?? [],\n })),\n count: events.length,\n message: todayOnly\n ? `Found ${events.length} event(s) for today.`\n : `Found ${events.length} event(s) in the next ${daysAhead} days.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Calendar not connected. Please connect your Google Calendar.\",\n connectUrl: \"/api/auth/calendar\",\n };\n }\n throw error;\n }\n },\n});\n",
192
+ "tools/update-event.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createCalendarClient } from \"../../lib/calendar-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"update-event\",\n description: \"Update an existing Google Calendar event by ID\",\n inputSchema: defineSchema((v) =>\n v.object({\n eventId: v.string().min(1).describe(\"Event ID to update\"),\n calendarId: v.string().default(\"primary\").describe(\"Calendar ID\"),\n title: v.string().optional().describe(\"Updated event title\"),\n startTime: v.string().optional().describe(\n \"Updated start time in ISO 8601 format\",\n ),\n endTime: v.string().optional().describe(\n \"Updated end time in ISO 8601 format\",\n ),\n description: v.string().optional().describe(\"Updated event description\"),\n location: v.string().optional().describe(\"Updated event location\"),\n attendees: v.array(v.string().email()).optional().describe(\n \"Updated attendee email addresses\",\n ),\n timeZone: v.string().default(\"UTC\").describe(\n \"Time zone for updated start/end values\",\n ),\n })\n )(),\n execute: async (\n {\n eventId,\n calendarId,\n title,\n startTime,\n endTime,\n description,\n location,\n attendees,\n timeZone,\n },\n context,\n ) => {\n const userId = requireUserIdFromContext(context);\n const calendar = createCalendarClient(userId);\n const event = await calendar.updateEvent(\n eventId,\n {\n summary: title,\n start: startTime,\n end: endTime,\n description,\n location,\n attendees,\n timeZone,\n },\n calendarId,\n );\n\n return {\n success: true,\n event: {\n id: event.id,\n title: event.summary,\n start: event.start.dateTime ?? event.start.date,\n end: event.end.dateTime ?? event.end.date,\n url: event.htmlLink,\n location: event.location,\n },\n message: `Event \"${event.summary}\" updated successfully.`,\n };\n },\n});\n"
185
193
  }
186
194
  },
187
195
  "integration:confluence": {
@@ -269,11 +277,17 @@ export default {
269
277
  "files": {
270
278
  "app/api/auth/github/callback/route.ts": "import { createOAuthCallbackHandler, githubConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(githubConfig, { tokenStore: hybridTokenStore });\n",
271
279
  "app/api/auth/github/route.ts": "import { createOAuthInitHandler, githubConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(githubConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
272
- "lib/github-client.ts": "/**\n * GitHub API Client\n *\n * Provides a type-safe interface to GitHub API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n\n return undefined;\n}\n\nconst GITHUB_API_BASE = \"https://api.github.com\";\n\nexport interface GitHubRepo {\n id: number;\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n}\n\nexport interface GitHubPullRequest {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string; avatar_url: string };\n created_at: string;\n updated_at: string;\n head: { ref: string; sha: string };\n base: { ref: string };\n mergeable: boolean | null;\n additions: number;\n deletions: number;\n changed_files: number;\n draft: boolean;\n labels: Array<{ name: string; color: string }>;\n}\n\nexport interface GitHubIssue {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n labels: Array<{ name: string; color: string }>;\n assignees: Array<{ login: string }>;\n}\n\nexport interface GitHubCommit {\n sha: string;\n commit: {\n message: string;\n author: { name: string; date: string };\n };\n html_url: string;\n author: { login: string; avatar_url: string } | null;\n}\n\n/**\n * GitHub OAuth provider configuration\n */\nexport const githubOAuthProvider = {\n name: \"github\",\n authorizationUrl: \"https://github.com/login/oauth/authorize\",\n tokenUrl: \"https://github.com/login/oauth/access_token\",\n clientId: getEnv(\"GITHUB_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GITHUB_CLIENT_SECRET\") ?? \"\",\n scopes: [\"repo\", \"read:user\", \"read:org\"],\n callbackPath: \"/api/auth/github/callback\",\n};\n\nexport function createGitHubClient(userId: string): {\n listRepos(options?: {\n sort?: \"created\" | \"updated\" | \"pushed\" | \"full_name\";\n perPage?: number;\n type?: \"all\" | \"owner\" | \"public\" | \"private\" | \"member\";\n }): Promise<GitHubRepo[]>;\n listPullRequests(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubPullRequest[]>;\n getPullRequest(owner: string, repo: string, pullNumber: number): Promise<GitHubPullRequest>;\n getPullRequestDiff(owner: string, repo: string, pullNumber: number): Promise<string>;\n createIssue(\n owner: string,\n repo: string,\n options: { title: string; body?: string; labels?: string[]; assignees?: string[] },\n ): Promise<GitHubIssue>;\n listIssues(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubIssue[]>;\n listCommits(\n owner: string,\n repo: string,\n options?: { sha?: string; perPage?: number },\n ): Promise<GitHubCommit[]>;\n getUser(): Promise<{ login: string; name: string; email: string }>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(githubOAuthProvider, userId, \"github\");\n if (!token) throw new Error(\"GitHub not connected. Please connect your GitHub account first.\");\n return token;\n }\n\n async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`GitHub API error: ${response.status} - ${error}`);\n }\n\n return response.json() as Promise<T>;\n }\n\n async function apiTextRequest(endpoint: string, accept: string): Promise<string> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: accept,\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n });\n\n if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);\n\n return response.text();\n }\n\n function toQueryString(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n }\n\n return {\n listRepos(options = {}): Promise<GitHubRepo[]> {\n const params = new URLSearchParams();\n if (options.sort) params.set(\"sort\", options.sort);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n if (options.type) params.set(\"type\", options.type);\n\n return apiRequest<GitHubRepo[]>(`/user/repos${toQueryString(params)}`);\n },\n\n listPullRequests(owner, repo, options = {}): Promise<GitHubPullRequest[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubPullRequest[]>(\n `/repos/${owner}/${repo}/pulls${toQueryString(params)}`,\n );\n },\n\n getPullRequest(owner, repo, pullNumber): Promise<GitHubPullRequest> {\n return apiRequest<GitHubPullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);\n },\n\n getPullRequestDiff(owner, repo, pullNumber): Promise<string> {\n return apiTextRequest(\n `/repos/${owner}/${repo}/pulls/${pullNumber}`,\n \"application/vnd.github.diff\",\n );\n },\n\n createIssue(owner, repo, options): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(`/repos/${owner}/${repo}/issues`, {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n listIssues(owner, repo, options = {}): Promise<GitHubIssue[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubIssue[]>(`/repos/${owner}/${repo}/issues${toQueryString(params)}`);\n },\n\n listCommits(owner, repo, options = {}): Promise<GitHubCommit[]> {\n const params = new URLSearchParams();\n if (options.sha) params.set(\"sha\", options.sha);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubCommit[]>(`/repos/${owner}/${repo}/commits${toQueryString(params)}`);\n },\n\n getUser(): Promise<{ login: string; name: string; email: string }> {\n return apiRequest(\"/user\");\n },\n };\n}\n\nexport type GitHubClient = ReturnType<typeof createGitHubClient>;\n",
273
- "tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description: \"Create a new issue in a GitHub repository\",\n inputSchema: defineSchema((v) => v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n title: v.string().min(1).describe(\"Issue title\"),\n body: v\n .string()\n .optional()\n .describe(\"Issue body/description (supports Markdown)\"),\n labels: v.array(v.string()).optional().describe(\"Labels to add to the issue\"),\n assignees: v\n .array(v.string())\n .optional()\n .describe(\"GitHub usernames to assign to the issue\"),\n }))(),\n execute: async ({ repo, title, body, labels, assignees }, context) => {\n const userId = requireUserIdFromContext(context);\n\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issue = await github.createIssue(owner, repoName, {\n title,\n body,\n labels,\n assignees,\n });\n\n return {\n success: true,\n issue: {\n number: issue.number,\n title: issue.title,\n url: issue.html_url,\n state: issue.state,\n labels: issue.labels.map((l: { name: string }) => l.name),\n assignees: issue.assignees.map((a: { login: string }) => a.login),\n },\n message: `Issue #${issue.number} created successfully in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
274
- "tools/get-pr-diff.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"get-pr-diff\",\n description: \"Get the diff for a pull request to review code changes\",\n inputSchema: defineSchema((v) => v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n prNumber: v.number().int().positive().describe(\"Pull request number\"),\n }))(),\n execute: async ({ repo, prNumber }, context) => {\n const userId = requireUserIdFromContext(context);\n\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n\n const pr = await github.getPullRequest(owner, repoName, prNumber);\n const diff = await github.getPullRequestDiff(owner, repoName, prNumber);\n\n const maxDiffLength = 50000;\n let truncatedDiff = diff;\n\n if (diff.length > maxDiffLength) {\n truncatedDiff = `${diff.substring(0, maxDiffLength)}\\n\\n... (diff truncated, ${\n diff.length - maxDiffLength\n } characters remaining)`;\n }\n\n return {\n pullRequest: {\n number: pr.number,\n title: pr.title,\n author: pr.user.login,\n url: pr.html_url,\n sourceBranch: pr.head.ref,\n targetBranch: pr.base.ref,\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n isDraft: pr.draft,\n state: pr.state,\n },\n diff: truncatedDiff,\n stats: {\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n },\n message: `Retrieved diff for PR #${prNumber} (${pr.additions} additions, ${pr.deletions} deletions across ${pr.changed_files} files).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
275
- "tools/list-prs.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype PullRequest = {\n number: number;\n title: string;\n state: string;\n draft: boolean;\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n head: { ref: string };\n base: { ref: string };\n additions: number;\n deletions: number;\n changed_files: number;\n labels: Array<{ name: string }>;\n};\n\nexport default tool({\n id: \"list-prs\",\n description: \"List pull requests for a GitHub repository\",\n inputSchema: defineSchema((v) => v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n state: v\n .enum([\"open\", \"closed\", \"all\"])\n .default(\"open\")\n .describe(\"State of pull requests to list\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of pull requests to return\"),\n }))(),\n execute: async ({ repo, state, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const prs = await github.listPullRequests(owner, repoName, {\n state,\n perPage: limit,\n });\n\n return {\n pullRequests: prs.map((pr: PullRequest) => ({\n number: pr.number,\n title: pr.title,\n state: pr.state,\n isDraft: pr.draft,\n url: pr.html_url,\n author: pr.user.login,\n createdAt: pr.created_at,\n updatedAt: pr.updated_at,\n sourceBranch: pr.head.ref,\n targetBranch: pr.base.ref,\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n labels: pr.labels.map(({ name }) => name),\n })),\n count: prs.length,\n repository: repo,\n message: `Found ${prs.length} ${state} pull request(s) in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
276
- "tools/list-repos.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype GitHubRepo = {\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n};\n\nexport default tool({\n id: \"list-repos\",\n description: \"List GitHub repositories for the authenticated user\",\n inputSchema: defineSchema((v) => v.object({\n type: v\n .enum([\"all\", \"owner\", \"public\", \"private\", \"member\"])\n .default(\"all\")\n .describe(\"Type of repositories to list\"),\n sort: v\n .enum([\"created\", \"updated\", \"pushed\", \"full_name\"])\n .default(\"updated\")\n .describe(\"How to sort the repositories\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of repositories to return\"),\n }))(),\n execute: async ({ type, sort, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const github = createGitHubClient(userId);\n const repos = await github.listRepos({ type, sort, perPage: limit });\n\n return {\n repositories: repos.map((repo: GitHubRepo) => ({\n name: repo.name,\n fullName: repo.full_name,\n description: repo.description ?? null,\n isPrivate: repo.private,\n url: repo.html_url,\n defaultBranch: repo.default_branch,\n language: repo.language,\n stars: repo.stargazers_count,\n forks: repo.forks_count,\n openIssues: repo.open_issues_count,\n updatedAt: repo.updated_at,\n })),\n count: repos.length,\n message: `Found ${repos.length} repository(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n"
280
+ "lib/github-client.ts": "/**\n * GitHub API Client\n *\n * Provides a type-safe interface to GitHub API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n\n return undefined;\n}\n\nconst GITHUB_API_BASE = \"https://api.github.com\";\n\nexport interface GitHubRepo {\n id: number;\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n}\n\nexport interface GitHubPullRequest {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string; avatar_url: string };\n created_at: string;\n updated_at: string;\n head: { ref: string; sha: string };\n base: { ref: string };\n mergeable: boolean | null;\n additions: number;\n deletions: number;\n changed_files: number;\n draft: boolean;\n labels: Array<{ name: string; color: string }>;\n}\n\nexport interface GitHubIssue {\n id: number;\n number: number;\n title: string;\n body: string | null;\n state: \"open\" | \"closed\";\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n labels: Array<{ name: string; color: string }>;\n assignees: Array<{ login: string }>;\n}\n\nexport interface GitHubCommit {\n sha: string;\n commit: {\n message: string;\n author: { name: string; date: string };\n };\n html_url: string;\n author: { login: string; avatar_url: string } | null;\n}\n\n/**\n * GitHub OAuth provider configuration\n */\nexport const githubOAuthProvider = {\n name: \"github\",\n authorizationUrl: \"https://github.com/login/oauth/authorize\",\n tokenUrl: \"https://github.com/login/oauth/access_token\",\n clientId: getEnv(\"GITHUB_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GITHUB_CLIENT_SECRET\") ?? \"\",\n scopes: [\"repo\", \"read:user\", \"read:org\"],\n callbackPath: \"/api/auth/github/callback\",\n};\n\nexport function createGitHubClient(userId: string): {\n listRepos(options?: {\n sort?: \"created\" | \"updated\" | \"pushed\" | \"full_name\";\n perPage?: number;\n type?: \"all\" | \"owner\" | \"public\" | \"private\" | \"member\";\n }): Promise<GitHubRepo[]>;\n getRepo(owner: string, repo: string): Promise<GitHubRepo>;\n listPullRequests(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubPullRequest[]>;\n getPullRequest(\n owner: string,\n repo: string,\n pullNumber: number,\n ): Promise<GitHubPullRequest>;\n getPullRequestDiff(\n owner: string,\n repo: string,\n pullNumber: number,\n ): Promise<string>;\n createIssue(\n owner: string,\n repo: string,\n options: {\n title: string;\n body?: string;\n labels?: string[];\n assignees?: string[];\n },\n ): Promise<GitHubIssue>;\n getIssue(\n owner: string,\n repo: string,\n issueNumber: number,\n ): Promise<GitHubIssue>;\n updateIssue(\n owner: string,\n repo: string,\n issueNumber: number,\n options: {\n title?: string;\n body?: string;\n state?: \"open\" | \"closed\";\n labels?: string[];\n assignees?: string[];\n },\n ): Promise<GitHubIssue>;\n addIssueComment(\n owner: string,\n repo: string,\n issueNumber: number,\n body: string,\n ): Promise<\n {\n id: number;\n html_url: string;\n body: string;\n user: { login: string };\n created_at: string;\n }\n >;\n listIssues(\n owner: string,\n repo: string,\n options?: { state?: \"open\" | \"closed\" | \"all\"; perPage?: number },\n ): Promise<GitHubIssue[]>;\n listCommits(\n owner: string,\n repo: string,\n options?: { sha?: string; perPage?: number },\n ): Promise<GitHubCommit[]>;\n getUser(): Promise<{ login: string; name: string; email: string }>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(githubOAuthProvider, userId, \"github\");\n if (!token) {\n throw new Error(\n \"GitHub not connected. Please connect your GitHub account first.\",\n );\n }\n return token;\n }\n\n async function apiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`GitHub API error: ${response.status} - ${error}`);\n }\n\n return response.json() as Promise<T>;\n }\n\n async function apiTextRequest(\n endpoint: string,\n accept: string,\n ): Promise<string> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {\n headers: {\n Authorization: `Bearer ${accessToken}`,\n Accept: accept,\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n });\n\n if (!response.ok) throw new Error(`GitHub API error: ${response.status}`);\n\n return response.text();\n }\n\n function toQueryString(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n }\n\n return {\n listRepos(options = {}): Promise<GitHubRepo[]> {\n const params = new URLSearchParams();\n if (options.sort) params.set(\"sort\", options.sort);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n if (options.type) params.set(\"type\", options.type);\n\n return apiRequest<GitHubRepo[]>(`/user/repos${toQueryString(params)}`);\n },\n\n getRepo(owner, repo): Promise<GitHubRepo> {\n return apiRequest<GitHubRepo>(`/repos/${owner}/${repo}`);\n },\n\n listPullRequests(owner, repo, options = {}): Promise<GitHubPullRequest[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubPullRequest[]>(\n `/repos/${owner}/${repo}/pulls${toQueryString(params)}`,\n );\n },\n\n getPullRequest(owner, repo, pullNumber): Promise<GitHubPullRequest> {\n return apiRequest<GitHubPullRequest>(\n `/repos/${owner}/${repo}/pulls/${pullNumber}`,\n );\n },\n\n getPullRequestDiff(owner, repo, pullNumber): Promise<string> {\n return apiTextRequest(\n `/repos/${owner}/${repo}/pulls/${pullNumber}`,\n \"application/vnd.github.diff\",\n );\n },\n\n createIssue(owner, repo, options): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(`/repos/${owner}/${repo}/issues`, {\n method: \"POST\",\n body: JSON.stringify(options),\n });\n },\n\n getIssue(owner, repo, issueNumber): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(\n `/repos/${owner}/${repo}/issues/${issueNumber}`,\n );\n },\n\n updateIssue(owner, repo, issueNumber, options): Promise<GitHubIssue> {\n return apiRequest<GitHubIssue>(\n `/repos/${owner}/${repo}/issues/${issueNumber}`,\n {\n method: \"PATCH\",\n body: JSON.stringify(options),\n },\n );\n },\n\n addIssueComment(owner, repo, issueNumber, body) {\n return apiRequest(\n `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,\n {\n method: \"POST\",\n body: JSON.stringify({ body }),\n },\n );\n },\n\n listIssues(owner, repo, options = {}): Promise<GitHubIssue[]> {\n const params = new URLSearchParams();\n params.set(\"state\", options.state ?? \"open\");\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubIssue[]>(\n `/repos/${owner}/${repo}/issues${toQueryString(params)}`,\n );\n },\n\n listCommits(owner, repo, options = {}): Promise<GitHubCommit[]> {\n const params = new URLSearchParams();\n if (options.sha) params.set(\"sha\", options.sha);\n if (options.perPage) params.set(\"per_page\", String(options.perPage));\n\n return apiRequest<GitHubCommit[]>(\n `/repos/${owner}/${repo}/commits${toQueryString(params)}`,\n );\n },\n\n getUser(): Promise<{ login: string; name: string; email: string }> {\n return apiRequest(\"/user\");\n },\n };\n}\n\nexport type GitHubClient = ReturnType<typeof createGitHubClient>;\n",
281
+ "lib/user-id.ts": "import type { ToolExecutionContext } from \"veryfront/tool\";\n\nexport function requireUserIdFromContext(\n context?: ToolExecutionContext,\n): string {\n const userId = context?.userId;\n if (!userId) {\n throw new Error(\"GitHub tool execution requires an authenticated user.\");\n }\n return userId;\n}\n",
282
+ "tools/add-issue-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"add-issue-comment\",\n description: \"Add a comment to a GitHub issue or pull request\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v.string().describe(\"Repository in format 'owner/repo'\"),\n issueNumber: v.number().int().positive().describe(\n \"Issue or pull request number\",\n ),\n body: v.string().min(1).describe(\"Comment body (supports Markdown)\"),\n })\n )(),\n execute: async ({ repo, issueNumber, body }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const comment = await github.addIssueComment(\n owner,\n repoName,\n issueNumber,\n body,\n );\n return {\n success: true,\n comment: {\n id: comment.id,\n url: comment.html_url,\n body: comment.body,\n author: comment.user.login,\n createdAt: comment.created_at,\n },\n message: `Comment added to issue #${issueNumber} in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
283
+ "tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description: \"Create a new issue in a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n title: v.string().min(1).describe(\"Issue title\"),\n body: v\n .string()\n .optional()\n .describe(\"Issue body/description (supports Markdown)\"),\n labels: v.array(v.string()).optional().describe(\n \"Labels to add to the issue\",\n ),\n assignees: v\n .array(v.string())\n .optional()\n .describe(\"GitHub usernames to assign to the issue\"),\n })\n )(),\n execute: async ({ repo, title, body, labels, assignees }, context) => {\n const userId = requireUserIdFromContext(context);\n\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issue = await github.createIssue(owner, repoName, {\n title,\n body,\n labels,\n assignees,\n });\n\n return {\n success: true,\n issue: {\n number: issue.number,\n title: issue.title,\n url: issue.html_url,\n state: issue.state,\n labels: issue.labels.map((l: { name: string }) => l.name),\n assignees: issue.assignees.map((a: { login: string }) => a.login),\n },\n message: `Issue #${issue.number} created successfully in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
284
+ "tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description: \"Get details of a GitHub issue\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v.string().describe(\"Repository in format 'owner/repo'\"),\n issueNumber: v.number().int().positive().describe(\"Issue number\"),\n })\n )(),\n execute: async ({ repo, issueNumber }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issue = await github.getIssue(owner, repoName, issueNumber);\n return {\n issue: {\n number: issue.number,\n title: issue.title,\n body: issue.body,\n state: issue.state,\n url: issue.html_url,\n author: issue.user.login,\n labels: issue.labels.map((label: { name: string }) => label.name),\n assignees: issue.assignees.map((assignee: { login: string }) =>\n assignee.login\n ),\n updatedAt: issue.updated_at,\n },\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
285
+ "tools/get-pr-diff.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"get-pr-diff\",\n description: \"Get the diff for a pull request to review code changes\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n prNumber: v.number().int().positive().describe(\"Pull request number\"),\n })\n )(),\n execute: async ({ repo, prNumber }, context) => {\n const userId = requireUserIdFromContext(context);\n\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n\n const pr = await github.getPullRequest(owner, repoName, prNumber);\n const diff = await github.getPullRequestDiff(owner, repoName, prNumber);\n\n const maxDiffLength = 50000;\n let truncatedDiff = diff;\n\n if (diff.length > maxDiffLength) {\n truncatedDiff = `${\n diff.substring(0, maxDiffLength)\n }\\n\\n... (diff truncated, ${\n diff.length - maxDiffLength\n } characters remaining)`;\n }\n\n return {\n pullRequest: {\n number: pr.number,\n title: pr.title,\n author: pr.user.login,\n url: pr.html_url,\n sourceBranch: pr.head.ref,\n targetBranch: pr.base.ref,\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n isDraft: pr.draft,\n state: pr.state,\n },\n diff: truncatedDiff,\n stats: {\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n },\n message:\n `Retrieved diff for PR #${prNumber} (${pr.additions} additions, ${pr.deletions} deletions across ${pr.changed_files} files).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
286
+ "tools/get-repo.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"get-repo\",\n description: \"Get details of a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n })\n )(),\n execute: async ({ repo }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const result = await github.getRepo(owner, repoName);\n\n return {\n repository: {\n name: result.name,\n fullName: result.full_name,\n description: result.description ?? null,\n isPrivate: result.private,\n url: result.html_url,\n defaultBranch: result.default_branch,\n language: result.language,\n stars: result.stargazers_count,\n forks: result.forks_count,\n openIssues: result.open_issues_count,\n updatedAt: result.updated_at,\n },\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
287
+ "tools/list-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype GitHubIssueListItem = {\n number: number;\n title: string;\n body: string | null;\n state: string;\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n labels: Array<{ name: string }>;\n assignees: Array<{ login: string }>;\n};\n\nexport default tool({\n id: \"list-issues\",\n description: \"List issues for a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n state: v\n .enum([\"open\", \"closed\", \"all\"])\n .default(\"open\")\n .describe(\"State of issues to list\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of issues to return\"),\n })\n )(),\n execute: async ({ repo, state, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issues = await github.listIssues(owner, repoName, {\n state,\n perPage: limit,\n });\n\n return {\n issues: issues.map((issue: GitHubIssueListItem) => ({\n number: issue.number,\n title: issue.title,\n body: issue.body,\n state: issue.state,\n url: issue.html_url,\n author: issue.user.login,\n labels: issue.labels.map((label) => label.name),\n assignees: issue.assignees.map((assignee) => assignee.login),\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n })),\n count: issues.length,\n repository: repo,\n message: `Found ${issues.length} ${state} issue(s) in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
288
+ "tools/list-prs.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype PullRequest = {\n number: number;\n title: string;\n state: string;\n draft: boolean;\n html_url: string;\n user: { login: string };\n created_at: string;\n updated_at: string;\n head: { ref: string };\n base: { ref: string };\n additions: number;\n deletions: number;\n changed_files: number;\n labels: Array<{ name: string }>;\n};\n\nexport default tool({\n id: \"list-prs\",\n description: \"List pull requests for a GitHub repository\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v\n .string()\n .describe(\"Repository in format 'owner/repo' (e.g., 'facebook/react')\"),\n state: v\n .enum([\"open\", \"closed\", \"all\"])\n .default(\"open\")\n .describe(\"State of pull requests to list\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(10)\n .describe(\"Maximum number of pull requests to return\"),\n })\n )(),\n execute: async ({ repo, state, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const prs = await github.listPullRequests(owner, repoName, {\n state,\n perPage: limit,\n });\n\n return {\n pullRequests: prs.map((pr: PullRequest) => ({\n number: pr.number,\n title: pr.title,\n state: pr.state,\n isDraft: pr.draft,\n url: pr.html_url,\n author: pr.user.login,\n createdAt: pr.created_at,\n updatedAt: pr.updated_at,\n sourceBranch: pr.head.ref,\n targetBranch: pr.base.ref,\n additions: pr.additions,\n deletions: pr.deletions,\n changedFiles: pr.changed_files,\n labels: pr.labels.map(({ name }) => name),\n })),\n count: prs.length,\n repository: repo,\n message: `Found ${prs.length} ${state} pull request(s) in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
289
+ "tools/list-repos.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\ntype GitHubRepo = {\n name: string;\n full_name: string;\n description: string | null;\n private: boolean;\n html_url: string;\n default_branch: string;\n language: string | null;\n stargazers_count: number;\n forks_count: number;\n open_issues_count: number;\n updated_at: string;\n};\n\nexport default tool({\n id: \"list-repos\",\n description: \"List GitHub repositories for the authenticated user\",\n inputSchema: defineSchema((v) =>\n v.object({\n type: v\n .enum([\"all\", \"owner\", \"public\", \"private\", \"member\"])\n .default(\"all\")\n .describe(\"Type of repositories to list\"),\n sort: v\n .enum([\"created\", \"updated\", \"pushed\", \"full_name\"])\n .default(\"updated\")\n .describe(\"How to sort the repositories\"),\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of repositories to return\"),\n })\n )(),\n execute: async ({ type, sort, limit }, context) => {\n const userId = requireUserIdFromContext(context);\n\n try {\n const github = createGitHubClient(userId);\n const repos = await github.listRepos({ type, sort, perPage: limit });\n\n return {\n repositories: repos.map((repo: GitHubRepo) => ({\n name: repo.name,\n fullName: repo.full_name,\n description: repo.description ?? null,\n isPrivate: repo.private,\n url: repo.html_url,\n defaultBranch: repo.default_branch,\n language: repo.language,\n stars: repo.stargazers_count,\n forks: repo.forks_count,\n openIssues: repo.open_issues_count,\n updatedAt: repo.updated_at,\n })),\n count: repos.length,\n message: `Found ${repos.length} repository(s).`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n",
290
+ "tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGitHubClient } from \"../../lib/github-client.ts\";\nimport { requireUserIdFromContext } from \"../../lib/user-id.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description: \"Update, close, or reopen a GitHub issue\",\n inputSchema: defineSchema((v) =>\n v.object({\n repo: v.string().describe(\"Repository in format 'owner/repo'\"),\n issueNumber: v.number().int().positive().describe(\"Issue number\"),\n title: v.string().optional().describe(\"Updated issue title\"),\n body: v.string().optional().describe(\"Updated issue body\"),\n state: v.enum([\"open\", \"closed\"]).optional().describe(\"Issue state\"),\n labels: v.array(v.string()).optional().describe(\n \"Replacement label names\",\n ),\n assignees: v.array(v.string()).optional().describe(\n \"Replacement assignee usernames\",\n ),\n })\n )(),\n execute: async (\n { repo, issueNumber, title, body, state, labels, assignees },\n context,\n ) => {\n const userId = requireUserIdFromContext(context);\n const [owner, repoName] = repo.split(\"/\");\n if (!owner || !repoName) {\n return { error: \"Invalid repository format. Use 'owner/repo' format.\" };\n }\n\n try {\n const github = createGitHubClient(userId);\n const issue = await github.updateIssue(owner, repoName, issueNumber, {\n title,\n body,\n state,\n labels,\n assignees,\n });\n return {\n success: true,\n issue: {\n number: issue.number,\n title: issue.title,\n state: issue.state,\n url: issue.html_url,\n labels: issue.labels.map((label: { name: string }) => label.name),\n assignees: issue.assignees.map((assignee: { login: string }) =>\n assignee.login\n ),\n },\n message: `Issue #${issue.number} updated successfully in ${repo}.`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"GitHub not connected. Please connect your GitHub account.\",\n connectUrl: \"/api/auth/github\",\n };\n }\n throw error;\n }\n },\n});\n"
277
291
  }
278
292
  },
279
293
  "integration:gitlab": {
@@ -281,12 +295,17 @@ export default {
281
295
  ".env.example": "# GitLab OAuth Configuration\n# Create a new application at: https://gitlab.com/-/profile/applications\n# Set the redirect URI to: http://localhost:3000/api/auth/gitlab/callback\n# (Update the URL for production)\n\nGITLAB_CLIENT_ID=your_gitlab_application_id\nGITLAB_CLIENT_SECRET=your_gitlab_application_secret\n",
282
296
  "app/api/auth/gitlab/callback/route.ts": "import { createOAuthCallbackHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(gitlabConfig, { tokenStore: hybridTokenStore });\n",
283
297
  "app/api/auth/gitlab/route.ts": "import { createOAuthInitHandler, gitlabConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(gitlabConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
284
- "lib/gitlab-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GITLAB_BASE_URL = \"https://gitlab.com/api/v4\";\n\nexport interface GitLabProject {\n id: number;\n name: string;\n name_with_namespace: string;\n description: string | null;\n web_url: string;\n path_with_namespace: string;\n default_branch: string;\n visibility: \"private\" | \"internal\" | \"public\";\n created_at: string;\n last_activity_at: string;\n}\n\nexport interface GitLabIssue {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\";\n created_at: string;\n updated_at: string;\n closed_at: string | null;\n labels: string[];\n milestone: {\n id: number;\n title: string;\n } | null;\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n web_url: string;\n time_stats: {\n time_estimate: number;\n total_time_spent: number;\n };\n}\n\nexport interface GitLabMergeRequest {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\" | \"merged\";\n created_at: string;\n updated_at: string;\n merged_at: string | null;\n closed_at: string | null;\n target_branch: string;\n source_branch: string;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n reviewers: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n labels: string[];\n draft: boolean;\n web_url: string;\n changes_count: string;\n diff_refs: {\n base_sha: string;\n head_sha: string;\n start_sha: string;\n };\n}\n\nexport interface GitLabUser {\n id: number;\n username: string;\n name: string;\n email: string;\n avatar_url: string;\n web_url: string;\n}\n\nfunction encodeProjectId(projectId: number | string): number | string {\n return typeof projectId === \"string\" ? encodeURIComponent(projectId) : projectId;\n}\n\nfunction buildQuery(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nasync function gitlabFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) throw new Error(\"Not authenticated with GitLab. Please connect your account.\");\n\n const response = await fetch(`${GITLAB_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as {\n message?: string;\n error?: string;\n };\n\n const message = error.message ?? error.error ?? response.statusText;\n throw new Error(`GitLab API error: ${response.status} ${message}`);\n }\n\n return (await response.json()) as T;\n}\n\nexport function getCurrentUser(): Promise<GitLabUser> {\n return gitlabFetch<GitLabUser>(\"/user\");\n}\n\nexport function listProjects(options?: {\n membership?: boolean;\n search?: string;\n orderBy?: \"id\" | \"name\" | \"created_at\" | \"updated_at\" | \"last_activity_at\";\n sort?: \"asc\" | \"desc\";\n perPage?: number;\n}): Promise<GitLabProject[]> {\n const params = new URLSearchParams();\n\n if (options?.membership !== false) params.set(\"membership\", \"true\");\n if (options?.search) params.set(\"search\", options.search);\n if (options?.orderBy) params.set(\"order_by\", options.orderBy);\n if (options?.sort) params.set(\"sort\", options.sort);\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n return gitlabFetch<GitLabProject[]>(`/projects${buildQuery(params)}`);\n}\n\nexport function getProject(projectId: number | string): Promise<GitLabProject> {\n return gitlabFetch<GitLabProject>(`/projects/${encodeProjectId(projectId)}`);\n}\n\nexport function searchIssues(options: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"all\";\n labels?: string[];\n search?: string;\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabIssue[]> {\n const params = new URLSearchParams();\n\n if (options.scope) params.set(\"scope\", options.scope);\n if (options.state) params.set(\"state\", options.state);\n if (options.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options.search) params.set(\"search\", options.search);\n if (options.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/issues`\n : \"/issues\";\n\n return gitlabFetch<GitLabIssue[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getIssue(projectId: number | string, issueIid: number): Promise<GitLabIssue> {\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues/${issueIid}`);\n}\n\nexport function createIssue(\n projectId: number | string,\n options: {\n title: string;\n description?: string;\n labels?: string[];\n assigneeIds?: number[];\n milestoneId?: number;\n dueDate?: string;\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = { title: options.title };\n\n if (options.description) body.description = options.description;\n if (options.labels?.length) body.labels = options.labels.join(\",\");\n if (options.assigneeIds?.length) body.assignee_ids = options.assigneeIds;\n if (options.milestoneId) body.milestone_id = options.milestoneId;\n if (options.dueDate) body.due_date = options.dueDate;\n\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues`, {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n}\n\nexport function updateIssue(\n projectId: number | string,\n issueIid: number,\n options: {\n title?: string;\n description?: string;\n state?: \"opened\" | \"closed\";\n labels?: string[];\n assigneeIds?: number[];\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = {};\n\n if (options.title) body.title = options.title;\n if (options.description !== undefined) body.description = options.description;\n if (options.state) body.state_event = options.state === \"closed\" ? \"close\" : \"reopen\";\n if (options.labels) body.labels = options.labels.join(\",\");\n if (options.assigneeIds) body.assignee_ids = options.assigneeIds;\n\n return gitlabFetch<GitLabIssue>(`/projects/${encodeProjectId(projectId)}/issues/${issueIid}`, {\n method: \"PUT\",\n body: JSON.stringify(body),\n });\n}\n\nexport function listMergeRequests(options?: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"merged\" | \"all\";\n labels?: string[];\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabMergeRequest[]> {\n const params = new URLSearchParams();\n\n if (options?.scope) params.set(\"scope\", options.scope);\n if (options?.state) params.set(\"state\", options.state);\n if (options?.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options?.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/merge_requests`\n : \"/merge_requests\";\n\n return gitlabFetch<GitLabMergeRequest[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getMergeRequest(\n projectId: number | string,\n mrIid: number,\n): Promise<GitLabMergeRequest> {\n return gitlabFetch<GitLabMergeRequest>(\n `/projects/${encodeProjectId(projectId)}/merge_requests/${mrIid}`,\n );\n}\n\nexport function formatIssueForDisplay(issue: GitLabIssue): string {\n const assignees = issue.assignees.map((a) => `@${a.username}`).join(\", \");\n const labels = issue.labels.length ? `[${issue.labels.join(\", \")}]` : \"\";\n\n return `#${issue.iid}: ${issue.title} ${labels}\nState: ${issue.state}\nAssignees: ${assignees || \"None\"}\nCreated: ${new Date(issue.created_at).toLocaleDateString()}\nURL: ${issue.web_url}`;\n}\n\nexport function formatMergeRequestForDisplay(mr: GitLabMergeRequest): string {\n const assignees = mr.assignees.map((a) => `@${a.username}`).join(\", \");\n const reviewers = mr.reviewers.map((r) => `@${r.username}`).join(\", \");\n const labels = mr.labels.length ? `[${mr.labels.join(\", \")}]` : \"\";\n\n return `!${mr.iid}: ${mr.title} ${labels}\nState: ${mr.state}${mr.draft ? \" (Draft)\" : \"\"}\nSource: ${mr.source_branch} → Target: ${mr.target_branch}\nAuthor: @${mr.author.username}\nAssignees: ${assignees || \"None\"}\nReviewers: ${reviewers || \"None\"}\nCreated: ${new Date(mr.created_at).toLocaleDateString()}\nURL: ${mr.web_url}`;\n}\n",
285
- "tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new issue in a GitLab project. Can set title, description, labels, assignees, milestone, and due date.\",\n inputSchema: defineSchema((v) => v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n title: v.string().min(1).describe(\"Issue title\"),\n description: v.string().optional().describe(\"Issue description in Markdown format\"),\n labels: v.array(v.string()).optional().describe('Labels to apply (e.g., [\"bug\", \"urgent\"])'),\n assigneeIds: v.array(v.number()).optional().describe(\"User IDs to assign the issue to\"),\n milestoneId: v.number().optional().describe(\"Milestone ID to associate with the issue\"),\n dueDate: v.string().optional().describe(\"Due date in YYYY-MM-DD format\"),\n }))(),\n async execute({ projectId, title, description, labels, assigneeIds, milestoneId, dueDate }) {\n const issue = await createIssue(projectId, {\n title,\n description,\n labels,\n assigneeIds,\n milestoneId,\n dueDate,\n });\n\n return {\n success: true,\n message: `Issue created successfully: #${issue.iid}`,\n issue: {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({ username, name })),\n webUrl: issue.web_url,\n createdAt: issue.created_at,\n },\n };\n },\n});\n",
286
- "tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific GitLab issue including full description, comments, time tracking, and metadata.\",\n inputSchema: defineSchema((v) => v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n issueIid: v\n .number()\n .describe(\n \"Issue IID (internal ID, the number shown in the issue URL like #123)\",\n ),\n }))(),\n async execute({ projectId, issueIid }) {\n const issue = await getIssue(projectId, issueIid);\n\n return {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n description: issue.description ?? \"No description provided\",\n state: issue.state,\n labels: issue.labels,\n milestone: issue.milestone\n ? { id: issue.milestone.id, title: issue.milestone.title }\n : null,\n assignees: issue.assignees.map(({ id, username, name, avatar_url }) => ({\n id,\n username,\n name,\n avatarUrl: avatar_url,\n })),\n author: {\n id: issue.author.id,\n username: issue.author.username,\n name: issue.author.name,\n avatarUrl: issue.author.avatar_url,\n },\n timeStats: {\n timeEstimate: issue.time_stats.time_estimate,\n totalTimeSpent: issue.time_stats.total_time_spent,\n },\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n closedAt: issue.closed_at,\n webUrl: issue.web_url,\n };\n },\n});\n",
287
- "tools/list-merge-requests.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { formatMergeRequestForDisplay, listMergeRequests } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"list-merge-requests\",\n description:\n \"List merge requests in GitLab. Can filter by scope, state, labels, and specific project. Returns MR titles, states, branches, assignees, and reviewers.\",\n inputSchema: defineSchema((v) => v.object({\n scope: v\n .enum([\"created_by_me\", \"assigned_to_me\", \"all\"])\n .default(\"all\")\n .describe(\"Scope of merge requests to list\"),\n state: v\n .enum([\"opened\", \"closed\", \"merged\", \"all\"])\n .default(\"opened\")\n .describe(\"State of merge requests to list\"),\n labels: v\n .array(v.string())\n .optional()\n .describe('Filter by labels (e.g., [\"feature\", \"review-needed\"])'),\n projectId: v\n .union([v.number(), v.string()])\n .optional()\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n limit: v.number().min(1).max(100).default(20).describe(\"Maximum number of results to return\"),\n }))(),\n async execute({ scope, state, labels, projectId, limit }) {\n const mergeRequests = await listMergeRequests({\n scope,\n state,\n labels,\n projectId,\n perPage: limit,\n });\n\n if (mergeRequests.length === 0) {\n return {\n message: \"No merge requests found matching the criteria.\",\n count: 0,\n mergeRequests: [],\n };\n }\n\n return {\n count: mergeRequests.length,\n mergeRequests: mergeRequests.map((mr) => {\n const description = mr.description ?? \"\";\n const truncatedDescription =\n description.length > 200 ? `${description.substring(0, 200)}...` : description;\n\n return {\n id: mr.id,\n iid: mr.iid,\n projectId: mr.project_id,\n title: mr.title,\n state: mr.state,\n draft: mr.draft,\n sourceBranch: mr.source_branch,\n targetBranch: mr.target_branch,\n labels: mr.labels,\n author: {\n username: mr.author.username,\n name: mr.author.name,\n },\n assignees: mr.assignees.map((a) => ({\n username: a.username,\n name: a.name,\n })),\n reviewers: mr.reviewers.map((r) => ({\n username: r.username,\n name: r.name,\n })),\n createdAt: mr.created_at,\n updatedAt: mr.updated_at,\n mergedAt: mr.merged_at,\n webUrl: mr.web_url,\n description: truncatedDescription,\n };\n }),\n summary: mergeRequests.map(formatMergeRequestForDisplay).join(\"\\n\\n\"),\n };\n },\n});\n",
288
- "tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List GitLab projects accessible to the authenticated user. Can search, filter by membership, and sort results.\",\n inputSchema: defineSchema((v) => v.object({\n search: v.string().optional().describe(\"Search query to filter projects by name or path\"),\n membership: v.boolean().default(true).describe(\"Only show projects where user is a member\"),\n orderBy: v\n .enum([\"id\", \"name\", \"created_at\", \"updated_at\", \"last_activity_at\"])\n .default(\"last_activity_at\")\n .describe(\"Field to order results by\"),\n sort: v.enum([\"asc\", \"desc\"]).default(\"desc\").describe(\"Sort direction\"),\n limit: v.number().min(1).max(100).default(20).describe(\"Maximum number of results to return\"),\n }))(),\n async execute({ search, membership, orderBy, sort, limit }) {\n const projects = await listProjects({\n search,\n membership,\n orderBy,\n sort,\n perPage: limit,\n });\n\n const mappedProjects = projects.map((project) => ({\n id: project.id,\n name: project.name,\n nameWithNamespace: project.name_with_namespace,\n path: project.path_with_namespace,\n description: project.description ?? \"No description\",\n visibility: project.visibility,\n defaultBranch: project.default_branch,\n webUrl: project.web_url,\n createdAt: project.created_at,\n lastActivityAt: project.last_activity_at,\n }));\n\n if (!mappedProjects.length) {\n return {\n message: \"No projects found matching the criteria.\",\n count: 0,\n projects: [],\n };\n }\n\n return {\n count: mappedProjects.length,\n projects: mappedProjects,\n };\n },\n});\n",
289
- "tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { formatIssueForDisplay, searchIssues } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n \"Search for issues in GitLab projects. Can search across all accessible projects or within a specific project. Returns issue titles, states, assignees, and labels.\",\n inputSchema: defineSchema((v) => v.object({\n scope: v\n .enum([\"created_by_me\", \"assigned_to_me\", \"all\"])\n .default(\"all\")\n .describe(\"Scope of issues to search\"),\n state: v\n .enum([\"opened\", \"closed\", \"all\"])\n .default(\"opened\")\n .describe(\"State of issues to search for\"),\n search: v.string().optional().describe(\"Search query to filter issues by title or description\"),\n labels: v.array(v.string()).optional().describe('Filter by labels (e.g., [\"bug\", \"urgent\"])'),\n projectId: v\n .union([v.number(), v.string()])\n .optional()\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n limit: v.number().min(1).max(100).default(20).describe(\"Maximum number of results to return\"),\n }))(),\n async execute({ scope, state, search, labels, projectId, limit }) {\n const issues = await searchIssues({\n scope,\n state,\n search,\n labels,\n projectId,\n perPage: limit,\n });\n\n if (issues.length === 0) {\n return {\n message: \"No issues found matching the criteria.\",\n count: 0,\n issues: [],\n };\n }\n\n return {\n count: issues.length,\n issues: issues.map((issue) => {\n const description = issue.description ?? \"\";\n const truncatedDescription =\n description.length > 200 ? `${description.substring(0, 200)}...` : description;\n\n return {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({ username, name })),\n author: {\n username: issue.author.username,\n name: issue.author.name,\n },\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n webUrl: issue.web_url,\n description: truncatedDescription,\n };\n }),\n summary: issues.map(formatIssueForDisplay).join(\"\\n\\n\"),\n };\n },\n});\n"
298
+ "lib/gitlab-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst GITLAB_BASE_URL = \"https://gitlab.com/api/v4\";\n\nexport interface GitLabProject {\n id: number;\n name: string;\n name_with_namespace: string;\n description: string | null;\n web_url: string;\n path_with_namespace: string;\n default_branch: string;\n visibility: \"private\" | \"internal\" | \"public\";\n created_at: string;\n last_activity_at: string;\n}\n\nexport interface GitLabIssue {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\";\n created_at: string;\n updated_at: string;\n closed_at: string | null;\n labels: string[];\n milestone: {\n id: number;\n title: string;\n } | null;\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n web_url: string;\n time_stats: {\n time_estimate: number;\n total_time_spent: number;\n };\n}\n\nexport interface GitLabMergeRequest {\n id: number;\n iid: number;\n project_id: number;\n title: string;\n description: string | null;\n state: \"opened\" | \"closed\" | \"merged\";\n created_at: string;\n updated_at: string;\n merged_at: string | null;\n closed_at: string | null;\n target_branch: string;\n source_branch: string;\n author: {\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n };\n assignees: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n reviewers: Array<{\n id: number;\n username: string;\n name: string;\n avatar_url: string;\n }>;\n labels: string[];\n draft: boolean;\n web_url: string;\n changes_count: string;\n diff_refs: {\n base_sha: string;\n head_sha: string;\n start_sha: string;\n };\n}\n\nexport interface GitLabUser {\n id: number;\n username: string;\n name: string;\n email: string;\n avatar_url: string;\n web_url: string;\n}\n\nexport interface GitLabNote {\n id: number;\n body: string;\n author: { id: number; username: string; name: string; avatar_url: string };\n created_at: string;\n updated_at: string;\n system: boolean;\n confidential?: boolean;\n internal?: boolean;\n}\n\nfunction encodeProjectId(projectId: number | string): number | string {\n return typeof projectId === \"string\"\n ? encodeURIComponent(projectId)\n : projectId;\n}\n\nfunction buildQuery(params: URLSearchParams): string {\n const query = params.toString();\n return query ? `?${query}` : \"\";\n}\n\nasync function gitlabFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\n \"Not authenticated with GitLab. Please connect your account.\",\n );\n }\n\n const response = await fetch(`${GITLAB_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({}))) as {\n message?: string;\n error?: string;\n };\n\n const message = error.message ?? error.error ?? response.statusText;\n throw new Error(`GitLab API error: ${response.status} ${message}`);\n }\n\n return (await response.json()) as T;\n}\n\nexport function getCurrentUser(): Promise<GitLabUser> {\n return gitlabFetch<GitLabUser>(\"/user\");\n}\n\nexport function listProjects(options?: {\n membership?: boolean;\n search?: string;\n orderBy?: \"id\" | \"name\" | \"created_at\" | \"updated_at\" | \"last_activity_at\";\n sort?: \"asc\" | \"desc\";\n perPage?: number;\n}): Promise<GitLabProject[]> {\n const params = new URLSearchParams();\n\n if (options?.membership !== false) params.set(\"membership\", \"true\");\n if (options?.search) params.set(\"search\", options.search);\n if (options?.orderBy) params.set(\"order_by\", options.orderBy);\n if (options?.sort) params.set(\"sort\", options.sort);\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n return gitlabFetch<GitLabProject[]>(`/projects${buildQuery(params)}`);\n}\n\nexport function getProject(projectId: number | string): Promise<GitLabProject> {\n return gitlabFetch<GitLabProject>(`/projects/${encodeProjectId(projectId)}`);\n}\n\nexport function searchIssues(options: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"all\";\n labels?: string[];\n search?: string;\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabIssue[]> {\n const params = new URLSearchParams();\n\n if (options.scope) params.set(\"scope\", options.scope);\n if (options.state) params.set(\"state\", options.state);\n if (options.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options.search) params.set(\"search\", options.search);\n if (options.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/issues`\n : \"/issues\";\n\n return gitlabFetch<GitLabIssue[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getIssue(\n projectId: number | string,\n issueIid: number,\n): Promise<GitLabIssue> {\n return gitlabFetch<GitLabIssue>(\n `/projects/${encodeProjectId(projectId)}/issues/${issueIid}`,\n );\n}\n\nexport function createIssue(\n projectId: number | string,\n options: {\n title: string;\n description?: string;\n labels?: string[];\n assigneeIds?: number[];\n milestoneId?: number;\n dueDate?: string;\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = { title: options.title };\n\n if (options.description) body.description = options.description;\n if (options.labels?.length) body.labels = options.labels.join(\",\");\n if (options.assigneeIds?.length) body.assignee_ids = options.assigneeIds;\n if (options.milestoneId) body.milestone_id = options.milestoneId;\n if (options.dueDate) body.due_date = options.dueDate;\n\n return gitlabFetch<GitLabIssue>(\n `/projects/${encodeProjectId(projectId)}/issues`,\n {\n method: \"POST\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function updateIssue(\n projectId: number | string,\n issueIid: number,\n options: {\n title?: string;\n description?: string;\n state?: \"opened\" | \"closed\";\n labels?: string[];\n assigneeIds?: number[];\n },\n): Promise<GitLabIssue> {\n const body: Record<string, unknown> = {};\n\n if (options.title) body.title = options.title;\n if (options.description !== undefined) body.description = options.description;\n if (options.state) {\n body.state_event = options.state === \"closed\" ? \"close\" : \"reopen\";\n }\n if (options.labels) body.labels = options.labels.join(\",\");\n if (options.assigneeIds) body.assignee_ids = options.assigneeIds;\n\n return gitlabFetch<GitLabIssue>(\n `/projects/${encodeProjectId(projectId)}/issues/${issueIid}`,\n {\n method: \"PUT\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function addIssueComment(\n projectId: number | string,\n issueIid: number,\n options: { body: string; confidential?: boolean },\n): Promise<GitLabNote> {\n const body: Record<string, unknown> = { body: options.body };\n if (options.confidential !== undefined) {\n body.confidential = options.confidential;\n }\n\n return gitlabFetch<GitLabNote>(\n `/projects/${encodeProjectId(projectId)}/issues/${issueIid}/notes`,\n {\n method: \"POST\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function listMergeRequests(options?: {\n scope?: \"created_by_me\" | \"assigned_to_me\" | \"all\";\n state?: \"opened\" | \"closed\" | \"merged\" | \"all\";\n labels?: string[];\n projectId?: number | string;\n perPage?: number;\n}): Promise<GitLabMergeRequest[]> {\n const params = new URLSearchParams();\n\n if (options?.scope) params.set(\"scope\", options.scope);\n if (options?.state) params.set(\"state\", options.state);\n if (options?.labels?.length) params.set(\"labels\", options.labels.join(\",\"));\n if (options?.perPage) params.set(\"per_page\", options.perPage.toString());\n\n const base = options?.projectId\n ? `/projects/${encodeProjectId(options.projectId)}/merge_requests`\n : \"/merge_requests\";\n\n return gitlabFetch<GitLabMergeRequest[]>(`${base}${buildQuery(params)}`);\n}\n\nexport function getMergeRequest(\n projectId: number | string,\n mrIid: number,\n): Promise<GitLabMergeRequest> {\n return gitlabFetch<GitLabMergeRequest>(\n `/projects/${encodeProjectId(projectId)}/merge_requests/${mrIid}`,\n );\n}\n\nexport function addMergeRequestComment(\n projectId: number | string,\n mrIid: number,\n options: { body: string; internal?: boolean },\n): Promise<GitLabNote> {\n const body: Record<string, unknown> = { body: options.body };\n if (options.internal !== undefined) body.internal = options.internal;\n\n return gitlabFetch<GitLabNote>(\n `/projects/${encodeProjectId(projectId)}/merge_requests/${mrIid}/notes`,\n {\n method: \"POST\",\n body: JSON.stringify(body),\n },\n );\n}\n\nexport function formatIssueForDisplay(issue: GitLabIssue): string {\n const assignees = issue.assignees.map((a) => `@${a.username}`).join(\", \");\n const labels = issue.labels.length ? `[${issue.labels.join(\", \")}]` : \"\";\n\n return `#${issue.iid}: ${issue.title} ${labels}\nState: ${issue.state}\nAssignees: ${assignees || \"None\"}\nCreated: ${new Date(issue.created_at).toLocaleDateString()}\nURL: ${issue.web_url}`;\n}\n\nexport function formatMergeRequestForDisplay(mr: GitLabMergeRequest): string {\n const assignees = mr.assignees.map((a) => `@${a.username}`).join(\", \");\n const reviewers = mr.reviewers.map((r) => `@${r.username}`).join(\", \");\n const labels = mr.labels.length ? `[${mr.labels.join(\", \")}]` : \"\";\n\n return `!${mr.iid}: ${mr.title} ${labels}\nState: ${mr.state}${mr.draft ? \" (Draft)\" : \"\"}\nSource: ${mr.source_branch} → Target: ${mr.target_branch}\nAuthor: @${mr.author.username}\nAssignees: ${assignees || \"None\"}\nReviewers: ${reviewers || \"None\"}\nCreated: ${new Date(mr.created_at).toLocaleDateString()}\nURL: ${mr.web_url}`;\n}\n",
299
+ "tools/add-issue-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addIssueComment } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"add-issue-comment\",\n description: \"Add a Markdown comment/note to a GitLab issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n issueIid: v\n .number()\n .describe(\n \"Issue IID (the project-local number shown in the issue URL)\",\n ),\n body: v.string().min(1).describe(\"Comment body in Markdown\"),\n confidential: v.boolean().optional().describe(\n \"Make the note confidential\",\n ),\n })\n )(),\n async execute({ projectId, issueIid, body, confidential }) {\n const note = await addIssueComment(projectId, issueIid, {\n body,\n confidential,\n });\n\n return {\n success: true,\n message: `Comment added to issue #${issueIid}.`,\n note,\n };\n },\n});\n",
300
+ "tools/add-merge-request-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addMergeRequestComment } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"add-merge-request-comment\",\n description: \"Add a Markdown comment/note to a GitLab merge request.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n mergeRequestIid: v\n .number()\n .describe(\n \"Merge request IID (the project-local number shown in the MR URL)\",\n ),\n body: v.string().min(1).describe(\"Comment body in Markdown\"),\n internal: v.boolean().optional().describe(\n \"Make the note internal when supported\",\n ),\n })\n )(),\n async execute({ projectId, mergeRequestIid, body, internal }) {\n const note = await addMergeRequestComment(projectId, mergeRequestIid, {\n body,\n internal,\n });\n\n return {\n success: true,\n message: `Comment added to merge request !${mergeRequestIid}.`,\n note,\n };\n },\n});\n",
301
+ "tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new issue in a GitLab project. Can set title, description, labels, assignees, milestone, and due date.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n title: v.string().min(1).describe(\"Issue title\"),\n description: v.string().optional().describe(\n \"Issue description in Markdown format\",\n ),\n labels: v.array(v.string()).optional().describe(\n 'Labels to apply (e.g., [\"bug\", \"urgent\"])',\n ),\n assigneeIds: v.array(v.number()).optional().describe(\n \"User IDs to assign the issue to\",\n ),\n milestoneId: v.number().optional().describe(\n \"Milestone ID to associate with the issue\",\n ),\n dueDate: v.string().optional().describe(\"Due date in YYYY-MM-DD format\"),\n })\n )(),\n async execute(\n {\n projectId,\n title,\n description,\n labels,\n assigneeIds,\n milestoneId,\n dueDate,\n },\n ) {\n const issue = await createIssue(projectId, {\n title,\n description,\n labels,\n assigneeIds,\n milestoneId,\n dueDate,\n });\n\n return {\n success: true,\n message: `Issue created successfully: #${issue.iid}`,\n issue: {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({\n username,\n name,\n })),\n webUrl: issue.web_url,\n createdAt: issue.created_at,\n },\n };\n },\n});\n",
302
+ "tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific GitLab issue including full description, comments, time tracking, and metadata.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n issueIid: v\n .number()\n .describe(\n \"Issue IID (internal ID, the number shown in the issue URL like #123)\",\n ),\n })\n )(),\n async execute({ projectId, issueIid }) {\n const issue = await getIssue(projectId, issueIid);\n\n return {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n description: issue.description ?? \"No description provided\",\n state: issue.state,\n labels: issue.labels,\n milestone: issue.milestone\n ? { id: issue.milestone.id, title: issue.milestone.title }\n : null,\n assignees: issue.assignees.map(({ id, username, name, avatar_url }) => ({\n id,\n username,\n name,\n avatarUrl: avatar_url,\n })),\n author: {\n id: issue.author.id,\n username: issue.author.username,\n name: issue.author.name,\n avatarUrl: issue.author.avatar_url,\n },\n timeStats: {\n timeEstimate: issue.time_stats.time_estimate,\n totalTimeSpent: issue.time_stats.total_time_spent,\n },\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n closedAt: issue.closed_at,\n webUrl: issue.web_url,\n };\n },\n});\n",
303
+ "tools/get-merge-request.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n formatMergeRequestForDisplay,\n getMergeRequest,\n} from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"get-merge-request\",\n description:\n \"Get detailed information about a specific GitLab merge request.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n mergeRequestIid: v\n .number()\n .describe(\n \"Merge request IID (the project-local number shown in the MR URL)\",\n ),\n })\n )(),\n async execute({ projectId, mergeRequestIid }) {\n const mr = await getMergeRequest(projectId, mergeRequestIid);\n\n return {\n id: mr.id,\n iid: mr.iid,\n projectId: mr.project_id,\n title: mr.title,\n description: mr.description ?? \"No description provided\",\n state: mr.state,\n draft: mr.draft,\n sourceBranch: mr.source_branch,\n targetBranch: mr.target_branch,\n labels: mr.labels,\n author: { username: mr.author.username, name: mr.author.name },\n assignees: mr.assignees.map(({ username, name }) => ({ username, name })),\n reviewers: mr.reviewers.map(({ username, name }) => ({ username, name })),\n createdAt: mr.created_at,\n updatedAt: mr.updated_at,\n mergedAt: mr.merged_at,\n closedAt: mr.closed_at,\n webUrl: mr.web_url,\n summary: formatMergeRequestForDisplay(mr),\n };\n },\n});\n",
304
+ "tools/get-project.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getProject } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"get-project\",\n description: \"Get detailed information about a GitLab project.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n })\n )(),\n async execute({ projectId }) {\n const project = await getProject(projectId);\n\n return {\n id: project.id,\n name: project.name,\n nameWithNamespace: project.name_with_namespace,\n description: project.description ?? \"No description\",\n path: project.path_with_namespace,\n visibility: project.visibility,\n defaultBranch: project.default_branch,\n webUrl: project.web_url,\n createdAt: project.created_at,\n lastActivityAt: project.last_activity_at,\n };\n },\n});\n",
305
+ "tools/list-merge-requests.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n formatMergeRequestForDisplay,\n listMergeRequests,\n} from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"list-merge-requests\",\n description:\n \"List merge requests in GitLab. Can filter by scope, state, labels, and specific project. Returns MR titles, states, branches, assignees, and reviewers.\",\n inputSchema: defineSchema((v) =>\n v.object({\n scope: v\n .enum([\"created_by_me\", \"assigned_to_me\", \"all\"])\n .default(\"all\")\n .describe(\"Scope of merge requests to list\"),\n state: v\n .enum([\"opened\", \"closed\", \"merged\", \"all\"])\n .default(\"opened\")\n .describe(\"State of merge requests to list\"),\n labels: v\n .array(v.string())\n .optional()\n .describe('Filter by labels (e.g., [\"feature\", \"review-needed\"])'),\n projectId: v\n .union([v.number(), v.string()])\n .optional()\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n limit: v.number().min(1).max(100).default(20).describe(\n \"Maximum number of results to return\",\n ),\n })\n )(),\n async execute({ scope, state, labels, projectId, limit }) {\n const mergeRequests = await listMergeRequests({\n scope,\n state,\n labels,\n projectId,\n perPage: limit,\n });\n\n if (mergeRequests.length === 0) {\n return {\n message: \"No merge requests found matching the criteria.\",\n count: 0,\n mergeRequests: [],\n };\n }\n\n return {\n count: mergeRequests.length,\n mergeRequests: mergeRequests.map((mr) => {\n const description = mr.description ?? \"\";\n const truncatedDescription = description.length > 200\n ? `${description.substring(0, 200)}...`\n : description;\n\n return {\n id: mr.id,\n iid: mr.iid,\n projectId: mr.project_id,\n title: mr.title,\n state: mr.state,\n draft: mr.draft,\n sourceBranch: mr.source_branch,\n targetBranch: mr.target_branch,\n labels: mr.labels,\n author: {\n username: mr.author.username,\n name: mr.author.name,\n },\n assignees: mr.assignees.map((a) => ({\n username: a.username,\n name: a.name,\n })),\n reviewers: mr.reviewers.map((r) => ({\n username: r.username,\n name: r.name,\n })),\n createdAt: mr.created_at,\n updatedAt: mr.updated_at,\n mergedAt: mr.merged_at,\n webUrl: mr.web_url,\n description: truncatedDescription,\n };\n }),\n summary: mergeRequests.map(formatMergeRequestForDisplay).join(\"\\n\\n\"),\n };\n },\n});\n",
306
+ "tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List GitLab projects accessible to the authenticated user. Can search, filter by membership, and sort results.\",\n inputSchema: defineSchema((v) =>\n v.object({\n search: v.string().optional().describe(\n \"Search query to filter projects by name or path\",\n ),\n membership: v.boolean().default(true).describe(\n \"Only show projects where user is a member\",\n ),\n orderBy: v\n .enum([\"id\", \"name\", \"created_at\", \"updated_at\", \"last_activity_at\"])\n .default(\"last_activity_at\")\n .describe(\"Field to order results by\"),\n sort: v.enum([\"asc\", \"desc\"]).default(\"desc\").describe(\"Sort direction\"),\n limit: v.number().min(1).max(100).default(20).describe(\n \"Maximum number of results to return\",\n ),\n })\n )(),\n async execute({ search, membership, orderBy, sort, limit }) {\n const projects = await listProjects({\n search,\n membership,\n orderBy,\n sort,\n perPage: limit,\n });\n\n const mappedProjects = projects.map((project) => ({\n id: project.id,\n name: project.name,\n nameWithNamespace: project.name_with_namespace,\n path: project.path_with_namespace,\n description: project.description ?? \"No description\",\n visibility: project.visibility,\n defaultBranch: project.default_branch,\n webUrl: project.web_url,\n createdAt: project.created_at,\n lastActivityAt: project.last_activity_at,\n }));\n\n if (!mappedProjects.length) {\n return {\n message: \"No projects found matching the criteria.\",\n count: 0,\n projects: [],\n };\n }\n\n return {\n count: mappedProjects.length,\n projects: mappedProjects,\n };\n },\n});\n",
307
+ "tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n formatIssueForDisplay,\n searchIssues,\n} from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n \"Search for issues in GitLab projects. Can search across all accessible projects or within a specific project. Returns issue titles, states, assignees, and labels.\",\n inputSchema: defineSchema((v) =>\n v.object({\n scope: v\n .enum([\"created_by_me\", \"assigned_to_me\", \"all\"])\n .default(\"all\")\n .describe(\"Scope of issues to search\"),\n state: v\n .enum([\"opened\", \"closed\", \"all\"])\n .default(\"opened\")\n .describe(\"State of issues to search for\"),\n search: v.string().optional().describe(\n \"Search query to filter issues by title or description\",\n ),\n labels: v.array(v.string()).optional().describe(\n 'Filter by labels (e.g., [\"bug\", \"urgent\"])',\n ),\n projectId: v\n .union([v.number(), v.string()])\n .optional()\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n limit: v.number().min(1).max(100).default(20).describe(\n \"Maximum number of results to return\",\n ),\n })\n )(),\n async execute({ scope, state, search, labels, projectId, limit }) {\n const issues = await searchIssues({\n scope,\n state,\n search,\n labels,\n projectId,\n perPage: limit,\n });\n\n if (issues.length === 0) {\n return {\n message: \"No issues found matching the criteria.\",\n count: 0,\n issues: [],\n };\n }\n\n return {\n count: issues.length,\n issues: issues.map((issue) => {\n const description = issue.description ?? \"\";\n const truncatedDescription = description.length > 200\n ? `${description.substring(0, 200)}...`\n : description;\n\n return {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({\n username,\n name,\n })),\n author: {\n username: issue.author.username,\n name: issue.author.name,\n },\n createdAt: issue.created_at,\n updatedAt: issue.updated_at,\n webUrl: issue.web_url,\n description: truncatedDescription,\n };\n }),\n summary: issues.map(formatIssueForDisplay).join(\"\\n\\n\"),\n };\n },\n});\n",
308
+ "tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateIssue } from \"../../lib/gitlab-client.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description: \"Update, close, or reopen a GitLab issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectId: v\n .union([v.number(), v.string()])\n .describe('Project ID or path (e.g., \"gitlab-org/gitlab\" or 278964)'),\n issueIid: v\n .number()\n .describe(\n \"Issue IID (the project-local number shown in the issue URL)\",\n ),\n title: v.string().optional().describe(\"Updated issue title\"),\n description: v.string().optional().describe(\"Updated issue description\"),\n state: v.enum([\"opened\", \"closed\"]).optional().describe(\"Issue state\"),\n labels: v.array(v.string()).optional().describe(\"Replacement labels\"),\n assigneeIds: v.array(v.number()).optional().describe(\n \"GitLab user IDs to assign\",\n ),\n })\n )(),\n async execute(\n { projectId, issueIid, title, description, state, labels, assigneeIds },\n ) {\n const issue = await updateIssue(projectId, issueIid, {\n title,\n description,\n state,\n labels,\n assigneeIds,\n });\n\n return {\n success: true,\n message: `Issue #${issue.iid} updated successfully.`,\n issue: {\n id: issue.id,\n iid: issue.iid,\n projectId: issue.project_id,\n title: issue.title,\n state: issue.state,\n labels: issue.labels,\n assignees: issue.assignees.map(({ username, name }) => ({\n username,\n name,\n })),\n webUrl: issue.web_url,\n updatedAt: issue.updated_at,\n },\n };\n },\n});\n"
290
309
  }
291
310
  },
292
311
  "integration:gmail": {
@@ -323,14 +342,12 @@ export default {
323
342
  "tools/search-emails.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient, parseEmailHeaders } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"search-emails\",\n description:\n \"Search emails using Gmail's search syntax. Supports queries like 'from:person@email.com', 'subject:meeting', 'after:2024/01/01', etc.\",\n inputSchema: defineSchema((v) => v.object({\n query: v\n .string()\n .min(1)\n .describe(\n \"Search query using Gmail search syntax (e.g., 'from:boss@company.com subject:urgent')\",\n ),\n maxResults: v\n .number()\n .min(1)\n .max(50)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }))(),\n execute: async ({ query, maxResults }, context) => {\n const userId = resolveUserId(context);\n const gmail = createGmailClient(userId);\n\n try {\n const list = await gmail.listMessages({ query, maxResults });\n\n if (!list.messages?.length) {\n return {\n emails: [],\n query,\n message: `No emails found matching: \"${query}\"`,\n searchTips: [\n \"from:email@example.com - Search by sender\",\n \"to:email@example.com - Search by recipient\",\n \"subject:keywords - Search in subject\",\n \"after:YYYY/MM/DD - Emails after date\",\n \"before:YYYY/MM/DD - Emails before date\",\n \"is:unread - Unread emails only\",\n \"has:attachment - Emails with attachments\",\n ],\n };\n }\n\n const emails = await Promise.all(\n list.messages.map(async ({ id }) => {\n const message = await gmail.getMessage(id, \"metadata\");\n const headers = parseEmailHeaders(message.payload?.headers ?? []);\n\n return {\n id: message.id,\n threadId: message.threadId,\n from: headers.from,\n to: headers.to,\n subject: headers.subject,\n date: headers.date,\n snippet: message.snippet,\n isUnread: message.labelIds?.includes(\"UNREAD\") ?? false,\n labels: message.labelIds,\n };\n }),\n );\n\n return {\n emails,\n query,\n count: emails.length,\n message: `Found ${emails.length} email(s) matching: \"${query}\"`,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
324
343
  "tools/send-draft.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"send-draft\",\n description: \"Send an existing Gmail draft.\",\n inputSchema: defineSchema((v) => v.object({\n draftId: v.string().min(1).describe(\"Gmail draft ID\"),\n }))(),\n execute: async ({ draftId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.sendDraft(draftId);\n\n return {\n success: true,\n messageId: message.id,\n threadId: message.threadId,\n message: \"Draft sent.\",\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
325
344
  "tools/send-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nfunction formatRecipients(value?: string | string[]): string | undefined {\n if (!value) return undefined;\n return Array.isArray(value) ? value.join(\", \") : value;\n}\n\nexport default tool({\n id: \"send-email\",\n description: \"Send an email via Gmail. Can send to multiple recipients with CC and BCC support.\",\n inputSchema: defineSchema((v) => v.object({\n to: v.union([v.string().email(), v.array(v.string().email())]).describe(\"Email recipient(s)\"),\n subject: v.string().min(1).describe(\"Email subject line\"),\n body: v.string().min(1).describe(\"Email body content\"),\n cc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"CC recipient(s)\"),\n bcc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"BCC recipient(s)\"),\n isHtml: v.boolean().default(false).describe(\"Whether the body contains HTML\"),\n }))(),\n execute: async ({ to, subject, body, cc, bcc, isHtml }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n\n const result = await gmail.sendEmail({ to, subject, body, cc, bcc, isHtml });\n\n const toFormatted = formatRecipients(to) ?? \"\";\n\n return {\n success: true,\n messageId: result.id,\n threadId: result.threadId,\n message: `Email sent successfully to ${toFormatted}.`,\n details: {\n to: toFormatted,\n subject,\n cc: formatRecipients(cc),\n bcc: formatRecipients(bcc),\n },\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
326
- "tools/stop-mailbox-watch.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"stop-mailbox-watch\",\n description: \"Stop Gmail push notifications for the connected mailbox.\",\n inputSchema: defineSchema((v) => v.object({}))(),\n execute: async (_input, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n await gmail.stopMailboxWatch();\n\n return {\n success: true,\n message: \"Mailbox watch stopped.\",\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
327
345
  "tools/trash-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"trash-email\",\n description: \"Move a Gmail message to trash.\",\n inputSchema: defineSchema((v) => v.object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n }))(),\n execute: async ({ messageId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.trashMessage(messageId);\n\n return {\n success: true,\n message,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
328
346
  "tools/trash-thread.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"trash-thread\",\n description: \"Move a Gmail thread to trash.\",\n inputSchema: defineSchema((v) => v.object({\n threadId: v.string().min(1).describe(\"Gmail thread ID\"),\n }))(),\n execute: async ({ threadId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const thread = await gmail.trashThread(threadId);\n\n return {\n success: true,\n thread,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
329
347
  "tools/untrash-email.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"untrash-email\",\n description: \"Remove a Gmail message from trash.\",\n inputSchema: defineSchema((v) => v.object({\n messageId: v.string().min(1).describe(\"Gmail message ID\"),\n }))(),\n execute: async ({ messageId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const message = await gmail.untrashMessage(messageId);\n\n return {\n success: true,\n message,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
330
348
  "tools/untrash-thread.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"untrash-thread\",\n description: \"Remove a Gmail thread from trash.\",\n inputSchema: defineSchema((v) => v.object({\n threadId: v.string().min(1).describe(\"Gmail thread ID\"),\n }))(),\n execute: async ({ threadId }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const thread = await gmail.untrashThread(threadId);\n\n return {\n success: true,\n thread,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
331
349
  "tools/update-draft.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"update-draft\",\n description: \"Replace the content of a Gmail draft.\",\n inputSchema: defineSchema((v) => v.object({\n draftId: v.string().min(1).describe(\"Gmail draft ID\"),\n to: v.union([v.string().email(), v.array(v.string().email())]).describe(\"Email recipient(s)\"),\n subject: v.string().min(1).describe(\"Email subject line\"),\n body: v.string().min(1).describe(\"Email body content\"),\n cc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"CC recipient(s)\"),\n bcc: v\n .union([v.string().email(), v.array(v.string().email())])\n .optional()\n .describe(\"BCC recipient(s)\"),\n replyTo: v.string().email().optional().describe(\"Reply-To address\"),\n isHtml: v.boolean().default(false).describe(\"Whether the body contains HTML\"),\n threadId: v.string().optional().describe(\"Thread ID to keep the draft in\"),\n }))(),\n execute: async ({ draftId, ...input }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const draft = await gmail.updateDraft(draftId, input);\n\n return {\n success: true,\n draft,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
332
- "tools/update-label.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"update-label\",\n description: \"Update a Gmail user label.\",\n inputSchema: defineSchema((v) => v.object({\n labelId: v.string().min(1).describe(\"Gmail label ID\"),\n name: v.string().min(1).describe(\"Label display name\"),\n messageListVisibility: v.enum([\"show\", \"hide\"]).optional().describe(\"Message list visibility\"),\n labelListVisibility: v\n .enum([\"labelShow\", \"labelShowIfUnread\", \"labelHide\"])\n .optional()\n .describe(\"Label list visibility\"),\n textColor: v.string().optional().describe(\"Label text color hex value\"),\n backgroundColor: v.string().optional().describe(\"Label background color hex value\"),\n }))(),\n execute: async ({ labelId, textColor, backgroundColor, ...input }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const label = await gmail.updateLabel(labelId, {\n ...input,\n ...(textColor && backgroundColor ? { color: { textColor, backgroundColor } } : {}),\n });\n\n return {\n success: true,\n label,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n",
333
- "tools/watch-mailbox.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"watch-mailbox\",\n description: \"Start Gmail push notifications for mailbox changes using a Cloud Pub/Sub topic.\",\n inputSchema: defineSchema((v) => v.object({\n topicName: v\n .string()\n .min(1)\n .describe(\"Cloud Pub/Sub topic name, for example projects/<PROJECT_ID>/topics/<TOPIC_ID>\"),\n labelIds: v.array(v.string().min(1)).optional().describe(\"Labels used to filter notifications\"),\n labelFilterBehavior: v\n .enum([\"include\", \"exclude\"])\n .optional()\n .describe(\"Whether labelIds are included or excluded\"),\n }))(),\n execute: async (input, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n return await gmail.watchMailbox(input);\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n"
350
+ "tools/update-label.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createGmailClient } from \"../lib/gmail-client.ts\";\nimport { resolveUserId } from \"../lib/context.ts\";\n\nexport default tool({\n id: \"update-label\",\n description: \"Update a Gmail user label.\",\n inputSchema: defineSchema((v) => v.object({\n labelId: v.string().min(1).describe(\"Gmail label ID\"),\n name: v.string().min(1).describe(\"Label display name\"),\n messageListVisibility: v.enum([\"show\", \"hide\"]).optional().describe(\"Message list visibility\"),\n labelListVisibility: v\n .enum([\"labelShow\", \"labelShowIfUnread\", \"labelHide\"])\n .optional()\n .describe(\"Label list visibility\"),\n textColor: v.string().optional().describe(\"Label text color hex value\"),\n backgroundColor: v.string().optional().describe(\"Label background color hex value\"),\n }))(),\n execute: async ({ labelId, textColor, backgroundColor, ...input }, context) => {\n const userId = resolveUserId(context);\n\n try {\n const gmail = createGmailClient(userId);\n const label = await gmail.updateLabel(labelId, {\n ...input,\n ...(textColor && backgroundColor ? { color: { textColor, backgroundColor } } : {}),\n });\n\n return {\n success: true,\n label,\n };\n } catch (error) {\n if (error instanceof Error && error.message.includes(\"not connected\")) {\n return {\n error: \"Gmail not connected. Please connect your Gmail account.\",\n connectUrl: \"/api/auth/gmail\",\n };\n }\n throw error;\n }\n },\n});\n"
334
351
  }
335
352
  },
336
353
  "integration:hubspot": {
@@ -350,12 +367,16 @@ export default {
350
367
  "files": {
351
368
  "app/api/auth/jira/callback/route.ts": "import { createOAuthCallbackHandler, jiraConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n async getTokens(serviceId: string, userId: string): Promise<unknown> {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ): Promise<void> {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string): Promise<void> {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ): Promise<void> {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string): Promise<unknown> {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(jiraConfig, { tokenStore: hybridTokenStore });\n",
352
369
  "app/api/auth/jira/route.ts": "import { createOAuthInitHandler, jiraConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(jiraConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
353
- "lib/jira-client.ts": "import { getAccessToken, getCloudId } from \"./token-store.ts\";\n\nconst JIRA_API_VERSION = \"3\";\n\ninterface JiraResponse<T> {\n expand?: string;\n startAt?: number;\n maxResults?: number;\n total?: number;\n issues?: T[];\n values?: T[];\n}\n\nexport interface JiraIssue {\n id: string;\n key: string;\n self: string;\n fields: {\n summary: string;\n description?:\n | {\n type: string;\n content: unknown[];\n }\n | string;\n status: {\n name: string;\n statusCategory: {\n key: string;\n name: string;\n };\n };\n issuetype: {\n id: string;\n name: string;\n iconUrl: string;\n };\n priority?: {\n name: string;\n iconUrl: string;\n };\n assignee?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n reporter?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n created: string;\n updated: string;\n project: {\n id: string;\n key: string;\n name: string;\n };\n labels?: string[];\n [key: string]: unknown;\n };\n}\n\nexport interface JiraProject {\n id: string;\n key: string;\n name: string;\n projectTypeKey: string;\n self: string;\n avatarUrls?: Record<string, string>;\n lead?: {\n displayName: string;\n accountId: string;\n };\n}\n\nexport interface JiraIssueType {\n id: string;\n name: string;\n description: string;\n iconUrl: string;\n subtask: boolean;\n}\n\nexport interface JiraTransition {\n id: string;\n name: string;\n to: {\n id: string;\n name: string;\n };\n}\n\nfunction buildAdfDescription(text: string): Record<string, unknown> {\n return {\n type: \"doc\",\n version: 1,\n content: [\n {\n type: \"paragraph\",\n content: [\n {\n type: \"text\",\n text,\n },\n ],\n },\n ],\n };\n}\n\nasync function jiraFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Jira. Please connect your account.\");\n }\n\n const cloudId = await getCloudId();\n if (!cloudId) {\n throw new Error(\"Jira cloud ID not found. Please reconnect your account.\");\n }\n\n const baseUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/${JIRA_API_VERSION}`;\n const url = endpoint.startsWith(\"http\") ? endpoint : `${baseUrl}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({} as unknown));\n const message =\n (error as any)?.errorMessages?.join(\", \") ||\n (error as any)?.message ||\n response.statusText;\n\n throw new Error(`Jira API error: ${response.status} ${message}`);\n }\n\n if (response.status === 204) {\n return {} as T;\n }\n\n return response.json();\n}\n\nexport async function searchIssues(\n jql: string,\n options?: {\n fields?: string[];\n maxResults?: number;\n startAt?: number;\n },\n): Promise<{ issues: JiraIssue[]; total: number }> {\n const params = new URLSearchParams({\n jql,\n maxResults: String(options?.maxResults ?? 50),\n startAt: String(options?.startAt ?? 0),\n });\n\n if (options?.fields?.length) {\n params.set(\"fields\", options.fields.join(\",\"));\n }\n\n const response = await jiraFetch<JiraResponse<JiraIssue>>(\n `/search?${params.toString()}`,\n );\n\n return {\n issues: response.issues ?? [],\n total: response.total ?? 0,\n };\n}\n\nexport function getIssue(issueIdOrKey: string): Promise<JiraIssue> {\n return jiraFetch<JiraIssue>(`/issue/${issueIdOrKey}`);\n}\n\nexport async function createIssue(options: {\n projectKey: string;\n summary: string;\n description?: string;\n issueType: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n}): Promise<JiraIssue> {\n const fields: Record<string, unknown> = {\n project: { key: options.projectKey },\n summary: options.summary,\n issuetype: { name: options.issueType },\n };\n\n if (options.description) {\n fields.description = buildAdfDescription(options.description);\n }\n\n if (options.priority) {\n fields.priority = { name: options.priority };\n }\n\n if (options.assigneeId) {\n fields.assignee = { id: options.assigneeId };\n }\n\n if (options.labels?.length) {\n fields.labels = options.labels;\n }\n\n const response = await jiraFetch<{ id: string; key: string; self: string }>(\n \"/issue\",\n {\n method: \"POST\",\n body: JSON.stringify({ fields }),\n },\n );\n\n return getIssue(response.key);\n}\n\nexport function updateIssue(\n issueIdOrKey: string,\n updates: {\n summary?: string;\n description?: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n },\n): Promise<void> {\n const fields: Record<string, unknown> = {};\n\n if (updates.summary) {\n fields.summary = updates.summary;\n }\n\n if (updates.description) {\n fields.description = buildAdfDescription(updates.description);\n }\n\n if (updates.priority) {\n fields.priority = { name: updates.priority };\n }\n\n if (updates.assigneeId) {\n fields.assignee = { id: updates.assigneeId };\n }\n\n if (updates.labels) {\n fields.labels = updates.labels;\n }\n\n return jiraFetch<void>(`/issue/${issueIdOrKey}`, {\n method: \"PUT\",\n body: JSON.stringify({ fields }),\n });\n}\n\nexport async function transitionIssue(\n issueIdOrKey: string,\n transitionId: string,\n): Promise<void> {\n await jiraFetch<void>(`/issue/${issueIdOrKey}/transitions`, {\n method: \"POST\",\n body: JSON.stringify({ transition: { id: transitionId } }),\n });\n}\n\nexport async function getIssueTransitions(\n issueIdOrKey: string,\n): Promise<JiraTransition[]> {\n const response = await jiraFetch<{ transitions: JiraTransition[] }>(\n `/issue/${issueIdOrKey}/transitions`,\n );\n return response.transitions ?? [];\n}\n\nexport async function listProjects(): Promise<JiraProject[]> {\n return jiraFetch<JiraProject[]>(\"/project\");\n}\n\nexport function getProject(projectIdOrKey: string): Promise<JiraProject> {\n return jiraFetch<JiraProject>(`/project/${projectIdOrKey}`);\n}\n\nexport async function getProjectIssueTypes(\n projectIdOrKey: string,\n): Promise<JiraIssueType[]> {\n return jiraFetch<JiraIssueType[]>(`/project/${projectIdOrKey}/statuses`);\n}\n\nexport function extractDescriptionText(description: unknown): string {\n if (typeof description === \"string\") {\n return description;\n }\n\n if (!description || typeof description !== \"object\") {\n return \"\";\n }\n\n const content = (description as { content?: unknown[] }).content;\n if (!Array.isArray(content)) {\n return \"\";\n }\n\n const texts: string[] = [];\n\n function extractText(node: any): void {\n if (node?.type === \"text\" && node.text) {\n texts.push(node.text);\n }\n\n if (Array.isArray(node?.content)) {\n node.content.forEach(extractText);\n }\n }\n\n content.forEach(extractText);\n return texts.join(\" \");\n}\n",
354
- "tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new Jira issue in a project. Requires project key, summary, and issue type. Optionally set description, priority, assignee, and labels.\",\n inputSchema: defineSchema((v) => v.object({\n projectKey: v.string().describe('The project key (e.g., \"PROJ\", \"DEV\")'),\n summary: v.string().describe(\"Brief summary/title of the issue\"),\n issueType: v.string().describe('Type of issue: \"Task\", \"Bug\", \"Story\", \"Epic\", etc.'),\n description: v.string().optional().describe(\"Detailed description of the issue\"),\n priority: v\n .string()\n .optional()\n .describe('Priority: \"Highest\", \"High\", \"Medium\", \"Low\", \"Lowest\"'),\n assigneeId: v.string().optional().describe(\"Atlassian account ID of the assignee (optional)\"),\n labels: v.array(v.string()).optional().describe(\"Array of labels to add to the issue\"),\n }))(),\n async execute({ projectKey, summary, issueType, description, priority, assigneeId, labels }) {\n const { key, id, fields } = await createIssue({\n projectKey,\n summary,\n issueType,\n description,\n priority,\n assigneeId,\n labels,\n });\n\n return {\n key,\n id,\n summary: fields.summary,\n status: fields.status.name,\n type: fields.issuetype.name,\n priority: fields.priority?.name,\n assignee: fields.assignee?.displayName,\n project: {\n key: fields.project.key,\n name: fields.project.name,\n },\n created: fields.created,\n message: `Issue ${key} created successfully`,\n };\n },\n});\n",
355
- "tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, getIssue } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific Jira issue by its key (e.g., PROJ-123) or ID. Returns all fields including description, comments, history, etc.\",\n inputSchema: defineSchema((v) => v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n }))(),\n async execute({ issueKey }) {\n const issue = await getIssue(issueKey);\n const { fields } = issue;\n\n const priority = fields.priority\n ? { name: fields.priority.name, iconUrl: fields.priority.iconUrl }\n : null;\n\n const assignee = fields.assignee\n ? {\n displayName: fields.assignee.displayName,\n email: fields.assignee.emailAddress,\n accountId: fields.assignee.accountId,\n }\n : null;\n\n const reporter = fields.reporter\n ? {\n displayName: fields.reporter.displayName,\n email: fields.reporter.emailAddress,\n accountId: fields.reporter.accountId,\n }\n : null;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: fields.summary,\n description: extractDescriptionText(fields.description),\n status: fields.status.name,\n statusCategory: fields.status.statusCategory.name,\n type: {\n name: fields.issuetype.name,\n iconUrl: fields.issuetype.iconUrl,\n },\n priority,\n assignee,\n reporter,\n project: {\n key: fields.project.key,\n name: fields.project.name,\n id: fields.project.id,\n },\n created: fields.created,\n updated: fields.updated,\n labels: fields.labels ?? [],\n url: issue.self,\n };\n },\n});\n",
356
- "tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List all accessible Jira projects in the connected site. Returns project keys, names, and basic information.\",\n inputSchema: defineSchema((v) => v.object({}))(),\n async execute() {\n const projects = await listProjects();\n\n return {\n total: projects.length,\n projects: projects.map((project) => {\n const lead = project.lead\n ? {\n displayName: project.lead.displayName,\n accountId: project.lead.accountId,\n }\n : null;\n\n return {\n key: project.key,\n id: project.id,\n name: project.name,\n projectType: project.projectTypeKey,\n lead,\n avatarUrl: project.avatarUrls?.[\"48x48\"],\n };\n }),\n };\n },\n});\n",
357
- "tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, searchIssues } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n 'Search for Jira issues using JQL (Jira Query Language). Returns matching issues with key details. Common JQL examples: \"assignee = currentUser() AND status != Done\", \"project = PROJ AND type = Bug\", \"created >= -7d\".',\n inputSchema: defineSchema((v) => v.object({\n jql: v\n .string()\n .describe(\n 'JQL query string to search issues. Examples: \"assignee = currentUser()\", \"project = PROJ\", \"status = Open\"',\n ),\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of results to return\"),\n fields: v\n .array(v.string())\n .optional()\n .describe(\n 'Specific fields to include (e.g., [\"summary\", \"status\", \"assignee\"])',\n ),\n }))(),\n async execute({ jql, maxResults, fields }) {\n const result = await searchIssues(jql, { maxResults, fields });\n\n return {\n total: result.total,\n issues: result.issues.map((issue) => {\n const issueFields = issue.fields;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: issueFields.summary,\n description: extractDescriptionText(issueFields.description),\n status: issueFields.status.name,\n statusCategory: issueFields.status.statusCategory.name,\n type: issueFields.issuetype.name,\n priority: issueFields.priority?.name,\n assignee: issueFields.assignee?.displayName,\n reporter: issueFields.reporter?.displayName,\n project: {\n key: issueFields.project.key,\n name: issueFields.project.name,\n },\n created: issueFields.created,\n updated: issueFields.updated,\n labels: issueFields.labels ?? [],\n };\n }),\n };\n },\n});\n",
358
- "tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n getIssue,\n getIssueTransitions,\n transitionIssue,\n updateIssue,\n} from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description:\n 'Update an existing Jira issue. Can update fields like summary, description, priority, assignee, labels, or transition the status (e.g., move to \"In Progress\", \"Done\").',\n inputSchema: defineSchema((v) => v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") to update'),\n summary: v.string().optional().describe(\"New summary/title for the issue\"),\n description: v.string().optional().describe(\"New description for the issue\"),\n priority: v\n .string()\n .optional()\n .describe('New priority: \"Highest\", \"High\", \"Medium\", \"Low\", \"Lowest\"'),\n assigneeId: v\n .string()\n .optional()\n .describe(\"Atlassian account ID of the new assignee\"),\n labels: v\n .array(v.string())\n .optional()\n .describe(\"New array of labels (replaces existing labels)\"),\n status: v\n .string()\n .optional()\n .describe(\n 'New status to transition to (e.g., \"In Progress\", \"Done\", \"To Do\")',\n ),\n }))(),\n async execute({\n issueKey,\n summary,\n description,\n priority,\n assigneeId,\n labels,\n status,\n }) {\n if (\n summary !== undefined ||\n description !== undefined ||\n priority !== undefined ||\n assigneeId !== undefined ||\n labels !== undefined\n ) {\n await updateIssue(issueKey, {\n summary,\n description,\n priority,\n assigneeId,\n labels,\n });\n }\n\n if (status) {\n const transitions = await getIssueTransitions(issueKey);\n const normalizedStatus = status.toLowerCase();\n\n const targetTransition = transitions.find((t) => {\n const transitionName = t.name.toLowerCase();\n const toName = t.to.name.toLowerCase();\n return transitionName === normalizedStatus || toName === normalizedStatus;\n });\n\n if (!targetTransition) {\n const available = transitions.map((t) => t.to.name).join(\", \");\n throw new Error(\n `Status \"${status}\" not found. Available transitions: ${available}`,\n );\n }\n\n await transitionIssue(issueKey, targetTransition.id);\n }\n\n const updatedIssue = await getIssue(issueKey);\n\n return {\n key: updatedIssue.key,\n id: updatedIssue.id,\n summary: updatedIssue.fields.summary,\n status: updatedIssue.fields.status.name,\n type: updatedIssue.fields.issuetype.name,\n priority: updatedIssue.fields.priority?.name,\n assignee: updatedIssue.fields.assignee?.displayName,\n project: {\n key: updatedIssue.fields.project.key,\n name: updatedIssue.fields.project.name,\n },\n updated: updatedIssue.fields.updated,\n labels: updatedIssue.fields.labels ?? [],\n message: `Issue ${issueKey} updated successfully`,\n };\n },\n});\n"
370
+ "lib/jira-client.ts": "import { getAccessToken, getCloudId } from \"./token-store.ts\";\n\nconst JIRA_API_VERSION = \"3\";\n\ninterface JiraResponse<T> {\n expand?: string;\n startAt?: number;\n maxResults?: number;\n total?: number;\n issues?: T[];\n values?: T[];\n}\n\nexport interface JiraIssue {\n id: string;\n key: string;\n self: string;\n fields: {\n summary: string;\n description?:\n | {\n type: string;\n content: unknown[];\n }\n | string;\n status: {\n name: string;\n statusCategory: {\n key: string;\n name: string;\n };\n };\n issuetype: {\n id: string;\n name: string;\n iconUrl: string;\n };\n priority?: {\n name: string;\n iconUrl: string;\n };\n assignee?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n reporter?: {\n displayName: string;\n emailAddress: string;\n accountId: string;\n };\n created: string;\n updated: string;\n project: {\n id: string;\n key: string;\n name: string;\n };\n labels?: string[];\n [key: string]: unknown;\n };\n}\n\nexport interface JiraProject {\n id: string;\n key: string;\n name: string;\n projectTypeKey: string;\n self: string;\n avatarUrls?: Record<string, string>;\n lead?: {\n displayName: string;\n accountId: string;\n };\n}\n\nexport interface JiraIssueType {\n id: string;\n name: string;\n description: string;\n iconUrl: string;\n subtask: boolean;\n}\n\nexport interface JiraTransition {\n id: string;\n name: string;\n to: {\n id: string;\n name: string;\n };\n}\n\nexport interface JiraComment {\n id: string;\n body: unknown;\n author?: {\n displayName: string;\n accountId: string;\n };\n created: string;\n updated: string;\n}\n\nfunction buildAdfDescription(text: string): Record<string, unknown> {\n return {\n type: \"doc\",\n version: 1,\n content: [\n {\n type: \"paragraph\",\n content: [\n {\n type: \"text\",\n text,\n },\n ],\n },\n ],\n };\n}\n\nasync function jiraFetch<T>(\n endpoint: string,\n options: RequestInit = {},\n): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\n \"Not authenticated with Jira. Please connect your account.\",\n );\n }\n\n const cloudId = await getCloudId();\n if (!cloudId) {\n throw new Error(\"Jira cloud ID not found. Please reconnect your account.\");\n }\n\n const baseUrl =\n `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/${JIRA_API_VERSION}`;\n const url = endpoint.startsWith(\"http\") ? endpoint : `${baseUrl}${endpoint}`;\n\n const response = await fetch(url, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({} as unknown));\n const message = (error as any)?.errorMessages?.join(\", \") ||\n (error as any)?.message ||\n response.statusText;\n\n throw new Error(`Jira API error: ${response.status} ${message}`);\n }\n\n if (response.status === 204) {\n return {} as T;\n }\n\n return response.json();\n}\n\nexport async function searchIssues(\n jql: string,\n options?: {\n fields?: string[];\n maxResults?: number;\n startAt?: number;\n },\n): Promise<{ issues: JiraIssue[]; total: number }> {\n const params = new URLSearchParams({\n jql,\n maxResults: String(options?.maxResults ?? 50),\n startAt: String(options?.startAt ?? 0),\n });\n\n if (options?.fields?.length) {\n params.set(\"fields\", options.fields.join(\",\"));\n }\n\n const response = await jiraFetch<JiraResponse<JiraIssue>>(\n `/search?${params.toString()}`,\n );\n\n return {\n issues: response.issues ?? [],\n total: response.total ?? 0,\n };\n}\n\nexport function getIssue(issueIdOrKey: string): Promise<JiraIssue> {\n return jiraFetch<JiraIssue>(`/issue/${issueIdOrKey}`);\n}\n\nexport async function createIssue(options: {\n projectKey: string;\n summary: string;\n description?: string;\n issueType: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n}): Promise<JiraIssue> {\n const fields: Record<string, unknown> = {\n project: { key: options.projectKey },\n summary: options.summary,\n issuetype: { name: options.issueType },\n };\n\n if (options.description) {\n fields.description = buildAdfDescription(options.description);\n }\n\n if (options.priority) {\n fields.priority = { name: options.priority };\n }\n\n if (options.assigneeId) {\n fields.assignee = { id: options.assigneeId };\n }\n\n if (options.labels?.length) {\n fields.labels = options.labels;\n }\n\n const response = await jiraFetch<{ id: string; key: string; self: string }>(\n \"/issue\",\n {\n method: \"POST\",\n body: JSON.stringify({ fields }),\n },\n );\n\n return getIssue(response.key);\n}\n\nexport async function listComments(\n issueIdOrKey: string,\n options?: { startAt?: number; maxResults?: number },\n): Promise<\n {\n comments: JiraComment[];\n total: number;\n startAt: number;\n maxResults: number;\n }\n> {\n const params = new URLSearchParams({\n startAt: String(options?.startAt ?? 0),\n maxResults: String(options?.maxResults ?? 50),\n });\n\n const response = await jiraFetch<{\n comments?: JiraComment[];\n total?: number;\n startAt?: number;\n maxResults?: number;\n }>(`/issue/${issueIdOrKey}/comment?${params.toString()}`);\n\n return {\n comments: response.comments ?? [],\n total: response.total ?? 0,\n startAt: response.startAt ?? 0,\n maxResults: response.maxResults ?? 0,\n };\n}\n\nexport function addComment(\n issueIdOrKey: string,\n body: string,\n): Promise<JiraComment> {\n return jiraFetch<JiraComment>(`/issue/${issueIdOrKey}/comment`, {\n method: \"POST\",\n body: JSON.stringify({ body: buildAdfDescription(body) }),\n });\n}\n\nexport function updateIssue(\n issueIdOrKey: string,\n updates: {\n summary?: string;\n description?: string;\n priority?: string;\n assigneeId?: string;\n labels?: string[];\n },\n): Promise<void> {\n const fields: Record<string, unknown> = {};\n\n if (updates.summary) {\n fields.summary = updates.summary;\n }\n\n if (updates.description) {\n fields.description = buildAdfDescription(updates.description);\n }\n\n if (updates.priority) {\n fields.priority = { name: updates.priority };\n }\n\n if (updates.assigneeId) {\n fields.assignee = { id: updates.assigneeId };\n }\n\n if (updates.labels) {\n fields.labels = updates.labels;\n }\n\n return jiraFetch<void>(`/issue/${issueIdOrKey}`, {\n method: \"PUT\",\n body: JSON.stringify({ fields }),\n });\n}\n\nexport async function transitionIssue(\n issueIdOrKey: string,\n transitionId: string,\n): Promise<void> {\n await jiraFetch<void>(`/issue/${issueIdOrKey}/transitions`, {\n method: \"POST\",\n body: JSON.stringify({ transition: { id: transitionId } }),\n });\n}\n\nexport async function getIssueTransitions(\n issueIdOrKey: string,\n): Promise<JiraTransition[]> {\n const response = await jiraFetch<{ transitions: JiraTransition[] }>(\n `/issue/${issueIdOrKey}/transitions`,\n );\n return response.transitions ?? [];\n}\n\nexport async function listProjects(): Promise<JiraProject[]> {\n return jiraFetch<JiraProject[]>(\"/project\");\n}\n\nexport function getProject(projectIdOrKey: string): Promise<JiraProject> {\n return jiraFetch<JiraProject>(`/project/${projectIdOrKey}`);\n}\n\nexport async function getProjectIssueTypes(\n projectIdOrKey: string,\n): Promise<JiraIssueType[]> {\n return jiraFetch<JiraIssueType[]>(`/project/${projectIdOrKey}/statuses`);\n}\n\nexport function extractDescriptionText(description: unknown): string {\n if (typeof description === \"string\") {\n return description;\n }\n\n if (!description || typeof description !== \"object\") {\n return \"\";\n }\n\n const content = (description as { content?: unknown[] }).content;\n if (!Array.isArray(content)) {\n return \"\";\n }\n\n const texts: string[] = [];\n\n function extractText(node: any): void {\n if (node?.type === \"text\" && node.text) {\n texts.push(node.text);\n }\n\n if (Array.isArray(node?.content)) {\n node.content.forEach(extractText);\n }\n }\n\n content.forEach(extractText);\n return texts.join(\" \");\n}\n",
371
+ "tools/add-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addComment, extractDescriptionText } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"add-comment\",\n description: \"Add a comment to a Jira issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n body: v.string().min(1).describe(\"Comment body text\"),\n })\n )(),\n async execute({ issueKey, body }) {\n const comment = await addComment(issueKey, body);\n\n return {\n success: true,\n id: comment.id,\n author: comment.author?.displayName,\n body: extractDescriptionText(comment.body),\n created: comment.created,\n updated: comment.updated,\n message: `Comment added to ${issueKey}`,\n };\n },\n});\n",
372
+ "tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new Jira issue in a project. Requires project key, summary, and issue type. Optionally set description, priority, assignee, and labels.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectKey: v.string().describe('The project key (e.g., \"PROJ\", \"DEV\")'),\n summary: v.string().describe(\"Brief summary/title of the issue\"),\n issueType: v.string().describe(\n 'Type of issue: \"Task\", \"Bug\", \"Story\", \"Epic\", etc.',\n ),\n description: v.string().optional().describe(\n \"Detailed description of the issue\",\n ),\n priority: v\n .string()\n .optional()\n .describe('Priority: \"Highest\", \"High\", \"Medium\", \"Low\", \"Lowest\"'),\n assigneeId: v.string().optional().describe(\n \"Atlassian account ID of the assignee (optional)\",\n ),\n labels: v.array(v.string()).optional().describe(\n \"Array of labels to add to the issue\",\n ),\n })\n )(),\n async execute(\n {\n projectKey,\n summary,\n issueType,\n description,\n priority,\n assigneeId,\n labels,\n },\n ) {\n const { key, id, fields } = await createIssue({\n projectKey,\n summary,\n issueType,\n description,\n priority,\n assigneeId,\n labels,\n });\n\n return {\n key,\n id,\n summary: fields.summary,\n status: fields.status.name,\n type: fields.issuetype.name,\n priority: fields.priority?.name,\n assignee: fields.assignee?.displayName,\n project: {\n key: fields.project.key,\n name: fields.project.name,\n },\n created: fields.created,\n message: `Issue ${key} created successfully`,\n };\n },\n});\n",
373
+ "tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, getIssue } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific Jira issue by its key (e.g., PROJ-123) or ID. Returns all fields including description, comments, history, etc.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n })\n )(),\n async execute({ issueKey }) {\n const issue = await getIssue(issueKey);\n const { fields } = issue;\n\n const priority = fields.priority\n ? { name: fields.priority.name, iconUrl: fields.priority.iconUrl }\n : null;\n\n const assignee = fields.assignee\n ? {\n displayName: fields.assignee.displayName,\n email: fields.assignee.emailAddress,\n accountId: fields.assignee.accountId,\n }\n : null;\n\n const reporter = fields.reporter\n ? {\n displayName: fields.reporter.displayName,\n email: fields.reporter.emailAddress,\n accountId: fields.reporter.accountId,\n }\n : null;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: fields.summary,\n description: extractDescriptionText(fields.description),\n status: fields.status.name,\n statusCategory: fields.status.statusCategory.name,\n type: {\n name: fields.issuetype.name,\n iconUrl: fields.issuetype.iconUrl,\n },\n priority,\n assignee,\n reporter,\n project: {\n key: fields.project.key,\n name: fields.project.name,\n id: fields.project.id,\n },\n created: fields.created,\n updated: fields.updated,\n labels: fields.labels ?? [],\n url: issue.self,\n };\n },\n});\n",
374
+ "tools/get-project.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getProject } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-project\",\n description: \"Get detailed information about a Jira project by key or ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n projectIdOrKey: v.string().describe('Project key or ID (e.g., \"PROJ\")'),\n })\n )(),\n async execute({ projectIdOrKey }) {\n const project = await getProject(projectIdOrKey);\n\n return {\n key: project.key,\n id: project.id,\n name: project.name,\n projectType: project.projectTypeKey,\n lead: project.lead\n ? {\n displayName: project.lead.displayName,\n accountId: project.lead.accountId,\n }\n : null,\n avatarUrl: project.avatarUrls?.[\"48x48\"],\n self: project.self,\n };\n },\n});\n",
375
+ "tools/get-transitions.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getIssueTransitions } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"get-transitions\",\n description: \"List available workflow transitions for a Jira issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n })\n )(),\n async execute({ issueKey }) {\n const transitions = await getIssueTransitions(issueKey);\n\n return {\n issueKey,\n transitions: transitions.map((transition) => ({\n id: transition.id,\n name: transition.name,\n to: transition.to.name,\n })),\n };\n },\n});\n",
376
+ "tools/list-comments.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, listComments } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"list-comments\",\n description: \"List comments on a Jira issue.\",\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe('The issue key (e.g., \"PROJ-123\") or ID'),\n startAt: v.number().min(0).default(0).describe(\"Pagination offset\"),\n maxResults: v.number().min(1).max(100).default(50).describe(\n \"Maximum comments to return\",\n ),\n })\n )(),\n async execute({ issueKey, startAt, maxResults }) {\n const result = await listComments(issueKey, { startAt, maxResults });\n\n return {\n total: result.total,\n startAt: result.startAt,\n maxResults: result.maxResults,\n comments: result.comments.map((comment) => ({\n id: comment.id,\n author: comment.author?.displayName,\n body: extractDescriptionText(comment.body),\n created: comment.created,\n updated: comment.updated,\n })),\n };\n },\n});\n",
377
+ "tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List all accessible Jira projects in the connected site. Returns project keys, names, and basic information.\",\n inputSchema: defineSchema((v) => v.object({}))(),\n async execute() {\n const projects = await listProjects();\n\n return {\n total: projects.length,\n projects: projects.map((project) => {\n const lead = project.lead\n ? {\n displayName: project.lead.displayName,\n accountId: project.lead.accountId,\n }\n : null;\n\n return {\n key: project.key,\n id: project.id,\n name: project.name,\n projectType: project.projectTypeKey,\n lead,\n avatarUrl: project.avatarUrls?.[\"48x48\"],\n };\n }),\n };\n },\n});\n",
378
+ "tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractDescriptionText, searchIssues } from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n 'Search for Jira issues using JQL (Jira Query Language). Returns matching issues with key details. Common JQL examples: \"assignee = currentUser() AND status != Done\", \"project = PROJ AND type = Bug\", \"created >= -7d\".',\n inputSchema: defineSchema((v) =>\n v.object({\n jql: v\n .string()\n .describe(\n 'JQL query string to search issues. Examples: \"assignee = currentUser()\", \"project = PROJ\", \"status = Open\"',\n ),\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of results to return\"),\n fields: v\n .array(v.string())\n .optional()\n .describe(\n 'Specific fields to include (e.g., [\"summary\", \"status\", \"assignee\"])',\n ),\n })\n )(),\n async execute({ jql, maxResults, fields }) {\n const result = await searchIssues(jql, { maxResults, fields });\n\n return {\n total: result.total,\n issues: result.issues.map((issue) => {\n const issueFields = issue.fields;\n\n return {\n key: issue.key,\n id: issue.id,\n summary: issueFields.summary,\n description: extractDescriptionText(issueFields.description),\n status: issueFields.status.name,\n statusCategory: issueFields.status.statusCategory.name,\n type: issueFields.issuetype.name,\n priority: issueFields.priority?.name,\n assignee: issueFields.assignee?.displayName,\n reporter: issueFields.reporter?.displayName,\n project: {\n key: issueFields.project.key,\n name: issueFields.project.name,\n },\n created: issueFields.created,\n updated: issueFields.updated,\n labels: issueFields.labels ?? [],\n };\n }),\n };\n },\n});\n",
379
+ "tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport {\n getIssue,\n getIssueTransitions,\n transitionIssue,\n updateIssue,\n} from \"../../lib/jira-client.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description:\n 'Update an existing Jira issue. Can update fields like summary, description, priority, assignee, labels, or transition the status (e.g., move to \"In Progress\", \"Done\").',\n inputSchema: defineSchema((v) =>\n v.object({\n issueKey: v.string().describe(\n 'The issue key (e.g., \"PROJ-123\") to update',\n ),\n summary: v.string().optional().describe(\n \"New summary/title for the issue\",\n ),\n description: v.string().optional().describe(\n \"New description for the issue\",\n ),\n priority: v\n .string()\n .optional()\n .describe('New priority: \"Highest\", \"High\", \"Medium\", \"Low\", \"Lowest\"'),\n assigneeId: v\n .string()\n .optional()\n .describe(\"Atlassian account ID of the new assignee\"),\n labels: v\n .array(v.string())\n .optional()\n .describe(\"New array of labels (replaces existing labels)\"),\n status: v\n .string()\n .optional()\n .describe(\n 'New status to transition to (e.g., \"In Progress\", \"Done\", \"To Do\")',\n ),\n })\n )(),\n async execute({\n issueKey,\n summary,\n description,\n priority,\n assigneeId,\n labels,\n status,\n }) {\n if (\n summary !== undefined ||\n description !== undefined ||\n priority !== undefined ||\n assigneeId !== undefined ||\n labels !== undefined\n ) {\n await updateIssue(issueKey, {\n summary,\n description,\n priority,\n assigneeId,\n labels,\n });\n }\n\n if (status) {\n const transitions = await getIssueTransitions(issueKey);\n const normalizedStatus = status.toLowerCase();\n\n const targetTransition = transitions.find((t) => {\n const transitionName = t.name.toLowerCase();\n const toName = t.to.name.toLowerCase();\n return transitionName === normalizedStatus ||\n toName === normalizedStatus;\n });\n\n if (!targetTransition) {\n const available = transitions.map((t) => t.to.name).join(\", \");\n throw new Error(\n `Status \"${status}\" not found. Available transitions: ${available}`,\n );\n }\n\n await transitionIssue(issueKey, targetTransition.id);\n }\n\n const updatedIssue = await getIssue(issueKey);\n\n return {\n key: updatedIssue.key,\n id: updatedIssue.id,\n summary: updatedIssue.fields.summary,\n status: updatedIssue.fields.status.name,\n type: updatedIssue.fields.issuetype.name,\n priority: updatedIssue.fields.priority?.name,\n assignee: updatedIssue.fields.assignee?.displayName,\n project: {\n key: updatedIssue.fields.project.key,\n name: updatedIssue.fields.project.name,\n },\n updated: updatedIssue.fields.updated,\n labels: updatedIssue.fields.labels ?? [],\n message: `Issue ${issueKey} updated successfully`,\n };\n },\n});\n"
359
380
  }
360
381
  },
361
382
  "integration:linear": {
@@ -363,10 +384,14 @@ export default {
363
384
  ".env.example": "# Linear Integration\n# Create an OAuth application at https://linear.app/settings/api\n# Set the callback URL to: http://localhost:3000/api/auth/linear/callback (or your production URL)\n\nLINEAR_CLIENT_ID=your_client_id_here\nLINEAR_CLIENT_SECRET=your_client_secret_here\n",
364
385
  "app/api/auth/linear/callback/route.ts": "/**\n * Linear OAuth Callback\n *\n * Handles the OAuth callback from Linear and stores the tokens.\n */\n\nimport { createOAuthCallbackHandler, linearConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(linearConfig, { tokenStore: hybridTokenStore });\n",
365
386
  "app/api/auth/linear/route.ts": "import { createOAuthInitHandler, linearConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(linearConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
366
- "lib/linear-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst LINEAR_API_URL = \"https://api.linear.app/graphql\";\n\nexport interface LinearIssue {\n id: string;\n identifier: string;\n title: string;\n description?: string;\n priority: number;\n priorityLabel: string;\n state: {\n id: string;\n name: string;\n type: string;\n };\n assignee?: {\n id: string;\n name: string;\n email: string;\n };\n team: {\n id: string;\n name: string;\n key: string;\n };\n project?: {\n id: string;\n name: string;\n };\n labels: {\n nodes: Array<{\n id: string;\n name: string;\n color: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n url: string;\n}\n\nexport interface LinearProject {\n id: string;\n name: string;\n description?: string;\n state: string;\n progress: number;\n url: string;\n lead?: {\n id: string;\n name: string;\n };\n teams: {\n nodes: Array<{\n id: string;\n name: string;\n key: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface LinearTeam {\n id: string;\n name: string;\n key: string;\n}\n\nexport interface LinearWorkflowState {\n id: string;\n name: string;\n type: string;\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: Array<{\n message: string;\n path?: string[];\n }>;\n}\n\nasync function linearFetch<T>(query: string, variables?: Record<string, unknown>): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Linear. Please connect your account.\");\n }\n\n const response = await fetch(LINEAR_API_URL, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`Linear API error: ${response.status} ${response.statusText}`);\n }\n\n const json: GraphQLResponse<T> = await response.json();\n\n const errorMessage = json.errors?.[0]?.message;\n if (errorMessage) {\n throw new Error(`Linear GraphQL error: ${errorMessage}`);\n }\n\n if (!json.data) {\n throw new Error(\"Linear API returned no data\");\n }\n\n return json.data;\n}\n\nexport async function searchIssues(\n query: string,\n options?: {\n limit?: number;\n includeArchived?: boolean;\n },\n): Promise<LinearIssue[]> {\n const gqlQuery = `\n query SearchIssues($query: String!, $first: Int, $includeArchived: Boolean) {\n issueSearch(query: $query, first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const data = await linearFetch<{ issueSearch: { nodes: LinearIssue[] } }>(gqlQuery, {\n query,\n first: options?.limit ?? 10,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.issueSearch.nodes;\n}\n\nexport async function getIssue(issueId: string): Promise<LinearIssue> {\n const query = `\n query GetIssue($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n `;\n\n const data = await linearFetch<{ issue: LinearIssue }>(query, { id: issueId });\n return data.issue;\n}\n\nexport async function createIssue(options: {\n teamId: string;\n title: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n}): Promise<LinearIssue> {\n const mutation = `\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {\n teamId: options.teamId,\n title: options.title,\n };\n\n if (options.description) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds?.length) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueCreate: { success: boolean; issue: LinearIssue } }>(mutation, {\n input,\n });\n\n if (!data.issueCreate.success) {\n throw new Error(\"Failed to create issue\");\n }\n\n return data.issueCreate.issue;\n}\n\nexport async function updateIssue(\n issueId: string,\n options: {\n title?: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n },\n): Promise<LinearIssue> {\n const mutation = `\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {};\n\n if (options.title) input.title = options.title;\n if (options.description !== undefined) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueUpdate: { success: boolean; issue: LinearIssue } }>(mutation, {\n id: issueId,\n input,\n });\n\n if (!data.issueUpdate.success) {\n throw new Error(\"Failed to update issue\");\n }\n\n return data.issueUpdate.issue;\n}\n\nexport async function listProjects(options?: {\n limit?: number;\n includeArchived?: boolean;\n}): Promise<LinearProject[]> {\n const query = `\n query ListProjects($first: Int, $includeArchived: Boolean) {\n projects(first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n name\n description\n state\n progress\n url\n lead {\n id\n name\n }\n teams {\n nodes {\n id\n name\n key\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n `;\n\n const data = await linearFetch<{ projects: { nodes: LinearProject[] } }>(query, {\n first: options?.limit ?? 20,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.projects.nodes;\n}\n\nexport async function getTeams(): Promise<LinearTeam[]> {\n const query = `\n query GetTeams {\n teams {\n nodes {\n id\n name\n key\n }\n }\n }\n `;\n\n const data = await linearFetch<{ teams: { nodes: LinearTeam[] } }>(query);\n return data.teams.nodes;\n}\n\nexport async function getWorkflowStates(teamId: string): Promise<LinearWorkflowState[]> {\n const query = `\n query GetWorkflowStates($teamId: String!) {\n team(id: $teamId) {\n states {\n nodes {\n id\n name\n type\n }\n }\n }\n }\n `;\n\n const data = await linearFetch<{ team: { states: { nodes: LinearWorkflowState[] } } }>(query, {\n teamId,\n });\n\n return data.team.states.nodes;\n}\n",
387
+ "lib/linear-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst LINEAR_API_URL = \"https://api.linear.app/graphql\";\n\nexport interface LinearIssue {\n id: string;\n identifier: string;\n title: string;\n description?: string;\n priority: number;\n priorityLabel: string;\n state: {\n id: string;\n name: string;\n type: string;\n };\n assignee?: {\n id: string;\n name: string;\n email: string;\n };\n team: {\n id: string;\n name: string;\n key: string;\n };\n project?: {\n id: string;\n name: string;\n };\n labels: {\n nodes: Array<{\n id: string;\n name: string;\n color: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n url: string;\n}\n\nexport interface LinearProject {\n id: string;\n name: string;\n description?: string;\n state: string;\n progress: number;\n url: string;\n lead?: {\n id: string;\n name: string;\n };\n teams: {\n nodes: Array<{\n id: string;\n name: string;\n key: string;\n }>;\n };\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface LinearTeam {\n id: string;\n name: string;\n key: string;\n}\n\nexport interface LinearWorkflowState {\n id: string;\n name: string;\n type: string;\n}\n\nexport interface LinearUser {\n id: string;\n name: string;\n displayName?: string;\n email: string;\n active: boolean;\n avatarUrl?: string;\n}\n\nexport interface LinearComment {\n id: string;\n body: string;\n createdAt: string;\n user?: {\n id: string;\n name: string;\n };\n issue?: {\n id: string;\n identifier: string;\n title: string;\n };\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: Array<{\n message: string;\n path?: string[];\n }>;\n}\n\nasync function linearFetch<T>(query: string, variables?: Record<string, unknown>): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Linear. Please connect your account.\");\n }\n\n const response = await fetch(LINEAR_API_URL, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`Linear API error: ${response.status} ${response.statusText}`);\n }\n\n const json: GraphQLResponse<T> = await response.json();\n\n const errorMessage = json.errors?.[0]?.message;\n if (errorMessage) {\n throw new Error(`Linear GraphQL error: ${errorMessage}`);\n }\n\n if (!json.data) {\n throw new Error(\"Linear API returned no data\");\n }\n\n return json.data;\n}\n\nexport async function searchIssues(\n query: string,\n options?: {\n limit?: number;\n includeArchived?: boolean;\n },\n): Promise<LinearIssue[]> {\n const gqlQuery = `\n query SearchIssues($query: String!, $first: Int, $includeArchived: Boolean) {\n issueSearch(query: $query, first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const data = await linearFetch<{ issueSearch: { nodes: LinearIssue[] } }>(gqlQuery, {\n query,\n first: options?.limit ?? 10,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.issueSearch.nodes;\n}\n\nexport async function getIssue(issueId: string): Promise<LinearIssue> {\n const query = `\n query GetIssue($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n `;\n\n const data = await linearFetch<{ issue: LinearIssue }>(query, { id: issueId });\n return data.issue;\n}\n\nexport async function createIssue(options: {\n teamId: string;\n title: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n}): Promise<LinearIssue> {\n const mutation = `\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {\n teamId: options.teamId,\n title: options.title,\n };\n\n if (options.description) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds?.length) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueCreate: { success: boolean; issue: LinearIssue } }>(mutation, {\n input,\n });\n\n if (!data.issueCreate.success) {\n throw new Error(\"Failed to create issue\");\n }\n\n return data.issueCreate.issue;\n}\n\nexport async function updateIssue(\n issueId: string,\n options: {\n title?: string;\n description?: string;\n priority?: number;\n stateId?: string;\n assigneeId?: string;\n projectId?: string;\n labelIds?: string[];\n },\n): Promise<LinearIssue> {\n const mutation = `\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n id\n identifier\n title\n description\n priority\n priorityLabel\n state {\n id\n name\n type\n }\n assignee {\n id\n name\n email\n }\n team {\n id\n name\n key\n }\n project {\n id\n name\n }\n labels {\n nodes {\n id\n name\n color\n }\n }\n createdAt\n updatedAt\n url\n }\n }\n }\n `;\n\n const input: Record<string, unknown> = {};\n\n if (options.title) input.title = options.title;\n if (options.description !== undefined) input.description = options.description;\n if (options.priority !== undefined) input.priority = options.priority;\n if (options.stateId) input.stateId = options.stateId;\n if (options.assigneeId) input.assigneeId = options.assigneeId;\n if (options.projectId) input.projectId = options.projectId;\n if (options.labelIds) input.labelIds = options.labelIds;\n\n const data = await linearFetch<{ issueUpdate: { success: boolean; issue: LinearIssue } }>(mutation, {\n id: issueId,\n input,\n });\n\n if (!data.issueUpdate.success) {\n throw new Error(\"Failed to update issue\");\n }\n\n return data.issueUpdate.issue;\n}\n\nexport async function listProjects(options?: {\n limit?: number;\n includeArchived?: boolean;\n}): Promise<LinearProject[]> {\n const query = `\n query ListProjects($first: Int, $includeArchived: Boolean) {\n projects(first: $first, includeArchived: $includeArchived) {\n nodes {\n id\n name\n description\n state\n progress\n url\n lead {\n id\n name\n }\n teams {\n nodes {\n id\n name\n key\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n `;\n\n const data = await linearFetch<{ projects: { nodes: LinearProject[] } }>(query, {\n first: options?.limit ?? 20,\n includeArchived: options?.includeArchived ?? false,\n });\n\n return data.projects.nodes;\n}\n\nexport async function getTeams(): Promise<LinearTeam[]> {\n const query = `\n query GetTeams {\n teams {\n nodes {\n id\n name\n key\n }\n }\n }\n `;\n\n const data = await linearFetch<{ teams: { nodes: LinearTeam[] } }>(query);\n return data.teams.nodes;\n}\n\nexport async function getWorkflowStates(teamId: string): Promise<LinearWorkflowState[]> {\n const query = `\n query GetWorkflowStates($teamId: String!) {\n team(id: $teamId) {\n states {\n nodes {\n id\n name\n type\n }\n }\n }\n }\n `;\n\n const data = await linearFetch<{ team: { states: { nodes: LinearWorkflowState[] } } }>(query, {\n teamId,\n });\n\n return data.team.states.nodes;\n}\n\nexport async function listUsers(options?: {\n limit?: number;\n}): Promise<LinearUser[]> {\n const query = `\n query ListUsers($first: Int) {\n users(first: $first) {\n nodes {\n id\n name\n displayName\n email\n active\n avatarUrl\n }\n }\n }\n `;\n\n const data = await linearFetch<{ users: { nodes: LinearUser[] } }>(query, {\n first: options?.limit ?? 50,\n });\n\n return data.users.nodes;\n}\n\nexport async function addComment(options: {\n issueId: string;\n body: string;\n}): Promise<LinearComment> {\n const mutation = `\n mutation AddComment($issueId: String!, $body: String!) {\n commentCreate(input: { issueId: $issueId, body: $body }) {\n success\n comment {\n id\n body\n createdAt\n user {\n id\n name\n }\n issue {\n id\n identifier\n title\n }\n }\n }\n }\n `;\n\n const data = await linearFetch<{ commentCreate: { success: boolean; comment: LinearComment } }>(\n mutation,\n options,\n );\n\n if (!data.commentCreate.success) {\n throw new Error(\"Failed to add comment\");\n }\n\n return data.commentCreate.comment;\n}\n",
388
+ "tools/add-comment.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { addComment } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"add-comment\",\n description: \"Add a comment to a Linear issue.\",\n inputSchema: defineSchema((v) => v.object({\n issueId: v.string().describe(\"Linear issue ID\"),\n body: v.string().min(1).describe(\"Comment body in markdown\"),\n }))(),\n async execute({ issueId, body }) {\n const comment = await addComment({ issueId, body });\n\n return {\n id: comment.id,\n body: comment.body,\n createdAt: comment.createdAt,\n user: comment.user\n ? { id: comment.user.id, name: comment.user.name }\n : null,\n issue: comment.issue\n ? {\n id: comment.issue.id,\n identifier: comment.issue.identifier,\n title: comment.issue.title,\n }\n : null,\n };\n },\n});\n",
367
389
  "tools/create-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createIssue } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"create-issue\",\n description:\n \"Create a new Linear issue in a specified team. You can optionally set priority, assign to someone, add to a project, and attach labels.\",\n inputSchema: defineSchema((v) => v.object({\n teamId: v\n .string()\n .describe(\n \"The ID of the team to create the issue in. Use list-projects tool first if you need to find team IDs.\",\n ),\n title: v.string().describe(\"Title of the issue\"),\n description: v\n .string()\n .optional()\n .describe(\"Detailed description of the issue (supports markdown)\"),\n priority: v\n .number()\n .min(0)\n .max(4)\n .optional()\n .describe(\"Priority level: 0=No priority, 1=Urgent, 2=High, 3=Medium, 4=Low\"),\n stateId: v\n .string()\n .optional()\n .describe('Workflow state ID (e.g., \"Todo\", \"In Progress\", \"Done\")'),\n assigneeId: v.string().optional().describe(\"User ID to assign the issue to\"),\n projectId: v.string().optional().describe(\"Project ID to add the issue to\"),\n labelIds: v\n .array(v.string())\n .optional()\n .describe(\"Array of label IDs to attach to the issue\"),\n }))(),\n async execute(\n { teamId, title, description, priority, stateId, assigneeId, projectId, labelIds },\n ) {\n const issue = await createIssue({\n teamId,\n title,\n description,\n priority,\n stateId,\n assigneeId,\n projectId,\n labelIds,\n });\n\n const assignee = issue.assignee\n ? { name: issue.assignee.name, email: issue.assignee.email }\n : null;\n\n const project = issue.project ? { name: issue.project.name } : null;\n\n const labels = issue.labels.nodes.map((label) => ({\n name: label.name,\n color: label.color,\n }));\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n status: issue.state.name,\n assignee,\n team: {\n name: issue.team.name,\n key: issue.team.key,\n },\n project,\n labels,\n url: issue.url,\n createdAt: issue.createdAt,\n };\n },\n});\n",
368
390
  "tools/get-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getIssue } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"get-issue\",\n description:\n \"Get detailed information about a specific Linear issue by its ID or identifier (e.g., ENG-123). Returns complete issue details including description, status, assignee, labels, and project.\",\n inputSchema: defineSchema((v) => v.object({\n issueId: v\n .string()\n .describe(\n 'The ID or identifier of the issue (e.g., \"ENG-123\" or full UUID)',\n ),\n }))(),\n async execute({ issueId }) {\n const issue = await getIssue(issueId);\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n priorityNumber: issue.priority,\n status: issue.state.name,\n statusType: issue.state.type,\n stateId: issue.state.id,\n assignee: issue.assignee\n ? {\n id: issue.assignee.id,\n name: issue.assignee.name,\n email: issue.assignee.email,\n }\n : null,\n team: {\n id: issue.team.id,\n name: issue.team.name,\n key: issue.team.key,\n },\n project: issue.project\n ? {\n id: issue.project.id,\n name: issue.project.name,\n }\n : null,\n labels: issue.labels.nodes.map(({ id, name, color }) => ({\n id,\n name,\n color,\n })),\n url: issue.url,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n };\n },\n});\n",
369
391
  "tools/list-projects.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listProjects } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-projects\",\n description:\n \"List all projects in the Linear workspace. Returns project details including name, state, progress, and associated teams.\",\n inputSchema: defineSchema((v) => v.object({\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of projects to return\"),\n includeArchived: v\n .boolean()\n .default(false)\n .describe(\"Whether to include archived projects in results\"),\n }))(),\n async execute({ limit, includeArchived }) {\n const projects = await listProjects({ limit, includeArchived });\n\n return projects.map((project) => ({\n id: project.id,\n name: project.name,\n description: project.description,\n state: project.state,\n progress: Math.round(project.progress * 100),\n url: project.url,\n lead: project.lead\n ? { id: project.lead.id, name: project.lead.name }\n : null,\n teams: project.teams.nodes.map((team) => ({\n id: team.id,\n name: team.name,\n key: team.key,\n })),\n createdAt: project.createdAt,\n updatedAt: project.updatedAt,\n }));\n },\n});\n",
392
+ "tools/list-teams.ts": "import { tool } from \"veryfront/tool\";\nimport { getTeams } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-teams\",\n description:\n \"List teams in the Linear workspace. Use this to find the team ID required when creating issues.\",\n async execute() {\n const teams = await getTeams();\n\n return teams.map((team) => ({\n id: team.id,\n name: team.name,\n key: team.key,\n }));\n },\n});\n",
393
+ "tools/list-users.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { listUsers } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-users\",\n description:\n \"List users in the Linear workspace. Use this to find assignee user IDs before assigning issues.\",\n inputSchema: defineSchema((v) => v.object({\n limit: v\n .number()\n .min(1)\n .max(100)\n .default(50)\n .describe(\"Maximum number of users to return\"),\n }))(),\n async execute({ limit }) {\n const users = await listUsers({ limit });\n\n return users.map((user) => ({\n id: user.id,\n name: user.name,\n displayName: user.displayName,\n email: user.email,\n active: user.active,\n avatarUrl: user.avatarUrl,\n }));\n },\n});\n",
394
+ "tools/list-workflow-states.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getWorkflowStates } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"list-workflow-states\",\n description:\n \"List workflow states for a Linear team. Use this to find a state ID before updating an issue status.\",\n inputSchema: defineSchema((v) => v.object({\n teamId: v.string().describe(\"Linear team ID\"),\n }))(),\n async execute({ teamId }) {\n const states = await getWorkflowStates(teamId);\n\n return states.map((state) => ({\n id: state.id,\n name: state.name,\n type: state.type,\n }));\n },\n});\n",
370
395
  "tools/search-issues.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { searchIssues } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"search-issues\",\n description:\n \"Search for Linear issues by title or description. Returns matching issues with their details including status, assignee, and team.\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query to find issues (searches in title and description)\"),\n limit: v.number().min(1).max(50).default(10).describe(\"Maximum number of results to return\"),\n includeArchived: v\n .boolean()\n .default(false)\n .describe(\"Whether to include archived issues in results\"),\n }))(),\n async execute({ query, limit, includeArchived }) {\n const issues = await searchIssues(query, { limit, includeArchived });\n\n return issues.map((issue) => {\n const assignee = issue.assignee\n ? { name: issue.assignee.name, email: issue.assignee.email }\n : null;\n\n const project = issue.project ? { name: issue.project.name } : null;\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n status: issue.state.name,\n statusType: issue.state.type,\n assignee,\n team: { name: issue.team.name, key: issue.team.key },\n project,\n labels: issue.labels.nodes.map((label) => ({\n name: label.name,\n color: label.color,\n })),\n url: issue.url,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n };\n });\n },\n});\n",
371
396
  "tools/update-issue.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { updateIssue } from \"../../lib/linear-client.ts\";\n\nexport default tool({\n id: \"update-issue\",\n description:\n \"Update an existing Linear issue. You can change the title, description, status, priority, assignee, project, or labels.\",\n inputSchema: defineSchema((v) => v.object({\n issueId: v.string().describe(\"The ID of the issue to update\"),\n title: v.string().optional().describe(\"New title for the issue\"),\n description: v\n .string()\n .optional()\n .describe(\"New description for the issue (supports markdown)\"),\n priority: v\n .number()\n .min(0)\n .max(4)\n .optional()\n .describe(\n \"New priority level: 0=No priority, 1=Urgent, 2=High, 3=Medium, 4=Low\",\n ),\n stateId: v\n .string()\n .optional()\n .describe(\"New workflow state ID to move the issue to\"),\n assigneeId: v\n .string()\n .optional()\n .describe(\"User ID to assign the issue to (or null to unassign)\"),\n projectId: v.string().optional().describe(\"Project ID to move the issue to\"),\n labelIds: v\n .array(v.string())\n .optional()\n .describe(\"New array of label IDs (replaces existing labels)\"),\n }))(),\n async execute({\n issueId,\n title,\n description,\n priority,\n stateId,\n assigneeId,\n projectId,\n labelIds,\n }) {\n const issue = await updateIssue(issueId, {\n title,\n description,\n priority,\n stateId,\n assigneeId,\n projectId,\n labelIds,\n });\n\n return {\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n description: issue.description,\n priority: issue.priorityLabel,\n status: issue.state.name,\n statusType: issue.state.type,\n assignee: issue.assignee\n ? { name: issue.assignee.name, email: issue.assignee.email }\n : null,\n team: {\n name: issue.team.name,\n key: issue.team.key,\n },\n project: issue.project ? { name: issue.project.name } : null,\n labels: issue.labels.nodes.map(({ name, color }) => ({ name, color })),\n url: issue.url,\n updatedAt: issue.updatedAt,\n };\n },\n});\n"
372
397
  }
@@ -399,11 +424,15 @@ export default {
399
424
  ".env.example": "# Notion Integration\n# Create an integration at https://www.notion.so/my-integrations\n# Make sure to enable \"Public Integration\" for OAuth\n\nNOTION_CLIENT_ID=your_client_id_here\nNOTION_CLIENT_SECRET=your_client_secret_here\n",
400
425
  "app/api/auth/notion/callback/route.ts": "import { createOAuthCallbackHandler, notionConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(notionConfig, { tokenStore: hybridTokenStore });\n",
401
426
  "app/api/auth/notion/route.ts": "import { createOAuthInitHandler, notionConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(notionConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
402
- "lib/notion-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst NOTION_API_VERSION = \"2022-06-28\";\nconst NOTION_BASE_URL = \"https://api.notion.com/v1\";\n\ninterface NotionResponse<T> {\n object: string;\n results?: T[];\n next_cursor?: string | null;\n has_more?: boolean;\n}\n\ninterface NotionPage {\n id: string;\n object: \"page\";\n created_time: string;\n last_edited_time: string;\n parent: { type: string; database_id?: string; page_id?: string };\n properties: Record<string, NotionProperty>;\n url: string;\n}\n\ninterface NotionDatabase {\n id: string;\n object: \"database\";\n title: Array<{ plain_text: string }>;\n properties: Record<string, { type: string }>;\n}\n\ninterface NotionBlock {\n id: string;\n type: string;\n [key: string]: unknown;\n}\n\ninterface NotionProperty {\n type: string;\n title?: Array<{ plain_text: string }>;\n rich_text?: Array<{ plain_text: string }>;\n [key: string]: unknown;\n}\n\nasync function notionFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Notion. Please connect your account.\");\n }\n\n const response = await fetch(`${NOTION_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Notion-Version\": NOTION_API_VERSION,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({} as { message?: string }))) as {\n message?: string;\n };\n throw new Error(\n `Notion API error: ${response.status} ${error.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport async function searchNotion(\n query: string,\n options?: {\n filter?: { property: \"object\"; value: \"page\" | \"database\" };\n pageSize?: number;\n },\n): Promise<Array<NotionPage | NotionDatabase>> {\n const body: Record<string, unknown> = {\n query,\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage | NotionDatabase>>(\"/search\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n return response.results ?? [];\n}\n\nexport function getPage(pageId: string): Promise<NotionPage> {\n return notionFetch<NotionPage>(`/pages/${pageId}`);\n}\n\nexport async function getPageContent(pageId: string): Promise<NotionBlock[]> {\n const response = await notionFetch<NotionResponse<NotionBlock>>(`/blocks/${pageId}/children`);\n return response.results ?? [];\n}\n\nexport async function queryDatabase(\n databaseId: string,\n options?: {\n filter?: Record<string, unknown>;\n sorts?: Array<{ property: string; direction: \"ascending\" | \"descending\" }>;\n pageSize?: number;\n },\n): Promise<NotionPage[]> {\n const body: Record<string, unknown> = {\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.sorts ? { sorts: options.sorts } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage>>(\n `/databases/${databaseId}/query`,\n { method: \"POST\", body: JSON.stringify(body) },\n );\n\n return response.results ?? [];\n}\n\nexport function createPage(options: {\n parentId: string;\n parentType: \"database\" | \"page\";\n title: string;\n content?: string;\n properties?: Record<string, unknown>;\n}): Promise<NotionPage> {\n const parent =\n options.parentType === \"database\"\n ? { database_id: options.parentId }\n : { page_id: options.parentId };\n\n const properties: Record<string, unknown> = options.properties ?? {};\n\n if (options.parentType === \"database\") {\n properties.title ??= { title: [{ text: { content: options.title } }] };\n }\n\n const children: Array<Record<string, unknown>> = [];\n\n if (options.parentType === \"page\") {\n children.push({\n object: \"block\",\n type: \"heading_1\",\n heading_1: {\n rich_text: [{ type: \"text\", text: { content: options.title } }],\n },\n });\n }\n\n for (const paragraph of options.content?.split(\"\\n\\n\") ?? []) {\n const trimmed = paragraph.trim();\n if (!trimmed) continue;\n\n children.push({\n object: \"block\",\n type: \"paragraph\",\n paragraph: {\n rich_text: [{ type: \"text\", text: { content: trimmed } }],\n },\n });\n }\n\n return notionFetch<NotionPage>(\"/pages\", {\n method: \"POST\",\n body: JSON.stringify({\n parent,\n properties,\n children: children.length ? children : undefined,\n }),\n });\n}\n\nexport function extractPlainText(blocks: NotionBlock[]): string {\n const texts: string[] = [];\n\n for (const block of blocks) {\n const content = block[block.type] as { rich_text?: Array<{ plain_text: string }> } | undefined;\n const text = content?.rich_text?.map((t) => t.plain_text).join(\"\");\n if (text) texts.push(text);\n }\n\n return texts.join(\"\\n\\n\");\n}\n\nexport function getPageTitle(page: NotionPage): string {\n for (const prop of Object.values(page.properties)) {\n if (prop.type === \"title\" && prop.title) {\n return prop.title.map((t) => t.plain_text).join(\"\");\n }\n }\n\n return \"Untitled\";\n}\n",
427
+ "lib/notion-client.ts": "import { getAccessToken } from \"./token-store.ts\";\n\nconst NOTION_API_VERSION = \"2022-06-28\";\nconst NOTION_BASE_URL = \"https://api.notion.com/v1\";\n\ninterface NotionResponse<T> {\n object: string;\n results?: T[];\n next_cursor?: string | null;\n has_more?: boolean;\n}\n\ninterface NotionPage {\n id: string;\n object: \"page\";\n created_time: string;\n last_edited_time: string;\n parent: { type: string; database_id?: string; page_id?: string };\n properties: Record<string, NotionProperty>;\n url: string;\n}\n\ninterface NotionDatabase {\n id: string;\n object: \"database\";\n title: Array<{ plain_text: string }>;\n properties: Record<string, { type: string }>;\n}\n\ninterface NotionBlock {\n id: string;\n type: string;\n [key: string]: unknown;\n}\n\ninterface NotionProperty {\n type: string;\n title?: Array<{ plain_text: string }>;\n rich_text?: Array<{ plain_text: string }>;\n [key: string]: unknown;\n}\n\nasync function notionFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n const token = await getAccessToken();\n if (!token) {\n throw new Error(\"Not authenticated with Notion. Please connect your account.\");\n }\n\n const response = await fetch(`${NOTION_BASE_URL}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Notion-Version\": NOTION_API_VERSION,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = (await response.json().catch(() => ({} as { message?: string }))) as {\n message?: string;\n };\n throw new Error(\n `Notion API error: ${response.status} ${error.message ?? response.statusText}`,\n );\n }\n\n return response.json();\n}\n\nexport async function searchNotion(\n query: string,\n options?: {\n filter?: { property: \"object\"; value: \"page\" | \"database\" };\n pageSize?: number;\n },\n): Promise<Array<NotionPage | NotionDatabase>> {\n const body: Record<string, unknown> = {\n query,\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage | NotionDatabase>>(\"/search\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n return response.results ?? [];\n}\n\nexport function getPage(pageId: string): Promise<NotionPage> {\n return notionFetch<NotionPage>(`/pages/${pageId}`);\n}\n\nexport async function getPageContent(pageId: string): Promise<NotionBlock[]> {\n const response = await notionFetch<NotionResponse<NotionBlock>>(`/blocks/${pageId}/children`);\n return response.results ?? [];\n}\n\nexport async function queryDatabase(\n databaseId: string,\n options?: {\n filter?: Record<string, unknown>;\n sorts?: Array<{ property: string; direction: \"ascending\" | \"descending\" }>;\n pageSize?: number;\n },\n): Promise<NotionPage[]> {\n const body: Record<string, unknown> = {\n ...(options?.filter ? { filter: options.filter } : {}),\n ...(options?.sorts ? { sorts: options.sorts } : {}),\n ...(options?.pageSize ? { page_size: options.pageSize } : {}),\n };\n\n const response = await notionFetch<NotionResponse<NotionPage>>(\n `/databases/${databaseId}/query`,\n { method: \"POST\", body: JSON.stringify(body) },\n );\n\n return response.results ?? [];\n}\n\nexport function createPage(options: {\n parentId: string;\n parentType: \"database\" | \"page\";\n title: string;\n content?: string;\n properties?: Record<string, unknown>;\n}): Promise<NotionPage> {\n const parent =\n options.parentType === \"database\"\n ? { database_id: options.parentId }\n : { page_id: options.parentId };\n\n const properties: Record<string, unknown> = options.properties ?? {};\n\n if (options.parentType === \"database\") {\n properties.title ??= { title: [{ text: { content: options.title } }] };\n }\n\n const children: Array<Record<string, unknown>> = [];\n\n if (options.parentType === \"page\") {\n children.push({\n object: \"block\",\n type: \"heading_1\",\n heading_1: {\n rich_text: [{ type: \"text\", text: { content: options.title } }],\n },\n });\n }\n\n for (const paragraph of options.content?.split(\"\\n\\n\") ?? []) {\n const trimmed = paragraph.trim();\n if (!trimmed) continue;\n\n children.push({\n object: \"block\",\n type: \"paragraph\",\n paragraph: {\n rich_text: [{ type: \"text\", text: { content: trimmed } }],\n },\n });\n }\n\n return notionFetch<NotionPage>(\"/pages\", {\n method: \"POST\",\n body: JSON.stringify({\n parent,\n properties,\n children: children.length ? children : undefined,\n }),\n });\n}\n\nexport function extractPlainText(blocks: NotionBlock[]): string {\n const texts: string[] = [];\n\n for (const block of blocks) {\n const content = block[block.type] as { rich_text?: Array<{ plain_text: string }> } | undefined;\n const text = content?.rich_text?.map((t) => t.plain_text).join(\"\");\n if (text) texts.push(text);\n }\n\n return texts.join(\"\\n\\n\");\n}\n\nexport function getPageTitle(page: NotionPage): string {\n for (const prop of Object.values(page.properties)) {\n if (prop.type === \"title\" && prop.title) {\n return prop.title.map((t) => t.plain_text).join(\"\");\n }\n }\n\n return \"Untitled\";\n}\n\nexport function getDatabase(databaseId: string): Promise<NotionDatabase> {\n return notionFetch<NotionDatabase>(`/databases/${databaseId}`);\n}\n\nexport async function appendBlocks(options: {\n blockId: string;\n children: Array<Record<string, unknown>>;\n after?: string;\n}): Promise<NotionBlock[]> {\n const response = await notionFetch<NotionResponse<NotionBlock>>(\n `/blocks/${options.blockId}/children`,\n {\n method: \"PATCH\",\n body: JSON.stringify({\n children: options.children,\n after: options.after,\n }),\n },\n );\n\n return response.results ?? [];\n}\n\nexport function updatePage(options: {\n pageId: string;\n properties?: Record<string, unknown>;\n archived?: boolean;\n icon?: Record<string, unknown>;\n cover?: Record<string, unknown>;\n}): Promise<NotionPage> {\n return notionFetch<NotionPage>(`/pages/${options.pageId}`, {\n method: \"PATCH\",\n body: JSON.stringify({\n properties: options.properties,\n archived: options.archived,\n icon: options.icon,\n cover: options.cover,\n }),\n });\n}\n",
428
+ "tools/append-blocks.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { appendBlocks } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"append-blocks\",\n description: \"Append child blocks to a Notion page or block.\",\n inputSchema: defineSchema((v) => v.object({\n blockId: v.string().describe(\"The page or block ID to append children to\"),\n children: v.array(v.record(v.string(), v.unknown())).describe(\"Notion block objects to append\"),\n after: v.string().optional().describe(\"Optional existing child block ID after which to append\"),\n }))(),\n async execute({ blockId, children, after }) {\n const blocks = await appendBlocks({ blockId, children, after });\n\n return blocks.map((block) => ({\n id: block.id,\n type: block.type,\n block,\n }));\n },\n});\n",
403
429
  "tools/create-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createPage, getPageTitle } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"create-page\",\n description:\n \"Create a new page in Notion. Can create as a subpage of an existing page or as a new entry in a database.\",\n inputSchema: defineSchema((v) => v.object({\n parentId: v.string().describe(\"The ID of the parent page or database\"),\n parentType: v.enum([\"page\", \"database\"]).describe(\"Whether the parent is a page or database\"),\n title: v.string().describe(\"Title of the new page\"),\n content: v\n .string()\n .optional()\n .describe(\n \"Initial content for the page (plain text, paragraphs separated by double newlines)\",\n ),\n }))(),\n async execute({ parentId, parentType, title, content }) {\n const page = await createPage({ parentId, parentType, title, content });\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n createdAt: page.created_time,\n };\n },\n});\n",
430
+ "tools/get-database.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getDatabase } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"get-database\",\n description: \"Get Notion database metadata, title, and property schema.\",\n inputSchema: defineSchema((v) => v.object({\n databaseId: v.string().describe(\"The ID of the Notion database to retrieve\"),\n }))(),\n async execute({ databaseId }) {\n const database = await getDatabase(databaseId);\n\n return {\n id: database.id,\n title: database.title?.map((item) => item.plain_text).join(\"\") ?? \"\",\n properties: database.properties,\n };\n },\n});\n",
431
+ "tools/get-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPage, getPageTitle } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"get-page\",\n description: \"Get Notion page metadata and properties without reading child block content.\",\n inputSchema: defineSchema((v) => v.object({\n pageId: v.string().describe(\"The ID of the Notion page to retrieve\"),\n }))(),\n async execute({ pageId }) {\n const page = await getPage(pageId);\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n parent: page.parent,\n properties: page.properties,\n lastEdited: page.last_edited_time,\n createdAt: page.created_time,\n };\n },\n});\n",
404
432
  "tools/query-database.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, queryDatabase } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"query-database\",\n description: \"Query a Notion database to retrieve entries. Supports filtering and sorting.\",\n inputSchema: defineSchema((v) => v.object({\n databaseId: v.string().describe(\"The ID of the Notion database to query\"),\n sortProperty: v.string().optional().describe(\"Property name to sort by\"),\n sortDirection: v\n .enum([\"ascending\", \"descending\"])\n .default(\"descending\")\n .describe(\"Sort direction\"),\n limit: v.number().min(1).max(50).default(20).describe(\"Maximum number of results\"),\n }))(),\n async execute({ databaseId, sortProperty, sortDirection, limit }) {\n const results = await queryDatabase(databaseId, {\n sorts: sortProperty ? [{ property: sortProperty, direction: sortDirection }] : undefined,\n pageSize: limit,\n });\n\n return results.map((page) => {\n const properties: Record<string, string> = {};\n\n for (const [key, prop] of Object.entries(page.properties)) {\n if (prop.type !== \"title\" && prop.type !== \"rich_text\") continue;\n\n const text =\n prop.type === \"title\"\n ? prop.title?.map((t) => t.plain_text).join(\"\") ?? \"\"\n : prop.rich_text?.map((t) => t.plain_text).join(\"\") ?? \"\";\n\n properties[key] = text;\n }\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n properties,\n lastEdited: page.last_edited_time,\n };\n });\n },\n});\n",
405
433
  "tools/read-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { extractPlainText, getPage, getPageContent, getPageTitle } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"read-page\",\n description: \"Read the content of a Notion page. Returns the page title and text content.\",\n inputSchema: defineSchema((v) => v.object({\n pageId: v.string().describe(\"The ID of the Notion page to read\"),\n }))(),\n async execute({ pageId }): Promise<{\n id: string;\n title: string;\n url: string;\n content: string;\n lastEdited: string;\n createdAt: string;\n }> {\n const [page, blocks] = await Promise.all([getPage(pageId), getPageContent(pageId)]);\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n content: extractPlainText(blocks),\n lastEdited: page.last_edited_time,\n createdAt: page.created_time,\n };\n },\n});\n",
406
- "tools/search-notion.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, searchNotion } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"search-notion\",\n description:\n \"Search for pages and databases in the connected Notion workspace. Returns matching pages with their titles and IDs.\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query to find pages or databases\"),\n type: v\n .enum([\"page\", \"database\", \"all\"])\n .default(\"all\")\n .describe(\"Type of objects to search for\"),\n limit: v\n .number()\n .min(1)\n .max(20)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }))(),\n async execute({ query, type, limit }) {\n const filter = type === \"all\" ? undefined : { property: \"object\", value: type };\n const results = await searchNotion(query, { filter, pageSize: limit });\n\n return results.map((item) => {\n if (item.object === \"page\") {\n return {\n id: item.id,\n type: \"page\",\n title: getPageTitle(item),\n url: item.url,\n lastEdited: item.last_edited_time,\n };\n }\n\n return {\n id: item.id,\n type: \"database\",\n title: item.title?.map((t) => t.plain_text).join(\"\") ?? \"\",\n url: item.url,\n };\n });\n },\n});\n"
434
+ "tools/search-notion.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, searchNotion } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"search-notion\",\n description:\n \"Search for pages and databases in the connected Notion workspace. Returns matching pages with their titles and IDs.\",\n inputSchema: defineSchema((v) => v.object({\n query: v.string().describe(\"Search query to find pages or databases\"),\n type: v\n .enum([\"page\", \"database\", \"all\"])\n .default(\"all\")\n .describe(\"Type of objects to search for\"),\n limit: v\n .number()\n .min(1)\n .max(20)\n .default(10)\n .describe(\"Maximum number of results to return\"),\n }))(),\n async execute({ query, type, limit }) {\n const filter = type === \"all\" ? undefined : { property: \"object\", value: type };\n const results = await searchNotion(query, { filter, pageSize: limit });\n\n return results.map((item) => {\n if (item.object === \"page\") {\n return {\n id: item.id,\n type: \"page\",\n title: getPageTitle(item),\n url: item.url,\n lastEdited: item.last_edited_time,\n };\n }\n\n return {\n id: item.id,\n type: \"database\",\n title: item.title?.map((t) => t.plain_text).join(\"\") ?? \"\",\n url: item.url,\n };\n });\n },\n});\n",
435
+ "tools/update-page.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { getPageTitle, updatePage } from \"../../lib/notion-client.ts\";\n\nexport default tool({\n id: \"update-page\",\n description: \"Update Notion page properties or archive/unarchive a page.\",\n inputSchema: defineSchema((v) => v.object({\n pageId: v.string().describe(\"The ID of the Notion page to update\"),\n properties: v.record(v.string(), v.unknown()).optional().describe(\"Page properties to update\"),\n archived: v.boolean().optional().describe(\"Whether the page should be archived\"),\n icon: v.record(v.string(), v.unknown()).optional().describe(\"Optional page icon object\"),\n cover: v.record(v.string(), v.unknown()).optional().describe(\"Optional page cover object\"),\n }))(),\n async execute({ pageId, properties, archived, icon, cover }) {\n const page = await updatePage({ pageId, properties, archived, icon, cover });\n\n return {\n id: page.id,\n title: getPageTitle(page),\n url: page.url,\n properties: page.properties,\n archived,\n lastEdited: page.last_edited_time,\n };\n },\n});\n"
407
436
  }
408
437
  },
409
438
  "integration:onedrive": {
@@ -493,11 +522,22 @@ export default {
493
522
  ".env.example": "# Google Sheets Integration\n# Create OAuth credentials at https://console.cloud.google.com/apis/credentials\n# Make sure to enable:\n# - Google Sheets API: https://console.cloud.google.com/apis/library/sheets.googleapis.com\n# - Google Drive API: https://console.cloud.google.com/apis/library/drive.googleapis.com\n\nGOOGLE_CLIENT_ID=your_client_id_here\nGOOGLE_CLIENT_SECRET=your_client_secret_here\n",
494
523
  "app/api/auth/sheets/callback/route.ts": "import { createOAuthCallbackHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { tokenStore } from \"../../../../../lib/token-store.ts\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\n\nconst hybridTokenStore = {\n getTokens(serviceId: string, userId: string) {\n return tokenStore.getToken(userId, serviceId);\n },\n async setTokens(\n serviceId: string,\n userId: string,\n tokens: { accessToken: string; refreshToken?: string; expiresAt?: number },\n ) {\n await tokenStore.setToken(userId, serviceId, tokens);\n },\n async clearTokens(serviceId: string, userId: string) {\n await tokenStore.revokeToken(userId, serviceId);\n },\n setState(\n state: string,\n meta: {\n userId: string;\n serviceId: string;\n codeVerifier?: string;\n redirectUri?: string;\n scopes?: string[];\n createdAt: number;\n },\n ) {\n return oauthMemoryTokenStore.setState(state, meta);\n },\n consumeState(state: string) {\n return oauthMemoryTokenStore.consumeState(state);\n },\n};\n\nexport const GET = createOAuthCallbackHandler(sheetsConfig, { tokenStore: hybridTokenStore });\n",
495
524
  "app/api/auth/sheets/route.ts": "import { createOAuthInitHandler, sheetsConfig } from \"veryfront/oauth\";\nimport { oauthMemoryTokenStore } from \"../../../../../lib/oauth-memory-store.ts\";\nimport { requireUserIdFromRequest } from \"../../../../../lib/user-id.ts\";\n\nfunction getUserId(request: Request): string {\n return requireUserIdFromRequest(request);\n}\n\nexport const GET = createOAuthInitHandler(sheetsConfig, {\n tokenStore: oauthMemoryTokenStore,\n getUserId,\n});",
496
- "lib/sheets-client.ts": "/**\n * Google Sheets API Client\n *\n * Provides a type-safe interface to Google Sheets API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst SHEETS_API_BASE = \"https://sheets.googleapis.com/v4\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Spreadsheet {\n spreadsheetId: string;\n properties: {\n title: string;\n locale: string;\n autoRecalc: string;\n timeZone: string;\n };\n sheets: Sheet[];\n spreadsheetUrl: string;\n}\n\nexport interface Sheet {\n properties: {\n sheetId: number;\n title: string;\n index: number;\n sheetType: \"GRID\" | \"OBJECT\";\n gridProperties?: {\n rowCount: number;\n columnCount: number;\n };\n };\n}\n\nexport interface SpreadsheetFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n}\n\nexport interface CellData {\n values: unknown[][];\n range: string;\n}\n\nexport interface CreateSpreadsheetOptions {\n title: string;\n sheets?: Array<{\n title: string;\n rowCount?: number;\n columnCount?: number;\n }>;\n}\n\nexport interface WriteRangeOptions {\n spreadsheetId: string;\n range: string;\n values: unknown[][];\n valueInputOption?: \"RAW\" | \"USER_ENTERED\";\n}\n\nexport const sheetsOAuthProvider = {\n name: \"sheets\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/spreadsheets\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n ],\n callbackPath: \"/api/auth/sheets/callback\",\n};\n\nexport function createSheetsClient(userId: string): {\n listSpreadsheets(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise<SpreadsheetFile[]>;\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet>;\n readRange(spreadsheetId: string, range: string): Promise<CellData>;\n readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]>;\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }>;\n appendRange(\n spreadsheetId: string,\n range: string,\n values: unknown[][],\n valueInputOption?: \"RAW\" | \"USER_ENTERED\",\n ): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }>;\n clearRange(spreadsheetId: string, range: string): Promise<{ clearedRange: string }>;\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet>;\n addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet>;\n deleteSheet(spreadsheetId: string, sheetId: number): Promise<void>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(sheetsOAuthProvider, userId, \"sheets\");\n if (token) return token;\n throw new Error(\"Google Sheets not connected. Please connect your Google account first.\");\n }\n\n async function apiRequest<T>(\n baseUrl: string,\n serviceName: \"Sheets\" | \"Drive\",\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`${serviceName} API error: ${response.status} - ${error}`);\n }\n\n return response.json();\n }\n\n function sheetsApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(SHEETS_API_BASE, \"Sheets\", endpoint, options);\n }\n\n function driveApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {\n return apiRequest<T>(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n return {\n async listSpreadsheets(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise<SpreadsheetFile[]> {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.spreadsheet' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files?: SpreadsheetFile[] }>(`/files?${params.toString()}`);\n return result.files ?? [];\n },\n\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet> {\n return sheetsApiRequest<Spreadsheet>(`/spreadsheets/${spreadsheetId}`);\n },\n\n async readRange(spreadsheetId: string, range: string): Promise<CellData> {\n const result = await sheetsApiRequest<{ values?: unknown[][]; range: string }>(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`,\n );\n\n return { values: result.values ?? [], range: result.range };\n },\n\n async readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]> {\n const params = new URLSearchParams();\n ranges.forEach((range) => params.append(\"ranges\", range));\n\n const result = await sheetsApiRequest<{\n valueRanges: Array<{ values?: unknown[][]; range: string }>;\n }>(`/spreadsheets/${spreadsheetId}/values:batchGet?${params.toString()}`);\n\n return result.valueRanges.map((vr) => ({ values: vr.values ?? [], range: vr.range }));\n },\n\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }> {\n const valueInputOption = options.valueInputOption ?? \"USER_ENTERED\";\n\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/values/${encodeURIComponent(options.range)}?valueInputOption=${valueInputOption}`,\n {\n method: \"PUT\",\n body: JSON.stringify({ values: options.values }),\n },\n );\n },\n\n appendRange(\n spreadsheetId: string,\n range: string,\n values: unknown[][],\n valueInputOption: \"RAW\" | \"USER_ENTERED\" = \"USER_ENTERED\",\n ): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }> {\n return sheetsApiRequest(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:append?valueInputOption=${valueInputOption}`,\n {\n method: \"POST\",\n body: JSON.stringify({ values }),\n },\n );\n },\n\n clearRange(spreadsheetId: string, range: string): Promise<{ clearedRange: string }> {\n return sheetsApiRequest(`/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}:clear`, {\n method: \"POST\",\n });\n },\n\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet> {\n const body: {\n properties: { title: string };\n sheets?: Array<{\n properties: {\n title: string;\n gridProperties?: { rowCount: number; columnCount: number };\n };\n }>;\n } = { properties: { title: options.title } };\n\n if (options.sheets?.length) {\n body.sheets = options.sheets.map((sheet) => ({\n properties: {\n title: sheet.title,\n gridProperties: {\n rowCount: sheet.rowCount ?? 1000,\n columnCount: sheet.columnCount ?? 26,\n },\n },\n }));\n }\n\n return sheetsApiRequest(\"/spreadsheets\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n },\n\n async addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet> {\n const result = await sheetsApiRequest<{\n replies: Array<{ addSheet?: { properties: Sheet[\"properties\"] } }>;\n }>(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [\n {\n addSheet: {\n properties: {\n title,\n gridProperties: {\n rowCount: options?.rowCount ?? 1000,\n columnCount: options?.columnCount ?? 26,\n },\n },\n },\n },\n ],\n }),\n });\n\n const properties = result.replies[0]?.addSheet?.properties;\n if (!properties) throw new Error(\"Failed to add sheet\");\n\n return { properties };\n },\n\n async deleteSheet(spreadsheetId: string, sheetId: number): Promise<void> {\n await sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{ deleteSheet: { sheetId } }],\n }),\n });\n },\n };\n}\n\nexport type SheetsClient = ReturnType<typeof createSheetsClient>;\n",
525
+ "lib/sheets-client.ts": "/**\n * Google Sheets API Client\n *\n * Provides a type-safe interface to Google Sheets API operations.\n */\n\nimport { getValidToken } from \"./oauth.ts\";\n\nfunction getEnv(key: string): string | undefined {\n // @ts-ignore - Deno global\n if (typeof Deno !== \"undefined\") return Deno.env.get(key);\n // @ts-ignore - process global\n if (typeof process !== \"undefined\" && process.env) return process.env[key];\n return undefined;\n}\n\nconst SHEETS_API_BASE = \"https://sheets.googleapis.com/v4\";\nconst DRIVE_API_BASE = \"https://www.googleapis.com/drive/v3\";\n\nexport interface Spreadsheet {\n spreadsheetId: string;\n properties: {\n title: string;\n locale: string;\n autoRecalc: string;\n timeZone: string;\n };\n sheets: Sheet[];\n spreadsheetUrl: string;\n}\n\nexport interface Sheet {\n properties: {\n sheetId: number;\n title: string;\n index: number;\n sheetType: \"GRID\" | \"OBJECT\";\n gridProperties?: {\n rowCount: number;\n columnCount: number;\n };\n };\n}\n\nexport interface SpreadsheetFile {\n id: string;\n name: string;\n mimeType: string;\n createdTime: string;\n modifiedTime: string;\n webViewLink: string;\n}\n\nexport interface CellData {\n values: unknown[][];\n range: string;\n}\n\nexport interface CreateSpreadsheetOptions {\n title: string;\n sheets?: Array<{\n title: string;\n rowCount?: number;\n columnCount?: number;\n }>;\n}\n\nexport interface WriteRangeOptions {\n spreadsheetId: string;\n range: string;\n values: unknown[][];\n valueInputOption?: \"RAW\" | \"USER_ENTERED\";\n}\n\nexport interface AppendRangeOptions extends WriteRangeOptions {\n insertDataOption?: \"OVERWRITE\" | \"INSERT_ROWS\";\n}\n\nexport interface BatchUpdateOptions {\n spreadsheetId: string;\n requests: Array<Record<string, unknown>>;\n includeSpreadsheetInResponse?: boolean;\n responseRanges?: string[];\n}\n\nexport const sheetsOAuthProvider = {\n name: \"sheets\",\n authorizationUrl: \"https://accounts.google.com/o/oauth2/v2/auth\",\n tokenUrl: \"https://oauth2.googleapis.com/token\",\n clientId: getEnv(\"GOOGLE_CLIENT_ID\") ?? \"\",\n clientSecret: getEnv(\"GOOGLE_CLIENT_SECRET\") ?? \"\",\n scopes: [\n \"https://www.googleapis.com/auth/spreadsheets\",\n \"https://www.googleapis.com/auth/drive.readonly\",\n \"https://www.googleapis.com/auth/drive.file\",\n ],\n callbackPath: \"/api/auth/sheets/callback\",\n};\n\nexport function createSheetsClient(userId: string): {\n listSpreadsheets(options?: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n }): Promise<SpreadsheetFile[]>;\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet>;\n readRange(spreadsheetId: string, range: string): Promise<CellData>;\n readRanges(spreadsheetId: string, ranges: string[]): Promise<CellData[]>;\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }>;\n appendRange(options: AppendRangeOptions): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }>;\n clearRange(\n spreadsheetId: string,\n range: string,\n ): Promise<{ clearedRange: string }>;\n batchUpdate(options: BatchUpdateOptions): Promise<unknown>;\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet>;\n addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet>;\n deleteSheet(spreadsheetId: string, sheetId: number): Promise<void>;\n renameSheet(\n spreadsheetId: string,\n sheetId: number,\n title: string,\n ): Promise<unknown>;\n deleteSpreadsheet(\n spreadsheetId: string,\n options?: { permanentlyDelete?: boolean },\n ): Promise<\n { deleted: true; spreadsheetId: string; permanentlyDeleted: boolean }\n >;\n findReplace(options: {\n spreadsheetId: string;\n find: string;\n replacement: string;\n sheetId?: number;\n matchCase?: boolean;\n matchEntireCell?: boolean;\n searchByRegex?: boolean;\n }): Promise<unknown>;\n copySheet(options: {\n spreadsheetId: string;\n sheetId: number;\n destinationSpreadsheetId: string;\n }): Promise<unknown>;\n createChart(\n spreadsheetId: string,\n chart: Record<string, unknown>,\n ): Promise<unknown>;\n setDataValidation(options: {\n spreadsheetId: string;\n range: Record<string, unknown>;\n rule: Record<string, unknown>;\n }): Promise<unknown>;\n} {\n async function getAccessToken(): Promise<string> {\n const token = await getValidToken(sheetsOAuthProvider, userId, \"sheets\");\n if (token) return token;\n throw new Error(\n \"Google Sheets not connected. Please connect your Google account first.\",\n );\n }\n\n async function apiRequest<T>(\n baseUrl: string,\n serviceName: \"Sheets\" | \"Drive\",\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n const accessToken = await getAccessToken();\n\n const response = await fetch(`${baseUrl}${endpoint}`, {\n ...options,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n ...options.headers,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(\n `${serviceName} API error: ${response.status} - ${error}`,\n );\n }\n\n if (response.status === 204) return undefined as T;\n return response.json();\n }\n\n function sheetsApiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n return apiRequest<T>(SHEETS_API_BASE, \"Sheets\", endpoint, options);\n }\n\n function driveApiRequest<T>(\n endpoint: string,\n options: RequestInit = {},\n ): Promise<T> {\n return apiRequest<T>(DRIVE_API_BASE, \"Drive\", endpoint, options);\n }\n\n return {\n async listSpreadsheets(options: {\n maxResults?: number;\n orderBy?: \"createdTime\" | \"modifiedTime\" | \"name\";\n } = {}): Promise<SpreadsheetFile[]> {\n const params = new URLSearchParams({\n q: \"mimeType='application/vnd.google-apps.spreadsheet' and trashed=false\",\n fields: \"files(id,name,mimeType,createdTime,modifiedTime,webViewLink)\",\n pageSize: String(options.maxResults ?? 20),\n orderBy: `${options.orderBy ?? \"modifiedTime\"} desc`,\n });\n\n const result = await driveApiRequest<{ files?: SpreadsheetFile[] }>(\n `/files?${params.toString()}`,\n );\n return result.files ?? [];\n },\n\n getSpreadsheet(spreadsheetId: string): Promise<Spreadsheet> {\n return sheetsApiRequest<Spreadsheet>(`/spreadsheets/${spreadsheetId}`);\n },\n\n async readRange(spreadsheetId: string, range: string): Promise<CellData> {\n const result = await sheetsApiRequest<\n { values?: unknown[][]; range: string }\n >(\n `/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`,\n );\n\n return { values: result.values ?? [], range: result.range };\n },\n\n async readRanges(\n spreadsheetId: string,\n ranges: string[],\n ): Promise<CellData[]> {\n const params = new URLSearchParams();\n ranges.forEach((range) => params.append(\"ranges\", range));\n\n const result = await sheetsApiRequest<{\n valueRanges: Array<{ values?: unknown[][]; range: string }>;\n }>(`/spreadsheets/${spreadsheetId}/values:batchGet?${params.toString()}`);\n\n return result.valueRanges.map((vr) => ({\n values: vr.values ?? [],\n range: vr.range,\n }));\n },\n\n writeRange(options: WriteRangeOptions): Promise<{\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n }> {\n const valueInputOption = options.valueInputOption ?? \"USER_ENTERED\";\n\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/values/${\n encodeURIComponent(options.range)\n }?valueInputOption=${valueInputOption}`,\n {\n method: \"PUT\",\n body: JSON.stringify({ values: options.values }),\n },\n );\n },\n\n appendRange(options: AppendRangeOptions): Promise<{\n updates: {\n updatedRange: string;\n updatedRows: number;\n updatedColumns: number;\n updatedCells: number;\n };\n }> {\n const params = new URLSearchParams({\n valueInputOption: options.valueInputOption ?? \"USER_ENTERED\",\n insertDataOption: options.insertDataOption ?? \"INSERT_ROWS\",\n });\n\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/values/${\n encodeURIComponent(options.range)\n }:append?${params.toString()}`,\n {\n method: \"POST\",\n body: JSON.stringify({ values: options.values }),\n },\n );\n },\n\n clearRange(\n spreadsheetId: string,\n range: string,\n ): Promise<{ clearedRange: string }> {\n return sheetsApiRequest(\n `/spreadsheets/${spreadsheetId}/values/${\n encodeURIComponent(range)\n }:clear`,\n {\n method: \"POST\",\n },\n );\n },\n\n batchUpdate(options: BatchUpdateOptions): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}:batchUpdate`,\n {\n method: \"POST\",\n body: JSON.stringify({\n requests: options.requests,\n includeSpreadsheetInResponse: options.includeSpreadsheetInResponse,\n responseRanges: options.responseRanges,\n }),\n },\n );\n },\n\n createSpreadsheet(options: CreateSpreadsheetOptions): Promise<Spreadsheet> {\n const body: {\n properties: { title: string };\n sheets?: Array<{\n properties: {\n title: string;\n gridProperties?: { rowCount: number; columnCount: number };\n };\n }>;\n } = { properties: { title: options.title } };\n\n if (options.sheets?.length) {\n body.sheets = options.sheets.map((sheet) => ({\n properties: {\n title: sheet.title,\n gridProperties: {\n rowCount: sheet.rowCount ?? 1000,\n columnCount: sheet.columnCount ?? 26,\n },\n },\n }));\n }\n\n return sheetsApiRequest(\"/spreadsheets\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n },\n\n async addSheet(\n spreadsheetId: string,\n title: string,\n options?: { rowCount?: number; columnCount?: number },\n ): Promise<Sheet> {\n const result = await sheetsApiRequest<{\n replies: Array<{ addSheet?: { properties: Sheet[\"properties\"] } }>;\n }>(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [\n {\n addSheet: {\n properties: {\n title,\n gridProperties: {\n rowCount: options?.rowCount ?? 1000,\n columnCount: options?.columnCount ?? 26,\n },\n },\n },\n },\n ],\n }),\n });\n\n const properties = result.replies[0]?.addSheet?.properties;\n if (!properties) throw new Error(\"Failed to add sheet\");\n\n return { properties };\n },\n\n async deleteSheet(spreadsheetId: string, sheetId: number): Promise<void> {\n await sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{ deleteSheet: { sheetId } }],\n }),\n });\n },\n\n renameSheet(\n spreadsheetId: string,\n sheetId: number,\n title: string,\n ): Promise<unknown> {\n return sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{\n updateSheetProperties: {\n properties: { sheetId, title },\n fields: \"title\",\n },\n }],\n }),\n });\n },\n\n async deleteSpreadsheet(\n spreadsheetId: string,\n options: { permanentlyDelete?: boolean } = {},\n ): Promise<\n { deleted: true; spreadsheetId: string; permanentlyDeleted: boolean }\n > {\n if (options.permanentlyDelete) {\n await driveApiRequest(`/files/${spreadsheetId}`, { method: \"DELETE\" });\n } else {\n await driveApiRequest(`/files/${spreadsheetId}`, {\n method: \"PATCH\",\n body: JSON.stringify({ trashed: true }),\n });\n }\n\n return {\n deleted: true,\n spreadsheetId,\n permanentlyDeleted: Boolean(options.permanentlyDelete),\n };\n },\n\n findReplace(options: {\n spreadsheetId: string;\n find: string;\n replacement: string;\n sheetId?: number;\n matchCase?: boolean;\n matchEntireCell?: boolean;\n searchByRegex?: boolean;\n }): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}:batchUpdate`,\n {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{\n findReplace: {\n find: options.find,\n replacement: options.replacement,\n sheetId: options.sheetId,\n matchCase: options.matchCase,\n matchEntireCell: options.matchEntireCell,\n searchByRegex: options.searchByRegex,\n allSheets: options.sheetId === undefined ? true : undefined,\n },\n }],\n }),\n },\n );\n },\n\n copySheet(options: {\n spreadsheetId: string;\n sheetId: number;\n destinationSpreadsheetId: string;\n }): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}:copyTo`,\n {\n method: \"POST\",\n body: JSON.stringify({\n destinationSpreadsheetId: options.destinationSpreadsheetId,\n }),\n },\n );\n },\n\n createChart(\n spreadsheetId: string,\n chart: Record<string, unknown>,\n ): Promise<unknown> {\n return sheetsApiRequest(`/spreadsheets/${spreadsheetId}:batchUpdate`, {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{ addChart: { chart } }],\n }),\n });\n },\n\n setDataValidation(options: {\n spreadsheetId: string;\n range: Record<string, unknown>;\n rule: Record<string, unknown>;\n }): Promise<unknown> {\n return sheetsApiRequest(\n `/spreadsheets/${options.spreadsheetId}:batchUpdate`,\n {\n method: \"POST\",\n body: JSON.stringify({\n requests: [{\n repeatCell: {\n range: options.range,\n cell: { dataValidation: options.rule },\n fields: \"dataValidation\",\n },\n }],\n }),\n },\n );\n },\n };\n}\n\nexport type SheetsClient = ReturnType<typeof createSheetsClient>;\n",
526
+ "tools/add-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"add-sheet\",\n description: \"Add a new sheet/tab to an existing spreadsheet.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n title: v.string().describe(\"Title for the new sheet/tab\"),\n rowCount: v.number().min(1).max(10000).optional(),\n columnCount: v.number().min(1).max(18278).optional(),\n })\n )(),\n execute({ spreadsheetId, title, rowCount, columnCount }) {\n return createSheetsClient(DEFAULT_USER_ID).addSheet(spreadsheetId, title, {\n rowCount,\n columnCount,\n });\n },\n});\n",
527
+ "tools/append-rows.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"append-rows\",\n description:\n \"Append rows to a Google Sheets range. Use for trackers, logs, and adding records without overwriting existing rows.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v.string().describe(\n \"A1 notation range/table to append to (e.g., 'Sheet1!A:C')\",\n ),\n values: v.array(v.array(v.any())).describe(\"2D array of rows to append\"),\n valueInputOption: v.enum([\"RAW\", \"USER_ENTERED\"]).default(\"USER_ENTERED\"),\n insertDataOption: v.enum([\"OVERWRITE\", \"INSERT_ROWS\"]).default(\n \"INSERT_ROWS\",\n ),\n })\n )(),\n execute(\n { spreadsheetId, range, values, valueInputOption, insertDataOption },\n ) {\n return createSheetsClient(DEFAULT_USER_ID).appendRange({\n spreadsheetId,\n range,\n values,\n valueInputOption,\n insertDataOption,\n });\n },\n});\n",
528
+ "tools/batch-update.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"batch-update\",\n description:\n \"Run Google Sheets batchUpdate requests for formatting, filters, dimensions, protected ranges, charts, and other advanced spreadsheet changes.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n requests: v.array(v.record(v.string(), v.any())).describe(\n \"Google Sheets API batchUpdate requests\",\n ),\n includeSpreadsheetInResponse: v.boolean().optional(),\n responseRanges: v.array(v.string()).optional(),\n })\n )(),\n execute(\n { spreadsheetId, requests, includeSpreadsheetInResponse, responseRanges },\n ) {\n return createSheetsClient(DEFAULT_USER_ID).batchUpdate({\n spreadsheetId,\n requests,\n includeSpreadsheetInResponse,\n responseRanges,\n });\n },\n});\n",
529
+ "tools/clear-range.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"clear-range\",\n description:\n \"Clear cell values from a Google Sheets range while preserving formatting.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v.string().describe(\n \"A1 notation range to clear (e.g., 'Sheet1!A2:D100')\",\n ),\n })\n )(),\n execute({ spreadsheetId, range }) {\n return createSheetsClient(DEFAULT_USER_ID).clearRange(spreadsheetId, range);\n },\n});\n",
530
+ "tools/copy-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"copy-sheet\",\n description: \"Copy a sheet/tab to another spreadsheet.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"Source spreadsheet ID\"),\n sheetId: v.number().describe(\"Numeric source sheet ID\"),\n destinationSpreadsheetId: v.string().describe(\n \"Destination spreadsheet ID\",\n ),\n })\n )(),\n execute({ spreadsheetId, sheetId, destinationSpreadsheetId }) {\n return createSheetsClient(DEFAULT_USER_ID).copySheet({\n spreadsheetId,\n sheetId,\n destinationSpreadsheetId,\n });\n },\n});\n",
531
+ "tools/create-chart.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"create-chart\",\n description:\n \"Create an embedded chart. Provide a Google Sheets API EmbeddedChart spec without chartId.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n chart: v.record(v.string(), v.any()).describe(\n \"Google Sheets API EmbeddedChart spec\",\n ),\n })\n )(),\n execute({ spreadsheetId, chart }) {\n return createSheetsClient(DEFAULT_USER_ID).createChart(\n spreadsheetId,\n chart,\n );\n },\n});\n",
497
532
  "tools/create-spreadsheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"create-spreadsheet\",\n description:\n \"Create a new Google Sheets spreadsheet with optional sheet configurations. Returns the new spreadsheet ID and URL.\",\n inputSchema: defineSchema((v) => v.object({\n title: v.string().describe(\"Title of the new spreadsheet\"),\n sheets: v\n .array(\n v.object({\n title: v.string().describe(\"Name of the sheet/tab\"),\n rowCount: v\n .number()\n .min(1)\n .max(10000)\n .optional()\n .describe(\"Number of rows (default: 1000)\"),\n columnCount: v\n .number()\n .min(1)\n .max(26)\n .optional()\n .describe(\"Number of columns (default: 26)\"),\n }),\n )\n .optional()\n .describe(\n \"Optional array of sheet configurations. If not provided, a single default sheet is created.\",\n ),\n initialData: v\n .object({\n sheetTitle: v.string().describe(\"Name of the sheet to write data to\"),\n range: v\n .string()\n .describe(\"Range in A1 notation (e.g., 'A1', 'A1:D10')\"),\n values: v\n .array(v.array(v.any()))\n .describe(\n \"2D array of values to write. Example: [['Name', 'Age'], ['John', 30]]\",\n ),\n })\n .optional()\n .describe(\"Optional initial data to populate the spreadsheet\"),\n }))(),\n async execute({ title, sheets, initialData }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const spreadsheet = await client.createSpreadsheet({ title, sheets });\n\n if (!initialData) {\n return {\n id: spreadsheet.spreadsheetId,\n title: spreadsheet.properties.title,\n url: spreadsheet.spreadsheetUrl,\n sheets: spreadsheet.sheets.map(({ properties }) => ({\n id: properties.sheetId,\n title: properties.title,\n index: properties.index,\n })),\n };\n }\n\n await client.writeRange({\n spreadsheetId: spreadsheet.spreadsheetId,\n range: `${initialData.sheetTitle}!${initialData.range}`,\n values: initialData.values,\n valueInputOption: \"USER_ENTERED\",\n });\n\n return {\n id: spreadsheet.spreadsheetId,\n title: spreadsheet.properties.title,\n url: spreadsheet.spreadsheetUrl,\n sheets: spreadsheet.sheets.map(({ properties }) => ({\n id: properties.sheetId,\n title: properties.title,\n index: properties.index,\n })),\n };\n },\n});\n",
533
+ "tools/delete-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"delete-sheet\",\n description: \"Delete a sheet/tab from a spreadsheet by numeric sheet ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n sheetId: v.number().describe(\n \"Numeric sheet ID to delete, from get-spreadsheet\",\n ),\n })\n )(),\n execute({ spreadsheetId, sheetId }) {\n return createSheetsClient(DEFAULT_USER_ID).deleteSheet(\n spreadsheetId,\n sheetId,\n );\n },\n});\n",
534
+ "tools/delete-spreadsheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"delete-spreadsheet\",\n description:\n \"Delete an app-accessible spreadsheet file. Defaults to moving it to trash for safer cleanup.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The spreadsheet/Drive file ID\"),\n permanentlyDelete: v.boolean().default(false).describe(\n \"If true, permanently deletes instead of moving to trash\",\n ),\n })\n )(),\n execute({ spreadsheetId, permanentlyDelete }) {\n return createSheetsClient(DEFAULT_USER_ID).deleteSpreadsheet(\n spreadsheetId,\n { permanentlyDelete },\n );\n },\n});\n",
535
+ "tools/find-replace.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"find-replace\",\n description:\n \"Find and replace text in a spreadsheet, optionally limited to a single sheet ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n find: v.string().describe(\"Text or regex pattern to find\"),\n replacement: v.string().describe(\"Replacement text\"),\n sheetId: v.number().optional().describe(\n \"Optional numeric sheet ID to limit replacement\",\n ),\n matchCase: v.boolean().optional(),\n matchEntireCell: v.boolean().optional(),\n searchByRegex: v.boolean().optional(),\n })\n )(),\n execute(\n {\n spreadsheetId,\n find,\n replacement,\n sheetId,\n matchCase,\n matchEntireCell,\n searchByRegex,\n },\n ) {\n return createSheetsClient(DEFAULT_USER_ID).findReplace({\n spreadsheetId,\n find,\n replacement,\n sheetId,\n matchCase,\n matchEntireCell,\n searchByRegex,\n });\n },\n});\n",
498
536
  "tools/get-spreadsheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"get-spreadsheet\",\n description:\n \"Get metadata about a Google Sheets spreadsheet including all sheet names, properties, and structure. Use this to discover available sheets and their dimensions.\",\n inputSchema: defineSchema((v) => v.object({\n spreadsheetId: v\n .string()\n .describe(\"The ID of the spreadsheet (from URL or list-spreadsheets)\"),\n }))(),\n async execute({ spreadsheetId }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const spreadsheet = await client.getSpreadsheet(spreadsheetId);\n\n return {\n id: spreadsheet.spreadsheetId,\n title: spreadsheet.properties.title,\n url: spreadsheet.spreadsheetUrl,\n locale: spreadsheet.properties.locale,\n timeZone: spreadsheet.properties.timeZone,\n sheets: spreadsheet.sheets.map(({ properties }) => ({\n id: properties.sheetId,\n title: properties.title,\n index: properties.index,\n type: properties.sheetType,\n rowCount: properties.gridProperties?.rowCount,\n columnCount: properties.gridProperties?.columnCount,\n })),\n };\n },\n});\n",
499
537
  "tools/list-spreadsheets.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"list-spreadsheets\",\n description:\n \"List recent Google Sheets spreadsheets from Google Drive. Returns spreadsheet names, IDs, and metadata.\",\n inputSchema: defineSchema((v) => v.object({\n maxResults: v\n .number()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum number of spreadsheets to return\"),\n orderBy: v\n .enum([\"createdTime\", \"modifiedTime\", \"name\"])\n .default(\"modifiedTime\")\n .describe(\"Sort order for results\"),\n }))(),\n async execute({ maxResults, orderBy }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const spreadsheets = await client.listSpreadsheets({ maxResults, orderBy });\n\n return spreadsheets.map((spreadsheet) => ({\n id: spreadsheet.id,\n name: spreadsheet.name,\n url: spreadsheet.webViewLink,\n createdTime: spreadsheet.createdTime,\n modifiedTime: spreadsheet.modifiedTime,\n }));\n },\n});\n",
500
538
  "tools/read-range.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"read-range\",\n description:\n \"Read cell data from a Google Sheets range. Returns a 2D array of values. Use A1 notation (e.g., 'Sheet1!A1:D10', 'A1:B', or just 'Sheet1' for entire sheet).\",\n inputSchema: defineSchema((v) => v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v\n .string()\n .describe(\n \"Range in A1 notation (e.g., 'Sheet1!A1:D10', 'A1:B5', or 'Sheet1' for entire sheet)\",\n ),\n }))(),\n async execute({ spreadsheetId, range }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n const { range: resultRange, values } = await client.readRange(\n spreadsheetId,\n range,\n );\n\n return {\n range: resultRange,\n values,\n rowCount: values.length,\n columnCount: values[0]?.length ?? 0,\n };\n },\n});\n",
539
+ "tools/rename-sheet.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"rename-sheet\",\n description: \"Rename an existing sheet/tab by numeric sheet ID.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n sheetId: v.number().describe(\"Numeric sheet ID to rename\"),\n title: v.string().describe(\"New sheet/tab title\"),\n })\n )(),\n execute({ spreadsheetId, sheetId, title }) {\n return createSheetsClient(DEFAULT_USER_ID).renameSheet(\n spreadsheetId,\n sheetId,\n title,\n );\n },\n});\n",
540
+ "tools/set-data-validation.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"set-data-validation\",\n description: \"Set a Google Sheets data validation rule on a grid range.\",\n inputSchema: defineSchema((v) =>\n v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v.record(v.string(), v.any()).describe(\n \"Google Sheets API GridRange\",\n ),\n rule: v.record(v.string(), v.any()).describe(\n \"Google Sheets API DataValidationRule\",\n ),\n })\n )(),\n execute({ spreadsheetId, range, rule }) {\n return createSheetsClient(DEFAULT_USER_ID).setDataValidation({\n spreadsheetId,\n range,\n rule,\n });\n },\n});\n",
501
541
  "tools/write-range.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { createSheetsClient } from \"../../lib/sheets-client.ts\";\n\nconst DEFAULT_USER_ID = \"demo-user\";\n\nexport default tool({\n id: \"write-range\",\n description:\n \"Write data to a Google Sheets range. Overwrites existing content in the specified range. Provide data as a 2D array where each inner array is a row.\",\n inputSchema: defineSchema((v) => v.object({\n spreadsheetId: v.string().describe(\"The ID of the spreadsheet\"),\n range: v\n .string()\n .describe(\n \"Range in A1 notation where to write data (e.g., 'Sheet1!A1', 'Sheet1!A1:D5')\",\n ),\n values: v\n .array(v.array(v.any()))\n .describe(\n \"2D array of values to write. Each inner array represents a row. Example: [['Name', 'Age'], ['John', 30], ['Jane', 25]]\",\n ),\n valueInputOption: v\n .enum([\"RAW\", \"USER_ENTERED\"])\n .default(\"USER_ENTERED\")\n .describe(\n \"RAW: Values are stored as-is. USER_ENTERED: Values are parsed as if typed by user (formulas, numbers, dates)\",\n ),\n }))(),\n async execute({ spreadsheetId, range, values, valueInputOption }) {\n const client = createSheetsClient(DEFAULT_USER_ID);\n return client.writeRange({ spreadsheetId, range, values, valueInputOption });\n },\n});\n"
502
542
  }
503
543
  },