diff --git a/apps/docs/api-reference/cloud/mcp.mdx b/apps/docs/api-reference/cloud/mcp.mdx index 4cffc494..c55cc513 100644 --- a/apps/docs/api-reference/cloud/mcp.mdx +++ b/apps/docs/api-reference/cloud/mcp.mdx @@ -3,4 +3,4 @@ title: 'MCP JSON-RPC' openapi: 'POST /api/mcp' --- -Invokes MCP tools such as `listResources`, `ask`, `addResource`, and `sync`. +Invokes MCP tools such as `listResources`, `ask`, and `addResource`. diff --git a/apps/docs/btca.spec.md b/apps/docs/btca.spec.md index 26400102..d52abc5e 100644 --- a/apps/docs/btca.spec.md +++ b/apps/docs/btca.spec.md @@ -704,7 +704,6 @@ Supported tools: - `listResources` - `ask` - `addResource` -- `sync` Example payload: diff --git a/apps/web/src/convex/_generated/api.d.ts b/apps/web/src/convex/_generated/api.d.ts index 664092bd..ab7ffc1d 100644 --- a/apps/web/src/convex/_generated/api.d.ts +++ b/apps/web/src/convex/_generated/api.d.ts @@ -8,68 +8,72 @@ * @module */ -import type * as analytics from '../analytics.js'; -import type * as analyticsEvents from '../analyticsEvents.js'; -import type * as apiHelpers from '../apiHelpers.js'; -import type * as authHelpers from '../authHelpers.js'; -import type * as clerkApiKeys from '../clerkApiKeys.js'; -import type * as clerkApiKeysQueries from '../clerkApiKeysQueries.js'; -import type * as cli from '../cli.js'; -import type * as cliInternal from '../cliInternal.js'; -import type * as crons from '../crons.js'; -import type * as http from '../http.js'; -import type * as instances_actions from '../instances/actions.js'; -import type * as instances_mutations from '../instances/mutations.js'; -import type * as instances_queries from '../instances/queries.js'; -import type * as mcp from '../mcp.js'; -import type * as mcpInternal from '../mcpInternal.js'; -import type * as mcpQuestions from '../mcpQuestions.js'; -import type * as messages from '../messages.js'; -import type * as migrations from '../migrations.js'; -import type * as projects from '../projects.js'; -import type * as resources from '../resources.js'; -import type * as scheduled_queries from '../scheduled/queries.js'; -import type * as scheduled_updates from '../scheduled/updates.js'; -import type * as scheduled_versionCheck from '../scheduled/versionCheck.js'; -import type * as seed from '../seed.js'; -import type * as streamSessions from '../streamSessions.js'; -import type * as threadTitle from '../threadTitle.js'; -import type * as threads from '../threads.js'; -import type * as usage from '../usage.js'; -import type * as users from '../users.js'; +import type * as analytics from "../analytics.js"; +import type * as analyticsEvents from "../analyticsEvents.js"; +import type * as apiHelpers from "../apiHelpers.js"; +import type * as authHelpers from "../authHelpers.js"; +import type * as clerkApiKeys from "../clerkApiKeys.js"; +import type * as clerkApiKeysQueries from "../clerkApiKeysQueries.js"; +import type * as cli from "../cli.js"; +import type * as cliInternal from "../cliInternal.js"; +import type * as crons from "../crons.js"; +import type * as http from "../http.js"; +import type * as instances_actions from "../instances/actions.js"; +import type * as instances_mutations from "../instances/mutations.js"; +import type * as instances_queries from "../instances/queries.js"; +import type * as mcp from "../mcp.js"; +import type * as mcpInternal from "../mcpInternal.js"; +import type * as mcpQuestions from "../mcpQuestions.js"; +import type * as messages from "../messages.js"; +import type * as migrations from "../migrations.js"; +import type * as projects from "../projects.js"; +import type * as resources from "../resources.js"; +import type * as scheduled_queries from "../scheduled/queries.js"; +import type * as scheduled_updates from "../scheduled/updates.js"; +import type * as scheduled_versionCheck from "../scheduled/versionCheck.js"; +import type * as seed from "../seed.js"; +import type * as streamSessions from "../streamSessions.js"; +import type * as threadTitle from "../threadTitle.js"; +import type * as threads from "../threads.js"; +import type * as usage from "../usage.js"; +import type * as users from "../users.js"; -import type { ApiFromModules, FilterApi, FunctionReference } from 'convex/server'; +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; declare const fullApi: ApiFromModules<{ - analytics: typeof analytics; - analyticsEvents: typeof analyticsEvents; - apiHelpers: typeof apiHelpers; - authHelpers: typeof authHelpers; - clerkApiKeys: typeof clerkApiKeys; - clerkApiKeysQueries: typeof clerkApiKeysQueries; - cli: typeof cli; - cliInternal: typeof cliInternal; - crons: typeof crons; - http: typeof http; - 'instances/actions': typeof instances_actions; - 'instances/mutations': typeof instances_mutations; - 'instances/queries': typeof instances_queries; - mcp: typeof mcp; - mcpInternal: typeof mcpInternal; - mcpQuestions: typeof mcpQuestions; - messages: typeof messages; - migrations: typeof migrations; - projects: typeof projects; - resources: typeof resources; - 'scheduled/queries': typeof scheduled_queries; - 'scheduled/updates': typeof scheduled_updates; - 'scheduled/versionCheck': typeof scheduled_versionCheck; - seed: typeof seed; - streamSessions: typeof streamSessions; - threadTitle: typeof threadTitle; - threads: typeof threads; - usage: typeof usage; - users: typeof users; + analytics: typeof analytics; + analyticsEvents: typeof analyticsEvents; + apiHelpers: typeof apiHelpers; + authHelpers: typeof authHelpers; + clerkApiKeys: typeof clerkApiKeys; + clerkApiKeysQueries: typeof clerkApiKeysQueries; + cli: typeof cli; + cliInternal: typeof cliInternal; + crons: typeof crons; + http: typeof http; + "instances/actions": typeof instances_actions; + "instances/mutations": typeof instances_mutations; + "instances/queries": typeof instances_queries; + mcp: typeof mcp; + mcpInternal: typeof mcpInternal; + mcpQuestions: typeof mcpQuestions; + messages: typeof messages; + migrations: typeof migrations; + projects: typeof projects; + resources: typeof resources; + "scheduled/queries": typeof scheduled_queries; + "scheduled/updates": typeof scheduled_updates; + "scheduled/versionCheck": typeof scheduled_versionCheck; + seed: typeof seed; + streamSessions: typeof streamSessions; + threadTitle: typeof threadTitle; + threads: typeof threads; + usage: typeof usage; + users: typeof users; }>; /** @@ -80,7 +84,10 @@ declare const fullApi: ApiFromModules<{ * const myFunctionReference = api.myModule.myFunction; * ``` */ -export declare const api: FilterApi>; +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; /** * A utility for referencing Convex functions in your app's internal API. @@ -90,88 +97,96 @@ export declare const api: FilterApi>; +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; export declare const components: { - migrations: { - lib: { - cancel: FunctionReference< - 'mutation', - 'internal', - { name: string }, - { - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - } - >; - cancelAll: FunctionReference< - 'mutation', - 'internal', - { sinceTs?: number }, - Array<{ - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - }> - >; - clearAll: FunctionReference<'mutation', 'internal', { before?: number }, null>; - getStatus: FunctionReference< - 'query', - 'internal', - { limit?: number; names?: Array }, - Array<{ - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - }> - >; - migrate: FunctionReference< - 'mutation', - 'internal', - { - batchSize?: number; - cursor?: string | null; - dryRun: boolean; - fnHandle: string; - name: string; - next?: Array<{ fnHandle: string; name: string }>; - oneBatchOnly?: boolean; - }, - { - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - } - >; - }; - }; + migrations: { + lib: { + cancel: FunctionReference< + "mutation", + "internal", + { name: string }, + { + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + } + >; + cancelAll: FunctionReference< + "mutation", + "internal", + { sinceTs?: number }, + Array<{ + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + }> + >; + clearAll: FunctionReference< + "mutation", + "internal", + { before?: number }, + null + >; + getStatus: FunctionReference< + "query", + "internal", + { limit?: number; names?: Array }, + Array<{ + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + }> + >; + migrate: FunctionReference< + "mutation", + "internal", + { + batchSize?: number; + cursor?: string | null; + dryRun: boolean; + fnHandle: string; + name: string; + next?: Array<{ fnHandle: string; name: string }>; + oneBatchOnly?: boolean; + }, + { + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + } + >; + }; + }; }; diff --git a/apps/web/src/convex/_generated/api.js b/apps/web/src/convex/_generated/api.js index 24593c74..44bf9858 100644 --- a/apps/web/src/convex/_generated/api.js +++ b/apps/web/src/convex/_generated/api.js @@ -8,7 +8,7 @@ * @module */ -import { anyApi, componentsGeneric } from 'convex/server'; +import { anyApi, componentsGeneric } from "convex/server"; /** * A utility for referencing Convex functions in your app's API. diff --git a/apps/web/src/convex/_generated/dataModel.d.ts b/apps/web/src/convex/_generated/dataModel.d.ts index 5428df6f..f97fd194 100644 --- a/apps/web/src/convex/_generated/dataModel.d.ts +++ b/apps/web/src/convex/_generated/dataModel.d.ts @@ -9,13 +9,13 @@ */ import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames -} from 'convex/server'; -import type { GenericId } from 'convex/values'; -import schema from '../schema.js'; + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; /** * The names of all of your Convex tables. @@ -27,7 +27,10 @@ export type TableNames = TableNamesInDataModel; * * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Doc = DocumentByName; +export type Doc = DocumentByName< + DataModel, + TableName +>; /** * An identifier for a document in Convex. @@ -42,7 +45,8 @@ export type Doc = DocumentByName = GenericId; +export type Id = + GenericId; /** * A type describing your Convex data model. diff --git a/apps/web/src/convex/_generated/server.d.ts b/apps/web/src/convex/_generated/server.d.ts index 1cc047ef..bec05e68 100644 --- a/apps/web/src/convex/_generated/server.d.ts +++ b/apps/web/src/convex/_generated/server.d.ts @@ -9,17 +9,17 @@ */ import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter -} from 'convex/server'; -import type { DataModel } from './dataModel.js'; + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; /** * Define a query in this Convex app's public API. @@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js'; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const query: QueryBuilder; +export declare const query: QueryBuilder; /** * Define a query that is only accessible from other Convex functions (but not from the client). @@ -39,7 +39,7 @@ export declare const query: QueryBuilder; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const internalQuery: QueryBuilder; +export declare const internalQuery: QueryBuilder; /** * Define a mutation in this Convex app's public API. @@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const mutation: MutationBuilder; +export declare const mutation: MutationBuilder; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). @@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const internalMutation: MutationBuilder; +export declare const internalMutation: MutationBuilder; /** * Define an action in this Convex app's public API. @@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder; * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ -export declare const action: ActionBuilder; +export declare const action: ActionBuilder; /** * Define an action that is only accessible from other Convex functions (but not from the client). @@ -80,7 +80,7 @@ export declare const action: ActionBuilder; * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ -export declare const internalAction: ActionBuilder; +export declare const internalAction: ActionBuilder; /** * Define an HTTP action. diff --git a/apps/web/src/convex/_generated/server.js b/apps/web/src/convex/_generated/server.js index a18aa285..bf3d25ad 100644 --- a/apps/web/src/convex/_generated/server.js +++ b/apps/web/src/convex/_generated/server.js @@ -9,14 +9,14 @@ */ import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric -} from 'convex/server'; + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; /** * Define a query in this Convex app's public API. diff --git a/apps/web/src/convex/instances/actions.ts b/apps/web/src/convex/instances/actions.ts index 84b7c773..8937098b 100644 --- a/apps/web/src/convex/instances/actions.ts +++ b/apps/web/src/convex/instances/actions.ts @@ -29,7 +29,7 @@ const BTCA_PACKAGE_NAME = 'btca@latest'; const instanceArgs = { instanceId: v.id('instances') }; -type ResourceConfig = { +type GitResourceConfig = { name: string; type: 'git'; url: string; @@ -38,6 +38,16 @@ type ResourceConfig = { specialNotes?: string; }; +type NpmResourceConfig = { + name: string; + type: 'npm'; + package: string; + version?: string; + specialNotes?: string; +}; + +type ResourceConfig = GitResourceConfig | NpmResourceConfig; + type InstalledVersions = { btcaVersion?: string; }; @@ -94,14 +104,24 @@ function generateBtcaConfig(resources: ResourceConfig[]): string { return JSON.stringify( { $schema: 'https://btca.dev/btca.schema.json', - resources: resources.map((resource) => ({ - name: resource.name, - type: resource.type, - url: resource.url, - branch: resource.branch, - searchPath: resource.searchPath, - specialNotes: resource.specialNotes - })), + resources: resources.map((resource) => + resource.type === 'git' + ? { + name: resource.name, + type: resource.type, + url: resource.url, + branch: resource.branch, + searchPath: resource.searchPath, + specialNotes: resource.specialNotes + } + : { + name: resource.name, + type: resource.type, + package: resource.package, + version: resource.version, + specialNotes: resource.specialNotes + } + ), model: DEFAULT_MODEL, provider: DEFAULT_PROVIDER }, @@ -167,15 +187,11 @@ const attachErrorContext = (error: unknown, context: Record) => return error; }; -const throwInstanceError = (error: WebError): never => { - throw error; -}; - const unwrapInstance = (result: InstanceActionResult): T => { - return Result.match(result, { - ok: (value) => value, - err: (error) => throwInstanceError(error) - }); + if (Result.isError(result)) { + throw result.error; + } + return result.value; }; const withStep = async ( @@ -233,11 +249,23 @@ async function getResourceConfigs( const merged = new Map(); for (const resource of [...resources.global, ...resources.custom]) { + if (resource.type === 'npm') { + merged.set(resource.name, { + name: resource.name, + type: 'npm', + package: resource.package, + version: resource.version ?? undefined, + specialNotes: resource.specialNotes ?? undefined + }); + continue; + } + + if (!resource.url) continue; merged.set(resource.name, { name: resource.name, type: 'git', url: resource.url, - branch: resource.branch, + branch: resource.branch ?? 'main', searchPath: resource.searchPath ?? undefined, specialNotes: resource.specialNotes ?? undefined }); @@ -245,6 +273,9 @@ async function getResourceConfigs( return [...merged.values()]; } +const hasNpmResources = (resources: ResourceConfig[]) => + resources.some((resource) => resource.type === 'npm'); + async function requireInstance( ctx: ActionCtx, instanceId: Id<'instances'> @@ -423,6 +454,10 @@ export const provision = action({ step = 'upload_config'; unwrapInstance(await withStep(step, () => uploadBtcaConfig(createdSandbox, resources))); + if (hasNpmResources(resources)) { + step = 'update_packages'; + unwrapInstance(await withStep(step, () => updatePackages(createdSandbox))); + } step = 'start_btca'; unwrapInstance(await withStep(step, () => startBtcaServer(createdSandbox))); @@ -653,6 +688,10 @@ async function createSandboxFromScratch( step = 'upload_config'; unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources))); + if (hasNpmResources(resources)) { + step = 'update_packages'; + unwrapInstance(await withStep(step, () => updatePackages(sandbox))); + } step = 'start_btca'; const serverUrl = unwrapInstance(await withStep(step, () => startBtcaServer(sandbox))); step = 'get_versions'; @@ -724,6 +763,10 @@ async function wakeInstanceInternal( unwrapInstance(await withStep(step, () => ensureSandboxStarted(sandbox))); step = 'upload_config'; unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources))); + if (hasNpmResources(resources)) { + step = 'update_packages'; + unwrapInstance(await withStep(step, () => updatePackages(sandbox))); + } step = 'start_btca'; serverUrl = unwrapInstance(await withStep(step, () => startBtcaServer(sandbox))); sandboxId = instance.sandboxId; @@ -1027,13 +1070,24 @@ export const syncResources = internalAction({ } }); -type CachedResourceInfo = { +type CachedGitResourceInfo = { name: string; + type: 'git'; url: string; branch: string; sizeBytes?: number; }; +type CachedNpmResourceInfo = { + name: string; + type: 'npm'; + package: string; + version?: string; + sizeBytes?: number; +}; + +type CachedResourceInfo = CachedGitResourceInfo | CachedNpmResourceInfo; + type SyncResult = { storageUsedBytes: number; cachedResources: CachedResourceInfo[]; @@ -1059,6 +1113,43 @@ async function getSandboxStatus(sandbox: Sandbox): Promise { const cachedResources: CachedResourceInfo[] = []; for (const dir of resourceDirs) { + const npmMetaPath = `${RESOURCES_DIR}/${dir}/.btca-npm-meta.json`; + const npmMetaResult = await sandbox.process.executeCommand( + `cat "${npmMetaPath}" 2>/dev/null || echo ""` + ); + const npmMetaText = npmMetaResult.result.trim(); + let npmPackage: string | undefined; + let npmVersion: string | undefined; + if (npmMetaText) { + try { + const npmMeta = JSON.parse(npmMetaText) as { + packageName?: string; + resolvedVersion?: string; + }; + npmPackage = npmMeta.packageName?.trim(); + npmVersion = npmMeta.resolvedVersion?.trim(); + } catch { + // Ignore malformed metadata and fall back to git metadata detection. + } + } + + const sizeResult = await sandbox.process.executeCommand( + `du -sb "${RESOURCES_DIR}/${dir}" 2>/dev/null || echo "0"` + ); + const sizeMatch = sizeResult.result.trim().match(/^(\d+)/); + const sizeBytes = sizeMatch ? parseInt(sizeMatch[1], 10) : undefined; + + if (npmPackage) { + cachedResources.push({ + name: dir, + type: 'npm', + package: npmPackage, + version: npmVersion, + sizeBytes + }); + continue; + } + const gitConfigPath = `${RESOURCES_DIR}/${dir}/.git/config`; const gitConfigResult = await sandbox.process.executeCommand( `cat "${gitConfigPath}" 2>/dev/null || echo ""` @@ -1077,15 +1168,10 @@ async function getSandboxStatus(sandbox: Sandbox): Promise { branch = branchMatch[1]; } - const sizeResult = await sandbox.process.executeCommand( - `du -sb "${RESOURCES_DIR}/${dir}" 2>/dev/null || echo "0"` - ); - const sizeMatch = sizeResult.result.trim().match(/^(\d+)/); - const sizeBytes = sizeMatch ? parseInt(sizeMatch[1], 10) : undefined; - if (url) { cachedResources.push({ name: dir, + type: 'git', url, branch, sizeBytes @@ -1102,12 +1188,22 @@ export const syncSandboxStatus = internalAction({ v.object({ storageUsedBytes: v.number(), cachedResources: v.array( - v.object({ - name: v.string(), - url: v.string(), - branch: v.string(), - sizeBytes: v.optional(v.number()) - }) + v.union( + v.object({ + name: v.string(), + type: v.literal('git'), + url: v.string(), + branch: v.string(), + sizeBytes: v.optional(v.number()) + }), + v.object({ + name: v.string(), + type: v.literal('npm'), + package: v.string(), + version: v.optional(v.string()), + sizeBytes: v.optional(v.number()) + }) + ) ) }), v.null() diff --git a/apps/web/src/convex/instances/mutations.ts b/apps/web/src/convex/instances/mutations.ts index cf54acd7..319ed05c 100644 --- a/apps/web/src/convex/instances/mutations.ts +++ b/apps/web/src/convex/instances/mutations.ts @@ -225,12 +225,22 @@ export const upsertCachedResources = mutation({ args: { instanceId: v.id('instances'), resources: v.array( - v.object({ - name: v.string(), - url: v.string(), - branch: v.string(), - sizeBytes: v.optional(v.number()) - }) + v.union( + v.object({ + name: v.string(), + type: v.literal('git'), + url: v.string(), + branch: v.string(), + sizeBytes: v.optional(v.number()) + }), + v.object({ + name: v.string(), + type: v.literal('npm'), + package: v.string(), + version: v.optional(v.string()), + sizeBytes: v.optional(v.number()) + }) + ) ) }, returns: v.null(), @@ -247,8 +257,20 @@ export const upsertCachedResources = mutation({ const existingResource = existingByName.get(resource.name); if (existingResource) { await ctx.db.patch(existingResource._id, { - url: resource.url, - branch: resource.branch, + type: resource.type, + ...(resource.type === 'git' + ? { + url: resource.url, + branch: resource.branch, + package: undefined, + version: undefined + } + : { + package: resource.package, + version: resource.version, + url: undefined, + branch: undefined + }), sizeBytes: resource.sizeBytes, lastUsedAt: now }); @@ -256,8 +278,10 @@ export const upsertCachedResources = mutation({ await ctx.db.insert('cachedResources', { instanceId: args.instanceId, name: resource.name, - url: resource.url, - branch: resource.branch, + type: resource.type, + ...(resource.type === 'git' + ? { url: resource.url, branch: resource.branch } + : { package: resource.package, version: resource.version }), sizeBytes: resource.sizeBytes, cachedAt: now, lastUsedAt: now diff --git a/apps/web/src/convex/instances/queries.ts b/apps/web/src/convex/instances/queries.ts index 32811462..1a235c43 100644 --- a/apps/web/src/convex/instances/queries.ts +++ b/apps/web/src/convex/instances/queries.ts @@ -48,8 +48,11 @@ const cachedResourceValidator = v.object({ instanceId: v.id('instances'), projectId: v.optional(v.id('projects')), name: v.string(), - url: v.string(), - branch: v.string(), + type: v.optional(v.union(v.literal('git'), v.literal('npm'))), + url: v.optional(v.string()), + branch: v.optional(v.string()), + package: v.optional(v.string()), + version: v.optional(v.string()), sizeBytes: v.optional(v.number()), cachedAt: v.number(), lastUsedAt: v.number() diff --git a/apps/web/src/convex/mcp.ts b/apps/web/src/convex/mcp.ts index b257ad4a..8850a7ff 100644 --- a/apps/web/src/convex/mcp.ts +++ b/apps/web/src/convex/mcp.ts @@ -17,75 +17,22 @@ const instanceMutations = instances.mutations; type AskResult = { ok: true; text: string } | { ok: false; error: string }; type McpActionResult = Result; -function stripJsonComments(content: string): string { - let result = ''; - let inString = false; - let inLineComment = false; - let inBlockComment = false; - let i = 0; - - while (i < content.length) { - const char = content[i]; - const next = content[i + 1]; - - if (inLineComment) { - if (char === '\n') { - inLineComment = false; - result += char; - } - i++; - continue; - } - - if (inBlockComment) { - if (char === '*' && next === '/') { - inBlockComment = false; - i += 2; - continue; - } - i++; - continue; - } - - if (inString) { - result += char; - if (char === '\\' && i + 1 < content.length) { - result += content[i + 1]; - i += 2; - continue; - } - if (char === '"') { - inString = false; - } - i++; - continue; - } - - if (char === '"') { - inString = true; - result += char; - i++; - continue; - } - - if (char === '/' && next === '/') { - inLineComment = true; - i += 2; - continue; - } - - if (char === '/' && next === '*') { - inBlockComment = true; - i += 2; - continue; - } - - result += char; - i++; +const NPM_PACKAGE_SEGMENT_REGEX = /^[a-z0-9][a-z0-9._-]*$/; +const NPM_VERSION_OR_TAG_REGEX = /^[^\s/]+$/; + +const isValidNpmPackage = (value: string) => { + if (!value) return false; + if (value.startsWith('@')) { + const parts = value.split('/'); + return ( + parts.length === 2 && + parts[0] !== '@' && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[0]!.slice(1)) && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[1]!) + ); } - - return result.replace(/,(\s*[}\]])/g, '$1'); -} + return !value.includes('/') && NPM_PACKAGE_SEGMENT_REGEX.test(value); +}; /** * Get or create a project by name for an instance. @@ -299,16 +246,27 @@ type ListResourcesResult = | { ok: false; error: string } | { ok: true; - resources: { - name: string; - displayName: string; - type: string; - url: string; - branch: string; - searchPath: string | undefined; - specialNotes: string | undefined; - isGlobal: false; - }[]; + resources: Array< + | { + name: string; + displayName: string; + type: 'git'; + url: string; + branch: string; + searchPath?: string; + specialNotes?: string; + isGlobal: false; + } + | { + name: string; + displayName: string; + type: 'npm'; + package: string; + version?: string; + specialNotes?: string; + isGlobal: false; + } + >; }; /** @@ -327,16 +285,27 @@ export const listResources = action({ v.object({ ok: v.literal(true), resources: v.array( - v.object({ - name: v.string(), - displayName: v.string(), - type: v.string(), - url: v.string(), - branch: v.string(), - searchPath: v.optional(v.string()), - specialNotes: v.optional(v.string()), - isGlobal: v.literal(false) - }) + v.union( + v.object({ + name: v.string(), + displayName: v.string(), + type: v.literal('git'), + url: v.string(), + branch: v.string(), + searchPath: v.optional(v.string()), + specialNotes: v.optional(v.string()), + isGlobal: v.literal(false) + }), + v.object({ + name: v.string(), + displayName: v.string(), + type: v.literal('npm'), + package: v.string(), + version: v.optional(v.string()), + specialNotes: v.optional(v.string()), + isGlobal: v.literal(false) + }) + ) ) }) ), @@ -374,15 +343,24 @@ type AddResourceResult = | { ok: false; error: string } | { ok: true; - resource: { - name: string; - displayName: string; - type: string; - url: string; - branch: string; - searchPath: string | undefined; - specialNotes: string | undefined; - }; + resource: + | { + name: string; + displayName: string; + type: 'git'; + url: string; + branch: string; + searchPath?: string; + specialNotes?: string; + } + | { + name: string; + displayName: string; + type: 'npm'; + package: string; + version?: string; + specialNotes?: string; + }; }; /** @@ -391,11 +369,14 @@ type AddResourceResult = export const addResource = action({ args: { apiKey: v.string(), - url: v.string(), + type: v.optional(v.union(v.literal('git'), v.literal('npm'))), name: v.string(), - branch: v.string(), + url: v.optional(v.string()), + branch: v.optional(v.string()), searchPath: v.optional(v.string()), searchPaths: v.optional(v.array(v.string())), + package: v.optional(v.string()), + version: v.optional(v.string()), notes: v.optional(v.string()), project: v.optional(v.string()) }, @@ -403,25 +384,38 @@ export const addResource = action({ v.object({ ok: v.literal(false), error: v.string() }), v.object({ ok: v.literal(true), - resource: v.object({ - name: v.string(), - displayName: v.string(), - type: v.string(), - url: v.string(), - branch: v.string(), - searchPath: v.optional(v.string()), - specialNotes: v.optional(v.string()) - }) + resource: v.union( + v.object({ + name: v.string(), + displayName: v.string(), + type: v.literal('git'), + url: v.string(), + branch: v.string(), + searchPath: v.optional(v.string()), + specialNotes: v.optional(v.string()) + }), + v.object({ + name: v.string(), + displayName: v.string(), + type: v.literal('npm'), + package: v.string(), + version: v.optional(v.string()), + specialNotes: v.optional(v.string()) + }) + ) }) ), handler: async (ctx, args): Promise => { const { apiKey, + type, url, name, branch, searchPath, searchPaths, + package: packageName, + version, notes, project: projectName } = args; @@ -446,10 +440,21 @@ export const addResource = action({ // Note: Usage tracking is handled in the validate action via touchUsage - // Validate URL (basic check) - if (!url.startsWith('https://')) { - return { ok: false as const, error: 'URL must be an HTTPS URL' }; + const hasGitFields = + typeof url === 'string' || + typeof branch === 'string' || + typeof searchPath === 'string' || + typeof searchPaths !== 'undefined'; + const hasNpmFields = typeof packageName === 'string' || typeof version === 'string'; + + if (!type && hasGitFields && hasNpmFields) { + return { + ok: false as const, + error: + 'Ambiguous resource payload. Set type to "git" or "npm" when sending both git and npm fields.' + }; } + const resolvedType = type ?? (hasNpmFields ? 'npm' : 'git'); // Check if resource with this name already exists in this project const exists = await ctx.runQuery(internal.resources.resourceExistsInProject, { @@ -460,230 +465,85 @@ export const addResource = action({ return { ok: false as const, error: `Resource "${name}" already exists in this project` }; } - // Add the resource - const finalSearchPath = searchPath ?? searchPaths?.[0]; - await ctx.runMutation(internal.mcpInternal.addResourceInternal, { - instanceId, - projectId, - name, - url, - branch, - searchPath: finalSearchPath, - specialNotes: notes - }); - - return { - ok: true as const, - resource: { + if (resolvedType === 'git') { + if (hasNpmFields) { + return { + ok: false as const, + error: 'Git resources cannot include npm package/version fields' + }; + } + if (!url?.trim()) { + return { ok: false as const, error: 'Git URL is required' }; + } + if (!url.startsWith('https://')) { + return { ok: false as const, error: 'URL must be an HTTPS URL' }; + } + const finalSearchPath = searchPath ?? searchPaths?.[0]; + const resolvedBranch = branch?.trim() || 'main'; + await ctx.runMutation(internal.mcpInternal.addResourceInternal, { + instanceId, + projectId, name, - displayName: name, type: 'git', url, - branch, + branch: resolvedBranch, searchPath: finalSearchPath, specialNotes: notes - } - }; - } -}); - -type SyncResult = { - ok: boolean; - errors?: string[]; - synced: string[]; - conflicts?: Array<{ - name: string; - local: { url: string; branch: string }; - remote: { url: string; branch: string }; - }>; -}; - -/** - * Sync remote config with cloud - authenticated via API key - */ -export const sync = action({ - args: { - apiKey: v.string(), - config: v.string(), - force: v.boolean() - }, - returns: v.object({ - ok: v.boolean(), - errors: v.optional(v.array(v.string())), - synced: v.array(v.string()), - conflicts: v.optional( - v.array( - v.object({ - name: v.string(), - local: v.object({ url: v.string(), branch: v.string() }), - remote: v.object({ url: v.string(), branch: v.string() }) - }) - ) - ) - }), - handler: async (ctx, args): Promise => { - const { apiKey, config: configStr, force } = args; - - // Validate API key with Clerk - const validation = (await ctx.runAction(api.clerkApiKeys.validate, { - apiKey - })) as ApiKeyValidationResult; - if (!validation.valid) { - return { ok: false, errors: [validation.error], synced: [] }; - } - - const instanceId = validation.instanceId; - - // Note: Usage tracking is handled in the validate action via touchUsage - - // Parse the config - let config: { - project: string; - model?: string; - resources: Array<{ - type?: string; - name: string; - url: string; - branch: string; - searchPath?: string; - searchPaths?: string[]; - specialNotes?: string; - }>; - }; + }); - try { - const stripped = stripJsonComments(configStr); - config = JSON.parse(stripped); - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown parse error'; return { - ok: false, - errors: [`Invalid JSON in config: ${errorMsg}`], - synced: [] + ok: true as const, + resource: { + name, + displayName: name, + type: 'git', + url, + branch: resolvedBranch, + searchPath: finalSearchPath, + specialNotes: notes + } }; } - if (!config.project || typeof config.project !== 'string') { + if (hasGitFields) { return { - ok: false, - errors: ['Missing or invalid "project" field in config (must be a string)'], - synced: [] + ok: false as const, + error: 'npm resources cannot include git URL/branch/searchPath fields' }; } - - if (!Array.isArray(config.resources)) { + if (!packageName?.trim()) { + return { ok: false as const, error: 'npm package is required' }; + } + if (!isValidNpmPackage(packageName.trim())) { return { - ok: false, - errors: ['Missing or invalid "resources" field in config (must be an array)'], - synced: [] + ok: false as const, + error: 'npm package must be a valid package name (for example react or @types/node)' }; } - - const resourceErrors: string[] = []; - for (let i = 0; i < config.resources.length; i++) { - const r = config.resources[i]; - if (!r || typeof r !== 'object') { - resourceErrors.push(`resources[${i}]: must be an object`); - continue; - } - if (!r.name || typeof r.name !== 'string') { - resourceErrors.push(`resources[${i}]: missing or invalid "name" (must be a string)`); - } - if (!r.url || typeof r.url !== 'string') { - resourceErrors.push(`resources[${i}]: missing or invalid "url" (must be a string)`); - } - if (!r.branch || typeof r.branch !== 'string') { - resourceErrors.push(`resources[${i}]: missing or invalid "branch" (must be a string)`); - } - } - - if (resourceErrors.length > 0) { - return { ok: false, errors: resourceErrors, synced: [] }; + if (version && !NPM_VERSION_OR_TAG_REGEX.test(version)) { + return { ok: false as const, error: 'npm version/tag must not contain spaces or "/"' }; } - - // Get or create the project - const projectIdResult = await getOrCreateProject(ctx, instanceId, config.project); - if (Result.isError(projectIdResult)) { - return { ok: false, errors: [projectIdResult.error.message], synced: [] }; - } - const projectId = projectIdResult.value; - - // Get current resources for this project - const existingResources = await ctx.runQuery(internal.resources.listByProject, { - projectId + const resolvedVersion = version?.trim() || undefined; + await ctx.runMutation(internal.mcpInternal.addResourceInternal, { + instanceId, + projectId, + name, + type: 'npm', + package: packageName.trim(), + version: resolvedVersion, + specialNotes: notes }); - const synced: string[] = []; - const errors: string[] = []; - const conflicts: SyncResult['conflicts'] = []; - - // Process each resource in the config - for (const localResource of config.resources) { - const existingResource = existingResources.find( - (r: { name: string; url: string; branch: string }) => - r.name.toLowerCase() === localResource.name.toLowerCase() - ); - - if (existingResource) { - // Check for conflicts - const urlMatch = existingResource.url === localResource.url; - const branchMatch = existingResource.branch === localResource.branch; - - if (!urlMatch || !branchMatch) { - if (force) { - // Update the resource - await ctx.runMutation(internal.mcpInternal.updateResourceInternal, { - instanceId, - projectId, - name: localResource.name, - url: localResource.url, - branch: localResource.branch, - searchPath: localResource.searchPath ?? localResource.searchPaths?.[0], - specialNotes: localResource.specialNotes - }); - synced.push(localResource.name); - } else { - conflicts.push({ - name: localResource.name, - local: { url: localResource.url, branch: localResource.branch }, - remote: { url: existingResource.url, branch: existingResource.branch } - }); - } - } - // If they match, nothing to do - } else { - // Add new resource - try { - await ctx.runMutation(internal.mcpInternal.addResourceInternal, { - instanceId, - projectId, - name: localResource.name, - url: localResource.url, - branch: localResource.branch, - searchPath: localResource.searchPath ?? localResource.searchPaths?.[0], - specialNotes: localResource.specialNotes - }); - synced.push(localResource.name); - } catch (err) { - errors.push( - `Failed to add "${localResource.name}": ${err instanceof Error ? err.message : String(err)}` - ); - } + return { + ok: true as const, + resource: { + name, + displayName: name, + type: 'npm', + package: packageName.trim(), + version: resolvedVersion, + specialNotes: notes } - } - - // Update project model if specified - if (config.model) { - await ctx.runMutation(internal.mcpInternal.updateProjectModelInternal, { - projectId, - model: config.model - }); - } - - if (conflicts.length > 0) { - return { ok: false, errors, synced, conflicts }; - } - - return { ok: errors.length === 0, errors: errors.length > 0 ? errors : undefined, synced }; + }; } }); diff --git a/apps/web/src/convex/mcpInternal.ts b/apps/web/src/convex/mcpInternal.ts index dc1f106a..8471d39f 100644 --- a/apps/web/src/convex/mcpInternal.ts +++ b/apps/web/src/convex/mcpInternal.ts @@ -7,10 +7,117 @@ import { WebConflictError, WebValidationError, type WebError } from '../lib/resu type McpInternalResult = Result; +const NPM_PACKAGE_SEGMENT_REGEX = /^[a-z0-9][a-z0-9._-]*$/; +const NPM_VERSION_OR_TAG_REGEX = /^[^\s/]+$/; + const throwMcpInternalError = (error: WebError): never => { throw error; }; +const isValidNpmPackage = (value: string) => { + if (!value) return false; + if (value.startsWith('@')) { + const parts = value.split('/'); + return ( + parts.length === 2 && + parts[0] !== '@' && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[0]!.slice(1)) && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[1]!) + ); + } + return !value.includes('/') && NPM_PACKAGE_SEGMENT_REGEX.test(value); +}; + +const resolveMcpResourceInput = (args: { + type?: 'git' | 'npm'; + name: string; + url?: string; + branch?: string; + searchPath?: string; + package?: string; + version?: string; + specialNotes?: string; +}) => { + const requestedType = args.type; + const hasGitFields = + typeof args.url === 'string' || + typeof args.branch === 'string' || + typeof args.searchPath === 'string'; + const hasNpmFields = typeof args.package === 'string' || typeof args.version === 'string'; + + if (!requestedType && hasGitFields && hasNpmFields) { + throw new WebValidationError({ + message: + 'Ambiguous resource payload. Set "type" to "git" or "npm" when sending both git and npm fields.', + field: 'type' + }); + } + + const type = requestedType ?? (hasNpmFields ? 'npm' : 'git'); + + if (type === 'git') { + if (hasNpmFields) { + throw new WebValidationError({ + message: 'Git resources cannot include npm package or version fields', + field: 'type' + }); + } + if (!args.url?.trim()) { + throw new WebValidationError({ message: 'Git URL is required', field: 'url' }); + } + let parsedUrl: URL; + try { + parsedUrl = new URL(args.url); + } catch { + throw new WebValidationError({ message: 'Invalid URL format', field: 'url' }); + } + if (parsedUrl.protocol !== 'https:') { + throw new WebValidationError({ message: 'URL must be an HTTPS URL', field: 'url' }); + } + + return { + type: 'git' as const, + name: args.name, + url: args.url.trim(), + branch: args.branch?.trim() || 'main', + searchPath: args.searchPath, + specialNotes: args.specialNotes + }; + } + + if (hasGitFields) { + throw new WebValidationError({ + message: 'npm resources cannot include git URL/branch/searchPath fields', + field: 'type' + }); + } + + const packageName = args.package?.trim(); + if (!packageName) { + throw new WebValidationError({ message: 'npm package is required', field: 'package' }); + } + if (!isValidNpmPackage(packageName)) { + throw new WebValidationError({ + message: 'npm package must be a valid package name (for example react or @types/node)', + field: 'package' + }); + } + if (args.version && !NPM_VERSION_OR_TAG_REGEX.test(args.version)) { + throw new WebValidationError({ + message: 'npm version/tag must not contain spaces or "/"', + field: 'version' + }); + } + + return { + type: 'npm' as const, + name: args.name, + package: packageName, + version: args.version?.trim() || undefined, + specialNotes: args.specialNotes + }; +}; + /** * Internal mutation to create a project (used by MCP to avoid auth requirements) */ @@ -22,7 +129,6 @@ export const createProjectInternal = internalMutation({ }, returns: v.id('projects'), handler: async (ctx, args): Promise> => { - // Double-check it doesn't exist (race condition protection) const existing = await ctx.db .query('projects') .withIndex('by_instance_and_name', (q) => @@ -73,14 +179,16 @@ export const addResourceInternal = internalMutation({ instanceId: v.id('instances'), projectId: v.id('projects'), name: v.string(), - url: v.string(), - branch: v.string(), + type: v.optional(v.union(v.literal('git'), v.literal('npm'))), + url: v.optional(v.string()), + branch: v.optional(v.string()), searchPath: v.optional(v.string()), + package: v.optional(v.string()), + version: v.optional(v.string()), specialNotes: v.optional(v.string()) }, returns: v.id('userResources'), handler: async (ctx, args): Promise> => { - // Check if resource with this name already exists for this project using compound index const existing = await ctx.db .query('userResources') .withIndex('by_project_and_name', (q) => @@ -98,75 +206,26 @@ export const addResourceInternal = internalMutation({ throwMcpInternalError(result.error); } + const resourceInput = resolveMcpResourceInput(args); + return await ctx.db.insert('userResources', { instanceId: args.instanceId, projectId: args.projectId, name: args.name, - type: 'git', - url: args.url, - branch: args.branch, - searchPath: args.searchPath, - specialNotes: args.specialNotes, + type: resourceInput.type, + ...(resourceInput.type === 'git' + ? { + url: resourceInput.url, + branch: resourceInput.branch, + searchPath: resourceInput.searchPath, + specialNotes: resourceInput.specialNotes + } + : { + package: resourceInput.package, + version: resourceInput.version, + specialNotes: resourceInput.specialNotes + }), createdAt: Date.now() }); } }); - -/** - * Internal mutation to update a resource (used by MCP sync) - */ -export const updateResourceInternal = internalMutation({ - args: { - instanceId: v.id('instances'), - projectId: v.id('projects'), - name: v.string(), - url: v.string(), - branch: v.string(), - searchPath: v.optional(v.string()), - specialNotes: v.optional(v.string()) - }, - returns: v.null(), - handler: async (ctx, args) => { - // Use compound index for efficient lookup by project - const existing = await ctx.db - .query('userResources') - .withIndex('by_project_and_name', (q) => - q.eq('projectId', args.projectId).eq('name', args.name) - ) - .first(); - - if (!existing) { - const result: McpInternalResult = Result.err( - new WebValidationError({ - message: `Resource "${args.name}" not found in this project`, - field: 'name' - }) - ); - throwMcpInternalError(result.error); - } - await ctx.db.patch(existing!._id, { - url: args.url, - branch: args.branch, - searchPath: args.searchPath, - specialNotes: args.specialNotes - }); - return null; - } -}); - -/** - * Internal mutation to update a project's model (used by MCP sync) - */ -export const updateProjectModelInternal = internalMutation({ - args: { - projectId: v.id('projects'), - model: v.string() - }, - returns: v.null(), - handler: async (ctx, args) => { - await ctx.db.patch(args.projectId, { - model: args.model - }); - return null; - } -}); diff --git a/apps/web/src/convex/resources.ts b/apps/web/src/convex/resources.ts index b7d02948..8f17f78b 100644 --- a/apps/web/src/convex/resources.ts +++ b/apps/web/src/convex/resources.ts @@ -4,6 +4,7 @@ import { Result } from 'better-result'; import { internalQuery, mutation, query } from './_generated/server'; import { internal } from './_generated/api'; +import type { Id } from './_generated/dataModel'; import { AnalyticsEvents } from './analyticsEvents'; import { instances } from './apiHelpers'; import { @@ -16,6 +17,32 @@ import type { WebError } from '../lib/result/errors'; type ResourceNameResult = Result; +type GitCustomResource = { + name: string; + displayName: string; + type: 'git'; + url: string; + branch: string; + searchPath?: string; + specialNotes?: string; + isGlobal: false; +}; + +type NpmCustomResource = { + name: string; + displayName: string; + type: 'npm'; + package: string; + version?: string; + specialNotes?: string; + isGlobal: false; +}; + +type CustomResource = GitCustomResource | NpmCustomResource; + +const NPM_PACKAGE_SEGMENT_REGEX = /^[a-z0-9][a-z0-9._-]*$/; +const NPM_VERSION_OR_TAG_REGEX = /^[^\s/]+$/; + const validateResourceNameResult = (name: string): ResourceNameResult => { const nameError = getResourceNameError(name); if (nameError) { @@ -28,11 +55,69 @@ const throwResourceError = (error: WebError): never => { throw error; }; -// Resource validators +const isValidNpmPackage = (value: string) => { + if (!value) return false; + if (value.startsWith('@')) { + const parts = value.split('/'); + return ( + parts.length === 2 && + parts[0] !== '@' && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[0]!.slice(1)) && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[1]!) + ); + } + return !value.includes('/') && NPM_PACKAGE_SEGMENT_REGEX.test(value); +}; + +const mapGlobalResource = (resource: (typeof GLOBAL_RESOURCES)[number]) => ({ + name: resource.name, + displayName: resource.displayName, + type: 'git' as const, + url: resource.url, + branch: resource.branch, + searchPath: resource.searchPath ?? resource.searchPaths?.[0], + specialNotes: resource.specialNotes, + isGlobal: true as const +}); + +const mapUserResource = (resource: { + name: string; + type: 'git' | 'npm'; + url?: string; + branch?: string; + searchPath?: string; + package?: string; + version?: string; + specialNotes?: string; +}): CustomResource => { + if (resource.type === 'npm') { + return { + name: resource.name, + displayName: resource.name, + type: 'npm', + package: resource.package ?? '', + version: resource.version, + specialNotes: resource.specialNotes, + isGlobal: false + }; + } + + return { + name: resource.name, + displayName: resource.name, + type: 'git', + url: resource.url ?? '', + branch: resource.branch ?? 'main', + searchPath: resource.searchPath, + specialNotes: resource.specialNotes, + isGlobal: false + }; +}; + const globalResourceValidator = v.object({ name: v.string(), displayName: v.string(), - type: v.string(), + type: v.literal('git'), url: v.string(), branch: v.string(), searchPath: v.optional(v.string()), @@ -40,7 +125,7 @@ const globalResourceValidator = v.object({ isGlobal: v.literal(true) }); -const customResourceValidator = v.object({ +const gitCustomResourceValidator = v.object({ name: v.string(), displayName: v.string(), type: v.literal('git'), @@ -51,20 +136,157 @@ const customResourceValidator = v.object({ isGlobal: v.literal(false) }); -const userResourceValidator = v.object({ +const npmCustomResourceValidator = v.object({ + name: v.string(), + displayName: v.string(), + type: v.literal('npm'), + package: v.string(), + version: v.optional(v.string()), + specialNotes: v.optional(v.string()), + isGlobal: v.literal(false) +}); + +const customResourceValidator = v.union(gitCustomResourceValidator, npmCustomResourceValidator); + +const gitUserResourceValidator = v.object({ _id: v.id('userResources'), _creationTime: v.number(), instanceId: v.id('instances'), projectId: v.optional(v.id('projects')), name: v.string(), type: v.literal('git'), - url: v.string(), - branch: v.string(), + url: v.optional(v.string()), + branch: v.optional(v.string()), searchPath: v.optional(v.string()), + package: v.optional(v.string()), + version: v.optional(v.string()), specialNotes: v.optional(v.string()), createdAt: v.number() }); +const npmUserResourceValidator = v.object({ + _id: v.id('userResources'), + _creationTime: v.number(), + instanceId: v.id('instances'), + projectId: v.optional(v.id('projects')), + name: v.string(), + type: v.literal('npm'), + url: v.optional(v.string()), + branch: v.optional(v.string()), + searchPath: v.optional(v.string()), + package: v.optional(v.string()), + version: v.optional(v.string()), + specialNotes: v.optional(v.string()), + createdAt: v.number() +}); + +const userResourceValidator = v.union(gitUserResourceValidator, npmUserResourceValidator); + +const addCustomResourceArgs = { + name: v.string(), + type: v.optional(v.union(v.literal('git'), v.literal('npm'))), + url: v.optional(v.string()), + branch: v.optional(v.string()), + searchPath: v.optional(v.string()), + package: v.optional(v.string()), + version: v.optional(v.string()), + specialNotes: v.optional(v.string()), + projectId: v.optional(v.id('projects')) +}; + +const resolveAddCustomResourceInput = (args: { + name: string; + type?: 'git' | 'npm'; + url?: string; + branch?: string; + searchPath?: string; + package?: string; + version?: string; + specialNotes?: string; + projectId?: Id<'projects'>; +}) => { + const requestedType = args.type; + const hasGitFields = + typeof args.url === 'string' || + typeof args.branch === 'string' || + typeof args.searchPath === 'string'; + const hasNpmFields = typeof args.package === 'string' || typeof args.version === 'string'; + + if (!requestedType && hasGitFields && hasNpmFields) { + throw new WebValidationError({ + message: + 'Ambiguous resource payload. Set "type" to "git" or "npm" when sending both git and npm fields.', + field: 'type' + }); + } + + const type = requestedType ?? (hasNpmFields ? 'npm' : 'git'); + + if (type === 'git') { + if (hasNpmFields) { + throw new WebValidationError({ + message: 'Git resources cannot include npm package or version fields', + field: 'type' + }); + } + if (!args.url?.trim()) { + throw new WebValidationError({ message: 'Git URL is required', field: 'url' }); + } + let parsedUrl: URL; + try { + parsedUrl = new URL(args.url); + } catch { + throw new WebValidationError({ message: 'Invalid URL format', field: 'url' }); + } + if (parsedUrl.protocol !== 'https:') { + throw new WebValidationError({ message: 'URL must be an HTTPS URL', field: 'url' }); + } + + return { + type: 'git' as const, + name: args.name, + url: args.url.trim(), + branch: args.branch?.trim() || 'main', + searchPath: args.searchPath, + specialNotes: args.specialNotes, + projectId: args.projectId + }; + } + + if (hasGitFields) { + throw new WebValidationError({ + message: 'npm resources cannot include git URL/branch/searchPath fields', + field: 'type' + }); + } + + const packageName = args.package?.trim(); + if (!packageName) { + throw new WebValidationError({ message: 'npm package is required', field: 'package' }); + } + if (!isValidNpmPackage(packageName)) { + throw new WebValidationError({ + message: 'npm package must be a valid package name (for example react or @types/node)', + field: 'package' + }); + } + if (args.version && !NPM_VERSION_OR_TAG_REGEX.test(args.version)) { + throw new WebValidationError({ + message: 'npm version/tag must not contain spaces or "/"', + field: 'version' + }); + } + + return { + type: 'npm' as const, + name: args.name, + package: packageName, + version: args.version?.trim() || undefined, + specialNotes: args.specialNotes, + projectId: args.projectId + }; +}; + /** * List global resources (public, no auth required) */ @@ -138,29 +360,10 @@ export const listAvailable = query({ .withIndex('by_instance', (q) => q.eq('instanceId', instance._id)) .collect(); - const global = GLOBAL_RESOURCES.map((resource) => ({ - name: resource.name, - displayName: resource.displayName, - type: resource.type, - url: resource.url, - branch: resource.branch, - searchPath: resource.searchPath ?? resource.searchPaths?.[0], - specialNotes: resource.specialNotes, - isGlobal: true as const - })); - - const custom = userResources.map((r) => ({ - name: r.name, - displayName: r.name, - type: r.type, - url: r.url, - branch: r.branch, - searchPath: r.searchPath, - specialNotes: r.specialNotes, - isGlobal: false as const - })); - - return { global, custom }; + return { + global: GLOBAL_RESOURCES.map(mapGlobalResource), + custom: userResources.map(mapUserResource) + }; } }); @@ -215,29 +418,10 @@ export const listAvailableInternal = internalQuery({ .withIndex('by_instance', (q) => q.eq('instanceId', args.instanceId)) .collect(); - const global = GLOBAL_RESOURCES.map((resource) => ({ - name: resource.name, - displayName: resource.displayName, - type: resource.type, - url: resource.url, - branch: resource.branch, - searchPath: resource.searchPath ?? resource.searchPaths?.[0], - specialNotes: resource.specialNotes, - isGlobal: true as const - })); - - const custom = userResources.map((r) => ({ - name: r.name, - displayName: r.name, - type: r.type, - url: r.url, - branch: r.branch, - searchPath: r.searchPath, - specialNotes: r.specialNotes, - isGlobal: false as const - })); - - return { global, custom }; + return { + global: GLOBAL_RESOURCES.map(mapGlobalResource), + custom: userResources.map(mapUserResource) + }; } }); @@ -259,29 +443,10 @@ export const listAvailableForProject = internalQuery({ .withIndex('by_project', (q) => q.eq('projectId', args.projectId)) .collect(); - const global = GLOBAL_RESOURCES.map((resource) => ({ - name: resource.name, - displayName: resource.displayName, - type: resource.type, - url: resource.url, - branch: resource.branch, - searchPath: resource.searchPath ?? resource.searchPaths?.[0], - specialNotes: resource.specialNotes, - isGlobal: true as const - })); - - const custom = userResources.map((r) => ({ - name: r.name, - displayName: r.name, - type: r.type, - url: r.url, - branch: r.branch, - searchPath: r.searchPath, - specialNotes: r.specialNotes, - isGlobal: false as const - })); - - return { global, custom }; + return { + global: GLOBAL_RESOURCES.map(mapGlobalResource), + custom: userResources.map(mapUserResource) + }; } }); @@ -289,14 +454,7 @@ export const listAvailableForProject = internalQuery({ * Add a custom resource to the authenticated user's instance */ export const addCustomResource = mutation({ - args: { - name: v.string(), - url: v.string(), - branch: v.string(), - searchPath: v.optional(v.string()), - specialNotes: v.optional(v.string()), - projectId: v.optional(v.id('projects')) - }, + args: addCustomResourceArgs, returns: v.id('userResources'), handler: async (ctx, args) => { const instance = await unwrapAuthResult(await getAuthenticatedInstanceResult(ctx)); @@ -305,15 +463,48 @@ export const addCustomResource = mutation({ throwResourceError(nameResult.error); } + const resourceInput = resolveAddCustomResourceInput(args); + const requestedName = resourceInput.name.toLowerCase(); + const scopedResources = resourceInput.projectId + ? ( + await ctx.db + .query('userResources') + .withIndex('by_project', (q) => q.eq('projectId', resourceInput.projectId)) + .collect() + ).filter((resource) => resource.instanceId === instance._id) + : ( + await ctx.db + .query('userResources') + .withIndex('by_instance', (q) => q.eq('instanceId', instance._id)) + .collect() + ).filter((resource) => resource.projectId === undefined); + const nameExists = scopedResources.some( + (resource) => resource.name.toLowerCase() === requestedName + ); + if (nameExists) { + throw new WebValidationError({ + message: `Resource "${resourceInput.name}" already exists in this project`, + field: 'name' + }); + } + const resourceId = await ctx.db.insert('userResources', { instanceId: instance._id, - projectId: args.projectId, - name: args.name, - type: 'git', - url: args.url, - branch: args.branch, - searchPath: args.searchPath, - specialNotes: args.specialNotes, + projectId: resourceInput.projectId, + name: resourceInput.name, + type: resourceInput.type, + ...(resourceInput.type === 'git' + ? { + url: resourceInput.url, + branch: resourceInput.branch, + searchPath: resourceInput.searchPath, + specialNotes: resourceInput.specialNotes + } + : { + package: resourceInput.package, + version: resourceInput.version, + specialNotes: resourceInput.specialNotes + }), createdAt: Date.now() }); @@ -327,11 +518,19 @@ export const addCustomResource = mutation({ properties: { instanceId: instance._id, resourceId, - resourceName: args.name, - resourceUrl: args.url, - hasBranch: args.branch !== 'main', - hasSearchPath: !!args.searchPath, - hasNotes: !!args.specialNotes + resourceName: resourceInput.name, + resourceType: resourceInput.type, + hasNotes: !!resourceInput.specialNotes, + ...(resourceInput.type === 'git' + ? { + resourceUrl: resourceInput.url, + hasBranch: resourceInput.branch !== 'main', + hasSearchPath: !!resourceInput.searchPath + } + : { + resourcePackage: resourceInput.package, + hasVersion: !!resourceInput.version + }) } }); @@ -362,7 +561,8 @@ export const removeCustomResource = mutation({ properties: { instanceId: resource.instanceId, resourceId: args.resourceId, - resourceName: resource.name + resourceName: resource.name, + resourceType: resource.type } }); diff --git a/apps/web/src/convex/schema.ts b/apps/web/src/convex/schema.ts index ae15384f..c653afe9 100644 --- a/apps/web/src/convex/schema.ts +++ b/apps/web/src/convex/schema.ts @@ -86,8 +86,11 @@ export default defineSchema({ instanceId: v.id('instances'), projectId: v.optional(v.id('projects')), name: v.string(), - url: v.string(), - branch: v.string(), + type: v.optional(v.union(v.literal('git'), v.literal('npm'))), + url: v.optional(v.string()), + branch: v.optional(v.string()), + package: v.optional(v.string()), + version: v.optional(v.string()), sizeBytes: v.optional(v.number()), cachedAt: v.number(), lastUsedAt: v.number() @@ -110,10 +113,12 @@ export default defineSchema({ instanceId: v.id('instances'), projectId: v.optional(v.id('projects')), name: v.string(), - type: v.literal('git'), - url: v.string(), - branch: v.string(), + type: v.union(v.literal('git'), v.literal('npm')), + url: v.optional(v.string()), + branch: v.optional(v.string()), searchPath: v.optional(v.string()), + package: v.optional(v.string()), + version: v.optional(v.string()), specialNotes: v.optional(v.string()), createdAt: v.number() }) diff --git a/apps/web/src/lib/components/InstanceCard.svelte b/apps/web/src/lib/components/InstanceCard.svelte index ca53b9d1..87d573cf 100644 --- a/apps/web/src/lib/components/InstanceCard.svelte +++ b/apps/web/src/lib/components/InstanceCard.svelte @@ -180,6 +180,18 @@ } } + function isNpmCachedResource(resource: (typeof instanceStore.cachedResources)[number]) { + return resource.type === 'npm' || (!!resource.package && !resource.url); + } + + function getCachedResourceDetail(resource: (typeof instanceStore.cachedResources)[number]) { + if (isNpmCachedResource(resource)) { + const packageName = resource.package ?? resource.name; + return resource.version ? `${packageName}@${resource.version}` : packageName; + } + return resource.branch ?? 'main'; + } + async function runAction(action: InstanceAction) { if (pendingAction) return; pendingAction = action; @@ -468,7 +480,10 @@ >
@{resource.name} - {resource.branch} + {getCachedResourceDetail(resource)} + + {isNpmCachedResource(resource) ? 'npm' : 'git'} +
Last used: {formatDateTime(resource.lastUsedAt)} diff --git a/apps/web/src/routes/api/mcp/+server.ts b/apps/web/src/routes/api/mcp/+server.ts index 78e29f7e..6dc1a660 100644 --- a/apps/web/src/routes/api/mcp/+server.ts +++ b/apps/web/src/routes/api/mcp/+server.ts @@ -72,20 +72,23 @@ const askSchema = z.object({ }); const addResourceSchema = z.object({ - url: z.string().describe('GitHub repository URL (https://github.com/owner/repo)'), name: z.string().describe('Resource name for reference'), + type: z + .enum(['git', 'npm']) + .optional() + .describe( + 'Resource type. Optional for backward compatibility; inferred from provided fields when omitted.' + ), + url: z.string().optional().describe('GitHub repository URL (https://github.com/owner/repo)'), branch: z.string().optional().describe('Git branch (default: main)'), - searchPath: z.string().optional().describe('Subdirectory to focus on'), - searchPaths: z.array(z.string()).optional().describe('Multiple subdirectories to focus on'), + searchPath: z.string().optional().describe('Git subdirectory to focus on'), + searchPaths: z.array(z.string()).optional().describe('Multiple git subdirectories to focus on'), + package: z.string().optional().describe('npm package name (for example react or @types/node)'), + version: z.string().optional().describe('npm package version or tag (optional)'), notes: z.string().optional().describe('Special notes for the agent'), project: z.string().optional().describe('Project name (optional, defaults to "default")') }); -const syncSchema = z.object({ - config: z.string().describe('Full text of local btca.remote.config.jsonc'), - force: z.boolean().optional().describe('Force push local config, overwriting cloud on conflicts') -}); - mcpServer.tool( { name: 'ask', @@ -127,10 +130,21 @@ mcpServer.tool( { name: 'addResource', description: - 'Add a new git resource to your instance. The resource will be cloned and made available for querying.', + 'Add a new git repo or npm package resource to your instance. The resource will be available for querying.', schema: addResourceSchema }, - async ({ url, name, branch, searchPath, searchPaths, notes, project }) => { + async ({ + type, + url, + name, + branch, + searchPath, + searchPaths, + package: packageName, + version, + notes, + project + }) => { const ctx = mcpServer.ctx.custom; if (!ctx) { return { @@ -142,11 +156,14 @@ mcpServer.tool( const convex = getConvexClient(); const result = await convex.action(api.mcp.addResource, { apiKey: ctx.apiKey, + type, url, name, - branch: branch ?? 'main', + branch, searchPath, searchPaths, + package: packageName, + version, notes, project }); @@ -164,36 +181,6 @@ mcpServer.tool( } ); -mcpServer.tool( - { - name: 'sync', - description: - 'Sync a local btca.remote.config.jsonc with the cloud. Creates/updates resources to match the local config.', - schema: syncSchema - }, - async ({ config, force }) => { - const ctx = mcpServer.ctx.custom; - if (!ctx) { - return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Not authenticated' }) }], - isError: true - }; - } - - const convex = getConvexClient(); - const result = await convex.action(api.mcp.sync, { - apiKey: ctx.apiKey, - config, - force: force ?? false - }); - - return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], - isError: !result.ok - }; - } -); - const transport = new HttpTransport(mcpServer, { path: '/api/mcp' }); const respondWithMcp = async (request: Request) => { diff --git a/apps/web/src/routes/app/settings/resources/+page.svelte b/apps/web/src/routes/app/settings/resources/+page.svelte index c4ad3078..ee906a20 100644 --- a/apps/web/src/routes/app/settings/resources/+page.svelte +++ b/apps/web/src/routes/app/settings/resources/+page.svelte @@ -10,7 +10,8 @@ Link, Check, X, - Layers + Layers, + Package } from '@lucide/svelte'; import { useQuery, useConvexClient } from 'convex-svelte'; import { goto } from '$app/navigation'; @@ -18,6 +19,7 @@ import { getAuthState } from '$lib/stores/auth.svelte'; import { getProjectStore } from '$lib/stores/project.svelte'; import { api } from '../../../../convex/_generated/api'; + import type { Id } from '../../../../convex/_generated/dataModel'; const auth = getAuthState(); const client = useConvexClient(); @@ -45,15 +47,21 @@ let showAddForm = $state(false); let showConfirmation = $state(false); let formName = $state(''); + let formType = $state<'git' | 'npm'>('git'); let formUrl = $state(''); let formBranch = $state('main'); let formSearchPath = $state(''); + let formPackage = $state(''); + let formVersion = $state(''); let formSpecialNotes = $state(''); let isSubmitting = $state(false); let formError = $state(null); let addingGlobal = $state(null); let globalAddError = $state(null); + const NPM_PACKAGE_SEGMENT_REGEX = /^[a-z0-9][a-z0-9._-]*$/; + const NPM_VERSION_OR_TAG_REGEX = /^[^\s/]+$/; + const userResourceNames = $derived(new Set((userResourcesQuery?.data ?? []).map((r) => r.name))); /** @@ -112,6 +120,40 @@ }; } + function isValidNpmPackage(value: string) { + if (!value) return false; + if (value.startsWith('@')) { + const parts = value.split('/'); + return ( + parts.length === 2 && + parts[0] !== '@' && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[0]!.slice(1)) && + NPM_PACKAGE_SEGMENT_REGEX.test(parts[1]!) + ); + } + return !value.includes('/') && NPM_PACKAGE_SEGMENT_REGEX.test(value); + } + + function getNpmPackageUrl(packageName: string) { + const encoded = packageName + .split('/') + .map((part) => encodeURIComponent(part)) + .join('/'); + return `https://www.npmjs.com/package/${encoded}`; + } + + function resetForm() { + formName = ''; + formType = 'git'; + formUrl = ''; + formBranch = 'main'; + formSearchPath = ''; + formPackage = ''; + formVersion = ''; + formSpecialNotes = ''; + formError = null; + } + async function handleQuickAdd() { parseError = null; isParsingUrl = true; @@ -126,9 +168,12 @@ // Prefill the form formName = parsed.name; + formType = 'git'; formUrl = parsed.url; formBranch = parsed.branch; formSearchPath = ''; + formPackage = ''; + formVersion = ''; formSpecialNotes = ''; // Show confirmation @@ -141,12 +186,7 @@ function handleCancelConfirmation() { showConfirmation = false; - formName = ''; - formUrl = ''; - formBranch = 'main'; - formSearchPath = ''; - formSpecialNotes = ''; - formError = null; + resetForm(); } async function handleConfirmAdd() { @@ -164,8 +204,8 @@ async function handleAddResource() { if (!auth.instanceId) return; - if (!formName.trim() || !formUrl.trim()) { - formError = 'Name and URL are required'; + if (!formName.trim()) { + formError = 'Name is required'; return; } @@ -175,33 +215,65 @@ return; } - // Basic URL validation - try { - new URL(formUrl); - } catch { - formError = 'Invalid URL format'; - return; + const payload: { + name: string; + type: 'git' | 'npm'; + url?: string; + branch?: string; + searchPath?: string; + package?: string; + version?: string; + specialNotes?: string; + projectId?: Id<'projects'>; + } = { + name: formName.trim(), + type: formType, + specialNotes: formSpecialNotes.trim() || undefined, + projectId: selectedProjectId + }; + + if (formType === 'git') { + if (!formUrl.trim()) { + formError = 'Git URL is required'; + return; + } + + try { + new URL(formUrl); + } catch { + formError = 'Invalid URL format'; + return; + } + + payload.url = formUrl.trim(); + payload.branch = formBranch.trim() || 'main'; + payload.searchPath = formSearchPath.trim() || undefined; + } else { + if (!formPackage.trim()) { + formError = 'npm package is required'; + return; + } + if (!isValidNpmPackage(formPackage.trim())) { + formError = 'npm package must be valid (for example react or @types/node)'; + return; + } + if (formVersion.trim() && !NPM_VERSION_OR_TAG_REGEX.test(formVersion.trim())) { + formError = 'npm version/tag must not contain spaces or "/"'; + return; + } + + payload.package = formPackage.trim(); + payload.version = formVersion.trim() || undefined; } isSubmitting = true; formError = null; try { - await client.mutation(api.resources.addCustomResource, { - name: formName.trim(), - url: formUrl.trim(), - branch: formBranch.trim() || 'main', - searchPath: formSearchPath.trim() || undefined, - specialNotes: formSpecialNotes.trim() || undefined, - projectId: selectedProjectId - }); + await client.mutation(api.resources.addCustomResource, payload); // Reset form - formName = ''; - formUrl = ''; - formBranch = 'main'; - formSearchPath = ''; - formSpecialNotes = ''; + resetForm(); showAddForm = false; } catch (error) { formError = error instanceof Error ? error.message : 'Failed to add resource'; @@ -231,6 +303,7 @@ try { await client.mutation(api.resources.addCustomResource, { name: resource.name, + type: 'git', url: resource.url, branch: resource.branch, searchPath: resource.searchPath ?? resource.searchPaths?.[0], @@ -278,7 +351,9 @@ -

Add your own git repositories as documentation resources.

+

+ Add your own git repositories or npm packages as documentation resources. +

@@ -441,38 +516,69 @@
- - + +
-
+ {#if formType === 'git'}
- + +
+ +
+
+ + +
+
+ + +
+
+ {:else} +
+ +
- +
-
+ {/if}
@@ -524,15 +630,27 @@
@{resource.name}
- - - + {#if resource.type === 'npm' && resource.package} + + + + {:else if resource.url} + + + + {/if}
-
- {resource.url.replace(/^https?:\/\//, '')} -
-
- {resource.branch} - {#if resource.searchPath} - · - {resource.searchPath} - {/if} -
+ {#if resource.type === 'npm'} +
+ {resource.package ?? 'Unknown package'} +
+
+ {resource.version ? `version ${resource.version}` : 'latest'} +
+ {:else} +
+ {resource.url?.replace(/^https?:\/\//, '') ?? 'Unknown repository'} +
+
+ {resource.branch ?? 'main'} + {#if resource.searchPath} + · + {resource.searchPath} + {/if} +
+ {/if} {#if resource.specialNotes}
{resource.specialNotes}
{/if} diff --git a/apps/web/static/btca.remote.schema.json b/apps/web/static/btca.remote.schema.json deleted file mode 100644 index c45065fa..00000000 --- a/apps/web/static/btca.remote.schema.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://btca.dev/btca.remote.schema.json", - "title": "btca Remote Configuration", - "description": "Configuration file for btca remote/cloud mode", - "type": "object", - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for IDE support" - }, - "project": { - "type": "string", - "description": "Project name for organizing resources in the cloud" - }, - "model": { - "type": "string", - "description": "The model to use for AI interactions", - "enum": ["claude-haiku"], - "default": "claude-haiku" - }, - "resources": { - "type": "array", - "description": "List of git resources to sync with the cloud", - "items": { - "$ref": "#/$defs/gitResource" - }, - "default": [] - } - }, - "required": ["project", "resources"], - "additionalProperties": false, - "$defs": { - "gitResource": { - "type": "object", - "title": "Git Resource", - "description": "A resource cloned from a remote git repository", - "properties": { - "type": { - "type": "string", - "const": "git", - "description": "Resource type identifier" - }, - "name": { - "type": "string", - "description": "Unique name for this resource" - }, - "url": { - "type": "string", - "description": "Git repository URL", - "format": "uri" - }, - "branch": { - "type": "string", - "description": "Git branch to use", - "default": "main" - }, - "searchPath": { - "type": "string", - "description": "Subdirectory within the repo to focus searches on" - }, - "searchPaths": { - "type": "array", - "description": "Multiple subdirectories within the repo to focus searches on", - "items": { - "type": "string" - } - }, - "specialNotes": { - "type": "string", - "description": "Additional context or notes about this resource for the AI" - } - }, - "required": ["type", "name", "url", "branch"], - "additionalProperties": false - } - } -}