diff --git a/README.md b/README.md index 3f2f3a7..8f80bec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

Code Fundi

-

The Codebase Context Layer for Agents, Teams and Applications.

+

The Codebase Map for AI Agents, Teams and Applications.

@@ -29,19 +29,91 @@ # Code-Fundi MCP Server -A production-grade [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for the [Code-Fundi](https://api.codefundi.app) API. Enables any MCP-compatible AI assistant — Claude, Cursor, Windsurf, VS Code Copilot, and more — to search, research, index, and scan code repositories through natural language. +A production-grade [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for the [Code-Fundi](https://api.codefundi.app) API: a codebase map and blast-radius engine for AI coding agents. Enables any MCP-compatible AI assistant (Claude, Claude Code, Cursor, Windsurf, VS Code Copilot, and more) to search, research, index, and scan code repositories through natural language, and see what a change breaks before it ships. Built with [FastMCP](https://github.com/punkpeye/fastmcp) (TypeScript) and [Zod](https://zod.dev) for schema validation. +## Getting started for AI agents + +If you are an AI agent (or configuring one) and **no `CODEFUNDI_API_KEY` is set**, you must self-authenticate before using search, repos, chat, or other authenticated tools. The auth tools (`code-fundi-auth-*`) work without a pre-existing key. + +### Which path to use + +| Situation | Action | +|-----------|--------| +| `CODEFUNDI_API_KEY` is set in MCP config | Use any tool immediately — the server sends it as `X-API-Key` on every request | +| No API key configured | Run the **OTP self-auth flow** below (recommended) or **password sign-in** | + +### Zero-config MCP setup (no API key) + +You can start the MCP server without `CODEFUNDI_API_KEY` and authenticate at runtime: + +```json +{ + "mcpServers": { + "code-fundi": { + "command": "npx", + "args": ["-y", "@codefundi/code-fundi-mcp"] + } + } +} +``` + +### Self-authenticate with OTP (recommended) + +Code-Fundi uses Supabase-backed auth (`POST /v2/auth/authenticate`, `/v2/auth/verify`, `/v2/auth/resend`). OTP emails contain a **6-digit code** — magic links are not supported on this path. + +1. **Ask the human user for their email address.** +2. Call **`code-fundi-auth-authenticate`** with: + - `auth_mode`: `"otp"` + - `email`: the user's email + - `should_create_user`: `true` for a **new** account, `false` for a **returning** user +3. Tell the user to check their inbox for a 6-digit code. The API may return `verification_required: true` and `api_key.key_state: "agent_pending"` until verification completes. +4. **Ask the user for the 6-digit OTP** (human-in-the-loop — you cannot guess or bypass this step). +5. Call **`code-fundi-auth-verify`** with the same `email` and the `token` (6 digits). +6. On success, the MCP server **automatically configures the API key in memory** for all subsequent tool calls in this session. + +If the code expired or was not received, call **`code-fundi-auth-resend`** with the same `email`, then repeat step 5. + +**Example dialogue:** + +``` +Agent: What email should I use to sign in to Code-Fundi? +User: dev@example.com +Agent: [calls code-fundi-auth-authenticate] I've sent a 6-digit code to dev@example.com. Please paste it here. +User: 482913 +Agent: [calls code-fundi-auth-verify] You're signed in. I can now search and index your repositories. +``` + +### Password sign-in (alternative) + +For existing accounts with a password, call **`code-fundi-auth-authenticate`** with `auth_mode: "password"`, the user's `email`, `should_create_user: false`, and the `password` parameter. The MCP client sends the password only in the `X-CodeFundi-Auth-Password` header (never in the JSON body). Production requires HTTPS. Returning users may receive an active API key immediately without a separate verify step. + +### After authentication + +- The API key is held **in memory** for the lifetime of the MCP server process. It is **not** persisted across IDE or MCP restarts. +- Recommend the user add the key to their MCP config as `CODEFUNDI_API_KEY` so future sessions start authenticated. +- A FREE-tier account and API key are created automatically on first signup. + +### Errors + +| HTTP status | What to do | +|-------------|------------| +| **401** Unauthorized | No valid key — run the OTP flow above, or set `CODEFUNDI_API_KEY` | +| **429** Too many requests | Auth endpoints are rate-limited per IP; wait for `Retry-After` seconds, then retry | + ## Features -- 🔍 **Semantic & grep code search** across indexed repositories -- 🧠 **AI-powered research** — search + AI analysis in a single call -- 📦 **Repository management** — index, status, README, listing -- 📄 **File documentation** — AI-generated docs for any indexed file -- 📊 **Usage statistics** — query usage, activity, language breakdowns -- 🔐 **Agent-driven authentication** — sign up/sign in via OTP without pre-configured keys -- 💬 **AI chat** — direct conversation with Code-Fundi AI +Every tool below is backed by the same codebase map: structural dependencies, call graph, and blast radius, indexed once and queried in milliseconds. + +- 🔍 **Semantic & grep code search** across your indexed codebase map +- 🧠 **AI-powered research**: search plus AI analysis in a single call +- 📦 **Repository management**: index, status, README, listing, public catalog +- 🛰️ **Repository intelligence**: cross-repo dependency map, blueprint, Blast-Radius Guard (impact analysis before you merge) +- 📄 **File documentation**: AI-generated docs for any indexed file +- 📊 **Usage statistics**: query usage, activity, language breakdowns +- 🔐 **Agent-driven authentication**: sign up/sign in via OTP without pre-configured keys +- 💬 **AI chat & model insight**: direct conversation with Code-Fundi AI, model catalog, and per-tier limits ## Quick Start @@ -70,17 +142,30 @@ npm run build ### Configure -Set your API key as an environment variable: +**Option A — API key (fastest):** set your key as an environment variable: ```bash export CODEFUNDI_API_KEY=your_api_key_here ``` -Or skip this step — agents can authenticate dynamically using the `code-fundi-auth-*` tools. +**Option B — no API key:** skip the env var and let the agent self-authenticate at runtime. See [Getting started for AI agents](#getting-started-for-ai-agents). + +Zero-config MCP example (no `env` block): + +```json +{ + "mcpServers": { + "code-fundi": { + "command": "npx", + "args": ["-y", "@codefundi/code-fundi-mcp"] + } + } +} +``` ### Use with Claude Desktop -After a global install (`npm i -g @codefundi/code-fundi-mcp`), point MCP at the published binary — **no path to `dist/index.js` required**: +After a global install (`npm i -g @codefundi/code-fundi-mcp`), point MCP at the published binary (no path to `dist/index.js` required): ```json { @@ -113,7 +198,7 @@ If the binary is not on your `PATH`, use `npx` (downloads or uses the local pack ### Use with Cursor -Same pattern as Claude — `command` + optional `args` only; no manual path to the repo: +Same pattern as Claude: `command` plus optional `args` only, no manual path to the repo: ```json { @@ -139,25 +224,34 @@ npx fastmcp inspect src/index.ts # Open MCP Inspector UI npx fastmcp dev src/index.ts # Test with MCP CLI ``` -## Tools Reference (22 tools) +## Tools Reference (27 tools) -Covers the Code-Fundi **V2** API: search (including search-with-chat / research), repositories (list, index, status, readme), files, history, statistics, API keys, authentication, plus **Fundi chat** and the model catalog (`POST /v1/fundi/chat`, `GET /v1/fundi/models` — there is no separate `/v2/chat` in the published OpenAPI). +Covers the Code-Fundi **V2** API for codebase mapping and blast-radius analysis: search (including search-with-chat / research), repositories (list, index, status, readme, public catalog), repository intelligence (map, blueprint, radius), files, history, statistics, API keys, authentication, plus **Fundi chat** (`POST /v1/fundi/chat`) and the V2 model catalog / limits (`GET /v2/models`, `GET /v2/models/limits`). ### Search | Tool | Description | |------|-------------| -| `code-fundi-search` | Semantic/grep search across repositories with filters | -| `code-fundi-research` | Search + AI-synthesized analysis of matching code | +| `code-fundi-search` | Semantic and grep search across your indexed codebase map, with filters | +| `code-fundi-research` | Search plus AI-synthesized analysis of matching code | ### Repositories | Tool | Description | |------|-------------| | `code-fundi-list-repos` | List indexed repositories with pagination | -| `code-fundi-index-repo` | Index a new GitHub repository | +| `code-fundi-index-repo` | Index a new GitHub repository into your codebase map | | `code-fundi-repo-status` | Check repository indexing status | -| `code-fundi-repo-readme` | Get repository README documentation | +| `code-fundi-repo-readme` | Get repository README documentation (deprecated, prefer blueprint) | +| `code-fundi-list-public-repos` | Browse the global catalog of indexed public repositories (no key needed) | + +### Repository Intelligence + +| Tool | Description | +|------|-------------| +| `code-fundi-repo-map` | Cross-repository dependency map: how services and packages actually connect | +| `code-fundi-repo-blueprint` | README plus dependency and convention overview (successor to repo-readme) | +| `code-fundi-repo-radius` | Blast-Radius Guard: every file and function that breaks before you merge (PRO+) | ### Files @@ -198,26 +292,12 @@ Covers the Code-Fundi **V2** API: search (including search-with-chat / research) | Tool | Description | |------|-------------| | `code-fundi-chat` | Fundi AI chat (`POST /v1/fundi/chat`; streamed responses are collected to text) | -| `code-fundi-list-models` | List available AI models | +| `code-fundi-list-models` | List the curated chat model catalog (`GET /v2/models`) | +| `code-fundi-model-limits` | Get AI model limits and tier configuration (`GET /v2/models/limits`) | ## Authentication -The server supports two authentication modes: - -### Pre-configured API Key (Recommended) - -Set `CODEFUNDI_API_KEY` in your MCP config environment. All tools work immediately. - -### Agent-Driven Auth (Dynamic) - -When no API key is set, agents can self-authenticate: - -1. Call `code-fundi-auth-authenticate` with email and `auth_mode: "otp"` -2. User receives an OTP code via email -3. Call `code-fundi-auth-verify` with the 6-digit code -4. API key is automatically configured — all tools now work - -This enables fully autonomous agent setup without manual configuration. +Two modes: **pre-configured API key** (`CODEFUNDI_API_KEY` in MCP config) or **agent-driven OTP/password auth** at runtime. Full step-by-step instructions, tool names, and error handling are in [Getting started for AI agents](#getting-started-for-ai-agents) at the top of this README. ## Environment Variables @@ -230,4 +310,4 @@ This enables fully autonomous agent setup without manual configuration. ## License -MIT +MIT \ No newline at end of file diff --git a/package.json b/package.json index 2522914..e49d21a 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,48 @@ { "name": "@codefundi/code-fundi-mcp", - "version": "0.1.2", - "description": "MCP server for the Code-Fundi API — search, research, index, and manage code repositories via any MCP-compatible AI assistant.", - "type": "module", - "main": "dist/index.js", + "version": "0.1.3", + "description": "MCP server for CodeFundi: the codebase map and blast-radius guard for AI coding agents like Claude Code, Cursor, and Copilot.", + "keywords": [ + "mcp", + "mcp-server", + "model-context-protocol", + "fastmcp", + "code-fundi", + "codefundi", + "codebase-map", + "blast-radius", + "code-intelligence", + "call-graph", + "dependency-graph", + "code-search", + "context-api", + "rag", + "llm-context", + "ai-agent", + "ai-coding-agent", + "claude-code", + "cursor", + "github-copilot" + ], + "homepage": "https://codefundi.app", + "bugs": { + "url": "https://github.com/Code-Fundi/code-fundi-mcp/issues" + }, "repository": { "type": "git", "url": "https://github.com/Code-Fundi/code-fundi-mcp" }, - "homepage": "https://codefundi.app", - "author": "Code Fundi", + "license": "MIT", + "author": "Code Fundi ", + "type": "module", + "main": "dist/index.js", "bin": { "code-fundi-mcp": "dist/index.js" }, + "files": [ + "dist", + "README.md" + ], "scripts": { "build": "tsc", "prepublishOnly": "npm run build", @@ -21,22 +51,9 @@ "typecheck": "tsc --noEmit", "test": "vitest" }, - "files": [ - "dist", - "README.md" - ], - "keywords": [ - "mcp", - "code-fundi", - "codefundi", - "ai", - "code-search", - "fastmcp" - ], - "license": "MIT", "dependencies": { "fastmcp": "^4.0.0", - "zod": "^3.25.0" + "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^22.0.0", @@ -47,4 +64,4 @@ "engines": { "node": ">=20.0.0" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67d9273..a593d70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^4.0.0 version: 4.0.1 zod: - specifier: ^3.25.0 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 devDependencies: '@types/node': specifier: ^22.0.0 @@ -1337,9 +1337,6 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2516,6 +2513,4 @@ snapshots: dependencies: zod: 4.3.6 - zod@3.25.76: {} - zod@4.3.6: {} diff --git a/src/client.ts b/src/client.ts index 807877b..50d580a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,8 +15,12 @@ import type { ActivityStatsResponse, LanguageStatsResponse, ApiKeysListResponse, ApiKeyRegenerateResponse, ApiKeyDeleteResponse, V2AuthAuthenticateResponse, V2AuthVerifyResponse, V2AuthResendResponse, V2AuthMode, - ChatRequest, ChatResponse, ModelsResponse, SearchFieldsParam, + ChatRequest, ChatResponse, ModelsResponse, V2ModelsListResponse, + ModelLimitsResponse, SearchFieldsParam, SortOrder, StatsRange, RepoScope, HistoryCategory, ErrorResponse, + PublicRepoListResponse, + RepoMapOptions, RepoMapResponse, RepoBlueprintResponse, + RepoRadiusRequest, RepoRadiusResponse, } from "./types.js"; // ============================================================================ @@ -86,6 +90,14 @@ export class CodeFundiClient { } } + /** + * Require an API key unless the caller opts into demo mode (pre-signup, IP-based credits). + * Endpoints that expose the OpenAPI `demo` flag can execute anonymously in demo mode. + */ + private requireKeyUnlessDemo(demo?: boolean): void { + if (!demo) this.requireApiKey(); + } + private authHeaders(): Record { const h: Record = { "Content-Type": "application/json" }; if (this.apiKey) h["X-API-Key"] = this.apiKey; @@ -239,6 +251,47 @@ export class CodeFundiClient { return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/readme`, { method: "GET" }); } + /** + * `GET /v2/public/repos` — global public repository catalog. No API key required + * (anonymous callers are IP rate limited; pass `internalSecret` for unlimited enumeration). + */ + async listPublicRepos( + opts: { limit?: number; offset?: number; since?: string; file_count?: number; internalSecret?: string } = {}, + ): Promise { + const { internalSecret, ...query } = opts; + const headers: Record = {}; + if (internalSecret) headers["X-Internal-Secret"] = internalSecret; + return this.request(`/v2/public/repos${buildQuery(query)}`, { method: "GET", headers }); + } + + // ==== V2 Repository Intelligence ==== + + async getRepoMap(repoKey: string, opts: RepoMapOptions = {}): Promise { + const { demo, ...rest } = opts; + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ ...rest, demo: demo || undefined }); + return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/map${qs}`, { method: "GET" }); + } + + async getRepoBlueprint( + repoKey: string, + opts: { conventions_path_prefix?: string; demo?: boolean } = {}, + ): Promise { + const { demo, ...rest } = opts; + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ ...rest, demo: demo || undefined }); + return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/blueprint${qs}`, { method: "GET" }); + } + + async getRepoRadius(repoKey: string, body: RepoRadiusRequest, demo?: boolean): Promise { + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ demo: demo || undefined }); + return this.request( + `/v2/repos/${encodeRepoKey(repoKey)}/radius${qs}`, + { method: "POST", body: JSON.stringify(body) }, + ); + } + // ==== V2 Files ==== async listFiles(repoKey: string, opts: { search?: string | null; limit?: number; offset?: number; order_by?: string; order?: SortOrder } = {}): Promise { @@ -324,7 +377,7 @@ export class CodeFundiClient { return this.postUnauth("/v2/auth/resend", { email: p.email, type: p.type ?? "signup" }); } - // ==== V1 Chat & Models (OpenAPI: AI Chat; server body uses `question`, not `prompt`) ==== + // ==== V1 Chat (OpenAPI: AI Chat; server body uses `question`, not `prompt`) ==== async chat(req: ChatRequest): Promise { this.requireApiKey(); @@ -341,9 +394,21 @@ export class CodeFundiClient { return this.parseFundiChatResponse(res); } + // ==== V2 Models ==== + + /** + * `GET /v2/models` — curated CodeFundi chat model catalog. The API wraps the list under + * `data.models`; this method normalizes it to `{ models }` for MCP tools. + */ async getModels(): Promise { this.requireApiKey(); - return this.request("/v1/fundi/models", { method: "GET" }); + const res = await this.request("/v2/models", { method: "GET" }); + return { models: res.data?.models ?? [] }; + } + + async getModelLimits(): Promise { + this.requireApiKey(); + return this.request("/v2/models/limits", { method: "GET" }); } } diff --git a/src/formatters.ts b/src/formatters.ts index ebdb665..03567d7 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -10,9 +10,28 @@ import type { UsageByType, ActivityByDay, LanguageStat, ApiKey, AIModel, ReadmeData, RepoStatusData, RepositoryIndexInitRepo, Pagination, TierName, + PublicRepoListItem, + RepoMapData, RepoBlueprintData, RepoBlueprintMeta, + RepoRadiusData, + ModelLimitsData, } from "./types.js"; import { CodeFundiApiError } from "./client.js"; +// ============================================================================ +// Internal helpers +// ============================================================================ + +/** Render an opaque JSON value as a fenced code block for faithful display. */ +function jsonBlock(value: unknown): string { + return "```json\n" + JSON.stringify(value, null, 2) + "\n```"; +} + +/** Read a string field from a loosely-typed record, if present. */ +function readString(obj: Record | undefined, key: string): string | undefined { + const v = obj?.[key]; + return typeof v === "string" ? v : undefined; +} + // ============================================================================ // Search // ============================================================================ @@ -145,6 +164,159 @@ export function formatReadme(data: ReadmeData): string { return parts.join("\n"); } +export function formatPublicRepoList(repos: PublicRepoListItem[], pagination?: Pagination): string { + const parts: string[] = []; + parts.push(`## Public Repositories (${pagination?.total ?? repos.length})\n`); + + if (repos.length === 0) { + parts.push("No public repositories found."); + return parts.join("\n"); + } + + for (const r of repos) { + parts.push(`### ${r.name}`); + parts.push(`- **ID:** \`${r.id}\``); + parts.push(`- **Link:** ${r.link}`); + if (r.updated_at) parts.push(`- **Updated:** ${r.updated_at}`); + if (r.top_files?.length) { + parts.push(`- **Top files:**`); + for (const f of r.top_files) { + const size = f.file_size_kb != null ? ` (${f.file_size_kb} KB)` : ""; + parts.push(` - \`${f.file_path}\`${size}`); + } + } + parts.push(""); + } + + if (pagination?.has_more) { + parts.push(`_Showing ${repos.length} of ${pagination.total}. Use offset=${pagination.offset + pagination.limit} for next page._`); + } + + return parts.join("\n"); +} + +// ============================================================================ +// Repository Intelligence (map, blueprint, radius) +// ============================================================================ + +export function formatRepoMap(data: RepoMapData): string { + const parts: string[] = []; + const srcName = readString(data.source_repo, "name"); + parts.push(`## Cross-Repository Dependency Map${srcName ? `: ${srcName}` : ""}\n`); + + if (data.scope && Object.keys(data.scope).length) { + parts.push("### Scope"); + parts.push(jsonBlock(data.scope)); + parts.push(""); + } + + const deps = data.dependencies ?? []; + if (!deps.length) { + parts.push("No dependency map entries found."); + return parts.join("\n"); + } + + parts.push(`### Dependencies (${deps.length})`); + for (const d of deps) { + const name = readString(d, "name") ?? "(unnamed)"; + const type = readString(d, "type"); + parts.push(`- **${name}**${type ? ` [${type}]` : ""}`); + const repos = Array.isArray(d.repos) ? d.repos : undefined; + if (repos?.length) parts.push(` - Present in ${repos.length} repo(s)`); + } + + return parts.join("\n"); +} + +export function formatRepoBlueprint(data: RepoBlueprintData, meta?: RepoBlueprintMeta): string { + const parts: string[] = []; + const name = data.repo?.name ?? data.file_name ?? "Repository"; + parts.push(`## ${name} — Blueprint\n`); + if (meta?.deprecated) { + parts.push("_Note: the `/readme` endpoint is deprecated; prefer `code-fundi-repo-blueprint`._\n"); + } + if (data.github_url) parts.push(`- **URL:** ${data.github_url}`); + + const deps = data.dependencies ?? []; + if (deps.length) { + parts.push(`\n### Top Dependencies (${deps.length})`); + for (const d of deps.slice(0, 25)) { + const ver = d.version ? `@${d.version}` : ""; + const typ = d.type ? ` [${d.type}]` : ""; + const usage = d.usage?.file_count !== undefined ? ` — ${d.usage.file_count} file(s)` : ""; + parts.push(`- **${d.name}**${ver}${typ}${usage}`); + } + if (deps.length > 25) parts.push(`_...and ${deps.length - 25} more_`); + } + + const tf = data.symbols?.top_functions ?? []; + if (tf.length) { + parts.push(`\n### Top Functions (${tf.length})`); + for (const f of tf.slice(0, 25)) parts.push(`- \`${f.name}\`${f.type ? ` [${f.type}]` : ""}`); + } + + const tv = data.symbols?.top_variables ?? []; + if (tv.length) { + parts.push(`\n### Top Variables (${tv.length})`); + for (const v of tv.slice(0, 25)) parts.push(`- \`${v.name}\`${v.type ? ` [${v.type}]` : ""}`); + } + + if (data.conventions && Object.keys(data.conventions).length) { + parts.push(`\n### Coding Conventions`); + parts.push(jsonBlock(data.conventions)); + } else if (meta?.conventions_tier_gated) { + parts.push(`\n_Coding conventions require PRO tier or higher._`); + } + + if (data.documentation) { + parts.push(`\n### README\n`); + parts.push(data.documentation); + } + + return parts.join("\n"); +} + +export function formatRepoRadius(data: RepoRadiusData): string { + const parts: string[] = []; + parts.push("## Blast Radius Analysis\n"); + + if (data.summary && Object.keys(data.summary).length) { + parts.push("### Summary"); + parts.push(jsonBlock(data.summary)); + parts.push(""); + } + + const targets = data.targets ?? []; + if (!targets.length) { + parts.push("No impact targets found."); + return parts.join("\n"); + } + + targets.forEach((t, i) => { + parts.push(`### Target ${i + 1}`); + if (t.entry_points?.length) parts.push(`- **Entry points:** ${t.entry_points.join(", ")}`); + if (t.data_flows?.length) { + parts.push(`- **Data flows (${t.data_flows.length}):**`); + for (const f of t.data_flows) { + parts.push(` - ${f.source_kind ?? "?"} → ${f.sink_kind ?? "?"} (risk: ${f.risk_level ?? "n/a"})`); + } + } + if (t.call_edges?.length) { + parts.push(`- **Call edges (${t.call_edges.length}):**`); + for (const e of t.call_edges) { + parts.push(` - ${e.caller ?? "?"} → ${e.callee ?? "?"}${e.call_type ? ` (${e.call_type})` : ""}`); + } + } + if (t.admin_enrichment && Object.keys(t.admin_enrichment).length) { + parts.push(`- **Admin enrichment:**`); + parts.push(jsonBlock(t.admin_enrichment)); + } + parts.push(""); + }); + + return parts.join("\n"); +} + // ============================================================================ // Files // ============================================================================ @@ -301,14 +473,54 @@ export function formatApiKeys(keys: ApiKey[]): string { export function formatModels(models: AIModel[]): string { const parts: string[] = []; parts.push(`## Available Models (${models.length})\n`); - parts.push("| Name | Provider | Tier |"); - parts.push("|------|----------|------|"); + parts.push("| Name | Provider | Tier | Context |"); + parts.push("|------|----------|------|---------|"); for (const m of models) { const tier = m.tier_required ?? (m.tier_requirements?.premium_only ? "PRO" as TierName : "FREE" as TierName); - parts.push(`| ${m.name} | ${m.provider} | ${tier} |`); + const ctx = m.context_length != null ? m.context_length.toLocaleString() : "—"; + parts.push(`| ${m.name} | ${m.provider} | ${tier} | ${ctx} |`); + } + return parts.join("\n"); +} + +export function formatModelLimits(data: ModelLimitsData): string { + const parts: string[] = []; + parts.push(`## Model Limits & Tier${data.tier ? `: ${data.tier}` : ""}\n`); + + const sub = data.subscription; + if (sub) { + const info: string[] = []; + if (sub.tokens !== undefined) info.push(`Tokens: ${sub.tokens}`); + if (sub.bonus_tokens !== undefined) info.push(`Bonus: ${sub.bonus_tokens}`); + if (sub.expiry_date) info.push(`Expires: ${sub.expiry_date}`); + if (info.length) parts.push(`**Subscription:** ${info.join(" | ")}`); } + + const m = data.model; + if (m) { + parts.push(`\n### Active Model`); + if (m.name) parts.push(`- **Name:** ${m.name}${m.provider ? ` (${m.provider})` : ""}`); + if (m.max_tokens !== undefined) parts.push(`- **Max tokens:** ${m.max_tokens}`); + if (m.context_length !== undefined) parts.push(`- **Context length:** ${m.context_length}`); + if (m.vector_search_length !== undefined) parts.push(`- **Vector search length:** ${m.vector_search_length}`); + if (m.knowledge_search_length !== undefined) parts.push(`- **Knowledge search length:** ${m.knowledge_search_length}`); + if (m.knowledge_storage_limit !== undefined) parts.push(`- **Knowledge storage limit:** ${m.knowledge_storage_limit}`); + } + + const l = data.limits; + if (l) { + parts.push(`\n### Limits`); + if (l.history_days !== undefined) parts.push(`- **History days:** ${l.history_days}`); + if (l.history_max_records !== undefined) parts.push(`- **History max records:** ${l.history_max_records}`); + if (l.repos_max !== undefined) parts.push(`- **Max repos:** ${l.repos_max}`); + if (l.files_per_repo_max !== undefined) parts.push(`- **Files per repo:** ${l.files_per_repo_max}`); + if (l.stats_range_max_days !== undefined) parts.push(`- **Stats range max days:** ${l.stats_range_max_days}`); + if (l.can_access_org_repos !== undefined) parts.push(`- **Can access org repos:** ${l.can_access_org_repos}`); + if (l.can_share_to_org !== undefined) parts.push(`- **Can share to org:** ${l.can_share_to_org}`); + } + return parts.join("\n"); } @@ -320,7 +532,7 @@ export function formatError(err: unknown): string { if (err instanceof CodeFundiApiError) { let msg = `**Code-Fundi API Error (${err.statusCode}):** ${err.message}`; if (err.statusCode === 401) { - msg += "\n\n_Tip: Use `code-fundi-auth-authenticate` and `code-fundi-auth-verify` to sign in, or set the CODEFUNDI_API_KEY environment variable._"; + msg += "\n\n_Tip: Use `code-fundi-auth-authenticate` and `code-fundi-auth-verify` to sign in, or set the CODEFUNDI_API_KEY environment variable. After auth, persist the key in MCP config so future sessions stay signed in._"; } if (err.retryAfter) msg += `\n_Retry after: ${err.retryAfter}s_`; return msg; diff --git a/src/index.ts b/src/index.ts index 3635737..ac8aa24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { FastMCP } from "fastmcp"; import { registerSearchTools } from "./tools/search.js"; import { registerRepoTools } from "./tools/repos.js"; +import { registerRepoIntelTools } from "./tools/intel.js"; import { registerFileTools } from "./tools/files.js"; import { registerHistoryTools } from "./tools/history.js"; import { registerStatsTools } from "./tools/stats.js"; @@ -28,6 +29,7 @@ const server = new FastMCP({ // Register all tool groups registerSearchTools(server); registerRepoTools(server); +registerRepoIntelTools(server); registerFileTools(server); registerHistoryTools(server); registerStatsTools(server); diff --git a/src/tools/auth.ts b/src/tools/auth.ts index 9de3ffa..3fa7c69 100644 --- a/src/tools/auth.ts +++ b/src/tools/auth.ts @@ -16,10 +16,11 @@ export function registerAuthTools(server: FastMCP): void { server.addTool({ name: "code-fundi-auth-authenticate", description: - "Start a Code-Fundi authentication flow. Supports OTP (email code) or password modes. " + - "For new users, set should_create_user to true. " + - "After calling this, use code-fundi-auth-verify to complete the sign-in with the OTP code. " + - "This tool does NOT require an existing API key.", + "Start Code-Fundi authentication (POST /v2/auth/authenticate). No existing API key required. " + + "Use auth_mode otp for new or returning users: set should_create_user true on first signup, false on sign-in. " + + "Sends a 6-digit email OTP (not a magic link); then call code-fundi-auth-verify after the human user provides the code. " + + "Password mode: pass password here (sent as X-CodeFundi-Auth-Password header only); should_create_user false for sign-in. " + + "On success with an active key, the API key is configured in-memory for this MCP session only.", parameters: z.object({ email: z.string().email().describe("Email address to authenticate"), auth_mode: z.enum(["otp", "password"]).describe("Authentication mode: 'otp' for email code, 'password' for password-based"), @@ -70,9 +71,10 @@ export function registerAuthTools(server: FastMCP): void { server.addTool({ name: "code-fundi-auth-verify", description: - "Verify a Code-Fundi OTP code to complete authentication. " + - "Use the 6-digit code sent to the email from code-fundi-auth-authenticate. " + - "On success, the API key is automatically configured for all subsequent tool calls.", + "Complete OTP sign-in (POST /v2/auth/verify). Requires the 6-digit code from the user's email after code-fundi-auth-authenticate. " + + "The human user must supply the token — ask them explicitly. " + + "On success, activates the API key and configures it in-memory for all subsequent tools in this MCP session. " + + "Persist CODEFUNDI_API_KEY in MCP config if the user wants auth to survive restarts.", parameters: z.object({ email: z.string().email().describe("Email address used in the authenticate step"), token: z.string().min(6).max(6).describe("6-digit OTP verification code"), @@ -108,7 +110,9 @@ export function registerAuthTools(server: FastMCP): void { server.addTool({ name: "code-fundi-auth-resend", - description: "Resend the OTP verification email if the previous one expired or wasn't received.", + description: + "Resend the OTP email (POST /v2/auth/resend) when the previous code expired or was not received. " + + "Use after code-fundi-auth-authenticate, before code-fundi-auth-verify. Default type is signup for new accounts.", parameters: z.object({ email: z.string().email().describe("Email address to resend the code to"), type: z.enum(["signup", "email_change", "email"]).optional().describe("Resend type (default: signup)"), diff --git a/src/tools/chat.ts b/src/tools/chat.ts index 3478b76..ed6e040 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -3,13 +3,13 @@ * * Chat uses `POST /v1/fundi/chat` (OpenAPI "AI Chat"). The production API streams HTML; * the client collects the full body into a single string for MCP tools. - * Model catalog uses `GET /v1/fundi/models` (no separate `/v2/chat` in the published OpenAPI). + * The model catalog uses `GET /v2/models` and per-tier limits use `GET /v2/models/limits`. */ import type { FastMCP } from "fastmcp"; import { z } from "zod"; import { getClient } from "../client.js"; -import { formatModels, formatError } from "../formatters.js"; +import { formatModels, formatModelLimits, formatError } from "../formatters.js"; export function registerChatTools(server: FastMCP): void { server.addTool({ @@ -65,7 +65,8 @@ export function registerChatTools(server: FastMCP): void { server.addTool({ name: "code-fundi-list-models", - description: "List all available AI models on Code-Fundi with their providers and required tier.", + description: + "List the curated Code-Fundi chat model catalog (GET /v2/models) with providers, required tier, and context length.", parameters: z.object({}), annotations: { title: "List AI Models", readOnlyHint: true }, execute: async () => { @@ -76,4 +77,21 @@ export function registerChatTools(server: FastMCP): void { } catch (err) { return formatError(err); } }, }); + + server.addTool({ + name: "code-fundi-model-limits", + description: + "Get the AI model limits and tier configuration for your account (GET /v2/models/limits): " + + "subscription tokens, the active model's context/knowledge limits, and account limits such as " + + "max repositories, files per repo, history retention, and organization access.", + parameters: z.object({}), + annotations: { title: "Model Limits", readOnlyHint: true }, + execute: async () => { + try { + const client = getClient(); + const res = await client.getModelLimits(); + return res.data ? formatModelLimits(res.data) : "No model limit data available."; + } catch (err) { return formatError(err); } + }, + }); } diff --git a/src/tools/intel.ts b/src/tools/intel.ts new file mode 100644 index 0000000..a1ab3cf --- /dev/null +++ b/src/tools/intel.ts @@ -0,0 +1,93 @@ +/** + * Code-Fundi MCP — Repository Intelligence Tools + * + * V2 repository intelligence: dependency map, blueprint, blast radius. + */ + +import type { FastMCP } from "fastmcp"; +import { z } from "zod"; +import { getClient } from "../client.js"; +import { + formatRepoMap, formatRepoBlueprint, + formatRepoRadius, formatError, +} from "../formatters.js"; + +export function registerRepoIntelTools(server: FastMCP): void { + server.addTool({ + name: "code-fundi-repo-map", + description: + "Build a cross-repository dependency map showing where a dependency appears across the source repo, " + + "optional compared repos, and (optionally) the public index. Returns structured JSON. Consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + dependency: z.string().optional().describe("Dependency name to map"), + type: z.enum(["package", "import", "export", "env"]).optional().describe("Dependency type filter"), + compare_repos: z.array(z.string()).optional().describe("Repository UUIDs to compare against (max 10)"), + include_public_index: z.boolean().optional().describe("Include matches from the public index (default false)"), + limit: z.number().int().min(1).max(50).optional().describe("Max dependencies (default 25)"), + files_per_repo: z.number().int().min(1).max(100).optional().describe("Max files per repo (default 25)"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Dependency Map", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, compare_repos, ...rest } = args; + const res = await client.getRepoMap(repo_key, { + ...rest, + compare_repos: compare_repos?.length ? compare_repos.join(",") : undefined, + }); + return res.data ? formatRepoMap(res.data) : "No dependency map available."; + } catch (err) { return formatError(err); } + }, + }); + + server.addTool({ + name: "code-fundi-repo-blueprint", + description: + "Get the canonical repository intelligence overview: README documentation, top dependencies, " + + "top functions/variables, and aggregated coding conventions (PRO+ only). " + + "This is the successor to `code-fundi-repo-readme`. Consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + conventions_path_prefix: z.string().optional().describe("Optional path prefix filter for conventions aggregation (PRO+)"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Blueprint", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, ...opts } = args; + const res = await client.getRepoBlueprint(repo_key, opts); + return res.data ? formatRepoBlueprint(res.data, res.meta) : "No blueprint available."; + } catch (err) { return formatError(err); } + }, + }); + + server.addTool({ + name: "code-fundi-repo-radius", + description: + "Run blast-radius / file impact analysis for one or more files or dependencies. " + + "Returns projected data flows, entry points, and call edges. Requires PRO, ENTERPRISE, or ADMIN tier " + + "(STARTUP is not eligible) and consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + file: z.string().optional().describe("Single file path to analyze"), + files: z.array(z.string()).optional().describe("Multiple file paths to analyze"), + dependency: z.string().optional().describe("Single dependency name to analyze"), + dependencies: z.array(z.string()).optional().describe("Multiple dependency names to analyze"), + include: z.array(z.string()).optional().describe("Projection kinds to include (e.g. data_flows, entry_points, call_edges)"), + limit: z.number().int().min(1).optional().describe("Max targets to return"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Blast Radius", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, demo, ...body } = args; + const res = await client.getRepoRadius(repo_key, body, demo); + return res.data ? formatRepoRadius(res.data) : "No blast radius data available."; + } catch (err) { return formatError(err); } + }, + }); +} diff --git a/src/tools/repos.ts b/src/tools/repos.ts index 7eb0629..9efba9b 100644 --- a/src/tools/repos.ts +++ b/src/tools/repos.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { getClient } from "../client.js"; import { formatRepoList, formatIndexResult, formatRepoStatus, - formatReadme, formatError, + formatReadme, formatPublicRepoList, formatError, } from "../formatters.js"; export function registerRepoTools(server: FastMCP): void { @@ -77,7 +77,8 @@ export function registerRepoTools(server: FastMCP): void { name: "code-fundi-repo-readme", description: "Get the parsed README documentation for a repository. " + - "Provide either a repository UUID or a clone URL.", + "Provide either a repository UUID or a clone URL. " + + "Deprecated in favor of code-fundi-repo-blueprint, which returns the same README plus dependency and convention intelligence.", parameters: z.object({ repo_key: z.string().describe("Repository UUID or clone URL"), }), @@ -90,4 +91,28 @@ export function registerRepoTools(server: FastMCP): void { } catch (err) { return formatError(err); } }, }); + + server.addTool({ + name: "code-fundi-list-public-repos", + description: + "Browse the global catalog of indexed public repositories (across all Code-Fundi users). " + + "Supports pagination, an `updated_since` cursor, and optional embedding of each repo's largest files. " + + "This endpoint does not require an API key.", + parameters: z.object({ + limit: z.number().int().min(1).max(500).optional().describe("Max results per page (default 100)"), + offset: z.number().int().min(0).optional().describe("Pagination offset"), + since: z.string().optional().describe("Return repos updated strictly after this ISO 8601 timestamp"), + file_count: z.number().int().min(0).max(50).optional().describe("When > 0, embed this many largest files per repo (default 0)"), + internal_secret: z.string().optional().describe("Internal sitemap secret for unlimited server-side enumeration (optional)"), + }), + annotations: { title: "List Public Repositories", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { internal_secret, ...opts } = args; + const res = await client.listPublicRepos({ ...opts, internalSecret: internal_secret }); + return formatPublicRepoList(res.data || [], res.pagination); + } catch (err) { return formatError(err); } + }, + }); } diff --git a/src/types.ts b/src/types.ts index 03a3467..b122796 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,7 @@ // Common Types // ============================================================================ -export type TierName = "FREE" | "DEV" | "PRO" | "ENTERPRISE"; +export type TierName = "FREE" | "DEV" | "STARTUP" | "PRO" | "ENTERPRISE"; export type SearchScope = "all" | "repos" | "files" | "code" | "functions"; @@ -268,6 +268,178 @@ export interface ReadmeData { export interface ReadmeResponse extends BaseResponse {} +// ============================================================================ +// Public Repository Catalog Types (`GET /v2/public/repos`) +// ============================================================================ + +export interface PublicRepoTopFile { + id: string; + file_name: string; + file_path: string; + file_size_kb?: number | null; + updated_at?: string | null; +} + +export interface PublicRepoListItem { + id: string; + link: string; + name: string; + updated_at?: string | null; + /** Present only when the `file_count` query param is > 0. */ + top_files?: PublicRepoTopFile[]; +} + +export interface PublicRepoListMeta extends Meta { + scope?: string; + file_count?: number; +} + +export interface PublicRepoListResponse extends PaginatedResponse { + meta?: PublicRepoListMeta; +} + +// ============================================================================ +// Repository Intelligence Types (map, blueprint, radius) +// ============================================================================ + +export type DependencyType = "package" | "import" | "export" | "env"; + +export interface DependencyUsage { + file_count?: number; + total_files?: number; + percentage?: number; + occurrence_count?: number; +} + +export interface DependencyComparison { + status?: "source_only" | "shared" | "comparison_only"; + repos?: Record[]; +} + +export interface DependencyItem { + name: string; + version?: string | null; + type?: string; + symbols?: string[]; + usage?: DependencyUsage; + comparison?: DependencyComparison; + files?: Record[]; + functions?: Record[]; +} + +export interface ScopeSymbolItem { + name: string; + type?: string; + usage?: DependencyUsage; + detail?: Record | null; +} + +// ---- Cross-repository dependency map (`GET /v2/repos/{repo_id}/map`) ---- + +export interface RepoMapOptions { + dependency?: string; + type?: DependencyType; + /** Comma-separated repository UUIDs (max 10). */ + compare_repos?: string; + include_public_index?: boolean; + limit?: number; + files_per_repo?: number; + demo?: boolean; +} + +export interface RepoMapData { + source_repo?: Record; + scope?: Record; + dependencies?: Record[]; +} + +export interface RepoMapResponse extends BaseResponse {} + +// ---- Blueprint (`GET /v2/repos/{repo_id}/blueprint`) ---- + +export interface RepoBlueprintRepo { + id?: string; + name?: string; + link?: string; + description?: string | null; + [key: string]: unknown; +} + +export interface RepoBlueprintSymbols { + top_functions?: ScopeSymbolItem[]; + top_variables?: ScopeSymbolItem[]; +} + +export interface RepoBlueprintData { + id?: string | null; + file_name?: string; + file_path?: string; + github_url?: string | null; + repo?: RepoBlueprintRepo; + /** Markdown README documentation. */ + documentation?: string; + /** Raw README file content. */ + data?: string | null; + dependencies?: DependencyItem[]; + symbols?: RepoBlueprintSymbols; + /** Aggregated conventions (null below PRO tier). */ + conventions?: Record | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface RepoBlueprintMeta extends Meta { + conventions_tier_gated?: boolean; + /** true on the `/readme` alias only. */ + deprecated?: boolean; +} + +export interface RepoBlueprintResponse extends BaseResponse { + meta?: RepoBlueprintMeta; +} + +// ---- Blast radius (`POST /v2/repos/{repo_id}/radius`) ---- + +export type DataFlowKind = "http_input" | "env_config" | "filesystem" | "cli_input" | "internal"; + +export interface DataFlowProjection { + id?: string | null; + risk_level?: string; + source_kind?: DataFlowKind; + sink_kind?: DataFlowKind; +} + +export interface CallEdgeProjection { + caller?: string; + callee?: string; + call_type?: string; +} + +export interface RepoRadiusRequest { + file?: string; + files?: string[]; + dependency?: string; + dependencies?: string[]; + include?: string[]; + limit?: number; +} + +export interface RepoRadiusTarget { + data_flows?: DataFlowProjection[]; + entry_points?: string[]; + call_edges?: CallEdgeProjection[]; + /** ADMIN only. */ + admin_enrichment?: Record; + [key: string]: unknown; +} + +export interface RepoRadiusData { + summary?: Record; + targets?: RepoRadiusTarget[]; +} + +export interface RepoRadiusResponse extends BaseResponse {} + // ============================================================================ // File Types // ============================================================================ @@ -592,24 +764,77 @@ export interface ChatResponse extends BaseResponse { } // ============================================================================ -// V1 Models Types (no V2 equivalent) +// Models Types (`GET /v2/models`, `GET /v2/models/limits`) // ============================================================================ +export interface AIModelCreditCost { + /** null means use tier credit_search_base from GET /v2/models/limits. */ + search_base?: number | null; + search_base_from_tier?: boolean; + chat_min?: number | null; + chat_min_from_tier?: boolean; +} + export interface AIModel { id: string; name: string; provider: string; - /** Present on normalized MCP catalog entries. */ + /** Curated tier requirement for the model. */ tier_required?: TierName; - /** Raw `/v1/fundi/models` entries from OpenRouter catalog mapping. */ + /** Raw catalog entries from the OpenRouter catalog mapping. */ tier_requirements?: { premium_only?: boolean; default_model?: boolean; - preview_mode?: unknown; + preview_mode?: Record | null; }; - context_length?: number; + context_length?: number | null; + /** Search/research credit costs (present on the V2 curated catalog). */ + credit_cost?: AIModelCreditCost; } +/** `GET /v2/models` returns the curated catalog wrapped under `data.models`. */ +export interface V2ModelsListData { + models: AIModel[]; +} + +export interface V2ModelsListResponse extends BaseResponse {} + +/** Normalized shape returned by the client for MCP tools. */ export interface ModelsResponse { models: AIModel[]; } + +export interface ModelLimitsSubscription { + tokens?: number; + bonus_tokens?: number; + expiry_date?: string; +} + +export interface ModelLimitsModel { + name?: string; + provider?: string; + max_tokens?: number; + context_length?: number; + vector_search_length?: number; + knowledge_search_length?: number; + knowledge_storage_limit?: number; +} + +export interface ModelLimitsLimits { + history_days?: number; + history_max_records?: number; + repos_max?: number; + files_per_repo_max?: number; + stats_range_max_days?: number; + can_access_org_repos?: boolean; + can_share_to_org?: boolean; +} + +export interface ModelLimitsData { + tier?: string; + subscription?: ModelLimitsSubscription; + model?: ModelLimitsModel | null; + limits?: ModelLimitsLimits; +} + +export interface ModelLimitsResponse extends BaseResponse {} diff --git a/tests/client.test.ts b/tests/client.test.ts index 3f53663..c73700a 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -109,6 +109,84 @@ describe("CodeFundiClient", () => { it("getModels should throw without API key", async () => { await expect(noKeyClient.getModels()).rejects.toThrow(CodeFundiApiError); }); + + it("getModelLimits should throw without API key", async () => { + await expect(noKeyClient.getModelLimits()).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoMap should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoMap("repo-id")).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoBlueprint should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoBlueprint("repo-id")).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoRadius should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoRadius("repo-id", { file: "src/index.ts" })).rejects.toThrow(CodeFundiApiError); + }); + }); + + // ==== V2 repository intelligence & demo mode ==== + + describe("V2 repository intelligence", () => { + it("listPublicRepos should not require an API key", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: [], pagination: { total: 0, limit: 100, offset: 0, has_more: false } }), + }); + vi.stubGlobal("fetch", mockFetch); + + const noKeyClient = new CodeFundiClient("https://api.test.codefundi.app"); + await noKeyClient.listPublicRepos({ limit: 10, file_count: 3 }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/public/repos"); + expect(url).toContain("limit=10"); + expect(url).toContain("file_count=3"); + expect(options.method).toBe("GET"); + expect(options.headers["X-API-Key"]).toBeUndefined(); + + vi.unstubAllGlobals(); + }); + + it("listPublicRepos should send X-Internal-Secret header when provided", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: [] }), + }); + vi.stubGlobal("fetch", mockFetch); + + const noKeyClient = new CodeFundiClient("https://api.test.codefundi.app"); + await noKeyClient.listPublicRepos({ internalSecret: "s3cr3t" }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).not.toContain("internalSecret"); + expect(options.headers["X-Internal-Secret"]).toBe("s3cr3t"); + + vi.unstubAllGlobals(); + }); + + it("getModels should GET /v2/models and normalize data.models", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: "success", + data: { models: [{ id: "gpt-4", name: "GPT-4", provider: "openai", tier_required: "PRO" }] }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const res = await client.getModels(); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://api.test.codefundi.app/v2/models"); + expect(options.method).toBe("GET"); + expect(res.models).toHaveLength(1); + expect(res.models[0].id).toBe("gpt-4"); + + vi.unstubAllGlobals(); + }); }); // ==== Auth methods do NOT require API key ==== diff --git a/tests/formatters.test.ts b/tests/formatters.test.ts index 167d8f8..386aa8e 100644 --- a/tests/formatters.test.ts +++ b/tests/formatters.test.ts @@ -9,12 +9,16 @@ import { formatFileList, formatFileDocumentation, formatHistoryList, formatHistoryDetail, formatConversation, formatUsageStats, formatActivityStats, formatLanguageStats, - formatApiKeys, formatModels, formatError, + formatApiKeys, formatModels, formatModelLimits, formatError, + formatPublicRepoList, formatRepoMap, + formatRepoBlueprint, formatRepoRadius, } from "../src/formatters.js"; import { CodeFundiApiError } from "../src/client.js"; import type { SearchResult, Repository, FileListItem, HistoryItem, FileDocumentationData, ReadmeData, RepositoryIndexInitRepo, + PublicRepoListItem, RepoMapData, RepoBlueprintData, + RepoRadiusData, ModelLimitsData, } from "../src/types.js"; // ==== Search ==== @@ -228,6 +232,114 @@ describe("formatModels", () => { }); }); +describe("formatModelLimits", () => { + it("should format tier, subscription, model, and limits", () => { + const data: ModelLimitsData = { + tier: "PRO", + subscription: { tokens: 100000, bonus_tokens: 5000, expiry_date: "2026-12-31T00:00:00Z" }, + model: { name: "GPT-4o", provider: "openai", max_tokens: 4096, context_length: 128000 }, + limits: { repos_max: 50, files_per_repo_max: 2000, history_days: 90, can_share_to_org: true }, + }; + const result = formatModelLimits(data); + expect(result).toContain("PRO"); + expect(result).toContain("100000"); + expect(result).toContain("GPT-4o"); + expect(result).toContain("Max repos:** 50"); + expect(result).toContain("Can share to org:** true"); + }); +}); + +// ==== Public Repos ==== + +describe("formatPublicRepoList", () => { + it("should format empty catalog", () => { + const result = formatPublicRepoList([]); + expect(result).toContain("No public repositories found"); + }); + + it("should format public repos with top files", () => { + const repos: PublicRepoListItem[] = [{ + id: "p1", name: "public-repo", link: "https://github.com/org/public-repo", + updated_at: "2026-01-01T00:00:00Z", + top_files: [{ id: "f1", file_name: "index.ts", file_path: "src/index.ts", file_size_kb: 12 }], + }]; + const result = formatPublicRepoList(repos, { total: 1, limit: 100, offset: 0, has_more: false }); + expect(result).toContain("public-repo"); + expect(result).toContain("p1"); + expect(result).toContain("src/index.ts"); + expect(result).toContain("12 KB"); + }); +}); + +// ==== Repository Intelligence ==== + +describe("formatRepoMap", () => { + it("should format map dependencies", () => { + const data: RepoMapData = { + source_repo: { name: "core" }, + dependencies: [{ name: "lodash", type: "package", repos: [{ id: "r1" }, { id: "r2" }] }], + }; + const result = formatRepoMap(data); + expect(result).toContain("core"); + expect(result).toContain("lodash"); + expect(result).toContain("Present in 2 repo(s)"); + }); + + it("should handle empty map", () => { + const result = formatRepoMap({ dependencies: [] }); + expect(result).toContain("No dependency map entries found"); + }); +}); + +describe("formatRepoBlueprint", () => { + it("should format dependencies, symbols, conventions gating, and README", () => { + const data: RepoBlueprintData = { + file_name: "README.md", + github_url: "https://github.com/org/repo", + repo: { name: "repo" }, + documentation: "# My Project", + dependencies: [{ name: "react", version: "18.0.0", type: "package", usage: { file_count: 30 } }], + symbols: { top_functions: [{ name: "main" }], top_variables: [{ name: "config" }] }, + conventions: null, + }; + const result = formatRepoBlueprint(data, { conventions_tier_gated: true }); + expect(result).toContain("repo — Blueprint"); + expect(result).toContain("**react**@18.0.0"); + expect(result).toContain("main"); + expect(result).toContain("config"); + expect(result).toContain("require PRO tier"); + expect(result).toContain("# My Project"); + }); + + it("should note deprecation when meta.deprecated is set", () => { + const result = formatRepoBlueprint({ repo: { name: "repo" } }, { deprecated: true }); + expect(result).toContain("deprecated"); + }); +}); + +describe("formatRepoRadius", () => { + it("should format targets with data flows and call edges", () => { + const data: RepoRadiusData = { + summary: { impacted_files: 3 }, + targets: [{ + entry_points: ["src/server.ts"], + data_flows: [{ source_kind: "http_input", sink_kind: "filesystem", risk_level: "high" }], + call_edges: [{ caller: "handler", callee: "writeFile", call_type: "direct" }], + }], + }; + const result = formatRepoRadius(data); + expect(result).toContain("Blast Radius Analysis"); + expect(result).toContain("src/server.ts"); + expect(result).toContain("http_input → filesystem"); + expect(result).toContain("handler → writeFile"); + }); + + it("should handle no targets", () => { + const result = formatRepoRadius({ targets: [] }); + expect(result).toContain("No impact targets found"); + }); +}); + // ==== Errors ==== describe("formatError", () => { @@ -236,6 +348,8 @@ describe("formatError", () => { const result = formatError(err); expect(result).toContain("401"); expect(result).toContain("code-fundi-auth-authenticate"); + expect(result).toContain("CODEFUNDI_API_KEY"); + expect(result).toContain("persist"); }); it("should format rate limit error with retry info", () => {