UNPKG

@contextvm/ctxcn

Version:

A command-line utility inspired by shadcn that streamlines the integration of ContextVM (CVM) servers into your TypeScript projects

333 lines (324 loc) 12.9 kB
import { compile } from "json-schema-to-typescript"; import { toPascalCase } from "../utils.js"; export function sanitizeSchema(schema) { // A valid JSON Schema is a boolean or an object. if (typeof schema === "boolean") { return schema; } // If it's not an object (e.g., string, null, number, array), it's invalid. Return empty schema. if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { return {}; } // It's an object, so we'll traverse it to remove invalid $refs. function traverse(obj) { if (typeof obj !== "object" || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(traverse); } const newObj = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { if (key === "$ref") { const refValue = obj[key]; // Only keep internal references. if (typeof refValue === "string" && refValue.startsWith("#")) { newObj[key] = refValue; } } else { newObj[key] = traverse(obj[key]); } } } return newObj; } return traverse(schema); } export function extractInlineType(typeDefinition) { // Extract the actual type definition from a generated type // Handle both interface and type definitions const interfaceMatch = typeDefinition.match(/export interface \w+ ([\s\S]*?)(?=export|\s*$)/); if (interfaceMatch && interfaceMatch[1]) { return formatInlineType(interfaceMatch[1]); } const typeMatch = typeDefinition.match(/export type \w+ = ([\s\S]*?);/); if (typeMatch && typeMatch[1]) { const shape = typeMatch[1].trim(); return shape === "unknown" ? "{}" : formatInlineType(shape); } return "{}"; } function formatInlineType(typeShape) { // If it's an object type, clean up the formatting if (typeShape.startsWith("{") && typeShape.endsWith("}")) { const objectContent = typeShape.slice(1, -1).trim(); if (objectContent) { const properties = objectContent .split("\n") .map((line) => line.trim()) .filter((line) => line) .map((line) => ` ${line}`) .join("\n"); return `{\n${properties}\n }`; } return "{}"; } return typeShape; } export function extractSchemaProperties(schema) { if (!schema?.properties || typeof schema !== "object") { return []; } const required = Array.isArray(schema.required) ? schema.required : []; return Object.entries(schema.properties) .filter(([_, propSchema]) => propSchema && typeof propSchema === "object") .map(([propName, propSchema]) => { const propType = propSchema.type || "unknown"; const isRequired = required.includes(propName); return { name: propName, type: mapJsonTypeToTypeScript(propType), required: isRequired, }; }); } function mapJsonTypeToTypeScript(jsonType) { const typeMap = { string: "string", number: "number", integer: "number", boolean: "boolean", array: "any[]", object: "object", }; return typeMap[jsonType] || "any"; } export async function generateTypeFromSchema(schema, typeName) { if (typeof schema === "boolean") { return `export type ${typeName} = ${schema ? "any" : "never"};`; } const sanitizedSchema = sanitizeSchema(schema); if (typeof sanitizedSchema === "boolean") { return `export type ${typeName} = ${sanitizedSchema ? "any" : "never"};`; } return await compile(sanitizedSchema, typeName, { bannerComment: "", additionalProperties: false, }); } export async function generateClientCode(pubkey, toolListResult, serverName, privateKey, relays) { const classMethods = []; const serverTypeMethods = []; for (const tool of toolListResult.tools) { const { methodDefinitions, serverMethod } = await generateToolMethods(tool, serverName); classMethods.push(methodDefinitions.classMethod); serverTypeMethods.push(serverMethod); } const clientName = `${serverName}Client`; const serverType = generateServerType(serverName, serverTypeMethods); const genericCallMethod = generateGenericCallMethod(); return assembleClientCode(clientName, pubkey, serverType, genericCallMethod, classMethods, serverName, privateKey, relays); } async function generateToolMethods(tool, serverName) { const toolName = tool.name; const capitalizedToolName = toPascalCase(tool.name); const inputTypeName = `${capitalizedToolName}Input`; const outputTypeName = `${capitalizedToolName}Output`; // Generate types temporarily to extract inline definitions const inputType = await generateTypeFromSchema(tool.inputSchema, inputTypeName); const outputType = await generateTypeFromSchema(tool.outputSchema, outputTypeName); // Extract inline type definitions for better IDE inference const inputInlineType = extractInlineType(inputType); const outputInlineType = extractInlineType(outputType); // Extract properties from schema for individual parameters const inputProperties = extractSchemaProperties(tool.inputSchema); // Generate JSDoc comment with the actual type shape const jsDocComment = generateJSDoc(tool, inputProperties, outputInlineType); // Generate method with individual parameters for better developer experience const methodDefinitions = inputProperties.length > 0 ? generateMethodWithIndividualParams(toolName, inputProperties, outputInlineType, jsDocComment) : generateMethodWithObjectParam(toolName, inputInlineType, outputInlineType, jsDocComment); // Add corresponding method signature to server type const serverMethod = inputProperties.length > 0 ? ` ${toolName}: (${methodDefinitions.parameters}) => Promise<${outputInlineType}>;` : ` ${toolName}: (args: ${inputInlineType}) => Promise<${outputInlineType}>;`; return { methodDefinitions, serverMethod }; } function generateJSDoc(tool, inputProperties, outputInlineType) { const lines = []; // Add main description if (tool.description) { lines.push(` * ${tool.description}`); } else { lines.push(` * ${tool.title || tool.name} tool`); } // Add parameter descriptions if (inputProperties.length > 0) { // Extract parameter descriptions from input schema if available const inputSchema = tool.inputSchema; if (inputSchema && inputSchema.properties) { for (const prop of inputProperties) { const propSchema = inputSchema.properties[prop.name]; let description = propSchema?.description || ""; // If no description is available, create a default one based on the parameter name if (!description) { description = generateParameterDescription(prop.name, tool.name); } const optional = prop.required ? "" : "[optional] "; lines.push(` * @param {${prop.type}} ${prop.name} ${optional}${description}`); } } } // Add return type description with the actual type shape const outputSchema = tool.outputSchema; const returnDescription = outputSchema?.description || `The result of the ${tool.name} operation`; // Format the return type for JSDoc - use the actual inline type if (outputInlineType.includes("\n")) { // For multi-line types with JSDoc comments, we need to clean them up // Remove inline JSDoc comments and just keep the type structure const cleanedType = outputInlineType .split("\n") .map((line) => { const trimmed = line.trim(); // Remove JSDoc comment lines if (trimmed.startsWith("/**") || trimmed.startsWith("*") || trimmed.endsWith("*/")) { return ""; } return trimmed; }) .filter((line) => line && !line.startsWith("*")) .join(" "); // If the cleaned type is too long or complex, use a generic type if (cleanedType.length > 100) { lines.push(` * @returns {Promise<object>} ${returnDescription}`); } else { lines.push(` * @returns {Promise<${cleanedType}>} ${returnDescription}`); } } else { lines.push(` * @returns {Promise<${outputInlineType}>} ${returnDescription}`); } return ` /**\n${lines.join("\n")}\n */`; } function generateParameterDescription(paramName, toolName) { // Generate a meaningful description based on the parameter name and tool context const paramDescriptions = { a: "The first number to add", b: "The second number to add", input: "The input value to be echoed", text: "The text to process", value: "The value to use", data: "The data to process", options: "Configuration options", config: "Configuration settings", url: "The URL to connect to", path: "The file or directory path", name: "The name identifier", id: "The unique identifier", }; // Check if we have a predefined description if (paramDescriptions[paramName]) { return paramDescriptions[paramName]; } // Generate a generic description based on the parameter name const capitalizedParam = paramName.charAt(0).toUpperCase() + paramName.slice(1); return `The ${paramName.replace(/([A-Z])/g, " $1").toLowerCase()} parameter`; } function generateMethodWithIndividualParams(toolName, properties, outputType, jsDocComment) { const parameters = properties .map((prop) => `${prop.name}${prop.required ? "" : "?"}: ${prop.type}`) .join(", "); const argsObject = properties.map((prop) => prop.name).join(", "); return { parameters, classMethod: ` ${jsDocComment} async ${toolName}( ${parameters} ): Promise<${outputType}> { return this.call("${toolName}", { ${argsObject} }); }`, }; } function generateMethodWithObjectParam(toolName, inputType, outputType, jsDocComment) { return { parameters: `args: ${inputType}`, classMethod: ` ${jsDocComment} async ${toolName}( args: ${inputType} ): Promise<${outputType}> { return this.call("${toolName}", args); }`, }; } function generateServerType(serverName, serverTypeMethods) { return `export type ${serverName} = { ${serverTypeMethods.join("\n")} };`; } function generateGenericCallMethod() { return ` private async call<T = unknown>( name: string, args: Record<string, unknown> ): Promise<T> { const result = await this.client.callTool({ name, arguments: { ...args }, }); return result.structuredContent as T; }`; } function assembleClientCode(clientName, pubkey, serverType, genericCallMethod, classMethods, serverName, privateKey, relays) { return `import { Client } from "@modelcontextprotocol/sdk/client"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { NostrClientTransport, type NostrTransportOptions, PrivateKeySigner, ApplesauceRelayPool, } from "@contextvm/sdk"; ${serverType} export class ${clientName} implements ${serverName} { static readonly SERVER_PUBKEY = "${pubkey}"; private client: Client; private transport: Transport; constructor( options: Partial<NostrTransportOptions> & { privateKey?: string; relays?: string[] } = {} ) { this.client = new Client({ name: "${clientName}", version: "1.0.0", }); const { privateKey, relays = ${relays ? JSON.stringify(relays) : `["wss://relay.contextvm.org"]`}, signer = new PrivateKeySigner(privateKey || process.env.CTXCN_PRIVATE_KEY${privateKey ? ` || "${privateKey}"` : ""}), relayHandler = new ApplesauceRelayPool(relays), ...rest } = options; this.transport = new NostrClientTransport({ serverPubkey: ${clientName}.SERVER_PUBKEY, signer, relayHandler, isStateless: true, ...rest, }); // Auto-connect in constructor this.client.connect(this.transport).catch((error) => { console.error(\`Failed to connect to server: \${error}\`); }); } async disconnect(): Promise<void> { await this.transport.close(); } ${genericCallMethod} ${classMethods.join("\n\n")} } `; } //# sourceMappingURL=schema.js.map