diff --git a/lib/ai/vibn-tools.ts b/lib/ai/vibn-tools.ts index 6d62070d..7d23cf89 100644 --- a/lib/ai/vibn-tools.ts +++ b/lib/ai/vibn-tools.ts @@ -5,6 +5,8 @@ */ import type { ToolDefinition } from './gemini-chat'; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; + export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [ { name: 'projects_list', @@ -85,6 +87,85 @@ export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [ required: ['appUuid'], }, }, + { + name: 'github_search', + description: + 'Search GitHub for public repositories. Use this to find open source projects, ' + + 'reference implementations, design systems, or starting points. ' + + 'Always add "license:mit" to the query to ensure permissive licensing. ' + + 'Example queries: "license:mit self-hosted crm", "license:mit kanban board react", ' + + '"license:mit design-system components".', + parameters: { + type: 'OBJECT', + properties: { + query: { + type: 'STRING', + description: + 'GitHub search query. Include license:mit unless you have a reason not to. ' + + 'You can filter by language (language:typescript), stars (stars:>500), ' + + 'topics (topic:self-hosted), and file presence (filename:docker-compose.yml).', + }, + sort: { + type: 'STRING', + description: 'Sort by: "stars" (default), "updated", or "forks".', + }, + limit: { + type: 'NUMBER', + description: 'Number of results to return (default 8, max 20).', + }, + }, + required: ['query'], + }, + }, + { + name: 'github_file', + description: + 'Read a specific file from a public GitHub repository. ' + + 'Use this to study a project\'s design system, component library, README, ' + + 'package.json, docker-compose.yml, or any other file. ' + + 'Great for understanding how an existing open source project is structured ' + + 'before building something inspired by it.', + 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", "packages/ui/src/index.ts", "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, public datasets, ' + + 'or any web resource relevant to the user\'s request. ' + + 'Response is truncated to 12KB to fit context.', + parameters: { + type: 'OBJECT', + properties: { + url: { + type: 'STRING', + description: 'The full URL to fetch (must be publicly accessible, https preferred).', + }, + headers: { + type: 'OBJECT', + description: 'Optional HTTP headers as key-value pairs.', + }, + }, + required: ['url'], + }, + }, ]; /** @@ -97,6 +178,11 @@ export async function executeMcpTool( mcpToken: string, baseUrl: string, ): Promise { + // Handle non-MCP tools directly + if (toolName === 'github_search') return executeGithubSearch(args); + if (toolName === 'github_file') return executeGithubFile(args); + if (toolName === 'http_fetch') return executeHttpFetch(args); + // Map tool names (underscored) back to MCP action names (dotted) const actionMap: Record = { projects_list: 'projects.list', @@ -130,3 +216,120 @@ export async function executeMcpTool( return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } } + +// ── Non-MCP tool 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, + hasDockerfile: r.topics?.includes('docker') || false, + 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) { + // Try alternate branch if main fails + if (ref === 'main') { + const res2 = await fetch( + `https://api.github.com/repos/${repo}/contents/${path}?ref=master`, + { headers }, + ); + if (res2.ok) { + const text = await res2.text(); + return text.slice(0, 12000); + } + } + return JSON.stringify({ error: `File not found: ${repo}/${path} (ref: ${ref})` }); + } + + const text = await res.text(); + // GitHub returns base64-encoded content for files; raw+json accept header gets raw text + return 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 || ''); + const extraHeaders = (args.headers as Record) || {}; + + 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') || ''; + let body: string; + + if (contentType.includes('json')) { + const json = await res.json(); + body = JSON.stringify(json, null, 2); + } else { + body = 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) }); + } +}