/* * Integration Package Generator * * Scaffolds a new integration package in showcase/integrations// * with all required files, demo stubs, and deployment configs. * * Usage: * npx tsx create-integration/index.ts \ * --name "Anthropic (Claude Agent SDK)" \ * --slug anthropic-claude-sdk \ * --category provider-sdk \ * --language python \ * --features agentic-chat,hitl-in-chat,tool-rendering */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import yaml from "yaml"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, "..", ".."); // Both directories are overridable via env so tests can run the generator // against an isolated tmpdir — without overrides the generator's writes // collided with sibling suites reading the real `showcase/integrations/` tree // (ENOENT on partial state) and with concurrent `git checkout HEAD --` // invocations on `.github/workflows/*.yml` (`.git/index.lock` races). An // env var (vs a CLI flag) keeps the public flag surface unchanged and is // trivial for test harnesses to set via `execFileSync`'s `env` option. const PACKAGES_DIR = process.env.CREATE_INTEGRATION_PACKAGES_DIR ?? path.join(ROOT, "integrations"); const WORKFLOWS_DIR = process.env.CREATE_INTEGRATION_WORKFLOWS_DIR ?? path.resolve(ROOT, "..", ".github", "workflows"); const FEATURE_REGISTRY_PATH = path.join( ROOT, "shared", "feature-registry.json", ); interface Feature { id: string; name: string; category: string; description: string; } interface CLIArgs { name: string; slug: string; category: Category; language: Language; features: string[]; extraDeps: string[]; } const CATEGORIES = [ "popular", "agent-framework", "enterprise-platform", "provider-sdk", "protocol", "emerging", "starter", ] as const; type Category = (typeof CATEGORIES)[number]; const LANGUAGES = ["python", "typescript", "dotnet"] as const; type Language = (typeof LANGUAGES)[number]; /** * Probe a filesystem path and classify the result so callers can * distinguish "not present" from "present but unreadable". Using * fs.existsSync collapses ENOENT and EACCES/EPERM/EIO into a bare * boolean, which means a permissions or I/O fault masquerades as * "missing" and leads to silently wrong branches (skip-update, * overwrite, etc.). statSync + errno discrimination keeps the three * outcomes distinct so callers can warn/exit/proceed appropriately. */ function probePath(p: string): "missing" | "exists" | "unreadable" { try { fs.statSync(p); return "exists"; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") return "missing"; return "unreadable"; } } /** * Escape regex metacharacters in a string so it can be safely embedded * into a RegExp source. Used by updateWorkflows() to anchor slug-based * idempotency checks to full-line matches — a bare `includes(slug)` * collides across unrelated lines and against longer sibling slugs * (e.g. `includes("- foo")` matches `"- foo-bar"`), which silently * skips the insert and produces a broken workflow file. */ function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Normalize a feature registry id (e.g. `"agentic-chat"`) into the * agent-id convention used by deployed showcase backends, which * register under underscore-separated ids (e.g. `"agentic_chat"`). The * feature registry keys the demo tree with hyphens; the real backend * agents are keyed with underscores. Callers that emit the agent id * into runtime code (generateSmokeRoute, generateDemoPage, ...) must * apply this rewrite so the emitted default lines up with the backend * convention; a hyphen/underscore mismatch surfaces as a 404 / * "agent not found" at smoke-test time, not at generator time. * * Emits a one-shot operator warning when the rewrite actually fires * (hyphen present in the input) so that a generator run producing a * rewritten default is visible in the console, matching the stronger * warning generateSmokeRoute already emitted before this helper was * factored out. */ function toAgentId(featureId: string, context: string): string { const agentId = featureId.replace(/-/g, "_"); if (agentId !== featureId) { console.warn( `[create-integration] ${context}: rewrote feature id "${featureId}" → agentId="${agentId}" (hyphens → underscores). ` + `Verify this matches the real agent id your backend registers.`, ); } return agentId; } export function parseArgs(): CLIArgs { const args = process.argv.slice(2); const parsed: Record = {}; // Track which flags have already been seen so a duplicate occurrence // (`--slug a --slug b`) is rejected loudly instead of silently // overwriting the earlier value. Matches the strict-parser contract // that capture-previews.ts and validate-parity.ts call out in their // comments when they mirror this behaviour. const seen = new Set(); // Walk the argv in lockstep. Every `--flag` MUST be followed by a value // that does not itself start with `--`; anything else is a usage error // we surface immediately rather than silently dropping the flag (which // would then trip the missing-required-flag check with a misleading // message about the wrong flag). for (let i = 0; i < args.length; i++) { const arg = args[i]; if (!arg.startsWith("--")) { console.error( `Error: Unexpected positional argument '${arg}'. All arguments must be --flag value pairs.`, ); process.exit(1); } const key = arg.slice(2); const val = args[i + 1]; if (val === undefined || val.startsWith("--")) { console.error( `Error: Flag --${key} expects a value but got ${ val === undefined ? "end-of-args" : `another flag (${val})` }.`, ); process.exit(1); } if (seen.has(key)) { console.error(`Error: --${key} specified more than once.`); process.exit(1); } seen.add(key); parsed[key] = val; i++; } // Validate --name shape before any consumer interpolates it unescaped // into JSX/markdown/YAML (page.tsx, README, MDX, manifest.yaml). A name // like "Hello " would otherwise inject raw // JSX/markdown into generated files. The character class is a // deliberately permissive superset for human-readable display names. if (parsed.name !== undefined) { if (parsed.name.length < 1) { console.error(`Error: --name must be at least 1 character.`); process.exit(1); } if (!/^[A-Za-z0-9 .\-/()[\]]+$/.test(parsed.name)) { console.error( `Error: Invalid --name '${parsed.name}'. Allowed characters: letters, digits, spaces, and . - / ( ) [ ]`, ); process.exit(1); } } // Validate --slug shape before any consumer interpolates it into // filesystem paths (path.join(PACKAGES_DIR, slug)) or YAML/shell // workflow blocks. Without this guard, a value like "../../etc/foo" // escapes the packages directory and a value containing "/" creates // nested subdirectories. The regex mirrors the format every existing // deployed slug already follows (lowercase alnum with hyphen // separators starting with a letter or digit). if (parsed.slug !== undefined && !/^[a-z0-9][a-z0-9-]*$/.test(parsed.slug)) { console.error( `Invalid slug '${parsed.slug}'. Slugs must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alphanumeric, hyphen-separated, starting with a letter or digit).`, ); process.exit(1); } if ( !parsed.name || !parsed.slug || !parsed.category || !parsed.language || !parsed.features ) { console.error( "Usage: create-integration --name --slug --category --language --features [--deps ]", ); console.error("\nRequired flags:"); console.error( " --name Display name (e.g. 'Anthropic (Claude Agent SDK)')", ); console.error(" --slug URL-safe ID (e.g. 'anthropic-claude-sdk')"); console.error(` --category One of: ${CATEGORIES.join(", ")}`); console.error(` --language One of: ${LANGUAGES.join(", ")}`); console.error( " --features Comma-separated feature IDs (e.g. 'agentic-chat,hitl-in-chat,tool-rendering')", ); console.error("\nOptional flags:"); console.error( " --deps Extra npm dependencies (e.g. '@ag-ui/mastra,@mastra/core')", ); process.exit(1); } // Validate that category and language are in the allowed literal unions; // the rest of the generator branches on these values and a typo used to // silently produce broken output (unknown category → registry lookup // miss; unknown language → skipped runtime files). if (!(CATEGORIES as readonly string[]).includes(parsed.category)) { console.error( `Error: Unknown --category '${parsed.category}'. One of: ${CATEGORIES.join(", ")}`, ); process.exit(1); } if (!(LANGUAGES as readonly string[]).includes(parsed.language)) { console.error( `Error: Unknown --language '${parsed.language}'. One of: ${LANGUAGES.join(", ")}`, ); process.exit(1); } return { name: parsed.name, slug: parsed.slug, category: parsed.category as Category, language: parsed.language as Language, // Filter empty strings so trailing/leading commas (e.g. "a,b," or ",a") // don't produce an empty-string entry that the registry lookup rejects // with a confusing "Unknown feature id ''" error. features: parsed.features .split(",") .map((f) => f.trim()) .filter((f) => f.length > 0), extraDeps: parsed.deps ? parsed.deps .split(",") .map((d) => d.trim()) .filter((d) => d.length > 0) : [], }; } /** * Read + parse + shape-check the feature registry JSON. Exported so * unit tests can exercise the error branches (ENOENT, invalid JSON, * shape-invalid top level) without spawning a subprocess. */ export function loadFeatureRegistry(): Feature[] { let raw: string; try { raw = fs.readFileSync(FEATURE_REGISTRY_PATH, "utf-8"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error( `Failed to read feature registry at ${FEATURE_REGISTRY_PATH}: ${msg}`, { cause: err }, ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error( `Feature registry at ${FEATURE_REGISTRY_PATH} is not valid JSON: ${msg}`, { cause: err }, ); } if ( parsed === null || typeof parsed !== "object" || !Array.isArray((parsed as { features?: unknown }).features) ) { throw new Error( `Feature registry at ${FEATURE_REGISTRY_PATH} must be a JSON object with a 'features' array.`, ); } return (parsed as { features: Feature[] }).features; } function writeFile(filePath: string, content: string) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, content); console.log(` Created: ${path.relative(ROOT, filePath)}`); } function generateManifest(args: CLIArgs, features: Feature[]): string { const demos = args.features.map((featureId) => { const feature = features.find((f) => f.id === featureId); const name = feature?.name || featureId; const description = feature?.description || ""; const tags = [feature?.category || "general"].filter(Boolean); return { id: featureId, name, description, tags, route: `/demos/${featureId}`, }; }); const manifest = { name: args.name, slug: args.slug, category: args.category, language: args.language, logo: `/logos/${args.slug}.svg`, description: `CopilotKit integration with ${args.name}`, partner_docs: null, repo: `https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/${args.slug}`, copilotkit_version: "2.0.0", // backend_url is intentionally omitted — generate-registry.ts synthesizes // it at build time from `SHOWCASE_BACKEND_HOST_PATTERN` + slug. deployed: false, generative_ui: ["constrained-explicit"], interaction_modalities: ["chat"], features: args.features, demos, managed_platform: undefined as { name: string; url: string } | undefined, }; const MANAGED_PLATFORMS: Record = { LangGraph: { name: "LangGraph Platform", url: "https://langsmith.com" }, Mastra: { name: "Mastra Cloud", url: "https://mastra.ai/cloud" }, CrewAI: { name: "CrewAI Enterprise", url: "https://crewai.com/amp" }, Agno: { name: "Agent OS", url: "https://os.agno.com" }, AG2: { name: "Agent OS", url: "https://ag2.ai/product" }, Strands: { name: "AWS Bedrock AgentCore", url: "https://aws.amazon.com/bedrock/agents/", }, }; for (const [prefix, platform] of Object.entries(MANAGED_PLATFORMS)) { if (args.name.startsWith(prefix)) { manifest.managed_platform = platform; break; } } return yaml.stringify(manifest); } function generatePackageJson(args: CLIArgs): string { return ( JSON.stringify( { name: `@copilotkit/showcase-${args.slug}`, version: "0.1.0", private: true, scripts: { dev: args.language === "typescript" ? "next dev --turbopack" : 'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload"', build: "next build", start: "next start", lint: "next lint", "test:e2e": "playwright test", }, dependencies: { "@ag-ui/client": "^0.0.43", "@copilotkit/react-core": "next", "@copilotkit/runtime": "next", next: "^15.0.0", react: "^19.0.0", "react-dom": "^19.0.0", zod: "^3.24.0", ...Object.fromEntries(args.extraDeps.map((d) => [d, "latest"])), }, devDependencies: { "@playwright/test": "^1.50.0", "@types/node": "^22.0.0", "@types/react": "^19.0.0", typescript: "^5.7.0", tailwindcss: "^4.0.0", "@tailwindcss/postcss": "^4.0.0", postcss: "^8.5.0", ...(args.language !== "typescript" ? { concurrently: "^9.1.0" } : {}), }, }, null, 2, ) + "\n" ); } function generateLayout(): string { return `import type { Metadata } from "next"; import "@copilotkit/react-core/v2/styles.css"; import "./globals.css"; export const metadata: Metadata = { title: "CopilotKit Showcase", }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return (