From 67fa4a2ccccf204addd19abfb7f4c978d7e693f7 Mon Sep 17 00:00:00 2001 From: mawkone Date: Tue, 19 May 2026 14:06:12 -0700 Subject: [PATCH] feat(runner): migrate vibn-agent-runner to use frontend MCP proxy tools and updated headless prompt --- patch_runner_context.js | 19 + patch_runner_server.js | 69 + patch_sessions_route.js | 55 + vibn-agent-runner/src/prompts/coder.ts | 96 +- vibn-agent-runner/src/tools/agent-api.ts | 46 - vibn-agent-runner/src/tools/agent.ts | 45 - vibn-agent-runner/src/tools/context.ts | 3 + vibn-agent-runner/src/tools/coolify-api.ts | 128 -- vibn-agent-runner/src/tools/coolify.ts | 104 - vibn-agent-runner/src/tools/file-api.ts | 110 - vibn-agent-runner/src/tools/file.ts | 111 - vibn-agent-runner/src/tools/git-api.ts | 102 - vibn-agent-runner/src/tools/git.ts | 25 - vibn-agent-runner/src/tools/gitea-api.ts | 149 -- vibn-agent-runner/src/tools/gitea.ts | 109 - vibn-agent-runner/src/tools/index.ts | 20 +- vibn-agent-runner/src/tools/mcp-client.ts | 38 + vibn-agent-runner/src/tools/memory-api.ts | 45 - vibn-agent-runner/src/tools/memory.ts | 35 - vibn-agent-runner/src/tools/prd-api.ts | 20 - vibn-agent-runner/src/tools/prd.ts | 28 - vibn-agent-runner/src/tools/registry.ts | 37 +- vibn-agent-runner/src/tools/search-api.ts | 55 - vibn-agent-runner/src/tools/search.ts | 24 - vibn-agent-runner/src/tools/security.ts | 47 - vibn-agent-runner/src/tools/shell-api.ts | 35 - vibn-agent-runner/src/tools/shell.ts | 26 - vibn-agent-runner/src/tools/skills-api.ts | 48 - vibn-agent-runner/src/tools/skills.ts | 44 - vibn-agent-runner/src/tools/utils.ts | 13 - vibn-agent-runner/src/tools/vibn-tools.ts | 1975 +++++++++++++++++ .../[projectId]/agent/sessions/route.ts | 32 + 32 files changed, 2275 insertions(+), 1418 deletions(-) create mode 100644 patch_runner_context.js create mode 100644 patch_runner_server.js create mode 100644 patch_sessions_route.js delete mode 100644 vibn-agent-runner/src/tools/agent-api.ts delete mode 100644 vibn-agent-runner/src/tools/agent.ts delete mode 100644 vibn-agent-runner/src/tools/coolify-api.ts delete mode 100644 vibn-agent-runner/src/tools/coolify.ts delete mode 100644 vibn-agent-runner/src/tools/file-api.ts delete mode 100644 vibn-agent-runner/src/tools/file.ts delete mode 100644 vibn-agent-runner/src/tools/git-api.ts delete mode 100644 vibn-agent-runner/src/tools/git.ts delete mode 100644 vibn-agent-runner/src/tools/gitea-api.ts delete mode 100644 vibn-agent-runner/src/tools/gitea.ts create mode 100644 vibn-agent-runner/src/tools/mcp-client.ts delete mode 100644 vibn-agent-runner/src/tools/memory-api.ts delete mode 100644 vibn-agent-runner/src/tools/memory.ts delete mode 100644 vibn-agent-runner/src/tools/prd-api.ts delete mode 100644 vibn-agent-runner/src/tools/prd.ts delete mode 100644 vibn-agent-runner/src/tools/search-api.ts delete mode 100644 vibn-agent-runner/src/tools/search.ts delete mode 100644 vibn-agent-runner/src/tools/security.ts delete mode 100644 vibn-agent-runner/src/tools/shell-api.ts delete mode 100644 vibn-agent-runner/src/tools/shell.ts delete mode 100644 vibn-agent-runner/src/tools/skills-api.ts delete mode 100644 vibn-agent-runner/src/tools/skills.ts delete mode 100644 vibn-agent-runner/src/tools/utils.ts create mode 100644 vibn-agent-runner/src/tools/vibn-tools.ts diff --git a/patch_runner_context.js b/patch_runner_context.js new file mode 100644 index 0000000..cbe7a52 --- /dev/null +++ b/patch_runner_context.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const file = 'vibn-agent-runner/src/tools/context.ts'; +let code = fs.readFileSync(file, 'utf8'); + +const newProps = ` coolify: { + apiUrl: string; + apiToken: string; + }; + mcpToken: string; + vibnApiUrl: string; + projectId?: string;`; + +code = code.replace(` coolify: { + apiUrl: string; + apiToken: string; + };`, newProps); + +fs.writeFileSync(file, code); +console.log("Patched context.ts"); diff --git a/patch_runner_server.js b/patch_runner_server.js new file mode 100644 index 0000000..ee89f24 --- /dev/null +++ b/patch_runner_server.js @@ -0,0 +1,69 @@ +const fs = require('fs'); +const file = 'vibn-agent-runner/src/server.ts'; +let code = fs.readFileSync(file, 'utf8'); + +// Update the type signature for the request body +const oldSig = ` } = req.body as { + sessionId?: string; + projectId?: string; + appName?: string; + appPath?: string; + giteaRepo?: string; + task?: string; + continueTask?: boolean; + autoApprove?: boolean; + coolifyAppUuid?: string; + };`; + +const newSig = ` mcpToken, vibnApiUrl + } = req.body as { + sessionId?: string; + projectId?: string; + appName?: string; + appPath?: string; + giteaRepo?: string; + task?: string; + continueTask?: boolean; + autoApprove?: boolean; + coolifyAppUuid?: string; + mcpToken?: string; + vibnApiUrl?: string; + };`; + +code = code.replace(oldSig, newSig); + +const oldCtx = ` const ctx: ToolContext = { + workspaceRoot: repoRoot, + gitea: { + apiUrl: GITEA_API_URL, + apiToken: GITEA_API_TOKEN, + username: GITEA_USERNAME, + }, + coolify: { + apiUrl: process.env.COOLIFY_API_URL || '', + apiToken: process.env.COOLIFY_API_TOKEN || '', + }, + memoryUpdates: [], + };`; + +const newCtx = ` const ctx: ToolContext = { + workspaceRoot: repoRoot, + gitea: { + apiUrl: GITEA_API_URL, + apiToken: GITEA_API_TOKEN, + username: GITEA_USERNAME, + }, + coolify: { + apiUrl: process.env.COOLIFY_API_URL || '', + apiToken: process.env.COOLIFY_API_TOKEN || '', + }, + mcpToken: mcpToken || '', + vibnApiUrl: vibnApiUrl || 'http://localhost:3000', + projectId, + memoryUpdates: [], + };`; + +code = code.replace(oldCtx, newCtx); + +fs.writeFileSync(file, code); +console.log("Patched Runner server.ts"); diff --git a/patch_sessions_route.js b/patch_sessions_route.js new file mode 100644 index 0000000..9e20fb6 --- /dev/null +++ b/patch_sessions_route.js @@ -0,0 +1,55 @@ +const fs = require('fs'); + +const file = 'vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts'; +let code = fs.readFileSync(file, 'utf8'); + +// Inject the workspace API key fetching logic +if (!code.includes('listWorkspaceApiKeys')) { + code = code.replace( + 'import { query } from "@/lib/db-postgres";', + 'import { query } from "@/lib/db-postgres";\nimport { listWorkspaceApiKeys, mintWorkspaceApiKey, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth";' + ); + + const injectCode = ` + const wsResult = await query<{ workspace_id: string }>( + \`SELECT vibn_workspace_id as workspace_id FROM fs_projects WHERE id = $1 LIMIT 1\`, + [projectId] + ); + if (!wsResult.length) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + const workspaceId = wsResult[0].workspace_id; + + // Grab or mint a default API key for the runner to use + let mcpToken = ""; + const keys = await listWorkspaceApiKeys(workspaceId); + let defaultKey = keys.find((k: any) => k.name === 'default' && !k.revoked_at); + if (!defaultKey) { + const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] }); + mcpToken = minted.token; + } else { + const revealed = await revealWorkspaceApiKey(workspaceId, defaultKey.id); + if (revealed) mcpToken = revealed.token; + else { + const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] }); + mcpToken = minted.token; + } + } + + // Add VIBN_API_URL so the runner knows where to send MCP requests + const vibnApiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; +`; + + code = code.replace( + 'const sessionId = rows[0].id;', + 'const sessionId = rows[0].id;\n' + injectCode + ); + + code = code.replace( + 'coolifyAppUuid,\n }),', + 'coolifyAppUuid,\n mcpToken,\n vibnApiUrl\n }),' + ); + + fs.writeFileSync(file, code); + console.log("Patched session route to forward MCP token"); +} diff --git a/vibn-agent-runner/src/prompts/coder.ts b/vibn-agent-runner/src/prompts/coder.ts index cb668e9..6eefab3 100644 --- a/vibn-agent-runner/src/prompts/coder.ts +++ b/vibn-agent-runner/src/prompts/coder.ts @@ -1,30 +1,86 @@ import { registerPrompt } from './loader'; +// Because we deleted the local tools and adopted the full VIBN_TOOL_DEFINITIONS schema, +// the runner agent now has the exact same capabilities as the frontend UI agent! +// It uses fs_*, shell_exec, dev_server_*, apps_*, and ship. + registerPrompt('coder', ` -You are an expert senior software engineer working autonomously on a Git repository. +You are Vibn AI — the technical co-founder of every Vibn user. You are currently running headlessly in the background. The user is offline or waiting for you to finish. -## Workflow -1. Explore the codebase: list_directory, find_files, read_file. -2. Search for patterns: search_code. -3. Plan your changes before making them. -4. Read every file BEFORE editing it. -5. Make changes: write_file for new files, replace_in_file for targeted edits. -6. Run tests/lint if applicable: execute_command. -7. Commit and push when complete: git_commit_and_push. +Your job is to read the task assigned to you, implement it, test it, and ship it to Coolify. +Do NOT ask the user questions. If you get stuck, log the error and stop. -## Code quality -- Match existing style exactly. -- No TODO comments — implement or skip. -- Write complete files, not partial snippets. -- Run tests and fix failures before committing. -- Commit messages: imperative mood, concise (e.g. "add user authentication"). +# Mode: Action -## Safety -- Never delete files unless explicitly told to. -- Never touch .env files or credentials. -- Never commit secrets or API keys. +Since you are running autonomously, you must take action immediately. -If triggered by a Gitea issue: close it with gitea_close_issue after committing. +# What "done" looks like + +A turn ends when you have fully completed the task AND shipped the code. +- **For build/edit tasks:** The natural stopping point is starting the dev server via \`dev_server_start\`, verifying it works via \`browser_console\`, and calling the \`ship\` tool to deploy to production. +- If you run into a fatal error that you cannot fix after two attempts, write a brief summary of the blocker and stop. + +# Hard rules — non-negotiable + +**Honesty about tool results:** +- **Cite the tool result, don't claim from memory.** +- **Trust the \`ok\` field.** Every tool result carries \`ok: true | false\`. If \`ok\` is false (or \`exitCode\` is non-zero, or \`healthCheck.status\` is >= 400), the operation FAILED. +- **\`fs_write\` and \`fs_edit\` results carry \`sha256\` and \`bytes\` on success.** +- **\`dev_server_start\` results carry \`healthCheck\` on success.** Before saying "the preview is ready," confirm \`healthCheck.status === 200\`. + +**Anchoring and scope:** +- **Anchor on current state before troubleshooting.** +- **Always pass \`projectId\`** to \`apps_create\` / \`databases_create\`. +- **Always \`apps_list { projectId }\` BEFORE \`apps_create\`** for a sanity check, and **always \`apps_templates_search\` BEFORE \`apps_create\`** for known third-party apps. +- **Trust idempotency.** When \`apps_create\` / \`databases_create\` returns \`alreadyExisted: true\`, your job is done — use the returned uuid and move on. +- **Never delete-and-recreate to escape an error.** "Container name already in use" → \`apps_unstick { uuid }\` → \`apps_deploy { uuid }\`. + +**Stopping conditions:** +- **If a deploy or tool call fails twice with the same error, STOP.** +- **If you've called the same tool with similar args 3 times this turn, STOP.** You're in a loop. +- **Long-running ops** (deploys, DNS, DB provisioning) take 1–5 min. + +# Tool reference (look up as needed) + +## How Vibn is structured +- **Project** — an initiative with its own isolated Coolify project. Has live state (apps + services from \`apps_list { projectId }\`). + +## Writing code in the dev container +Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` and run commands via \`shell_exec\`. Sub-second feedback vs. ~5 min Gitea-push-to-prod. + +**Start a coding session:** \`devcontainer_ensure { projectId }\` (idempotent; first call ~10s, then instant). + +**Orient yourself once.** On the first code-modifying turn of a chat, call \`fs_tree\` once to learn the repo layout. Don't re-run it on every turn — the layout doesn't change between user messages. + +**Iterate:** +- \`shell_exec { projectId, command }\` — anything: \`ls\`, \`npm install\`, \`npm test\`, \`git status\`. Cwd defaults to \`/workspace\`. +- \`fs_read\` / \`fs_write\` / \`fs_edit { path, oldString, newString, startLine, endLine }\`. +- \`fs_glob\` / \`fs_grep\` (ripgrep, respects .gitignore) / \`fs_list\` / \`fs_delete\`. + +**Path convention for fs_* tools:** Pass paths relative to the project root — \`src/app/page.tsx\`, NOT \`/workspace/slug/src/app/page.tsx\` and NOT \`slug/src/app/page.tsx\`. + +**Dev servers** (preview URL via \`*.preview.vibnai.com\` wildcard): +- \`dev_server_start { projectId, command, port: 3000 }\` is a **one-shot** call. It kills old processes on the port, checks the port is free, sets HOST=0.0.0.0 + PORT, launches your command, and returns a \`previewUrl\` plus a \`healthCheck\` block. +- **Port \`3000\` is reserved for the primary user-facing UI.** +- \`dev_server_stop\` / \`dev_server_list\` / \`dev_server_logs\` — use only AFTER a failed start, to diagnose the error the function returned. Never on success. + +**Verify the page actually renders:** +- After \`dev_server_start\` returns a \`previewUrl\` AND \`healthCheck.status === 200\`, call \`browser_console { url: previewUrl }\` to capture frontend console errors. +- If \`browser_console\` returns errors, fix them with \`fs_edit\` before declaring done. A green \`healthCheck\` plus a clean console is the real "done" signal for UI work. + +**Visual QA:** \`request_visual_qa { targetPath }\` critiques a UI file against a 5-dim design rubric. **Call this whenever you modify visual UI code** before returning the \`previewUrl\`. + +**Sentry is auto-provisioned per project.** \`NEXT_PUBLIC_SENTRY_DSN\` and \`SENTRY_AUTH_TOKEN\` are injected into the Coolify app env automatically by \`apps_create\`. + +## Gitea (one-time setup only) +For editing files in existing repos, ALWAYS use \`fs_*\` in the dev container — \`ship\` commits and pushes. + +## Troubleshooting +- **"exited (1)" / deploy stuck:** \`apps_logs { uuid }\` + \`apps_containers_ps { uuid }\`. +- **502 / "no available server":** \`apps_get\`; if \`fqdn\` is empty, attach a domain. +- **"tenant" / "does not belong to":** uuid not in this workspace. Re-list with \`apps_list\`. +- **Compose stack weird:** \`apps_repair { uuid }\` re-applies Traefik labels + port forwarding. +- **Nuke and redeploy:** \`apps_delete { uuid, confirm }\` {{skills}} `.trim()); diff --git a/vibn-agent-runner/src/tools/agent-api.ts b/vibn-agent-runner/src/tools/agent-api.ts deleted file mode 100644 index a21db9d..0000000 --- a/vibn-agent-runner/src/tools/agent-api.ts +++ /dev/null @@ -1,46 +0,0 @@ -// ============================================================================= -// Pure sub-agent orchestration API. Wraps the vibn-agent-runner HTTP endpoints -// so the same logic is usable from the in-process tool and from an MCP server. -// ============================================================================= - -export interface AgentRunnerConfig { - runnerUrl: string; -} - -export interface SpawnAgentInput { - agent: string; // "Coder" | "PM" | "Marketing" - task: string; - repo: string; // "owner/name" -} - -export async function spawnAgent(cfg: AgentRunnerConfig, input: SpawnAgentInput): Promise { - try { - const res = await fetch(`${cfg.runnerUrl}/api/agent/run`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Internal': 'true' }, - body: JSON.stringify({ agent: input.agent, task: input.task, repo: input.repo }), - }); - const data = (await res.json()) as any; - return { jobId: data.jobId, agent: input.agent, status: 'dispatched' }; - } catch (err) { - return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` }; - } -} - -export async function getJobStatus(cfg: AgentRunnerConfig, jobId: string): Promise { - try { - const res = await fetch(`${cfg.runnerUrl}/api/jobs/${jobId}`); - const job = (await res.json()) as any; - return { - id: job.id, - agent: job.agent, - status: job.status, - progress: job.progress, - toolCalls: job.toolCalls?.length, - result: job.result, - error: job.error, - }; - } catch (err) { - return { error: `Failed to get job: ${err instanceof Error ? err.message : String(err)}` }; - } -} diff --git a/vibn-agent-runner/src/tools/agent.ts b/vibn-agent-runner/src/tools/agent.ts deleted file mode 100644 index f1b740c..0000000 --- a/vibn-agent-runner/src/tools/agent.ts +++ /dev/null @@ -1,45 +0,0 @@ -// ============================================================================= -// Sub-agent orchestration tool registrations. Logic lives in ./agent-api.ts. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './agent-api'; - -function runnerUrl(): string { - return process.env.AGENT_RUNNER_URL || 'http://localhost:3333'; -} - -registerTool({ - name: 'spawn_agent', - description: 'Dispatch a sub-agent job to run in the background. Returns a job ID. Use this to delegate specialized work to Coder, PM, or Marketing agents.', - parameters: { - type: 'object', - properties: { - agent: { type: 'string', description: '"Coder", "PM", or "Marketing"' }, - task: { type: 'string', description: 'Detailed task description for the agent' }, - repo: { type: 'string', description: 'Gitea repo in "owner/name" format the agent should work on' } - }, - required: ['agent', 'task', 'repo'] - }, - async handler(args, _ctx) { - return api.spawnAgent( - { runnerUrl: runnerUrl() }, - { agent: String(args.agent), task: String(args.task), repo: String(args.repo) }, - ); - } -}); - -registerTool({ - name: 'get_job_status', - description: 'Check the status of a previously spawned agent job by job ID.', - parameters: { - type: 'object', - properties: { - job_id: { type: 'string', description: 'Job ID returned by spawn_agent' } - }, - required: ['job_id'] - }, - async handler(args, _ctx) { - return api.getJobStatus({ runnerUrl: runnerUrl() }, String(args.job_id)); - } -}); diff --git a/vibn-agent-runner/src/tools/context.ts b/vibn-agent-runner/src/tools/context.ts index 031f964..0ed927f 100644 --- a/vibn-agent-runner/src/tools/context.ts +++ b/vibn-agent-runner/src/tools/context.ts @@ -15,6 +15,9 @@ export interface ToolContext { apiUrl: string; apiToken: string; }; + mcpToken: string; + vibnApiUrl: string; + projectId?: string; /** Accumulated memory updates from save_memory tool calls in this turn */ memoryUpdates: MemoryUpdate[]; } diff --git a/vibn-agent-runner/src/tools/coolify-api.ts b/vibn-agent-runner/src/tools/coolify-api.ts deleted file mode 100644 index c595a45..0000000 --- a/vibn-agent-runner/src/tools/coolify-api.ts +++ /dev/null @@ -1,128 +0,0 @@ -// ============================================================================= -// Pure Coolify API — no ToolContext coupling, no registry coupling. -// -// Everything in here takes a plain { apiUrl, apiToken } config and calls -// the Coolify v1 API directly. Security guardrails (PROTECTED_COOLIFY_PROJECT, -// PROTECTED_COOLIFY_APPS, assertCoolifyDeployable) are enforced inside each -// function so every caller — in-process tool handler, MCP server, or future -// direct SDK user — gets the same protection. -// -// This is the shared core consumed by: -// - tools/coolify.ts (in-process registry used by agent-runner loop) -// - mcp/coolify-server.ts (stdio MCP server exposed to Goose/Claude/Cursor) -// ============================================================================= - -import { PROTECTED_COOLIFY_PROJECT, PROTECTED_COOLIFY_APPS, assertCoolifyDeployable } from './security'; - -export interface CoolifyConfig { - apiUrl: string; - apiToken: string; -} - -type CoolifyError = { error: string }; - -async function coolifyFetch( - cfg: CoolifyConfig, - path: string, - method: string = 'GET', - body?: unknown -): Promise { - const res = await fetch(`${cfg.apiUrl}/api/v1${path}`, { - method, - headers: { - 'Authorization': `Bearer ${cfg.apiToken}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: body ? JSON.stringify(body) : undefined - }); - if (!res.ok) { - return { error: `Coolify API error: ${res.status} ${res.statusText}` } satisfies CoolifyError; - } - return res.json(); -} - -// --------------------------------------------------------------------------- -// Public API — each function corresponds 1:1 with a registered tool today -// --------------------------------------------------------------------------- - -export async function listProjects(cfg: CoolifyConfig): Promise { - const projects = await coolifyFetch(cfg, '/projects') as any[]; - if (!Array.isArray(projects)) return projects; - return projects.filter((p: any) => p.uuid !== PROTECTED_COOLIFY_PROJECT); -} - -export async function listApplications(cfg: CoolifyConfig, projectUuid: string): Promise { - const all = await coolifyFetch(cfg, '/applications') as any[]; - if (!Array.isArray(all)) return all; - return all.filter((a: any) => a.project_uuid === projectUuid); -} - -export async function deploy(cfg: CoolifyConfig, applicationUuid: string): Promise { - assertCoolifyDeployable(applicationUuid); - const apps = await coolifyFetch(cfg, '/applications') as any[]; - if (Array.isArray(apps)) { - const app = apps.find((a: any) => a.uuid === applicationUuid); - if (app?.project_uuid === PROTECTED_COOLIFY_PROJECT) { - return { - error: `SECURITY: App "${applicationUuid}" belongs to the protected Vibn project. Agents cannot deploy platform apps.` - } satisfies CoolifyError; - } - } - return coolifyFetch(cfg, `/applications/${applicationUuid}/deploy`, 'POST'); -} - -export async function getLogs(cfg: CoolifyConfig, applicationUuid: string, limit: number = 50): Promise { - return coolifyFetch(cfg, `/applications/${applicationUuid}/logs?limit=${limit}`); -} - -export async function listAllApps(cfg: CoolifyConfig): Promise { - const apps = await coolifyFetch(cfg, '/applications') as any[]; - if (!Array.isArray(apps)) return apps; - return apps - .filter((a: any) => a.project_uuid !== PROTECTED_COOLIFY_PROJECT && !PROTECTED_COOLIFY_APPS.has(a.uuid)) - .map((a: any) => ({ - uuid: a.uuid, - name: a.name, - fqdn: a.fqdn, - status: a.status, - repo: a.git_repository, - branch: a.git_branch - })); -} - -export async function getAppStatus(cfg: CoolifyConfig, appName: string): Promise { - const apps = await coolifyFetch(cfg, '/applications') as any[]; - if (!Array.isArray(apps)) return apps; - const app = apps.find((a: any) => - a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName - ); - if (!app) return { error: `App "${appName}" not found` } satisfies CoolifyError; - if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) { - return { - error: `SECURITY: "${appName}" is a protected Vibn platform app. Status is not exposed to agents.` - } satisfies CoolifyError; - } - const logs = await coolifyFetch(cfg, `/applications/${app.uuid}/logs?limit=20`); - return { name: app.name, uuid: app.uuid, status: app.status, fqdn: app.fqdn, logs }; -} - -export async function deployApp(cfg: CoolifyConfig, appName: string): Promise { - const apps = await coolifyFetch(cfg, '/applications') as any[]; - if (!Array.isArray(apps)) return apps; - const app = apps.find((a: any) => - a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName - ); - if (!app) return { error: `App "${appName}" not found` } satisfies CoolifyError; - if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) { - return { - error: `SECURITY: "${appName}" is a protected Vibn platform application. ` + - `Agents can only deploy user project apps, not platform infrastructure.` - } satisfies CoolifyError; - } - // Non-project-prefixed deploy endpoint — older Coolify entry point still in use - const result = await fetch(`${cfg.apiUrl}/api/v1/deploy?uuid=${app.uuid}&force=false`, { - headers: { 'Authorization': `Bearer ${cfg.apiToken}` } - }); - return result.json(); -} diff --git a/vibn-agent-runner/src/tools/coolify.ts b/vibn-agent-runner/src/tools/coolify.ts deleted file mode 100644 index a830ca3..0000000 --- a/vibn-agent-runner/src/tools/coolify.ts +++ /dev/null @@ -1,104 +0,0 @@ -// ============================================================================= -// Coolify tool registrations (in-process path used by agent-runner). -// -// All logic lives in ./coolify-api.ts so the MCP server (src/mcp/coolify-server.ts) -// and this in-process registry call the exact same code path. Keep this file -// purely about: (a) surface-shape for the LLM (name/description/parameters), -// (b) mapping ctx.coolify → CoolifyConfig. No business logic here. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './coolify-api'; - -registerTool({ - name: 'coolify_list_projects', - description: 'List all projects in the Coolify instance. Returns project names and UUIDs.', - parameters: { type: 'object', properties: {} }, - async handler(_args, ctx) { - return api.listProjects(ctx.coolify); - } -}); - -registerTool({ - name: 'coolify_list_applications', - description: 'List applications in a Coolify project.', - parameters: { - type: 'object', - properties: { - project_uuid: { type: 'string', description: 'Project UUID from coolify_list_projects' } - }, - required: ['project_uuid'] - }, - async handler(args, ctx) { - return api.listApplications(ctx.coolify, String(args.project_uuid)); - } -}); - -registerTool({ - name: 'coolify_deploy', - description: 'Trigger a deployment for a Coolify application.', - parameters: { - type: 'object', - properties: { - application_uuid: { type: 'string', description: 'Application UUID to deploy' } - }, - required: ['application_uuid'] - }, - async handler(args, ctx) { - return api.deploy(ctx.coolify, String(args.application_uuid)); - } -}); - -registerTool({ - name: 'coolify_get_logs', - description: 'Get recent deployment logs for a Coolify application.', - parameters: { - type: 'object', - properties: { - application_uuid: { type: 'string', description: 'Application UUID' } - }, - required: ['application_uuid'] - }, - async handler(args, ctx) { - return api.getLogs(ctx.coolify, String(args.application_uuid)); - } -}); - -registerTool({ - name: 'list_all_apps', - description: 'List all Coolify applications across all projects with their status (running/stopped/error) and domain.', - parameters: { type: 'object', properties: {} }, - async handler(_args, ctx) { - return api.listAllApps(ctx.coolify); - } -}); - -registerTool({ - name: 'get_app_status', - description: 'Get the current deployment status and recent logs for a specific Coolify application by name or UUID.', - parameters: { - type: 'object', - properties: { - app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend") or UUID' } - }, - required: ['app_name'] - }, - async handler(args, ctx) { - return api.getAppStatus(ctx.coolify, String(args.app_name)); - } -}); - -registerTool({ - name: 'deploy_app', - description: 'Trigger a Coolify deployment for an app by name. Use after an agent commits code.', - parameters: { - type: 'object', - properties: { - app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend")' } - }, - required: ['app_name'] - }, - async handler(args, ctx) { - return api.deployApp(ctx.coolify, String(args.app_name)); - } -}); diff --git a/vibn-agent-runner/src/tools/file-api.ts b/vibn-agent-runner/src/tools/file-api.ts deleted file mode 100644 index 2acda70..0000000 --- a/vibn-agent-runner/src/tools/file-api.ts +++ /dev/null @@ -1,110 +0,0 @@ -// ============================================================================= -// Pure file-system API — no ToolContext coupling. -// Takes a workspaceRoot string and safely-resolves paths beneath it. -// ============================================================================= - -import * as fs from 'fs'; -import * as path from 'path'; -import * as cp from 'child_process'; -import * as util from 'util'; -import { Minimatch } from 'minimatch'; -import { safeResolve, EXCLUDED } from './utils'; - -const execAsync = util.promisify(cp.exec); - -export async function readFile(workspaceRoot: string, relPath: string): Promise { - const abs = safeResolve(workspaceRoot, relPath); - try { - return fs.readFileSync(abs, 'utf8'); - } catch { - return { error: `File not found: ${relPath}` }; - } -} - -export async function writeFile(workspaceRoot: string, relPath: string, content: string): Promise { - const abs = safeResolve(workspaceRoot, relPath); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, content, 'utf8'); - return { success: true, path: relPath, bytes: Buffer.byteLength(content) }; -} - -export async function replaceInFile( - workspaceRoot: string, - relPath: string, - oldContent: string, - newContent: string -): Promise { - const abs = safeResolve(workspaceRoot, relPath); - const current = fs.readFileSync(abs, 'utf8'); - if (!current.includes(oldContent)) { - return { error: 'old_content not found in file. Read the file again to get the current content.' }; - } - fs.writeFileSync(abs, current.replace(oldContent, newContent), 'utf8'); - return { success: true, path: relPath }; -} - -export async function listDirectory(workspaceRoot: string, relPath: string): Promise { - const abs = safeResolve(workspaceRoot, relPath); - try { - const entries = fs.readdirSync(abs, { withFileTypes: true }); - return entries - .filter(e => !EXCLUDED.has(e.name)) - .map(e => e.isDirectory() ? `${e.name}/` : e.name); - } catch { - return { error: `Directory not found: ${relPath}` }; - } -} - -export async function findFiles(workspaceRoot: string, pattern: string): Promise { - const matcher = new Minimatch(pattern, { dot: false }); - const results: string[] = []; - - function walk(dir: string): void { - if (results.length >= 200) return; - let entries: fs.Dirent[]; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } - for (const e of entries) { - if (EXCLUDED.has(e.name)) continue; - const abs = path.join(dir, e.name); - const rel = path.relative(workspaceRoot, abs).split(path.sep).join('/'); - if (e.isDirectory()) { - walk(abs); - } else if (matcher.match(rel)) { - results.push(rel); - } - } - } - - walk(workspaceRoot); - return { files: results, truncated: results.length >= 200 }; -} - -export async function searchCode( - workspaceRoot: string, - query: string, - fileExtensions?: string[] -): Promise { - const globPatterns = fileExtensions?.map(e => `*.${e}`) || []; - const rgArgs = ['--line-number', '--no-heading', '--color=never', '--max-count=30']; - for (const ex of EXCLUDED) { rgArgs.push('--glob', `!${ex}`); } - if (globPatterns.length > 0) { for (const g of globPatterns) rgArgs.push('--glob', g); } - rgArgs.push('--fixed-strings', query, workspaceRoot); - - try { - const { stdout } = await execAsync(`rg ${rgArgs.map(a => `'${a}'`).join(' ')}`, { - cwd: workspaceRoot, timeout: 15000, - }); - return stdout.trim().split('\n').filter(Boolean).map(line => { - const m = line.match(/^(.+?):(\d+):(.*)$/); - if (!m) return null; - return { - file: path.relative(workspaceRoot, m[1]).split(path.sep).join('/'), - line: parseInt(m[2]), - content: m[3].trim(), - }; - }).filter(Boolean); - } catch (err: any) { - if (err.code === 1) return []; // ripgrep exit 1 = no matches - return { error: `Search failed: ${err.message}` }; - } -} diff --git a/vibn-agent-runner/src/tools/file.ts b/vibn-agent-runner/src/tools/file.ts deleted file mode 100644 index 82c3c80..0000000 --- a/vibn-agent-runner/src/tools/file.ts +++ /dev/null @@ -1,111 +0,0 @@ -// ============================================================================= -// File-system tool registrations (in-process path used by agent-runner). -// All logic lives in ./file-api.ts. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './file-api'; - -registerTool({ - name: 'read_file', - description: 'Read the complete content of a file in the workspace. Always read before editing.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path from workspace root (e.g. "src/index.ts")' } - }, - required: ['path'] - }, - async handler(args, ctx) { - return api.readFile(ctx.workspaceRoot, String(args.path)); - } -}); - -registerTool({ - name: 'write_file', - description: 'Write complete content to a file. Creates parent directories if needed. Overwrites existing files.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path from workspace root' }, - content: { type: 'string', description: 'Complete new file content' } - }, - required: ['path', 'content'] - }, - async handler(args, ctx) { - return api.writeFile(ctx.workspaceRoot, String(args.path), String(args.content)); - } -}); - -registerTool({ - name: 'replace_in_file', - description: 'Replace an exact string in a file. The old_content must match character-for-character. Read the file first.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path from workspace root' }, - old_content: { type: 'string', description: 'Exact text to replace' }, - new_content: { type: 'string', description: 'Replacement text' } - }, - required: ['path', 'old_content', 'new_content'] - }, - async handler(args, ctx) { - return api.replaceInFile( - ctx.workspaceRoot, - String(args.path), - String(args.old_content), - String(args.new_content) - ); - } -}); - -registerTool({ - name: 'list_directory', - description: 'List files and subdirectories in a directory. Directories have trailing "/".', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path from workspace root. Use "." for root.' } - }, - required: ['path'] - }, - async handler(args, ctx) { - return api.listDirectory(ctx.workspaceRoot, String(args.path)); - } -}); - -registerTool({ - name: 'find_files', - description: 'Find files matching a glob pattern in the workspace. Returns up to 200 relative paths.', - parameters: { - type: 'object', - properties: { - pattern: { type: 'string', description: 'Glob pattern e.g. "**/*.ts", "src/**/*.test.js"' } - }, - required: ['pattern'] - }, - async handler(args, ctx) { - return api.findFiles(ctx.workspaceRoot, String(args.pattern)); - } -}); - -registerTool({ - name: 'search_code', - description: 'Search file contents for a string or regex pattern. Returns file path, line number, and matching line.', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search term or regex' }, - file_extensions: { - type: 'array', - items: { type: 'string' }, - description: 'Optional: limit to these extensions e.g. ["ts","js"]' - } - }, - required: ['query'] - }, - async handler(args, ctx) { - const exts = Array.isArray(args.file_extensions) ? (args.file_extensions as string[]) : undefined; - return api.searchCode(ctx.workspaceRoot, String(args.query), exts); - } -}); diff --git a/vibn-agent-runner/src/tools/git-api.ts b/vibn-agent-runner/src/tools/git-api.ts deleted file mode 100644 index eac649e..0000000 --- a/vibn-agent-runner/src/tools/git-api.ts +++ /dev/null @@ -1,102 +0,0 @@ -// ============================================================================= -// Pure git API — no ToolContext coupling. -// Requires a GitPushConfig with Gitea credentials for authenticated push. -// ============================================================================= - -import * as cp from "child_process"; -import * as util from "util"; -import { PROTECTED_GITEA_REPOS } from "./security"; - -const execAsync = util.promisify(cp.exec); - -import fs from "fs"; -import path from "path"; - -export interface GitPushConfig { - apiUrl: string; - apiToken: string; - username: string; -} - -export async function gitCommitAndPush( - workspaceRoot: string, - message: string, - cfg: GitPushConfig, -): Promise { - const cwd = workspaceRoot; - const { apiUrl, apiToken, username } = cfg; - - try { - // Check remote URL before committing — block pushes to protected repos - let remoteCheck = ""; - try { - remoteCheck = ( - await execAsync("git remote get-url origin", { cwd }) - ).stdout.trim(); - } catch { - /* no remote yet */ - } - - for (const protectedRepo of PROTECTED_GITEA_REPOS) { - const repoPath = protectedRepo.replace("mark/", ""); - if ( - remoteCheck.includes(`/${repoPath}`) || - remoteCheck.includes(`/${repoPath}.git`) - ) { - return { - error: - `SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` + - `Agents cannot push to platform repos. Only user project repos are writable.`, - }; - } - } - - // Write commit message to a temporary file to avoid shell injection - const msgFile = path.join(cwd, ".git", "COMMIT_EDITMSG"); - fs.writeFileSync(msgFile, message, "utf8"); - - await execAsync("git add -A", { cwd }); - await execAsync("git commit -F .git/COMMIT_EDITMSG", { cwd }); - try { - fs.unlinkSync(msgFile); - } catch { - /* ignore */ - } - - // Strip any existing credentials from remote URL and re-inject cleanly - let remoteUrl = ""; - try { - remoteUrl = ( - await execAsync("git remote get-url origin", { cwd }) - ).stdout.trim(); - } catch { - /* no remote */ - } - - const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, "https://"); - const baseUrl = cleanUrl || apiUrl; - const authedUrl = baseUrl.replace( - "https://", - `https://${username}:${apiToken}@`, - ); - - await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch( - async () => { - await execAsync(`git remote add origin "${authedUrl}"`, { cwd }); - }, - ); - - const branch = ( - await execAsync("git rev-parse --abbrev-ref HEAD", { cwd }) - ).stdout.trim(); - await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 }); - - return { success: true, message, branch }; - } catch (err: any) { - const cleaned = (err.message || "").replace( - new RegExp(apiToken, "g"), - "***", - ); - return { error: `Git operation failed: ${cleaned}` }; - } -} diff --git a/vibn-agent-runner/src/tools/git.ts b/vibn-agent-runner/src/tools/git.ts deleted file mode 100644 index 78e0df7..0000000 --- a/vibn-agent-runner/src/tools/git.ts +++ /dev/null @@ -1,25 +0,0 @@ -// ============================================================================= -// Git commit-and-push tool registration. Logic lives in ./git-api.ts. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './git-api'; - -registerTool({ - name: 'git_commit_and_push', - description: 'Stage all changes, commit with a message, and push to the remote. Call this when work is complete.', - parameters: { - type: 'object', - properties: { - message: { type: 'string', description: 'Commit message describing the changes made' } - }, - required: ['message'] - }, - async handler(args, ctx) { - return api.gitCommitAndPush(ctx.workspaceRoot, String(args.message), { - apiUrl: ctx.gitea.apiUrl, - apiToken: ctx.gitea.apiToken, - username: ctx.gitea.username, - }); - } -}); diff --git a/vibn-agent-runner/src/tools/gitea-api.ts b/vibn-agent-runner/src/tools/gitea-api.ts deleted file mode 100644 index 21f37bf..0000000 --- a/vibn-agent-runner/src/tools/gitea-api.ts +++ /dev/null @@ -1,149 +0,0 @@ -// ============================================================================= -// Pure Gitea API — no ToolContext coupling, no registry coupling. -// -// Takes a plain { apiUrl, apiToken, username } config. Security guardrails -// (PROTECTED_GITEA_REPOS, assertGiteaWritable) are enforced inside each -// function so every caller gets the same protection. -// -// Consumed by: -// - tools/gitea.ts (in-process registry used by agent-runner loop) -// - mcp/gitea-server.ts (stdio MCP server exposed to any MCP client) -// ============================================================================= - -import { PROTECTED_GITEA_REPOS, assertGiteaWritable } from './security'; - -export interface GiteaConfig { - apiUrl: string; - apiToken: string; - username?: string; -} - -type GiteaError = { error: string }; - -async function giteaFetch( - cfg: GiteaConfig, - path: string, - method: string = 'GET', - body?: unknown -): Promise { - const res = await fetch(`${cfg.apiUrl}/api/v1${path}`, { - method, - headers: { - 'Authorization': `token ${cfg.apiToken}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: body ? JSON.stringify(body) : undefined - }); - if (!res.ok) { - return { error: `Gitea API error: ${res.status} ${res.statusText}` } satisfies GiteaError; - } - return res.json(); -} - -// --------------------------------------------------------------------------- -// Public API — 1:1 with tool surface -// --------------------------------------------------------------------------- - -export interface CreateIssueInput { - repo: string; - title: string; - body: string; - labels?: string[]; -} - -export async function createIssue(cfg: GiteaConfig, input: CreateIssueInput): Promise { - assertGiteaWritable(input.repo); - return giteaFetch(cfg, `/repos/${input.repo}/issues`, 'POST', { - title: input.title, - body: input.body, - labels: input.labels, - }); -} - -export async function listIssues(cfg: GiteaConfig, repo: string, state: string = 'open'): Promise { - return giteaFetch(cfg, `/repos/${repo}/issues?state=${state}&limit=20`); -} - -export async function closeIssue(cfg: GiteaConfig, repo: string, issueNumber: number): Promise { - assertGiteaWritable(repo); - return giteaFetch(cfg, `/repos/${repo}/issues/${issueNumber}`, 'PATCH', { state: 'closed' }); -} - -export async function listRepos(cfg: GiteaConfig): Promise { - const res = await fetch(`${cfg.apiUrl}/api/v1/repos/search?limit=50`, { - headers: { 'Authorization': `token ${cfg.apiToken}` } - }); - if (!res.ok) { - return { error: `Gitea API error: ${res.status} ${res.statusText}` } satisfies GiteaError; - } - const data = await res.json() as any; - return (data.data || []) - .filter((r: any) => !PROTECTED_GITEA_REPOS.has(r.full_name)) - .map((r: any) => ({ - name: r.full_name, - description: r.description, - default_branch: r.default_branch, - updated: r.updated, - stars: r.stars_count, - open_issues: r.open_issues_count, - })); -} - -export async function listAllIssues( - cfg: GiteaConfig, - opts: { repo?: string; state?: string } = {} -): Promise { - const state = opts.state || 'open'; - - if (opts.repo) { - if (PROTECTED_GITEA_REPOS.has(opts.repo)) { - return { - error: `SECURITY: "${opts.repo}" is a protected Vibn platform repo. Agents cannot access its issues.` - } satisfies GiteaError; - } - return giteaFetch(cfg, `/repos/${opts.repo}/issues?state=${state}&limit=20`); - } - - // Fetch across all non-protected repos (cap at 10 repos to bound request count) - const reposRes = await fetch(`${cfg.apiUrl}/api/v1/repos/search?limit=50`, { - headers: { 'Authorization': `token ${cfg.apiToken}` } - }); - if (!reposRes.ok) { - return { error: `Gitea API error: ${reposRes.status} ${reposRes.statusText}` } satisfies GiteaError; - } - const reposData = await reposRes.json() as any; - const repos = (reposData.data || []).filter((r: any) => !PROTECTED_GITEA_REPOS.has(r.full_name)); - - const allIssues: unknown[] = []; - for (const r of repos.slice(0, 10)) { - const issues = await giteaFetch(cfg, `/repos/${r.full_name}/issues?state=${state}&limit=10`) as any[]; - if (Array.isArray(issues)) { - allIssues.push(...issues.map((i: any) => ({ - repo: r.full_name, - number: i.number, - title: i.title, - state: i.state, - labels: i.labels?.map((l: any) => l.name), - created: i.created_at, - }))); - } - } - return allIssues; -} - -export async function readRepoFile(cfg: GiteaConfig, repo: string, filePath: string): Promise { - try { - const res = await fetch(`${cfg.apiUrl}/api/v1/repos/${repo}/contents/${filePath}`, { - headers: { 'Authorization': `token ${cfg.apiToken}` } - }); - if (!res.ok) return { error: `File not found: ${filePath} in ${repo}` } satisfies GiteaError; - const data = await res.json() as any; - const content = Buffer.from(data.content, 'base64').toString('utf8'); - return { repo, path: filePath, content }; - } catch (err) { - return { - error: `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}` - } satisfies GiteaError; - } -} diff --git a/vibn-agent-runner/src/tools/gitea.ts b/vibn-agent-runner/src/tools/gitea.ts deleted file mode 100644 index 36b6ef6..0000000 --- a/vibn-agent-runner/src/tools/gitea.ts +++ /dev/null @@ -1,109 +0,0 @@ -// ============================================================================= -// Gitea tool registrations (in-process path used by agent-runner). -// -// All logic lives in ./gitea-api.ts so the MCP server (src/mcp/gitea-server.ts) -// and this in-process registry call the exact same code path. Keep this file -// purely about: (a) surface-shape for the LLM (name/description/parameters), -// (b) mapping ctx.gitea → GiteaConfig. No business logic here. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './gitea-api'; - -registerTool({ - name: 'gitea_create_issue', - description: 'Create a new issue in a Gitea repository.', - parameters: { - type: 'object', - properties: { - repo: { type: 'string', description: 'Repository in "owner/name" format' }, - title: { type: 'string', description: 'Issue title' }, - body: { type: 'string', description: 'Issue body (markdown)' }, - labels: { type: 'array', items: { type: 'string' }, description: 'Optional label names' } - }, - required: ['repo', 'title', 'body'] - }, - async handler(args, ctx) { - return api.createIssue(ctx.gitea, { - repo: String(args.repo), - title: String(args.title), - body: String(args.body), - labels: Array.isArray(args.labels) ? (args.labels as string[]) : undefined, - }); - } -}); - -registerTool({ - name: 'gitea_list_issues', - description: 'List open issues in a Gitea repository.', - parameters: { - type: 'object', - properties: { - repo: { type: 'string', description: 'Repository in "owner/name" format' }, - state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' } - }, - required: ['repo'] - }, - async handler(args, ctx) { - return api.listIssues(ctx.gitea, String(args.repo), String(args.state || 'open')); - } -}); - -registerTool({ - name: 'gitea_close_issue', - description: 'Close an issue in a Gitea repository.', - parameters: { - type: 'object', - properties: { - repo: { type: 'string', description: 'Repository in "owner/name" format' }, - issue_number: { type: 'number', description: 'Issue number to close' } - }, - required: ['repo', 'issue_number'] - }, - async handler(args, ctx) { - return api.closeIssue(ctx.gitea, String(args.repo), Number(args.issue_number)); - } -}); - -registerTool({ - name: 'list_repos', - description: 'List all Git repositories in the Gitea organization. Returns repo names, descriptions, and last update time.', - parameters: { type: 'object', properties: {} }, - async handler(_args, ctx) { - return api.listRepos(ctx.gitea); - } -}); - -registerTool({ - name: 'list_all_issues', - description: 'List open issues across all repos or a specific repo. Use this to understand what work is queued or in progress.', - parameters: { - type: 'object', - properties: { - repo: { type: 'string', description: 'Optional: "owner/name" to scope to one repo. Omit for all repos.' }, - state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' } - } - }, - async handler(args, ctx) { - return api.listAllIssues(ctx.gitea, { - repo: args.repo ? String(args.repo) : undefined, - state: args.state ? String(args.state) : undefined, - }); - } -}); - -registerTool({ - name: 'read_repo_file', - description: 'Read a file from any Gitea repository without cloning it. Useful for understanding project structure.', - parameters: { - type: 'object', - properties: { - repo: { type: 'string', description: 'Repo in "owner/name" format' }, - path: { type: 'string', description: 'File path within the repo (e.g. "src/app/page.tsx")' } - }, - required: ['repo', 'path'] - }, - async handler(args, ctx) { - return api.readRepoFile(ctx.gitea, String(args.repo), String(args.path)); - } -}); diff --git a/vibn-agent-runner/src/tools/index.ts b/vibn-agent-runner/src/tools/index.ts index 806db43..50b9630 100644 --- a/vibn-agent-runner/src/tools/index.ts +++ b/vibn-agent-runner/src/tools/index.ts @@ -1,17 +1,3 @@ -// Import domain files first — side effects register each tool into the registry. -// Order determines ALL_TOOLS array order (informational only). -import './file'; -import './shell'; -import './git'; -import './gitea'; -import './coolify'; -import './agent'; -import './memory'; -import './skills'; -import './prd'; -import './search'; - -// Re-export the public API — identical surface to the old tools.ts -export { ALL_TOOLS, executeTool, ToolDefinition } from './registry'; -export { ToolContext, MemoryUpdate } from './context'; -export { PROTECTED_GITEA_REPOS, PROTECTED_COOLIFY_PROJECT, PROTECTED_COOLIFY_APPS, assertGiteaWritable, assertCoolifyDeployable } from './security'; +export * from './context'; +export * from './registry'; +export * from './mcp-client'; diff --git a/vibn-agent-runner/src/tools/mcp-client.ts b/vibn-agent-runner/src/tools/mcp-client.ts new file mode 100644 index 0000000..104bdf9 --- /dev/null +++ b/vibn-agent-runner/src/tools/mcp-client.ts @@ -0,0 +1,38 @@ +import { ToolContext } from './context'; +import { VIBN_TOOL_DEFINITIONS } from './vibn-tools'; + +export const ALL_TOOLS = VIBN_TOOL_DEFINITIONS; + +export async function executeTool( + name: string, + args: Record, + ctx: ToolContext +): Promise { + // Some tools might just be executed locally by the Runner in the future, + // but right now we forward all non-github/http tools to the frontend MCP. + + // Convert underscore to dot format as expected by MCP + const action = name.replace(/_/g, "."); + + try { + const response = await fetch(`${ctx.vibnApiUrl}/api/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${ctx.mcpToken}`, + ...(ctx.projectId ? { "X-Vibn-Project-Id": ctx.projectId } : {}) + }, + body: JSON.stringify({ action, params: args }), + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || `HTTP ${response.status}: ${response.statusText}` }; + } + + return data.result || data; + } catch (e) { + return { error: `Failed to execute tool ${name} via MCP: ${e instanceof Error ? e.message : String(e)}` }; + } +} diff --git a/vibn-agent-runner/src/tools/memory-api.ts b/vibn-agent-runner/src/tools/memory-api.ts deleted file mode 100644 index 41c1e21..0000000 --- a/vibn-agent-runner/src/tools/memory-api.ts +++ /dev/null @@ -1,45 +0,0 @@ -// ============================================================================= -// Pure memory API. The in-process agent-runner collects memory updates into an -// array on the ToolContext (ctx.memoryUpdates) so the supervisor loop can -// persist them at end-of-turn. MCP clients don't share that array, so the MCP -// server keeps its own module-level store keyed by an optional sessionKey. -// ============================================================================= - -export interface MemoryEntry { - key: string; - type: string; // "tech_stack" | "decision" | "feature" | "goal" | "constraint" | "note" - value: string; -} - -export interface MemoryInput { - key: string; - type: string; - value: string; -} - -export function toEntry(input: MemoryInput): MemoryEntry { - return { key: input.key, type: input.type, value: input.value }; -} - -// ------------------------------------------------------------------- -// In-memory store used by the MCP server path (the in-process path -// appends directly to ctx.memoryUpdates and ignores this store). -// ------------------------------------------------------------------- - -const memoryStore = new Map(); - -export function saveMemoryToStore(sessionKey: string, input: MemoryInput): { saved: true; entry: MemoryEntry } { - const entry = toEntry(input); - const list = memoryStore.get(sessionKey) ?? []; - list.push(entry); - memoryStore.set(sessionKey, list); - return { saved: true, entry }; -} - -export function listMemoryFromStore(sessionKey: string): MemoryEntry[] { - return [...(memoryStore.get(sessionKey) ?? [])]; -} - -export function clearMemoryStore(sessionKey: string): void { - memoryStore.delete(sessionKey); -} diff --git a/vibn-agent-runner/src/tools/memory.ts b/vibn-agent-runner/src/tools/memory.ts deleted file mode 100644 index 7074071..0000000 --- a/vibn-agent-runner/src/tools/memory.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ============================================================================= -// save_memory tool registration. Logic lives in ./memory-api.ts. -// In-process: appends to ctx.memoryUpdates so the supervisor loop can persist -// at end-of-turn. MCP server path uses memory-api's internal store. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './memory-api'; - -registerTool({ - name: 'save_memory', - description: 'Persist an important fact about this project to long-term memory. Use this to save decisions, tech stack choices, feature descriptions, constraints, or goals so they are remembered across conversations.', - parameters: { - type: 'object', - properties: { - key: { type: 'string', description: 'Short unique label (e.g. "primary_language", "auth_strategy", "deploy_target")' }, - type: { - type: 'string', - enum: ['tech_stack', 'decision', 'feature', 'goal', 'constraint', 'note'], - description: 'Category of the memory item' - }, - value: { type: 'string', description: 'The fact to remember (1-3 sentences)' } - }, - required: ['key', 'type', 'value'] - }, - async handler(args, ctx) { - const entry = api.toEntry({ - key: String(args.key), - type: String(args.type), - value: String(args.value), - }); - ctx.memoryUpdates.push(entry); - return { saved: true, key: entry.key, type: entry.type }; - } -}); diff --git a/vibn-agent-runner/src/tools/prd-api.ts b/vibn-agent-runner/src/tools/prd-api.ts deleted file mode 100644 index be07ecf..0000000 --- a/vibn-agent-runner/src/tools/prd-api.ts +++ /dev/null @@ -1,20 +0,0 @@ -// ============================================================================= -// Pure PRD API. The store is module-level so atlas.ts can inspect it after each -// turn (it imports `prdStore` from prd.ts which re-exports from here). Keep -// this module side-effect-free otherwise. -// ============================================================================= - -/** sessionKey (workspaceRoot) → PRD markdown */ -export const prdStore = new Map(); - -export function finalizePrd(sessionKey: string, content: string): { saved: true; message: string } { - prdStore.set(sessionKey, content); - return { - saved: true, - message: 'PRD saved. Let the user know their product requirements document is ready and the platform will now architect the technical solution.', - }; -} - -export function getPrd(sessionKey: string): string | null { - return prdStore.get(sessionKey) ?? null; -} diff --git a/vibn-agent-runner/src/tools/prd.ts b/vibn-agent-runner/src/tools/prd.ts deleted file mode 100644 index 45d4f70..0000000 --- a/vibn-agent-runner/src/tools/prd.ts +++ /dev/null @@ -1,28 +0,0 @@ -// ============================================================================= -// finalize_prd tool registration. Logic + store live in ./prd-api.ts. -// We re-export `prdStore` so existing imports (atlas.ts) continue to work. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './prd-api'; - -export { prdStore } from './prd-api'; - -registerTool({ - name: 'finalize_prd', - description: 'Call this when you have finished writing the complete PRD document. Pass the full PRD markdown as content. This saves the document and signals to the user that discovery is complete.', - parameters: { - type: 'object', - properties: { - content: { - type: 'string', - description: 'The complete PRD document in markdown format' - } - }, - required: ['content'] - }, - async handler(args, ctx) { - // Store against workspaceRoot as a unique key (each project has its own workspace) - return api.finalizePrd(ctx.workspaceRoot, String(args.content)); - } -}); diff --git a/vibn-agent-runner/src/tools/registry.ts b/vibn-agent-runner/src/tools/registry.ts index 480791e..2f24386 100644 --- a/vibn-agent-runner/src/tools/registry.ts +++ b/vibn-agent-runner/src/tools/registry.ts @@ -1,34 +1,7 @@ import { ToolContext } from './context'; +import { ALL_TOOLS } from './mcp-client'; -export interface ToolDefinition { - name: string; - description: string; - parameters: Record; - /** Implementation — called by executeTool(). Not sent to the LLM. */ - handler: (args: Record, ctx: ToolContext) => Promise; -} - -/** Live registry — grows as domain files are imported. */ -const _registry = new Map(); - -/** - * Mutable array kept in sync with the registry. - * Used by agents.ts to pick tool subsets by name (backwards-compatible with ALL_TOOLS). - */ -export const ALL_TOOLS: ToolDefinition[] = []; - -export function registerTool(tool: ToolDefinition): void { - _registry.set(tool.name, tool); - ALL_TOOLS.push(tool); -} - -/** Dispatch a tool call by name — O(1) map lookup, no switch needed. */ -export async function executeTool( - name: string, - args: Record, - ctx: ToolContext -): Promise { - const tool = _registry.get(name); - if (!tool) return { error: `Unknown tool: ${name}` }; - return tool.handler(args, ctx); -} +export { ALL_TOOLS }; +// Legacy exports to satisfy imports in agent-runner +export const executeTool = require('./mcp-client').executeTool; +export type ToolDefinition = any; diff --git a/vibn-agent-runner/src/tools/search-api.ts b/vibn-agent-runner/src/tools/search-api.ts deleted file mode 100644 index 2ea0c7c..0000000 --- a/vibn-agent-runner/src/tools/search-api.ts +++ /dev/null @@ -1,55 +0,0 @@ -// ============================================================================= -// Pure web-search API via DuckDuckGo HTML endpoint. No API key required. -// ============================================================================= - -export async function webSearch(query: string): Promise { - const trimmed = query.trim(); - if (!trimmed) return { error: 'No query provided' }; - - const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(trimmed)}`; - - try { - const res = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; VIBN-Atlas/1.0)', - Accept: 'text/html', - }, - signal: AbortSignal.timeout(15_000), - }); - - if (!res.ok) { - return { error: `Search failed with status ${res.status}` }; - } - - const html = await res.text(); - - const titles: string[] = []; - for (const m of html.matchAll(/class="result__a"[^>]*href="[^"]*"[^>]*>(.*?)<\/a>/gs)) { - const title = m[1].replace(/<[^>]+>/g, '').trim(); - if (title) titles.push(title); - } - - const snippets: string[] = []; - for (const m of html.matchAll(/class="result__snippet"[^>]*>(.*?)<\/a>/gs)) { - const snippet = m[1].replace(/<[^>]+>/g, '').trim(); - if (snippet) snippets.push(snippet); - } - - const count = Math.min(6, Math.max(titles.length, snippets.length)); - const results: string[] = []; - for (let i = 0; i < count; i++) { - const title = titles[i] || ''; - const snippet = snippets[i] || ''; - if (title || snippet) results.push(`**${title}**\n${snippet}`); - } - - if (results.length === 0) return { error: 'No results found' }; - - const text = results.join('\n\n'); - const truncated = text.length > 5000 ? text.slice(0, 5000) + '\n\n[...results truncated]' : text; - return { query: trimmed, results: truncated }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return { error: `Search request failed: ${message}` }; - } -} diff --git a/vibn-agent-runner/src/tools/search.ts b/vibn-agent-runner/src/tools/search.ts deleted file mode 100644 index 16a3423..0000000 --- a/vibn-agent-runner/src/tools/search.ts +++ /dev/null @@ -1,24 +0,0 @@ -// ============================================================================= -// web_search tool registration. Logic lives in ./search-api.ts. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './search-api'; - -registerTool({ - name: 'web_search', - description: 'Search the web for current information. Use this to research competitors, market trends, pricing models, existing solutions, technology choices, or any topic the user mentions that would benefit from real-world context. Returns a summary of top search results.', - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query. Be specific — e.g. "SaaS project management tools pricing 2024" rather than just "project management".' - } - }, - required: ['query'] - }, - async handler(args) { - return api.webSearch(String(args.query)); - } -}); diff --git a/vibn-agent-runner/src/tools/security.ts b/vibn-agent-runner/src/tools/security.ts deleted file mode 100644 index 5ab63a0..0000000 --- a/vibn-agent-runner/src/tools/security.ts +++ /dev/null @@ -1,47 +0,0 @@ -// ============================================================================= -// SECURITY GUARDRAILS — Protected VIBN Platform Resources -// -// These repos and Coolify resources belong to the Vibn platform itself. -// Agents must never be allowed to push code or trigger deployments here. -// Read-only operations (list, read file, get status) are still permitted -// so agents can observe platform state, but all mutations are blocked. -// ============================================================================= - -/** Gitea repos agents can NEVER push to, commit to, or write issues on. */ -export const PROTECTED_GITEA_REPOS = new Set([ - 'mark/vibn-frontend', - 'mark/vibn-agent-runner', - 'mark/vibn-api', - 'mark/master-ai', -]); - -/** Coolify project UUID for the VIBN platform — agents cannot deploy here. */ -export const PROTECTED_COOLIFY_PROJECT = 'f4owwggokksgw0ogo0844os0'; - -/** - * Specific Coolify app UUIDs that must never be deployed by an agent. - * Belt-and-suspenders check in case the project UUID filter is bypassed. - */ -export const PROTECTED_COOLIFY_APPS = new Set([ - 'y4cscsc8s08c8808go0448s0', // vibn-frontend - 'kggs4ogckc0w8ggwkkk88kck', // vibn-postgres - 'o4wwck0g0c04wgoo4g4s0004', // gitea -]); - -export function assertGiteaWritable(repo: string): void { - if (PROTECTED_GITEA_REPOS.has(repo)) { - throw new Error( - `SECURITY: Repo "${repo}" is a protected Vibn platform repo. ` + - `Agents cannot push code or modify issues in this repository.` - ); - } -} - -export function assertCoolifyDeployable(appUuid: string): void { - if (PROTECTED_COOLIFY_APPS.has(appUuid)) { - throw new Error( - `SECURITY: App "${appUuid}" is a protected Vibn platform application. ` + - `Agents cannot trigger deployments for this application.` - ); - } -} diff --git a/vibn-agent-runner/src/tools/shell-api.ts b/vibn-agent-runner/src/tools/shell-api.ts deleted file mode 100644 index c565f64..0000000 --- a/vibn-agent-runner/src/tools/shell-api.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ============================================================================= -// Pure shell execution API — no ToolContext coupling. -// ============================================================================= - -import * as cp from 'child_process'; -import * as util from 'util'; -import { safeResolve } from './utils'; - -const execAsync = util.promisify(cp.exec); - -const BLOCKED_COMMANDS = ['rm -rf /', 'mkfs', ':(){:|:&};:']; - -export async function executeCommand( - workspaceRoot: string, - command: string, - workingDirectory?: string -): Promise { - if (BLOCKED_COMMANDS.some(b => command.includes(b))) { - return { error: 'Command blocked for safety.' }; - } - const cwd = workingDirectory ? safeResolve(workspaceRoot, workingDirectory) : workspaceRoot; - try { - const { stdout, stderr } = await execAsync(command, { - cwd, timeout: 120_000, maxBuffer: 1024 * 1024, - }); - return { exitCode: 0, stdout: stdout.trim(), stderr: stderr.trim() }; - } catch (err: any) { - return { - exitCode: err.code, - stdout: (err.stdout || '').trim(), - stderr: (err.stderr || '').trim(), - error: err.message, - }; - } -} diff --git a/vibn-agent-runner/src/tools/shell.ts b/vibn-agent-runner/src/tools/shell.ts deleted file mode 100644 index 559b34b..0000000 --- a/vibn-agent-runner/src/tools/shell.ts +++ /dev/null @@ -1,26 +0,0 @@ -// ============================================================================= -// Shell execution tool registration. Logic lives in ./shell-api.ts. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './shell-api'; - -registerTool({ - name: 'execute_command', - description: 'Run a shell command in the workspace and return stdout + stderr. 120s timeout. Use for: npm install, npm test, git status, building, etc.', - parameters: { - type: 'object', - properties: { - command: { type: 'string', description: 'Shell command to run' }, - working_directory: { type: 'string', description: 'Optional: relative subdirectory to run in' } - }, - required: ['command'] - }, - async handler(args, ctx) { - return api.executeCommand( - ctx.workspaceRoot, - String(args.command), - args.working_directory ? String(args.working_directory) : undefined - ); - } -}); diff --git a/vibn-agent-runner/src/tools/skills-api.ts b/vibn-agent-runner/src/tools/skills-api.ts deleted file mode 100644 index 37c6f91..0000000 --- a/vibn-agent-runner/src/tools/skills-api.ts +++ /dev/null @@ -1,48 +0,0 @@ -// ============================================================================= -// Pure skills API. Skills live in a Gitea repo at .skills//SKILL.md. -// Takes a GiteaReadConfig so it can read from any Gitea instance (in-process -// agent passes ctx.gitea, MCP server loads from env). -// ============================================================================= - -const SKILL_FILE = 'SKILL.md'; -const SKILLS_DIR = '.skills'; - -export interface GiteaReadConfig { - apiUrl: string; - apiToken: string; -} - -async function giteaGetContents( - cfg: GiteaReadConfig, - repo: string, - filePath: string -): Promise { - const res = await fetch(`${cfg.apiUrl}/api/v1/repos/${repo}/contents/${filePath}`, { - headers: { Authorization: `token ${cfg.apiToken}` }, - }); - if (!res.ok) return null; - return res.json(); -} - -export async function listSkills(cfg: GiteaReadConfig, repo: string): Promise { - const contents = await giteaGetContents(cfg, repo, SKILLS_DIR); - if (!contents || !Array.isArray(contents)) { - return { skills: [], message: `No .skills/ directory found in ${repo}` }; - } - const skills = contents - .filter((entry: any) => entry.type === 'dir') - .map((entry: any) => ({ name: entry.name, path: entry.path })); - return { repo, skills }; -} - -export async function getSkill(cfg: GiteaReadConfig, repo: string, skillName: string): Promise { - const filePath = `${SKILLS_DIR}/${skillName}/${SKILL_FILE}`; - const file = await giteaGetContents(cfg, repo, filePath); - if (!file || !file.content) { - return { error: `Skill "${skillName}" not found in ${repo}. Try list_skills to see available skills.` }; - } - const content = Buffer.from(file.content, 'base64').toString('utf8'); - // Strip YAML frontmatter if present, return just the markdown body - const body = content.replace(/^---[\s\S]*?---\s*/m, '').trim(); - return { repo, skill: skillName, content: body }; -} diff --git a/vibn-agent-runner/src/tools/skills.ts b/vibn-agent-runner/src/tools/skills.ts deleted file mode 100644 index 280bc7e..0000000 --- a/vibn-agent-runner/src/tools/skills.ts +++ /dev/null @@ -1,44 +0,0 @@ -// ============================================================================= -// Skills tool registrations. Logic lives in ./skills-api.ts. -// ============================================================================= - -import { registerTool } from './registry'; -import * as api from './skills-api'; - -registerTool({ - name: 'list_skills', - description: `List available skills for a project repo. Skills are stored in .skills//SKILL.md and provide reusable instructions the agent should follow (e.g. deploy process, test commands, code conventions).`, - parameters: { - type: 'object', - properties: { - repo: { type: 'string', description: 'Repo in "owner/name" format' } - }, - required: ['repo'] - }, - async handler(args, ctx) { - return api.listSkills( - { apiUrl: ctx.gitea.apiUrl, apiToken: ctx.gitea.apiToken }, - String(args.repo), - ); - } -}); - -registerTool({ - name: 'get_skill', - description: `Read the full content of a specific skill from a project repo. Call list_skills first to see what's available. Use this before spawning agents so they have the relevant project-specific instructions.`, - parameters: { - type: 'object', - properties: { - repo: { type: 'string', description: 'Repo in "owner/name" format' }, - skill_name: { type: 'string', description: 'Skill name (directory name inside .skills/)' } - }, - required: ['repo', 'skill_name'] - }, - async handler(args, ctx) { - return api.getSkill( - { apiUrl: ctx.gitea.apiUrl, apiToken: ctx.gitea.apiToken }, - String(args.repo), - String(args.skill_name), - ); - } -}); diff --git a/vibn-agent-runner/src/tools/utils.ts b/vibn-agent-runner/src/tools/utils.ts deleted file mode 100644 index dc78a71..0000000 --- a/vibn-agent-runner/src/tools/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as path from 'path'; - -/** Directory names to skip when walking or listing workspaces. */ -export const EXCLUDED = new Set(['node_modules', '.git', 'dist', 'build', 'lib', '.cache', 'coverage']); - -/** Resolve a relative path safely within a workspace root — throws if it tries to escape. */ -export function safeResolve(root: string, rel: string): string { - const resolved = path.resolve(root, rel); - if (!resolved.startsWith(path.resolve(root))) { - throw new Error(`Path escapes workspace: ${rel}`); - } - return resolved; -} diff --git a/vibn-agent-runner/src/tools/vibn-tools.ts b/vibn-agent-runner/src/tools/vibn-tools.ts new file mode 100644 index 0000000..7033972 --- /dev/null +++ b/vibn-agent-runner/src/tools/vibn-tools.ts @@ -0,0 +1,1975 @@ +/** + * Vibn MCP tool definitions for the Gemini chat assistant. + * + * These mirror the full MCP surface documented in AI_CAPABILITIES.md. + * Tool names use underscores (e.g. apps_create) which map 1:1 to the + * MCP action names (e.g. apps.create) by replacing _ with . + * + * Non-MCP tools (github_search, github_file, http_fetch) are handled + * locally at the bottom of this file. + */ +import type { ToolDefinition } from "./gemini-chat"; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ""; + +export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [ + // ── Workspace & identity ───────────────────────────────────────────────── + + { + name: "fs_tree", + description: + "Get the directory structure of the project as a tree view (up to 3 levels deep). ALWAYS call this first when exploring a new project so you do not waste time guessing paths.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + path: { + type: "STRING", + description: + "Optional directory path under /workspace. Defaults to root.", + }, + }, + required: ["projectId"], + }, + }, + { + name: "browser_navigate", + description: + "Load a URL in a headless browser and return the rendered text, HTTP status, and any uncaught frontend errors.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + url: { + type: "STRING", + description: "The URL to navigate to (e.g. the preview URL).", + }, + }, + required: ["projectId", "url"], + }, + }, + { + name: "browser_console", + description: + "Load a URL and capture all console.error logs from the browser. Use this to catch hydration errors or runtime JS crashes after scaffolding a component or starting a dev server.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + url: { + type: "STRING", + description: "The URL to test (e.g. the preview URL).", + }, + }, + required: ["projectId", "url"], + }, + }, + { + name: "workspace_describe", + description: + "Returns workspace details: slug, Coolify project UUID, Gitea org, and provision status.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "gitea_credentials", + description: + "Returns the workspace bot's Gitea username, PAT, clone URL template, and SSH remote template. Use for any git clone/push operations.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + + // ── Projects ───────────────────────────────────────────────────────────── + + { + name: "projects_list", + description: + "List all Vibn projects in the workspace (planning records — not live deployments). Use apps_list to see what is actually running.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "get_design_template", + description: + "Get the contents of the SKILL.md file for a specific design template. This file contains the guidelines, patterns, and code structure needed to implement the template.", + parameters: { + type: "OBJECT", + properties: { + template_id: { + type: "STRING", + description: "The ID of the template (e.g. 'dashboard', 'blog-post')", + }, + }, + required: ["template_id"], + }, + }, + { + name: "projects_get", + description: + "Get details for a single Vibn project by ID: name, status, vision, linked Coolify UUID, Sentry slug + DSN, possibleDeployments, and designKit fields when the founder saved a kit on the Design tab (designKitForCodegen has resolved color ramps + radius + font, DESIGN.md guidelines, and tokens.css). Use these resolved fields and guidelines to align frontend tokens and styling; saving in UI does not edit repo files.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + }, + required: ["projectId"], + }, + }, + + // ── Market Research & GTM ─────────────────────────────────────────────── + + { + name: "market_categories_suggest", + description: + "Suggests the top 10 most relevant Google Business Profile categories for a given software niche or business type. ALWAYS call this tool FIRST to propose categories to the user before running market_research_run. Let the user approve or reject the list.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + niche: { + type: "STRING", + description: + "The software niche or target market (e.g., 'summer camps', 'dental software').", + }, + }, + required: ["projectId", "niche"], + }, + }, + + { + name: "market_research_run", + description: + "Run market research for a specific business category and location. Fetches real business leads from DataForSEO, stores them in the Vibn database (Data Co-op), and returns the TAM (Total Addressable Market) count, competitor data, and a sample of domains/emails for analysis. This tool costs money. You MUST ask the user for explicit permission before calling it. Pass user_explicitly_approved: true once you have permission.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + categories: { + type: "ARRAY", + items: { type: "STRING" }, + description: + 'Array of approved Google Business Categories (e.g., ["summer_camp", "camp", "youth_center"]). Max 10.', + }, + location: { + type: "STRING", + description: + 'Location string (e.g., "Victoria, British Columbia, Canada", "California, United States").', + }, + user_explicitly_approved: { + type: "BOOLEAN", + description: + "Must be true. Indicates the user agreed to run this cost-incurring research.", + }, + }, + required: [ + "projectId", + "categories", + "location", + "user_explicitly_approved", + ], + }, + }, + { + name: "market_seo_analyze", + description: + "Analyze a competitor's domain for SEO and Google Ads metrics using DataForSEO. Returns estimated organic traffic, organic keywords, paid Google Ads traffic, estimated monthly Ad Spend, and top paid keywords. Use this to understand a competitor's GTM strategy.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + domain: { + type: "STRING", + description: + 'The competitor\'s domain name (e.g., "curvedental.com").', + }, + }, + required: ["projectId", "domain"], + }, + }, + + { + name: "tech_stack_analyze", + description: + "Analyze a list of URLs to determine what software, CMS, booking tools, and analytics they use. Returns aggregated statistics. Use this to determine market gaps (e.g. 'How many dentists use WordPress but lack a booking widget?').", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + urls: { + type: "ARRAY", + items: { type: "STRING" }, + description: "An array of URLs to analyze (max 100 per call).", + }, + software_category_id: { + type: "STRING", + description: + "The ID of the software category (e.g., 'dental-practice-management') to dynamically load competitors from BigQuery and check if the leads are using them.", + }, + custom_checks: { + type: "ARRAY", + items: { type: "STRING" }, + description: + "Optional array of specific technology strings or domains to look for (e.g., ['hubspot.com', 'stripe.com/v3', 'gtag']). Use this if you want to check for competitors that aren't in the database.", + }, + }, + required: ["projectId", "urls", "software_category_id"], + }, + }, + + { + name: "market_competitor_research", + description: + "Search the proprietary database for software competitors and open-source starter kits related to a specific niche. Use this to understand the competitive landscape, pricing models, and find MIT-licensed code to fork.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + niche: { + type: "STRING", + description: + "The software niche (e.g., 'dental', 'accounting', 'crm', 'booking').", + }, + }, + required: ["projectId", "niche"], + }, + }, + + { + name: "market_aggregate_insights", + description: + "Fetches aggregated insights for a specific market niche. Returns a breakdown of sub-categories, total websites, and most importantly, the top keywords mentioned in customer reviews. Use this to understand patient/customer pain points before writing a business plan or marketing copy.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + category: { + type: "STRING", + description: + "The primary Google Category to aggregate (e.g., 'dentist', 'summer_camp').", + }, + location: { + type: "STRING", + description: + "The location (e.g., 'Victoria, BC' or '48.4284,-123.3656,20').", + }, + }, + required: ["projectId", "category", "location"], + }, + }, + + // ── Sentry (Stage 3 of Sentry-as-product) ─────────────────────────────── + + { + name: "project_recent_errors", + description: + 'List recent unresolved Sentry issues for a Vibn project. Each item has id, title, level, count, lastSeen, culprit, permalink. Use this when the user asks "is anything broken?" or before declaring something done. Returns [] if Sentry is not yet provisioned (project too new) — that is fine.', + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + sinceHours: { + type: "NUMBER", + description: + "Look-back window in hours. Default 24, max 168 (1 week).", + }, + limit: { + type: "NUMBER", + description: "Max issues to return (1-50). Default 10.", + }, + }, + required: ["projectId"], + }, + }, + { + name: "project_error_detail", + description: + "Fetch the most recent event for a Sentry issue: stack frames (top 12, source-mapped to real filenames), breadcrumbs (last 20 user actions before the error), user/request context, and a Session Replay link if one was captured. Call this AFTER project_recent_errors gives you an issue id.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + issueId: { + type: "STRING", + description: "Sentry issue id from project_recent_errors.", + }, + }, + required: ["projectId", "issueId"], + }, + }, + { + name: "project_error_resolve", + description: + "Mark a Sentry issue resolved. Call this AFTER you have shipped a fix and either run a verifying test, watched the error stop firing, or had the user confirm. Do NOT mark resolved speculatively — Sentry auto-reopens issues on regression but it is noisy.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + issueId: { type: "STRING", description: "Sentry issue id to resolve." }, + }, + required: ["projectId", "issueId"], + }, + }, + + // ── Applications ───────────────────────────────────────────────────────── + + { + name: "apps_list", + description: + "List live applications and services. Without projectId, lists everything across the workspace. With projectId, scopes to that single Vibn project.", + parameters: { + type: "OBJECT", + properties: { + projectId: { + type: "STRING", + description: + "Optional Vibn project ID to scope the list to one project.", + }, + }, + required: [], + }, + }, + { + name: "apps_get", + description: + "Get full details for a single app: status, domain, git info, build pack, environment.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_create", + description: `Create and deploy a new application. Four pathways — pick the right one: +1. template (PREFERRED for popular apps — Twenty, n8n, WordPress, Ghost, Supabase, etc.): pass { template: "" }. Always search apps_templates_search first. +2. image (single Docker container): pass { image: "nginx:latest" }. +3. composeRaw (custom multi-service stack, no template exists): pass { composeRaw: "" }. +4. repo (user's own code in Gitea): pass { repo: "" }. +Auto-domain {name}.{workspace}.vibnai.com is assigned automatically.`, + parameters: { + type: "OBJECT", + properties: { + projectId: { + type: "STRING", + description: + "The Vibn project ID to deploy this app under. STRONGLY RECOMMENDED — gives the app its own isolated Coolify project so all related resources (databases, services) are grouped together and can be lifecycle-managed as one unit. If omitted, the app lands in the workspace's shared/legacy Coolify project.", + }, + name: { + type: "STRING", + description: + 'App name (slug-friendly, e.g. "my-crm"). Required for all pathways.', + }, + domain: { + type: "STRING", + description: + 'Custom subdomain (e.g. "crm.mark.vibnai.com"). Optional — auto-generated if omitted.', + }, + template: { + type: "STRING", + description: + 'Coolify one-click template slug (e.g. "twenty", "n8n", "wordpress"). Use apps_templates_search to find the slug.', + }, + image: { + type: "STRING", + description: + 'Docker image (e.g. "nginx:latest"). For single-container third-party apps.', + }, + composeRaw: { + type: "STRING", + description: + "Raw Docker Compose YAML for custom multi-service stacks. Only use when no template exists.", + }, + repo: { + type: "STRING", + description: + 'Gitea repo name (e.g. "my-site") for deploying the user\'s own code.', + }, + ports: { + type: "STRING", + description: + 'Port(s) the app listens on (e.g. "3000"). Required for repo/image pathways.', + }, + envsJson: { + type: "STRING", + description: + 'Environment variables as a JSON object string (e.g. \'{"KEY":"value"}\'). Optional.', + }, + instantDeploy: { + type: "BOOLEAN", + description: "Whether to deploy immediately (default true).", + }, + }, + required: ["name"], + }, + }, + { + name: "apps_update", + description: + "Update whitelisted fields on an existing app (name, description, git branch, ports, build commands, base directory, etc.). Returns applied/ignored/rerouted arrays. Setting domains/git_repository returns a rerouted hint pointing at apps_domains_set or apps_rewire_git.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + patchJson: { + type: "STRING", + description: + 'Fields to update as a JSON object string (e.g. \'{"name":"new-name","ports_exposes":"3001"}\').', + }, + }, + required: ["uuid", "patchJson"], + }, + }, + { + name: "apps_rewire_git", + description: + "Re-point an app's git_repository at the canonical HTTPS+PAT clone URL. Use to recover older apps created with SSH URLs, or to refresh a rotated bot PAT.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + repo: { + type: "STRING", + description: + "Gitea repo name. Optional — inferred from current URL if omitted.", + }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_delete", + description: + "Destroy an application. Volumes are kept by default. confirm must equal the app's exact name.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + confirm: { + type: "STRING", + description: + 'Must equal the exact app name (e.g. "my-crm"). Prevents accidental deletion.', + }, + }, + required: ["uuid", "confirm"], + }, + }, + { + name: "apps_deploy", + description: "Trigger a new deployment for an existing application.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + force: { + type: "BOOLEAN", + description: "Force rebuild even if nothing changed.", + }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_deployments", + description: + "List recent deployments for an app with their status (finished, in_progress, failed, queued).", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_logs", + description: + "Get runtime logs from a running application. Compose-aware: returns per-service logs for multi-service stacks. Use to diagnose crashes, DB errors, or startup failures.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + service: { + type: "STRING", + description: + 'For compose apps: specific service name to filter (e.g. "server", "worker"). Omit for all services.', + }, + lines: { + type: "NUMBER", + description: "Number of log lines (default 200, max 5000).", + }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_exec", + description: + "Run a one-shot command inside a running app container (via docker exec). Use for database migrations, seeds, CLI invocations, and debugging. Shell syntax works. Default timeout 60s.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + command: { + type: "STRING", + description: + 'Shell command to run (e.g. "yarn command:prod database:migrate:prod", "psql $DATABASE_URL -c \'select 1\'").', + }, + service: { + type: "STRING", + description: + 'For compose apps with multiple containers: the service to exec into (e.g. "server", "db").', + }, + user: { + type: "STRING", + description: + "User to run command as (default: container default user).", + }, + workdir: { + type: "STRING", + description: "Working directory inside the container.", + }, + timeout_ms: { + type: "NUMBER", + description: "Timeout in milliseconds (default 60000, max 600000).", + }, + max_bytes: { + type: "NUMBER", + description: "Max output bytes (default 1MB, max 5MB).", + }, + }, + required: ["uuid", "command"], + }, + }, + { + name: "apps_volumes_list", + description: + "List Docker volumes belonging to an app (name + size in bytes). Use before apps_volumes_wipe to confirm volume names.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_volumes_wipe", + description: + "DESTRUCTIVE. Stop all app containers, remove a specific volume, leave app ready for a fresh deploy. Use to recover from stale DB state on first boot. confirm must equal the exact volume name.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + volume: { + type: "STRING", + description: + "Exact volume name to wipe (get from apps_volumes_list).", + }, + confirm: { + type: "STRING", + description: "Must equal the exact volume name to confirm deletion.", + }, + }, + required: ["uuid", "volume", "confirm"], + }, + }, + { + name: "apps_containers_up", + description: + "Run docker compose up -d directly on the Coolify host. Use when apps_deploy returned started:false or containers are in Created/Exited state. Idempotent.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_containers_ps", + description: + "Run docker compose ps to see container states. Diagnoses Created (queue failure), Exited (crash), Restarting (boot loop), Up healthy/unhealthy.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_repair", + description: + "Re-run post-deploy fixes on an existing service: proxy network attach, Traefik label injection, proxy restart. Use when a deploy succeeded but the app returns 502/503 or Mixed Content errors.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify service UUID." }, + fqdn: { + type: "STRING", + description: + 'The public domain the app should be reachable at (e.g. "crm.mark.vibnai.com").', + }, + publicAppName: { + type: "STRING", + description: + 'The name of the public-facing container/service within the stack (e.g. "server", "web").', + }, + port: { + type: "NUMBER", + description: + "The upstream port the container listens on (e.g. 3000). Optional.", + }, + }, + required: ["uuid", "fqdn", "publicAppName"], + }, + }, + { + name: "apps_unstick", + description: `Recover a service stuck on a Docker "container name already in use" conflict. Force-removes orphan containers (everything matching name suffix -) so the next apps_deploy can boot clean. + +USE THIS — DO NOT delete-and-recreate the service. Deleting and re-creating produces a NEW uuid + NEW container names, which side-steps the conflict but leaves the orphan running AND forks a duplicate copy of the stack. We've burned ourselves on this before (4 orphan twenty-* services, 12GB RAM eaten). + +Recipe when a deploy fails with "Conflict. The container name X is already in use": + 1. apps_unstick { uuid: "" } + 2. apps_deploy { uuid: "" } + 3. apps_get { uuid: "" } to confirm fqdn / status. + +Pass wipeVolumes: true ONLY if the user explicitly said "nuke the data".`, + parameters: { + type: "OBJECT", + properties: { + uuid: { + type: "STRING", + description: "The Coolify service / app / database UUID.", + }, + wipeVolumes: { + type: "BOOLEAN", + description: + "If true, also remove anonymous volumes (data loss). Default false.", + }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_templates_list", + description: + "Browse the Coolify one-click template catalog (320+ apps: CRMs, AI tools, CMSes, dashboards, databases). Each is deployable via apps_create with { template: slug }.", + parameters: { + type: "OBJECT", + properties: { + limit: { + type: "NUMBER", + description: "Number of templates to return (default 50, max 500).", + }, + offset: { type: "NUMBER", description: "Pagination offset." }, + tag: { + type: "STRING", + description: + 'Filter by tag substring (e.g. "crm", "ai", "database").', + }, + }, + required: [], + }, + }, + { + name: "apps_templates_search", + description: + "Search for a Coolify template by name or keyword. Always call this before apps_create to find the correct template slug. Returns ranked matches.", + parameters: { + type: "OBJECT", + properties: { + query: { + type: "STRING", + description: + 'Search term (e.g. "twenty", "n8n", "ghost blog", "kanban").', + }, + tag: { + type: "STRING", + description: + 'Filter by tag (e.g. "crm", "ai"). Can be used with or without query.', + }, + limit: { + type: "NUMBER", + description: "Max results (default 25, max 100).", + }, + }, + required: [], + }, + }, + { + name: "apps_domains_list", + description: "List the current domain set for an application.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_domains_set", + description: `Set the public domain(s) for an application or service. All entries must end with .{workspace}.vibnai.com. + +Auto-detects whether uuid points to an application (Dockerfile / nixpacks / docker-image / compose buildpack) or a service (template-based, e.g. Twenty CRM, n8n) and uses the right Coolify pipeline for each: + +- Application: writes to applications.fqdn (or docker_compose_domains for compose buildpack). +- Service: writes to service_applications.fqdn AND triggers Coolify's updateCompose() + service.parse() so Traefik labels regenerate. Without this dance, the change gets reverted on next deploy. We learned this the hard way with twenty-live. + +For services with a required port (twenty-crm uses 3000), pass { port: 3000 } so the saved fqdn is "https://host:3000" — Coolify hard-fails the save otherwise. Look up the required port via apps_templates_search if you don't know it. + +After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Traefik labels.`, + parameters: { + type: "OBJECT", + properties: { + uuid: { + type: "STRING", + description: "The Coolify application or service UUID.", + }, + domains: { + type: "ARRAY", + description: + 'Array of domain strings (e.g. ["myapp.mark.vibnai.com"]).', + items: { type: "STRING" }, + }, + service: { + type: "STRING", + description: + 'For compose apps OR services: the inner app/service name to attach the domain to (e.g. "server", "twenty"). Default: auto-pick first non-worker app.', + }, + port: { + type: "NUMBER", + description: + "Required for services that need a fixed upstream port (Twenty CRM = 3000, n8n = 5678, Ghost = 2368). Look up via apps_templates_search.", + }, + }, + required: ["uuid", "domains"], + }, + }, + { + name: "apps_envs_list", + description: + "List environment variables for an application. Secret values (is_shown_once) are redacted.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "apps_envs_upsert", + description: + "Create or update a single environment variable on an application.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + key: { + type: "STRING", + description: 'Env var key (uppercase, e.g. "DATABASE_URL").', + }, + value: { type: "STRING", description: "Env var value." }, + isShownOnce: { + type: "BOOLEAN", + description: + "If true, value is write-only after creation (for secrets). Default false.", + }, + isMultiline: { + type: "BOOLEAN", + description: "If true, value spans multiple lines.", + }, + }, + required: ["uuid", "key", "value"], + }, + }, + { + name: "apps_envs_delete", + description: "Delete an environment variable from an application.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify application UUID." }, + key: { type: "STRING", description: "Env var key to delete." }, + }, + required: ["uuid", "key"], + }, + }, + + // ── Databases ───────────────────────────────────────────────────────────── + + { + name: "databases_list", + description: + "List all databases in the workspace across all flavors (Postgres, MySQL, Redis, MongoDB, etc.).", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "databases_create", + description: + "Provision a new database. Supported types: postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.", + parameters: { + type: "OBJECT", + properties: { + type: { + type: "STRING", + description: + 'Database type: "postgresql", "mysql", "mariadb", "mongodb", "redis", "keydb", "dragonfly", or "clickhouse".', + }, + name: { + type: "STRING", + description: + "Database name (slug-friendly). Auto-generated if omitted.", + }, + isPublic: { + type: "BOOLEAN", + description: + "Whether to expose a public port. Default false (internal only).", + }, + publicPort: { + type: "NUMBER", + description: "Public port number if isPublic is true.", + }, + }, + required: ["type"], + }, + }, + { + name: "databases_get", + description: + "Get details for a database including the internal connection URL.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify database UUID." }, + }, + required: ["uuid"], + }, + }, + { + name: "databases_update", + description: + "Update database settings: name, public visibility, image version, resource limits.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify database UUID." }, + patchJson: { + type: "STRING", + description: + 'Fields to update as a JSON object string (e.g. \'{"name":"new-name","is_public":true}\').', + }, + }, + required: ["uuid", "patchJson"], + }, + }, + { + name: "databases_delete", + description: + "Destroy a database. Volumes kept by default. confirm must equal the database's exact name.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify database UUID." }, + confirm: { + type: "STRING", + description: + "Must equal the exact database name to confirm deletion.", + }, + }, + required: ["uuid", "confirm"], + }, + }, + + // ── Auth providers ──────────────────────────────────────────────────────── + + { + name: "auth_list", + description: + "List deployed authentication providers in the workspace plus the allowlist of supported providers.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "auth_create", + description: + "Deploy an auth provider from the allowlist. Supported providers: pocketbase, authentik, keycloak, keycloak-with-postgres, pocket-id, pocket-id-with-postgresql, logto, supertokens-with-postgresql.", + parameters: { + type: "OBJECT", + properties: { + provider: { + type: "STRING", + description: + 'Provider key (e.g. "pocketbase", "authentik", "keycloak").', + }, + name: { + type: "STRING", + description: "Instance name. Auto-generated if omitted.", + }, + description: { type: "STRING", description: "Optional description." }, + instantDeploy: { + type: "BOOLEAN", + description: "Deploy immediately (default true).", + }, + }, + required: ["provider"], + }, + }, + { + name: "auth_delete", + description: + "Destroy an auth provider. User data volumes are kept by default. confirm must equal the service's exact name.", + parameters: { + type: "OBJECT", + properties: { + uuid: { type: "STRING", description: "The Coolify service UUID." }, + confirm: { + type: "STRING", + description: "Must equal the exact service name to confirm deletion.", + }, + }, + required: ["uuid", "confirm"], + }, + }, + + // ── Domains ─────────────────────────────────────────────────────────────── + + { + name: "domains_search", + description: + "Check availability and pricing for domain names via OpenSRS. Stateless — does not reserve anything.", + parameters: { + type: "OBJECT", + properties: { + names: { + type: "ARRAY", + description: + 'Array of domain names to check (e.g. ["myapp.com", "myapp.io"]). Max 25.', + items: { type: "STRING" }, + }, + period: { + type: "NUMBER", + description: + "Registration period in years (default 1). Note: .ai requires 2 years minimum.", + }, + }, + required: ["names"], + }, + }, + { + name: "domains_list", + description: + "List all domains owned by the workspace with their status, registrar order ID, expiry, and DNS provider.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "domains_get", + description: + "Get full record and last 20 lifecycle events for a specific domain.", + parameters: { + type: "OBJECT", + properties: { + domain: { + type: "STRING", + description: 'The domain name (e.g. "myapp.com").', + }, + }, + required: ["domain"], + }, + }, + { + name: "domains_register", + description: + "Register a domain through OpenSRS. Idempotent per (workspace, domain). Confirm availability with domains_search first.", + parameters: { + type: "OBJECT", + properties: { + domain: { + type: "STRING", + description: 'Domain name to register (e.g. "myapp.com").', + }, + period: { + type: "NUMBER", + description: "Registration period in years (default 1).", + }, + whoisPrivacy: { + type: "BOOLEAN", + description: "Enable WHOIS privacy (default true).", + }, + }, + required: ["domain"], + }, + }, + { + name: "domains_attach", + description: + "Wire a registered domain to a Coolify app: creates Cloud DNS zone, writes A/CNAME records, updates registrar nameservers, appends domain to Coolify app. Idempotent.", + parameters: { + type: "OBJECT", + properties: { + domain: { + type: "STRING", + description: 'The registered domain name (e.g. "myapp.com").', + }, + appUuid: { + type: "STRING", + description: "Coolify app UUID to attach the domain to.", + }, + subdomains: { + type: "ARRAY", + description: 'Subdomains to wire (default ["@", "www"]).', + items: { type: "STRING" }, + }, + }, + required: ["domain"], + }, + }, + + // ── Storage ─────────────────────────────────────────────────────────────── + + { + name: "storage_describe", + description: + "Report workspace GCS bucket name, region, S3-compatible endpoint, and access key ID. No secret returned.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "storage_provision", + description: + "Idempotently create or reconcile the workspace GCS bucket, service account, IAM binding, and HMAC key. Safe to re-run.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "storage_inject_env", + description: + "Push STORAGE_* env vars (endpoint, region, bucket, access key, secret) into a Coolify app. Secret is written server-side and never returned in the response.", + parameters: { + type: "OBJECT", + properties: { + uuid: { + type: "STRING", + description: + "The Coolify application UUID to inject storage credentials into.", + }, + prefix: { + type: "STRING", + description: + 'Env var prefix (default "STORAGE_"; use "S3_" for AWS-standard names).', + }, + }, + required: ["uuid"], + }, + }, + + // ── Gitea — repos & file CRUD (write code, not just deploy it) ──────────── + // + // All gitea_* tools are scoped to the workspace's Gitea org. The AI can + // create repos, read & write files, and manage branches inside its own + // org but never outside it (enforced by requireGiteaOrg + ensureRepoOwnerInOrg + // in the MCP route). + + { + name: "gitea_repos_list", + description: + "List every Gitea repo in the workspace org. Use to discover repos already provisioned for projects.", + parameters: { type: "OBJECT", properties: {}, required: [] }, + }, + { + name: "gitea_repo_get", + description: + "Get metadata for a single Gitea repo (default branch, clone URL, html URL, private flag).", + parameters: { + type: "OBJECT", + properties: { + repo: { + type: "STRING", + description: "Repo name (without org prefix).", + }, + owner: { + type: "STRING", + description: + "Optional org/user. Defaults to the workspace Gitea org.", + }, + }, + required: ["repo"], + }, + }, + { + name: "gitea_repo_create", + description: + "Create a new Gitea repo inside the workspace org. By default initializes with a README and is private. " + + "Use this when scaffolding a new app the AI is going to write code for and then deploy via apps_create({ repo }).", + parameters: { + type: "OBJECT", + properties: { + name: { + type: "STRING", + description: "Repo name. Will be slugified (lowercase, hyphens).", + }, + description: { + type: "STRING", + description: "Optional repo description.", + }, + private: { + type: "BOOLEAN", + description: "Whether the repo is private (default true).", + }, + autoInit: { + type: "BOOLEAN", + description: + "Initialize with README (default true). Set false if writing files immediately yourself.", + }, + }, + required: ["name"], + }, + }, + // gitea_file_{read,write,delete} were intentionally hard-removed from + // the AI tool list. The MCP REST endpoints (gitea.file.read / .write + // / .delete) remain live for 30 days for any external clients still + // depending on them, but the AI is now expected to use shell.exec + + // fs.* against the dev container for ALL iterative file work, and + // `ship` to push the result. See AI_PATH_B_EXECUTION_PLAN.md §5. + // (Repo-level orchestration tools — gitea_repos_list, gitea_repo_get, + // gitea_repo_create, gitea_branches_list, gitea_branch_create — are + // still exposed because they handle one-time setup that doesn't have + // a clean dev-container equivalent.) + { + name: "gitea_branches_list", + description: + "List all branches of a workspace Gitea repo with their head SHA.", + parameters: { + type: "OBJECT", + properties: { + repo: { type: "STRING", description: "Repo name." }, + owner: { + type: "STRING", + description: "Optional org. Defaults to the workspace Gitea org.", + }, + }, + required: ["repo"], + }, + }, + { + name: "gitea_branch_create", + description: + "Create a new branch in a workspace Gitea repo, branched off an existing one (default = repo default branch).", + parameters: { + type: "OBJECT", + properties: { + repo: { type: "STRING", description: "Repo name." }, + name: { type: "STRING", description: "New branch name." }, + from: { + type: "STRING", + description: + "Existing branch to branch from (default: repo default branch).", + }, + owner: { + type: "STRING", + description: "Optional org. Defaults to the workspace Gitea org.", + }, + }, + required: ["repo", "name"], + }, + }, + + // ── Path B: dev container + shell + filesystem (PREFERRED for code authoring) ── + // + // These run inside the per-project vibn-dev container. Dramatically faster + // iteration than gitea_file_* (sub-second feedback vs ~5 min redeploy). + // Use these for ALL code writing/editing/scaffolding work. Keep gitea_* + // for orchestration (creating new repos, listing branches) only. + + { + name: "devcontainer_ensure", + description: + "Ensure a per-project AI dev container exists and is running. Idempotent — first call ~10s (provisions a Coolify service), subsequent calls are instant. " + + "Call this at the start of any code-authoring session. Returns the dev container service UUID and state.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + }, + required: ["projectId"], + }, + }, + { + name: "devcontainer_status", + description: + "Cheap status check for the project dev container. Returns { exists, state, serviceUuid }.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + }, + required: ["projectId"], + }, + }, + { + name: "shell_exec", + description: + "Run a shell command inside the project dev container as the `vibn` user (uid 1000) under /workspace. " + + "This is your universal escape hatch — install deps (`npm install`), run tests (`npm test`), scaffold code (`npx create-...`), " + + "inspect output, run migrations. Use this instead of gitea_file_* for any iterative work. " + + "Output is capped at 1 MB; default timeout 60s, max 600s.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + command: { + type: "STRING", + description: + "Shell command (passes through `sh -lc`, so pipes/redirects work).", + }, + cwd: { + type: "STRING", + description: + "Working directory (default /workspace). Must stay under /workspace.", + }, + timeoutMs: { + type: "NUMBER", + description: "Timeout in ms. Default 60000, max 600000.", + }, + }, + required: ["projectId", "command"], + }, + }, + { + name: "fs_read", + description: + "Read a file inside the project dev container. Returns the full text. Optional offset/limit for windowed reads on big files.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + path: { + type: "STRING", + description: + "File path. Relative paths are resolved under /workspace.", + }, + offset: { + type: "NUMBER", + description: "Optional 0-based starting line.", + }, + limit: { type: "NUMBER", description: "Optional max lines to return." }, + }, + required: ["projectId", "path"], + }, + }, + { + name: "fs_write", + description: + "Create or overwrite a file inside the project dev container. Use to scaffold new files. " + + "For surgical edits to existing files, prefer fs_edit (less brittle, smaller diffs).", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + path: { + type: "STRING", + description: + "File path. Relative paths under /workspace. Parent dirs are mkdir -p'd.", + }, + content: { type: "STRING", description: "Full file content." }, + }, + required: ["projectId", "path", "content"], + }, + }, + { + name: "fs_edit", + description: + "Modify a file. You can either use line-number based replacement (PREFERRED) or search-and-replace. " + + "To use line numbers, provide startLine, endLine, and newString. " + + "To use search-and-replace, provide oldString and newString. (Always include 2-3 lines of surrounding context in oldString).", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + path: { type: "STRING", description: "File path under /workspace." }, + newString: { type: "STRING", description: "Replacement text." }, + startLine: { + type: "NUMBER", + description: "The 1-indexed start line number to replace.", + }, + endLine: { + type: "NUMBER", + description: "The 1-indexed end line number to replace (inclusive).", + }, + oldString: { + type: "STRING", + description: + "Exact substring to find (used only if line numbers are not provided).", + }, + replaceAll: { + type: "BOOLEAN", + description: + "If true, replace every occurrence of oldString. Default false.", + }, + }, + required: ["projectId", "path", "newString"], + }, + }, + { + name: "fs_list", + description: + "List files in a directory inside the project dev container (`ls -lA`). Capped at 200 entries.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + path: { + type: "STRING", + description: "Directory path. Default /workspace.", + }, + }, + required: ["projectId"], + }, + }, + { + name: "fs_delete", + description: + "Delete a file or directory inside the project dev container. Set recursive=true to remove a non-empty directory.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + path: { + type: "STRING", + description: "Path to delete. Cannot be /workspace itself.", + }, + recursive: { + type: "BOOLEAN", + description: "rm -rf if true. Default false.", + }, + }, + required: ["projectId", "path"], + }, + }, + { + name: "fs_glob", + description: + "Find files matching a glob pattern (ripgrep-backed, respects .gitignore). Returns up to 500 paths.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + pattern: { + type: "STRING", + description: 'Glob, e.g. "**/*.tsx" or "src/**/*.ts".', + }, + cwd: { + type: "STRING", + description: "Search root (default /workspace).", + }, + }, + required: ["projectId", "pattern"], + }, + }, + { + name: "fs_grep", + description: + "ripgrep-backed code search inside the project dev container. Capped at 50 matches per file, 500 total.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + pattern: { type: "STRING", description: "Regex or literal string." }, + glob: { + type: "STRING", + description: 'Optional file glob to filter (e.g. "*.ts").', + }, + cwd: { + type: "STRING", + description: "Search root (default /workspace).", + }, + contextLines: { + type: "NUMBER", + description: "Lines of context around each match (0-10).", + }, + }, + required: ["projectId", "pattern"], + }, + }, + + // ── Path B: dev servers (preview URLs) ──────────────────────────────────── + + { + name: "dev_server_start", + description: + "Launch a long-running process inside the dev container (e.g. `npm run dev`, `python -m http.server`). " + + "Returns a preview URL the user can open in a browser. The process keeps running across shell.exec calls. " + + "IMPORTANT: bind your server to 0.0.0.0 — we set HOST=0.0.0.0 + PORT= automatically, but verify the framework respects them.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + command: { + type: "STRING", + description: 'Shell command to run (e.g. "npm run dev").', + }, + port: { + type: "NUMBER", + description: "TCP port the server will listen on (1-65535).", + }, + name: { + type: "STRING", + description: + "Optional friendly name for the server (used in the preview subdomain).", + }, + }, + required: ["projectId", "command", "port"], + }, + }, + { + name: "dev_server_stop", + description: "Kill a previously-started dev server by id.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + id: { + type: "STRING", + description: "Dev server id from dev_server_start.", + }, + }, + required: ["projectId", "id"], + }, + }, + { + name: "dev_server_list", + description: "List active (non-stopped) dev servers for a project.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + }, + required: ["projectId"], + }, + }, + { + name: "dev_server_logs", + description: + "Tail recent stdout+stderr from a dev server (default last 200 lines).", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + id: { type: "STRING", description: "Dev server id." }, + lines: { + type: "NUMBER", + description: "Number of trailing lines (1-2000, default 200).", + }, + }, + required: ["projectId", "id"], + }, + }, + + { + name: "request_visual_qa", + description: + "Runs a fast background AI agent to critique a UI file (like page.tsx or .css) against a strict 5-dimensional design rubric. Use this before finishing any turn that involves visual changes.", + parameters: { + type: "OBJECT", + properties: { + targetPath: { + type: "STRING", + description: + "The path of the file to critique, e.g. apps/web/app/page.tsx", + }, + }, + required: ["targetPath"], + }, + }, + + { + name: "apps_templates_scaffold", + description: + "Scaffold a premium pre-built UI template directly into your project. Replaces empty Next.js setups with high-end boilerplate.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING" }, + templateName: { + type: "STRING", + description: + "The template to scaffold. Available: 'dashboard', 'pitch-deck'", + enum: ["dashboard", "pitch-deck"], + }, + }, + required: ["projectId", "templateName"], + }, + }, + + { + name: "generate_media", + description: + "Generate images or motion graphics and save them directly into the workspace to use in your UI.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING" }, + prompt: { + type: "STRING", + description: "Detailed description of the media to generate", + }, + type: { + type: "STRING", + enum: ["image", "video"], + description: "The type of media to generate", + }, + outputPath: { + type: "STRING", + description: + "Where to save the file, e.g. /workspace//public/hero.png", + }, + }, + required: ["projectId", "prompt", "type", "outputPath"], + }, + }, + + // ── Path B: ship to production ───────────────────────────────────────────── + + { + name: "ship", + description: + "Graduate the project from dev container to production. Commits everything in /workspace, pushes to the project Gitea repo, " + + 'and triggers a Coolify production deploy if the project is linked to one. Use when the user says "ship it", "deploy this", ' + + "or after a stable working state has been verified via dev_server_*. Pass `commitMsg` for a meaningful commit; otherwise an ISO-timestamp message is used. " + + "Returns { commitSha, giteaCommitUrl, deploymentUuid, coolifyDeployUrl, summaryHint }. " + + "IMPORTANT: do NOT call gitea_*, shell_exec, or apps_* afterwards to verify — the result is authoritative. " + + "Just report commitSha + coolifyDeployUrl to the user.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + commitMsg: { + type: "STRING", + description: 'Commit message (default: "ship: ").', + }, + repo: { + type: "STRING", + description: "Repo name in workspace org (defaults to project slug).", + }, + branch: { + type: "STRING", + description: 'Branch to push to (default "main").', + }, + deploy: { + type: "BOOLEAN", + description: "Trigger Coolify deploy after push (default true).", + }, + }, + required: ["projectId"], + }, + }, + + // ── Non-MCP: GitHub & web ───────────────────────────────────────────────── + + { + name: "github_search", + description: + "Search GitHub for public repositories. Use to find open source reference projects, design systems, or starting points. " + + 'Add "license:mit" to ensure permissive licensing. ' + + 'Example queries: "license:mit self-hosted crm", "license:mit kanban react", "license:mit design-system components".', + parameters: { + type: "OBJECT", + properties: { + query: { + type: "STRING", + description: + "GitHub search query. Include license:mit unless intentionally looking for non-MIT. " + + "Supports: language:typescript, stars:>500, topic:self-hosted, filename:docker-compose.yml.", + }, + sort: { + type: "STRING", + description: 'Sort by: "stars" (default), "updated", or "forks".', + }, + limit: { + type: "NUMBER", + description: "Results to return (default 8, max 20).", + }, + }, + required: ["query"], + }, + }, + { + name: "github_file", + description: + "Read a specific file from a public GitHub repository. Use to study design systems, component libraries, " + + "README files, package.json, docker-compose.yml, or any file in an open source project.", + parameters: { + type: "OBJECT", + properties: { + repo: { + type: "STRING", + description: + 'Repository in "owner/repo" format (e.g. "makeplane/plane").', + }, + path: { + type: "STRING", + description: + 'File path within the repo (e.g. "README.md", "docker-compose.yml").', + }, + ref: { + type: "STRING", + description: 'Branch or commit ref (default: "main").', + }, + }, + required: ["repo", "path"], + }, + }, + { + name: "http_fetch", + description: + "Fetch any public URL and return the response body as text. Use for reading documentation, " + + "API responses, or any public web resource. Response truncated to 12KB.", + parameters: { + type: "OBJECT", + properties: { + url: { + type: "STRING", + description: "The full URL to fetch (https preferred).", + }, + headersJson: { + type: "STRING", + description: + 'Optional HTTP headers as a JSON object string (e.g. \'{"Accept":"application/json"}\').', + }, + }, + required: ["url"], + }, + }, + + // ── Plan (vision · tasks · decisions · ideas) ──────────────────────────── + // The Plan tab is where the founder's THINKING lives. The AI is the + // scribe: capture decisions in the moment so they don't get re-litigated, + // log tasks the AI commits to next, and parking-lot stray ideas. + + { + name: "plan_get", + description: + "Read the full Plan for a project: vision, ideas, open + done tasks, and decisions. Use to check what has already been decided BEFORE asking the user to re-decide.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + }, + required: ["projectId"], + }, + }, + { + name: "plan_vision_set", + description: + "Update the project objective or vision. If a detailed objective document already exists, ONLY call this if you are explicitly appending to it or replacing it with a better, comprehensive version. Do NOT overwrite a detailed brief with a short summary.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + text: { + type: "STRING", + description: + "The new or updated vision statement / objective doc. Be thorough.", + }, + }, + required: ["projectId", "text"], + }, + }, + { + name: "plan_decision_log", + description: + "Log a decision the user has made. Call this PROACTIVELY whenever a non-trivial choice gets settled in conversation (database engine, auth approach, framework, pricing model, copy, branding…) — so it shows up in the Plan tab and you stop re-asking it next session. Don't ask permission; log it and move on.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + title: { + type: "STRING", + description: + 'Short topic of the decision (e.g. "Database engine", "Auth provider").', + }, + choice: { + type: "STRING", + description: 'What was chosen (e.g. "Postgres", "Stripe Checkout").', + }, + why: { + type: "STRING", + description: "Optional 1-2 sentence reasoning. Strongly recommended.", + }, + }, + required: ["projectId", "title", "choice"], + }, + }, + { + name: "plan_task_add", + description: + "Add an open task. Tasks are SCOPED UNITS OF WORK with a markdown spec — a feature, refactor, investigation, or migration. Each task should be substantive enough that an autonomous agent could execute it. Provide a verb-led `title` AND a markdown `description` containing: ## Goal, ## Context, ## Acceptance criteria (checklist), and ## Notes if relevant. Don't use this for trivial reminders — only for things that warrant a brief.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + title: { + type: "STRING", + description: + 'Short verb-led headline (e.g. "Migrate auth to NextAuth v5").', + }, + description: { + type: "STRING", + description: + "Markdown spec for this task. Include ## Goal, ## Context, ## Acceptance criteria, optionally ## Notes. Strongly recommended.", + }, + }, + required: ["projectId", "title"], + }, + }, + { + name: "plan_task_edit", + description: + 'Edit an existing task\'s title, description, or status. When you have finished a task, put it in "review" status unless the user explicitly told you to mark it as "done". Look up the taskId from plan_get first.', + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + taskId: { + type: "STRING", + description: "Task id from plan_get.tasks[].id.", + }, + title: { + type: "STRING", + description: "Updated short verb-led headline.", + }, + description: { + type: "STRING", + description: "Updated markdown spec for this task.", + }, + status: { + type: "STRING", + description: + 'Status of the task: "open", "in_progress", "review", "done", "blocked".', + enum: ["open", "in_progress", "review", "done", "blocked"], + }, + }, + required: ["projectId", "taskId"], + }, + }, + { + name: "plan_task_complete", + description: + "Mark an open task done. Call when you (or the user with your help) just finished something already on the task list. Look up the taskId from plan_get first.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + taskId: { + type: "STRING", + description: "Task id from plan_get.tasks[].id.", + }, + }, + required: ["projectId", "taskId"], + }, + }, + { + name: "plan_idea_add", + description: + "Park an idea the user mentions but isn't ready to act on. Use sparingly — only when the thought is genuinely worth remembering and isn't already a task or decision.", + parameters: { + type: "OBJECT", + properties: { + projectId: { type: "STRING", description: "The Vibn project ID." }, + text: { + type: "STRING", + description: "The idea, in the user's own words when possible.", + }, + }, + required: ["projectId", "text"], + }, + }, +]; + +// ── Tool execution ──────────────────────────────────────────────────────────── + +const NON_MCP_TOOLS = new Set(["github_search", "github_file", "http_fetch"]); + +/** + * Execute any Vibn tool. Non-MCP tools (GitHub, http_fetch) run locally. + * All MCP tools forward to POST /api/mcp — the tool name maps to the MCP + * action by replacing underscores with dots (e.g. apps_create → apps.create). + */ +export async function executeMcpTool( + toolName: string, + args: Record, + mcpToken: string, + baseUrl: string, + projectId?: string, +): Promise { + if (toolName === "github_search") return executeGithubSearch(args); + if (toolName === "github_file") return executeGithubFile(args); + if (toolName === "http_fetch") return executeHttpFetch(args); + + // Convert underscore tool name → dotted MCP action (apps_create → apps.create) + const action = toolName.replace(/_/g, "."); + + // Unpack JSON-string args (Gemini schemas can't represent free-form objects, + // so we accept *Json string fields and parse them server-side). + const params: Record = { ...args }; + for (const key of Object.keys(params)) { + if (key.endsWith("Json") && typeof params[key] === "string") { + const realKey = key.slice(0, -4); // envsJson → envs, patchJson → patch + try { + params[realKey] = JSON.parse(params[key] as string); + } catch { + return JSON.stringify({ error: `Invalid JSON for ${key}` }); + } + delete params[key]; + } + } + + try { + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${mcpToken}`, + }; + if (projectId) { + headers["X-Vibn-Project-Id"] = projectId; + } + + const res = await fetch(`${baseUrl}/api/mcp`, { + method: "POST", + headers, + body: JSON.stringify({ action, params }), + }); + const data = await res.json(); + return JSON.stringify(data.result ?? data.error ?? data, null, 2).slice( + 0, + 8000, + ); + } catch (e) { + return JSON.stringify({ + error: e instanceof Error ? e.message : String(e), + }); + } +} + +// ── Non-MCP implementations ─────────────────────────────────────────────────── + +async function executeGithubSearch( + args: Record, +): Promise { + const query = String(args.query || ""); + const sort = String(args.sort || "stars"); + const limit = Math.min(Number(args.limit || 8), 20); + + try { + const params = new URLSearchParams({ + q: query, + sort, + order: "desc", + per_page: String(limit), + }); + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (GITHUB_TOKEN) headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`; + + const res = await fetch( + `https://api.github.com/search/repositories?${params}`, + { headers }, + ); + const data = await res.json(); + if (!res.ok) + return JSON.stringify({ error: data.message || "GitHub API error" }); + + const repos = (data.items || []).map((r: any) => ({ + name: r.full_name, + description: r.description, + stars: r.stargazers_count, + language: r.language, + license: r.license?.spdx_id, + topics: r.topics, + updatedAt: r.pushed_at?.slice(0, 10), + url: r.html_url, + defaultBranch: r.default_branch, + })); + + return JSON.stringify({ total: data.total_count, repos }, null, 2); + } catch (e) { + return JSON.stringify({ + error: e instanceof Error ? e.message : String(e), + }); + } +} + +async function executeGithubFile( + args: Record, +): Promise { + const repo = String(args.repo || ""); + const path = String(args.path || ""); + const ref = String(args.ref || "main"); + + try { + const headers: Record = { + Accept: "application/vnd.github.raw+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (GITHUB_TOKEN) headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`; + + const res = await fetch( + `https://api.github.com/repos/${repo}/contents/${path}?ref=${ref}`, + { headers }, + ); + if (!res.ok) { + if (ref === "main") { + const res2 = await fetch( + `https://api.github.com/repos/${repo}/contents/${path}?ref=master`, + { headers }, + ); + if (res2.ok) return (await res2.text()).slice(0, 12000); + } + return JSON.stringify({ + error: `File not found: ${repo}/${path} (ref: ${ref})`, + }); + } + return (await res.text()).slice(0, 12000); + } catch (e) { + return JSON.stringify({ + error: e instanceof Error ? e.message : String(e), + }); + } +} + +async function executeHttpFetch( + args: Record, +): Promise { + const url = String(args.url || ""); + let extraHeaders: Record = {}; + if (typeof args.headersJson === "string") { + try { + extraHeaders = JSON.parse(args.headersJson); + } catch { + /* ignore */ + } + } + + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return JSON.stringify({ error: "URL must start with http:// or https://" }); + } + + try { + const res = await fetch(url, { + headers: { "User-Agent": "Vibn-AI/1.0", ...extraHeaders }, + signal: AbortSignal.timeout(10_000), + }); + const contentType = res.headers.get("content-type") || ""; + const body = contentType.includes("json") + ? JSON.stringify(await res.json(), null, 2) + : await res.text(); + return `HTTP ${res.status}\nContent-Type: ${contentType}\n\n${body}`.slice( + 0, + 12000, + ); + } catch (e) { + return JSON.stringify({ + error: e instanceof Error ? e.message : String(e), + }); + } +} diff --git a/vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts b/vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts index 951f431..7347c6b 100644 --- a/vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts +++ b/vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts @@ -11,6 +11,7 @@ import { NextResponse } from "next/server"; import { authSession } from "@/lib/auth/session-server"; import { query } from "@/lib/db-postgres"; +import { listWorkspaceApiKeys, mintWorkspaceApiKey, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; @@ -78,6 +79,35 @@ export async function POST( ); const sessionId = rows[0].id; + const wsResult = await query<{ workspace_id: string }>( + `SELECT vibn_workspace_id as workspace_id FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId] + ); + if (!wsResult.length) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + const workspaceId = wsResult[0].workspace_id; + + // Grab or mint a default API key for the runner to use + let mcpToken = ""; + const keys = await listWorkspaceApiKeys(workspaceId); + let defaultKey = keys.find((k: any) => k.name === 'default' && !k.revoked_at); + if (!defaultKey) { + const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] }); + mcpToken = minted.token; + } else { + const revealed = await revealWorkspaceApiKey(workspaceId, defaultKey.id); + if (revealed) mcpToken = revealed.token; + else { + const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] }); + mcpToken = minted.token; + } + } + + // Add VIBN_API_URL so the runner knows where to send MCP requests + const vibnApiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + // Fire-and-forget: call agent-runner to start the execution loop. // autoApprove: true — agent commits + deploys automatically on completion. fetch(`${AGENT_RUNNER_URL}/agent/execute`, { @@ -92,6 +122,8 @@ export async function POST( task: task.trim(), autoApprove: true, coolifyAppUuid, + mcpToken, + vibnApiUrl }), }).catch(err => { // Agent runner may not be wired yet — log but don't fail