Files
2026-07-13 12:58:18 +08:00

2177 lines
78 KiB
TypeScript

/*
* Integration Package Generator
*
* Scaffolds a new integration package in showcase/integrations/<slug>/
* 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<string, string> = {};
// 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<string>();
// 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 <script>alert(1)</script>" 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 <name> --slug <slug> --category <category> --language <language> --features <comma-separated> [--deps <comma-separated>]",
);
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<string, { name: string; url: string }> = {
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 (
<html lang="en">
<body>
<script
dangerouslySetInnerHTML={{
__html: \`
console.log('[showcase] Demo loaded:', window.location.href);
console.log('[showcase] In iframe:', window.self !== window.top);
window.addEventListener('error', function(e) {
console.error('[showcase] Uncaught error:', e.message, e.filename, e.lineno);
});
window.addEventListener('unhandledrejection', function(e) {
console.error('[showcase] Unhandled rejection:', e.reason);
});
\`,
}}
/>
{children}
</body>
</html>
);
}
`;
}
function generatePostcssConfig(): string {
return `/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
`;
}
function generateGlobalsCss(): string {
return `@import "tailwindcss";
@import "@copilotkit/react-core/v2/styles.css";
:root {
--copilot-kit-background-color: #f8f9fa;
--copilot-kit-primary-color: #0066ff;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
}
`;
}
function generateIndexPage(args: CLIArgs, features: Feature[]): string {
const demoLinks = args.features
.map((featureId) => {
const feature = features.find((f) => f.id === featureId);
const name = feature?.name || featureId;
const desc = feature?.description || "";
return ` <a key="${featureId}" href="/demos/${featureId}" className="demo-card">
<h3>${name}</h3>
<p>${desc}</p>
</a>`;
})
.join("\n");
return `export default function Home() {
return (
<main style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}>
<h1>${args.name}</h1>
<p>Integration ID: ${args.slug}</p>
<h2 style={{ marginTop: "2rem" }}>Demos</h2>
<div style={{ display: "grid", gap: "1rem", marginTop: "1rem" }}>
${demoLinks}
</div>
</main>
);
}
`;
}
function generateDemoPage(
featureId: string,
feature: Feature | undefined,
): string {
// The generated body only references `CopilotKit`, `CopilotChat`, and
// `useConfigureSuggestions` — the rest of the v2 hooks (and `z` from
// zod) were imported eagerly so operators had an autocomplete-friendly
// starting point. With `strict: true` tsconfig + Next's ESLint the
// generated package failed lint/typecheck out of the box on every
// unused-import warning. Derive the import list from the body instead
// so the emitted file is clean from the first build while preserving
// the "hooks available" comment block as an author-facing reference.
// Normalize the agent prop to the backend's underscore-id convention.
// The feature registry keys the demo tree with hyphens, but real
// deployed packages (e.g. showcase/integrations/langgraph-fastapi/src/app/
// demos/agentic-chat/page.tsx) register their backend agent under
// `agent="agentic_chat"`. Emitting the raw featureId here produces a
// generated page that 404s against every deployed backend; normalize
// via the shared toAgentId helper so the emitted agent prop matches
// the same rewrite generateSmokeRoute already applied to
// SMOKE_AGENT_ID. Shared helper → one source of truth for the
// convention.
const agentId = toAgentId(featureId, "generateDemoPage");
const body = `export default function ${toPascalCase(featureId)}Demo() {
return (
<DemoErrorBoundary demoName="${feature?.name || featureId}">
<CopilotKit runtimeUrl="/api/copilotkit" agent="${agentId}">
<DemoContent />
</CopilotKit>
</DemoErrorBoundary>
);
}
function DemoContent() {
// TODO: Implement ${feature?.name || featureId} demo
// See the LangGraph Python reference implementation for patterns
//
// IMPORTANT: Use inline styles for any UI rendered inside the chat
// (useRenderTool, useHumanInTheLoop callbacks). Tailwind classes get
// purged by Tailwind v4 in this context. See STYLING-GUIDE.md.
//
// Key hooks available:
// useFrontendTool({ name, description, parameters: z.object({...}), handler })
// useRenderTool({ name: "tool_name", render: ({ args }) => <Component /> })
// useHumanInTheLoop({ name, description, parameters, handler: ({ args, respond }) => ... })
// useAgentContext({ description, value })
// useConfigureSuggestions({ suggestions: [{ title, message }] })
// useInterrupt({ render: ({ event, resolve }) => <Component /> })
useConfigureSuggestions({
suggestions: [
{ title: "Get started", message: "Hello! What can you do?" },
],
});
return (
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full md:w-4/5 md:h-4/5 rounded-lg">
<CopilotChat
className="h-full rounded-2xl max-w-6xl mx-auto"
/>
</div>
</div>
);
}
`;
// Candidate hooks exported from @copilotkit/react-core/v2. `CopilotChat`
// is always emitted (the body unconditionally renders it). Every other
// hook is included only when the body's *executable code* references
// it as an identifier, so additions to the body in the future
// automatically pick up the right imports without reviving dead ones.
//
// The "Key hooks available:" documentation block names every hook
// inside `//` comments — we strip JS line and block comments before
// scanning so those reference-only mentions don't force the imports
// back in. Word boundaries also matter: a bare `includes("z")`
// matches any line containing the letter z (`organize`, `size`, ...).
const codeOnly = body
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
const v2Candidates = [
"useFrontendTool",
"useRenderTool",
"useAgentContext",
"useConfigureSuggestions",
"useHumanInTheLoop",
"useInterrupt",
] as const;
const v2Imports = [
"CopilotChat",
...v2Candidates.filter((h) => new RegExp(`\\b${h}\\b`).test(codeOnly)),
];
const zUsed = /\bz\b/.test(codeOnly);
const importLines = [
`import React from "react";`,
`import { CopilotKit } from "@copilotkit/react-core";`,
`import {\n${v2Imports.map((n) => ` ${n},`).join("\n")}\n} from "@copilotkit/react-core/v2";`,
...(zUsed ? [`import { z } from "zod";`] : []),
`import { DemoErrorBoundary } from "../error-boundary";`,
];
return `"use client";
${importLines.join("\n")}
${body}`;
}
// Accumulates feature ids that hit the default/placeholder branches of
// getDemoInteraction / getDemoTechnicalDetails. main() surfaces this set
// at the end of the run so operators see exactly which demos need
// authoring before shipping the generated package.
const unauthoredDemoFeatures = new Set<string>();
function getDemoInteraction(featureId: string): string {
switch (featureId) {
case "tool-rendering":
return `- "What's the weather like in San Francisco?"
- "Check the weather in Tokyo and New York"
- "Can you look up the current conditions in London?"`;
case "hitl-in-chat":
return `- "Plan a trip to Mars in 5 steps"
- "Please plan a pasta dish in 10 steps"
- "Draft a product launch checklist in 7 steps"
The agent proposes a plan as a **StepSelector** card with a checkbox per step. Toggle individual steps on or off (the selected count updates as "N/N selected"), then click **Perform Steps (N)** / **Confirm (N)** to approve, or **Reject** to cancel. The card reflects the final decision ("Accepted" / "Rejected") and disables its buttons afterward.`;
case "gen-ui-tool-based":
return `- "What's the weather forecast for this week in San Francisco?"
- "Show me the weather in Paris"
- "Compare the weather in Tokyo and London"
The agent generates structured data via tools, and the frontend renders it as rich UI components.`;
default:
// Unauthored demos land here. Previously returned a bare "TODO" line
// that was easy to miss in review. Keep the content generator-safe
// (callers expect a string) but make the placeholder self-describing
// and include the feature id so search-and-replace is unambiguous.
// Also record the id so main() can warn about unauthored demos at
// the end of the run rather than relying on reviewers to spot TODOs.
unauthoredDemoFeatures.add(featureId);
return `- TODO(${featureId}): authoring required. Known authored ids: ${KNOWN_AUTHORED_FEATURE_IDS.join(
", ",
)}. See getDemoInteraction in showcase/scripts/create-integration/index.ts.`;
}
}
// Feature ids that have authored per-demo content in the switches above
// and below. When adding a new id to the registry with custom content,
// add it here and to both switches in the same change.
const KNOWN_AUTHORED_FEATURE_IDS = [
"tool-rendering",
"hitl-in-chat",
"gen-ui-tool-based",
] as const;
function getDemoTechnicalDetails(featureId: string): string {
switch (featureId) {
case "tool-rendering":
return `- **Backend tools** are defined in the agent (e.g., \`get_weather\`) and called by the LLM when the user's query matches
- **\`useRenderTool\`** on the frontend registers a React component that renders whenever the agent calls that tool
- The render function receives \`args\` (input parameters), \`result\` (tool output), and \`status\` ("executing" or "complete") so the UI can show loading states
- The tool result is displayed as a rich UI card instead of plain text — demonstrating how agent actions can produce structured, visual output`;
case "hitl-in-chat":
return `- **Human-in-the-Loop (HITL)** lets the agent pause and hand control back to the user to review a proposed plan before continuing
- The agent calls a tool (e.g. \`generate_task_steps\`) whose payload is a list of steps; the frontend renders a **StepSelector** card rather than executing immediately
- \`useHumanInTheLoop\` (frontend-tool flow) or \`useInterrupt\` (interrupt flow, imported from \`@copilotkit/react-core/v2\`) registers a \`render\` that receives \`{ args, respond, status }\` — the StepSelector keeps local state for which steps are enabled and calls \`respond({ accepted, steps })\` on Confirm / Reject (CopilotKit v2 API; existing showcase packages use v1 \`useLangGraphInterrupt\` for the interrupt-flow demo alongside v2 hooks like \`useHumanInTheLoop\` — new integrations generated by \`create-integration\` default to the v2 \`useInterrupt\`.)
- The card exposes per-step checkboxes, a live "N/N selected" count, and Confirm / Reject buttons; after a decision the buttons disable and the card shows "Accepted" or "Rejected" so the outcome is auditable in the transcript
- This pattern is essential for plan-style flows — multi-step actions where the user needs to curate, edit, or veto what the agent is about to do before any of it runs`;
case "gen-ui-tool-based":
return `- **Generative UI** means the agent's tool calls produce structured data that the frontend renders as custom React components
- Unlike plain text responses, the agent returns tool results with typed parameters (city, temperature, conditions)
- \`useRenderTool\` maps each tool name to a React component, so \`get_weather\` renders a weather card with icons, temperature displays, and forecast details
- The agent decides when to call the tool based on context — it can mix tool-based UI generation with regular text responses
- This pattern enables agents to create dynamic, data-driven interfaces on demand`;
default:
// See getDemoInteraction's default for rationale. Keep the two
// switches in lockstep.
unauthoredDemoFeatures.add(featureId);
return `- TODO(${featureId}): technical details unauthored. Known authored ids: ${KNOWN_AUTHORED_FEATURE_IDS.join(
", ",
)}. Add a switch case in getDemoTechnicalDetails (and the peer getDemoInteraction) when documenting this demo.`;
}
}
function generateDemoReadme(
featureId: string,
feature: Feature | undefined,
): string {
return `# ${feature?.name || featureId}
## What This Demo Shows
${feature?.description || "TODO: Add description"}
## How to Interact
Try asking your Copilot to:
${getDemoInteraction(featureId)}
## Technical Details
What's happening technically:
${getDemoTechnicalDetails(featureId)}
## Building With This
If you're extending this demo or building something similar, here are key things to know:
### Styling Inside the Chat
Content rendered inside CopilotKit's chat area (via \`useRenderTool\`, \`useHumanInTheLoop\`, \`useFrontendTool\`) runs inside CopilotKit's component tree. Standard Tailwind classes may not work here because Tailwind v4 can't statically detect them.
**Use inline styles** for any UI rendered inside the chat:
\`\`\`tsx
// Do this
<div style={{ padding: "24px", borderRadius: "12px", background: "#fff" }}>
// Not this — Tailwind may purge these classes
<div className="p-6 rounded-xl bg-white">
\`\`\`
### Chat Layout
Wrap \`CopilotChat\` in a constraining div for proper spacing:
\`\`\`tsx
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full md:w-4/5 md:h-4/5 rounded-lg">
<CopilotChat className="h-full rounded-2xl max-w-6xl mx-auto" />
</div>
</div>
\`\`\`
### Overriding CopilotKit Styles
CopilotKit uses \`cpk:\` prefixed classes internally. To override them, create a **separate CSS file** (not in globals.css — Tailwind purges it):
\`\`\`css
/* copilotkit-overrides.css */
.copilotKitInput {
border-radius: 0.75rem;
border: 1px solid var(--copilot-kit-separator-color) !important;
}
\`\`\`
Import it in \`layout.tsx\` after \`globals.css\`.
### Images and Icons
- Don't reference local image files from agent-generated content (they won't exist). Add \`onError\` fallbacks.
- Use emoji instead of SVG icons inside chat messages (\`fill="currentColor"\` renders unpredictably in the chat context).
See the full [Styling Guide](https://github.com/CopilotKit/CopilotKit/blob/main/showcase/STYLING-GUIDE.md) for more details.
`;
}
function generateRuntimeRoute(args: CLIArgs): string {
if (args.language === "typescript") {
return `import { NextRequest } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
// TODO: Import the appropriate agent adapter for ${args.name}
// Examples:
// import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
// import { MastraAgent } from "@ag-ui/mastra";
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// TODO: Configure agents for ${args.name}
// agents: { default: new YourAgent({ ... }) },
}),
});
return handleRequest(req);
};
`;
}
return `import { NextRequest } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// TODO: Configure the agent adapter for ${args.name}
// The adapter should point to AGENT_URL
}),
});
return handleRequest(req);
};
`;
}
function generateHealthRoute(args: CLIArgs): string {
const isLangGraph = args.name.startsWith("LangGraph");
const isInProcess = args.language === "typescript" && !isLangGraph;
const agentUrl = isInProcess
? ""
: isLangGraph
? "http://localhost:8123"
: "http://localhost:8000";
// LangGraph Platform (dev server default `:8123`) exposes `/ok`; our
// custom FastAPI backends expose `/health`. Individual starters may
// override the port (e.g. `examples/v2/interrupts-langgraph` uses
// `:8125`) — the template's constant below is only the default for
// newly-generated packages.
const probePath = isLangGraph ? "/ok" : "/health";
return `import { NextRequest, NextResponse } from "next/server";
// Baked at generation time (template-literal substitution) so the
// generated route can skip the fetch entirely for TypeScript in-process
// integrations rather than probing a bogus URL.
const IS_IN_PROCESS = ${isInProcess ? "true" : "false"};
// Resolve env first so we can distinguish "unset" (unconfigured) from
// "set but unreachable" (down). The baked fallback "${agentUrl}" keeps
// local dev working out-of-the-box but is only used when nothing is set.
const RAW_AGENT_URL = process.env.AGENT_URL || process.env.LANGGRAPH_DEPLOYMENT_URL;
const AGENT_URL = RAW_AGENT_URL || "${agentUrl}";
export async function GET(req: NextRequest) {
// Check agent backend reachability
let agentStatus: "unknown" | "in-process" | "ok" | "error" | "down" | "unconfigured" = "unknown";
let agentDetail = "";
if (IS_IN_PROCESS) {
// No out-of-process agent to probe; the runtime lives in this
// Next.js process, so treat it as healthy unconditionally.
agentStatus = "in-process";
} else if (!RAW_AGENT_URL) {
// Neither AGENT_URL nor LANGGRAPH_DEPLOYMENT_URL is set — surface this
// as a distinct status so operators can tell "missing config" apart
// from "network/backend down" without digging through logs.
agentStatus = "unconfigured";
agentDetail = "AGENT_URL / LANGGRAPH_DEPLOYMENT_URL env var not set";
} else {
try {
const res = await fetch(\`\${AGENT_URL}${probePath}\`, { signal: AbortSignal.timeout(3000) });
agentStatus = res.ok ? "ok" : "error";
agentDetail = \`HTTP \${res.status}\`;
} catch (err) {
agentStatus = "down";
agentDetail = err instanceof Error ? err.message : String(err);
// Log the underlying error so operators can see timeout vs DNS
// vs connection-refused without needing a debug token.
console.error(\`[health] agent probe failed (\${AGENT_URL}${probePath}): \${agentDetail}\`);
}
}
// Public response: safe to expose
const publicResponse: Record<string, unknown> = {
status: "ok",
integration: "${args.slug}",
agent: agentStatus,
timestamp: new Date().toISOString(),
};
// Extended diagnostics: only with debug token
const token = req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("debug");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (token && expectedToken && token === expectedToken) {
publicResponse.diagnostics = {
agent_url: IS_IN_PROCESS ? "(in-process)" : AGENT_URL,
agent_detail: agentDetail || undefined,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
},
};
}
const httpStatus = agentStatus === "ok" || agentStatus === "in-process" ? 200 : 503;
return NextResponse.json(publicResponse, { status: httpStatus });
}
`;
}
function generateDebugRoute(args: CLIArgs): string {
const isLangGraph = args.name.startsWith("LangGraph");
// LangGraph Platform exposes /ok; our backends expose /health
const probePath = isLangGraph ? "/ok" : "/health";
return `import { NextRequest, NextResponse } from "next/server";
// Request log (in-memory ring buffer, last 50 requests)
const requestLog: Array<{ time: string; method: string; path: string; status: number; durationMs: number }> = [];
const MAX_LOG_SIZE = 50;
export function logRequest(method: string, path: string, status: number, durationMs: number) {
requestLog.push({ time: new Date().toISOString(), method, path, status, durationMs });
if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
}
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token = req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
// Distinguish "env var not set" (unconfigured) from "set but unreachable"
// (down). Previously we fell back to the literal string "unknown" and
// then fetched "unknown${probePath}", which always TypeError'd and was
// reported as agent=down — indistinguishable from a real network failure.
const RAW_AGENT_URL = process.env.AGENT_URL || process.env.LANGGRAPH_DEPLOYMENT_URL;
const AGENT_URL = RAW_AGENT_URL || "unknown";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
if (!RAW_AGENT_URL) {
agentStatus = "unconfigured";
agentDetail = "AGENT_URL / LANGGRAPH_DEPLOYMENT_URL env var not set";
} else {
try {
const res = await fetch(\`\${AGENT_URL}${probePath}\`, { signal: AbortSignal.timeout(3000) });
agentStatus = res.ok ? "ok" : "error";
agentDetail = \`HTTP \${res.status}\`;
} catch (e: unknown) {
agentStatus = "down";
agentDetail = e instanceof Error ? e.message : String(e);
console.error(\`[debug] agent probe failed (\${AGENT_URL}${probePath}): \${agentDetail}\`);
}
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "${args.slug}",
uptime: \`\${Math.floor(uptime / 60)}m \${Math.floor(uptime % 60)}s\`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: \`\${Math.round(mem.rss / 1024 / 1024)}MB\`,
heapUsed: \`\${Math.round(mem.heapUsed / 1024 / 1024)}MB\`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
recentRequests: requestLog.slice(-20),
nodeVersion: process.version,
});
}
`;
}
function generateErrorBoundary(): string {
return `"use client";
import React from "react";
interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
export class DemoErrorBoundary extends React.Component<
{ children: React.ReactNode; demoName: string },
ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode; demoName: string }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(\`[DemoErrorBoundary] \${this.props.demoName} crashed:\`, error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", height: "100vh", padding: "2rem",
fontFamily: "system-ui, sans-serif", color: "#888", textAlign: "center",
}}>
<div style={{ fontSize: "48px", marginBottom: "16px" }}>Warning</div>
<h2 style={{ fontSize: "18px", fontWeight: 600, color: "#ccc", marginBottom: "8px" }}>
{this.props.demoName} — Demo Error
</h2>
<p style={{ fontSize: "14px", maxWidth: "400px", lineHeight: 1.5 }}>
The demo encountered an error. This usually means the agent backend is not responding.
Check the server logs and /api/health endpoint.
</p>
<pre style={{
marginTop: "16px", padding: "12px 16px", background: "#1a1a2e",
borderRadius: "8px", fontSize: "12px", color: "#f87171",
maxWidth: "500px", overflow: "auto", textAlign: "left",
}}>
{this.state.error?.message}
</pre>
<button
onClick={() => this.setState({ hasError: false, error: null })}
style={{
marginTop: "16px", padding: "8px 20px", background: "#333",
border: "1px solid #555", borderRadius: "8px", color: "#ccc",
cursor: "pointer", fontSize: "13px",
}}
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
`;
}
function generateDockerfile(args: CLIArgs): string {
if (args.language === "typescript") {
return `FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
EXPOSE 10000
ENV NODE_ENV=production
CMD ["npm", "start"]
`;
}
return `# Stage 1: Build Next.js frontend
FROM node:20-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Production image with Node.js + Python
FROM python:3.12-slim AS runner
RUN apt-get update && apt-get install -y --no-install-recommends \\
curl && \\
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \\
apt-get install -y nodejs && \\
npm install -g corepack && \\
corepack enable && \\
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Python dependencies
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Next.js build artifacts
COPY --from=frontend /app/.next ./.next
COPY --from=frontend /app/node_modules ./node_modules
COPY --from=frontend /app/package.json ./
COPY --from=frontend /app/public ./public
# Agent code
COPY src/agent_server.py ./
COPY src/app/demos/*/agent.py ./agents/
# Entrypoint
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
EXPOSE 10000
ENV NODE_ENV=production
CMD ["./entrypoint.sh"]
`;
}
function generateEntrypoint(args: CLIArgs): string {
if (args.language === "typescript") {
return `#!/bin/bash
exec npx next start --port \${PORT:-10000}
`;
}
return `#!/bin/bash
set -e
# Start agent backend
python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &
# Start Next.js frontend
npx next start --port \${PORT:-10000} &
# Wait for either process to exit
wait -n
exit $?
`;
}
function generateSmokeRoute(args: CLIArgs): string {
// Derive the default agentId from the package's primary feature so the
// generated smoke route doesn't hard-code `agentic_chat` for every
// package. If the caller didn't pass any --features we fall back to
// "agentic_chat" and log a warning so the operator knows to replace it
// before merging.
//
// TODO(operator): the agentId baked in below is a best-effort default.
// Replace it with the real agent id your backend registers (e.g. the
// string passed to `agent="…"` on <CopilotKit>) before shipping.
const primaryFeature = args.features[0];
let agentId: string;
if (primaryFeature) {
// Most agents register with an underscore id; the feature registry
// uses hyphens. Normalize via toAgentId so the default is a
// plausible backend key (and any rewrite is announced). The helper
// is shared with generateDemoPage so the agent prop and SMOKE_AGENT_ID
// stay in sync — see also the operator-replacement TODO in
// src/app/api/smoke/route.ts.
agentId = toAgentId(primaryFeature, "generateSmokeRoute");
} else {
agentId = "agentic_chat";
console.warn(
`[create-integration] generateSmokeRoute: no --features supplied for ${args.slug}; falling back to agentId="agentic_chat". Replace this with the real agent id before committing.`,
);
}
return `import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "${args.slug}";
// TODO(operator): replace the agentId below with the real agent id your
// backend registers. The generator chose "${agentId}" as a best-effort
// default based on the package's primary feature.
const SMOKE_AGENT_ID = "${agentId}";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
|| \`http://localhost:\${process.env.PORT || 10000}\`;
try {
const res = await fetch(\`\${baseUrl}/api/copilotkit\`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: SMOKE_AGENT_ID },
body: {
threadId: \`smoke-\${Date.now()}\`,
runId: \`smoke-run-\${Date.now()}\`,
state: {},
messages: [
{
id: \`smoke-msg-\${Date.now()}\`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json({
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: \`Runtime returned \${res.status}: \${errBody.slice(0, 200)}\`,
latency_ms: latency,
timestamp: new Date().toISOString(),
}, { status: 502 });
}
// Response is SSE stream — just verify we got content
const body = await res.text();
if (body.length === 0) {
return NextResponse.json({
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
}, { status: 502 });
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.name === "TimeoutError" || err.message.includes("timeout")) stage = "timeout";
else if (err.message.includes("fetch") || err.message.includes("ECONNREFUSED")) stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json({
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
}, { status: 502 });
}
}
`;
}
function generateEnvExample(args: CLIArgs): string {
const lines = [
"# API Keys (shared across integrations)",
"OPENAI_API_KEY=sk-...",
"ANTHROPIC_API_KEY=sk-ant-...",
"",
];
if (args.language !== "typescript") {
lines.push("# Agent backend URL (for the CopilotKit runtime proxy)");
lines.push("AGENT_URL=http://localhost:8000");
lines.push("");
}
lines.push("# Showcase");
lines.push("NEXT_PUBLIC_BASE_URL=http://localhost:3000");
return lines.join("\n") + "\n";
}
function generateAgentServer(args: CLIArgs): string {
if (args.language === "typescript") {
return "";
}
return `"""
Agent Server for ${args.name}
FastAPI server that hosts the agent backend.
The Next.js CopilotKit runtime proxies requests here.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="${args.name} Agent Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "ok"}
# TODO: Add CopilotKit agent endpoint
# See the LangGraph Python reference implementation for patterns
# @app.post("/copilotkit")
# async def copilotkit(request: Request):
# ...
`;
}
function generateRequirementsTxt(args: CLIArgs): string {
if (args.language === "typescript") {
return "";
}
return `fastapi>=0.115.0
uvicorn>=0.34.0
copilotkit>=0.1.0
# TODO: Add framework-specific dependencies
`;
}
function generateE2ETest(
featureId: string,
feature: Feature | undefined,
): string {
return `import { test, expect } from "@playwright/test";
test.describe("${feature?.name || featureId}", () => {
test("page loads and chat renders", async ({ page }) => {
await page.goto("/demos/${featureId}");
// Chat interface should be visible
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
});
test("can send a message and receive a response", async ({ page }) => {
await page.goto("/demos/${featureId}");
const input = page.getByPlaceholder("Type a message");
await input.fill("Hello");
await input.press("Enter");
// Wait for agent response (adjust timeout as needed)
await expect(page.locator('.copilotKitAssistantMessage').first()).toBeVisible({
timeout: 30000,
});
});
// TODO: Add feature-specific assertions
});
`;
}
function generateQATemplate(
featureId: string,
feature: Feature | undefined,
args: CLIArgs,
): string {
return `# QA: ${feature?.name || featureId}${args.name}
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the demo page
- [ ] Verify the chat interface loads
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
- [ ] TODO: Add checks specific to ${feature?.name || featureId}
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
`;
}
function generatePlaywrightConfig(): string {
return `import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: process.env.CI
? undefined
: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
},
});
`;
}
function generateNextConfig(): string {
return `import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow iframe embedding from the showcase shell
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: "frame-ancestors *;",
},
],
},
];
},
};
export default nextConfig;
`;
}
function generateTsConfig(): string {
return (
JSON.stringify(
{
compilerOptions: {
target: "ES2017",
lib: ["dom", "dom.iterable", "esnext"],
allowJs: true,
skipLibCheck: true,
strict: true,
noEmit: true,
esModuleInterop: true,
module: "esnext",
moduleResolution: "bundler",
resolveJsonModule: true,
isolatedModules: true,
jsx: "preserve",
incremental: true,
plugins: [{ name: "next" }],
paths: { "@/*": ["./src/*"] },
},
include: [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
],
exclude: ["node_modules"],
},
null,
2,
) + "\n"
);
}
function generateDockerComposeTest(): string {
return `# Docker Compose stack for e2e smoke testing with aimock.
# Usage: docker compose -f docker-compose.test.yml up -d
services:
aimock:
image: ghcr.io/copilotkit/aimock:latest
ports:
- "4010:4010"
volumes:
- ./fixtures:/fixtures:ro
command: ["--fixtures", "/fixtures", "--host", "0.0.0.0", "--validate-on-load"]
app:
build:
context: .
dockerfile: Dockerfile
args:
INSTALL_MODE: fresh
ports:
- "3000:3000"
environment:
- OPENAI_API_KEY=test-key-for-aimock
- OPENAI_BASE_URL=http://aimock:4010/v1
depends_on:
aimock:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/"]
interval: 5s
timeout: 5s
retries: 30
start_period: 60s
`;
}
function generateDefaultFixtures(slug: string): string {
return JSON.stringify(
{
fixtures: [
{
match: { userMessage: "Hello" },
response: {
content: `Hello! I'm the ${slug} AI assistant. How can I help you?`,
},
},
{
match: {},
response: {
content:
"You're currently running against aimock (a mock LLM server). This response is a catch-all for requests that don't match any test fixture. To use a real LLM: (1) Add your OPENAI_API_KEY to .env, (2) Remove or unset OPENAI_BASE_URL from your environment so requests go to OpenAI instead of aimock, (3) Restart with `pnpm dev`.",
},
},
],
},
null,
2,
);
}
function generateGitignore(): string {
return `node_modules/
.next/
.env.local
.env
.copilotkit/
*.pyc
__pycache__/
.venv/
dist/
playwright-report/
test-results/
`;
}
function toPascalCase(str: string): string {
return str
.split("-")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("");
}
// Languages with fully-implemented generator branches. `dotnet` is declared
// in LANGUAGES so the CLI validates the surface, but no .NET-specific code
// path exists (Dockerfile, entrypoint, dev script, agent server, etc. all
// fall through to the Python paths). Running with --language dotnet would
// produce a broken package (Python Dockerfile, uvicorn dev script, .py
// agent server), so gate it behind an explicit allowlist.
const FULLY_SUPPORTED_LANGUAGES: readonly Language[] = [
"typescript",
"python",
] as const;
async function main() {
const args = parseArgs();
if (
!(FULLY_SUPPORTED_LANGUAGES as readonly string[]).includes(args.language)
) {
console.error(
`Error: Language '${args.language}' is not yet supported by this generator. ` +
`Supported: ${FULLY_SUPPORTED_LANGUAGES.join(", ")}.`,
);
process.exit(1);
}
const features = loadFeatureRegistry();
// Fail fast on unknown feature ids. Without this check, a typo in the
// --features flag silently produces manifest/demo entries with TODO
// placeholder text (the default branch of getDemoInteraction etc.),
// which then drift into PRs. Enumerate the valid ids so the user can
// copy the correct value.
const knownFeatureIds = new Set(features.map((f) => f.id));
const unknownFeatures = args.features.filter(
(id) => !knownFeatureIds.has(id),
);
if (unknownFeatures.length > 0) {
const known = [...knownFeatureIds].sort().join(", ");
console.error(
`Error: Unknown feature id(s): ${unknownFeatures.join(", ")}.\nKnown ids: ${known}`,
);
process.exit(1);
}
const packageDir = path.join(PACKAGES_DIR, args.slug);
const packageDirState = probePath(packageDir);
if (packageDirState === "unreadable") {
console.error(
`Error: Package directory exists but is unreadable: ${packageDir}`,
);
process.exit(1);
}
if (packageDirState === "exists") {
console.error(`Error: Package directory already exists: ${packageDir}`);
process.exit(1);
}
console.log(`\nCreating integration package: ${args.name}\n`);
console.log(` Slug: ${args.slug}`);
console.log(` Category: ${args.category}`);
console.log(` Language: ${args.language}`);
console.log(` Features: ${args.features.join(", ")}`);
console.log("");
// Validate CI workflows BEFORE writing the package tree so a regex
// mismatch (layout drift in showcase_deploy.yml / test_smoke-starter.yml)
// throws early rather than leaving a half-scaffolded package on disk that
// the next run rejects via the packageDirState === "exists" guard above.
// updateWorkflows() is self-contained — it reads only args and mutates
// files in WORKFLOWS_DIR, so reordering is safe. Respect the same
// CREATE_INTEGRATION_SKIP_WORKFLOWS opt-out here; the original call
// below is now a no-op once workflows already include this slug
// (idempotent re-run), and is retained as a safety net.
if (process.env.CREATE_INTEGRATION_SKIP_WORKFLOWS === "1") {
console.log(
"--- Skipping CI workflow updates (CREATE_INTEGRATION_SKIP_WORKFLOWS=1) ---\n",
);
} else {
console.log("--- Updating CI workflows ---\n");
updateWorkflows(args);
console.log("");
}
// Root files
writeFile(
path.join(packageDir, "manifest.yaml"),
generateManifest(args, features),
);
writeFile(path.join(packageDir, "package.json"), generatePackageJson(args));
writeFile(path.join(packageDir, "Dockerfile"), generateDockerfile(args));
writeFile(path.join(packageDir, "entrypoint.sh"), generateEntrypoint(args));
writeFile(path.join(packageDir, ".env.example"), generateEnvExample(args));
writeFile(path.join(packageDir, ".gitignore"), generateGitignore());
writeFile(path.join(packageDir, "next.config.ts"), generateNextConfig());
writeFile(path.join(packageDir, "tsconfig.json"), generateTsConfig());
writeFile(
path.join(packageDir, "postcss.config.mjs"),
generatePostcssConfig(),
);
writeFile(
path.join(packageDir, "playwright.config.ts"),
generatePlaywrightConfig(),
);
// E2E test infrastructure
writeFile(
path.join(packageDir, "docker-compose.test.yml"),
generateDockerComposeTest(),
);
writeFile(
path.join(packageDir, "fixtures", "default.json"),
generateDefaultFixtures(args.slug),
);
if (args.language !== "typescript") {
writeFile(
path.join(packageDir, "requirements.txt"),
generateRequirementsTxt(args),
);
writeFile(
path.join(packageDir, "src", "agent_server.py"),
generateAgentServer(args),
);
}
// App source
writeFile(
path.join(packageDir, "src", "app", "layout.tsx"),
generateLayout(),
);
writeFile(
path.join(packageDir, "src", "app", "globals.css"),
generateGlobalsCss(),
);
writeFile(
path.join(packageDir, "src", "app", "page.tsx"),
generateIndexPage(args, features),
);
writeFile(
path.join(packageDir, "src", "app", "api", "copilotkit", "route.ts"),
generateRuntimeRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "api", "health", "route.ts"),
generateHealthRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "api", "debug", "route.ts"),
generateDebugRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "api", "smoke", "route.ts"),
generateSmokeRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "demos", "error-boundary.tsx"),
generateErrorBoundary(),
);
// Demo stubs
for (const featureId of args.features) {
const feature = features.find((f) => f.id === featureId);
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "page.tsx"),
generateDemoPage(featureId, feature),
);
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "README.md"),
generateDemoReadme(featureId, feature),
);
if (args.language !== "typescript") {
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "agent.py"),
`"""
Agent implementation for ${feature?.name || featureId}
TODO: Implement the agent logic for ${args.name}
See the LangGraph Python reference implementation for patterns.
"""
`,
);
} else {
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "agent.ts"),
`/**
* Agent implementation for ${feature?.name || featureId}
*
* TODO: Implement the agent logic for ${args.name}
* See the LangGraph Python reference implementation for patterns.
*/
`,
);
}
// E2E test stub
writeFile(
path.join(packageDir, "tests", "e2e", `${featureId}.spec.ts`),
generateE2ETest(featureId, feature),
);
// QA template
writeFile(
path.join(packageDir, "qa", `${featureId}.md`),
generateQATemplate(featureId, feature, args),
);
}
console.log(`\nPackage created at: showcase/integrations/${args.slug}/`);
// Auto-migrate agent code from examples/integrations/ if available
console.log("\n--- Migrating agent code from examples/integrations/ ---\n");
try {
const { migrateForSlug } =
await import("../migrate-integration-examples.js");
const migResult = migrateForSlug(args.slug);
if (migResult.errors.length > 0) {
console.error(` Migration FAILED for ${args.slug}:`);
for (const err of migResult.errors) console.error(` ${err}`);
process.exit(1);
} else if (migResult.files.length > 0) {
console.log(
` Migrated ${migResult.files.length} agent files from examples/integrations/`,
);
for (const f of migResult.files) console.log(` ${f}`);
} else if (migResult.skipped.length > 0) {
console.log(` No migration needed: ${migResult.skipped[0]}`);
}
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
// Node sets `code` on module-resolution failures; narrow the
// "skippable" branch to exactly those so real errors (TypeError,
// SyntaxError, fs EACCES, etc.) surface as failures instead of
// being swallowed with a misleading "skipped" message.
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
// Route the skip notice to stderr so pipeline consumers that
// capture stdout (for manifests, reports, etc.) still see it.
console.error(` Migration skipped (module not found): ${detail}`);
console.error(
" (Run migrate-integration-examples.ts manually if needed)",
);
} else {
console.error(` Migration FAILED: ${detail}`);
process.exit(1);
}
}
// (CI workflow updates already ran pre-write at the top of main() so a
// regex-mismatch fails before any filesystem mutation. Do not re-run
// here — a second call would attempt a duplicate append and throw on
// the options/outputs/filters anchored-idempotency checks.)
console.log("\nNext steps:");
console.log(" 1. Write/customize the agent code in src/agents/");
console.log(
" 2. Pin framework deps to exact versions from the Dojo example",
);
console.log(" 3. Fill in E2E test assertions");
console.log(
` 4. Deploy to Railway: npx tsx showcase/scripts/deploy-to-railway.ts ${args.slug}`,
);
console.log(
` 5. Go live: npx tsx showcase/scripts/deploy-to-railway.ts --go-live ${args.slug}`,
);
console.log(" 6. Open a PR to the monorepo\n");
// Final warning: surface every feature that landed in the TODO
// placeholder branches of getDemoInteraction / getDemoTechnicalDetails
// so operators see them at the end of the run rather than hunting for
// `TODO(` strings across the generated tree.
if (unauthoredDemoFeatures.size > 0) {
const ids = [...unauthoredDemoFeatures].sort();
console.warn(
`\nWARNING: ${ids.length} demo(s) used unauthored placeholder content:`,
);
for (const id of ids) {
const demoPath = `showcase/integrations/${args.slug}/src/app/demos/${id}/README.md`;
console.warn(` - ${id} (edit: ${demoPath})`);
}
console.warn(
" Add switch cases to getDemoInteraction and getDemoTechnicalDetails",
);
console.warn(
" in showcase/scripts/create-integration/index.ts before shipping.\n",
);
}
}
export function updateWorkflows(args: CLIArgs) {
const workflowsDir = WORKFLOWS_DIR;
// 1. Update showcase_deploy.yml — add change detection + build job
const deployPath = path.join(workflowsDir, "showcase_deploy.yml");
const deployState = probePath(deployPath);
if (deployState === "unreadable") {
console.error(
`Error: ${deployPath} exists but is unreadable; refusing to silently skip workflow update.`,
);
process.exit(1);
}
if (deployState === "missing") {
console.warn(
` [WARN] ${deployPath} not found; skipping showcase_deploy.yml update.`,
);
}
if (deployState === "exists") {
let deploy = fs.readFileSync(deployPath, "utf-8");
const slug = args.slug;
const slugVar = slug.replace(/-/g, "_");
// Add to workflow_dispatch options if not present. If the regex doesn't
// match the expected structure, assert loudly rather than silently
// shipping a no-op change to the workflow file.
//
// The indent of the appended line is derived from the LAST existing
// `- <entry>` line in the block (captured as `$2`) instead of being
// hardcoded, so a YAML reflow that changes the block's indent doesn't
// produce a misaligned insertion.
// Anchor to a full option list line (leading whitespace, `- <slug>`,
// trailing whitespace, end-of-line). A bare `includes("- <slug>")`
// collides against longer sibling slugs (adding `foo` when `foo-bar`
// exists already matches `"- foo-bar"`) and against stray
// occurrences in comments/env vars/step names.
const optionRe = new RegExp(`^\\s+- ${escapeRegex(slug)}\\s*$`, "m");
if (!optionRe.test(deploy)) {
const before = deploy;
deploy = deploy.replace(
/(\s+options:\n(?:(\s+)- .+\n)+)/,
`$1$2- ${slug}\n`,
);
if (deploy === before) {
throw new Error(
`updateWorkflows: failed to locate the 'options:' block in ${deployPath}. ` +
"The workflow file layout may have changed; update the regex in updateWorkflows().",
);
}
}
// Anchor the outputs-block idempotency check to a line that starts
// with `<slugVar>:` (optionally indented) — `includes("<slug>:")`
// matches slug-shaped substrings inside env vars, step names, or
// YAML comments and silently skips the insert. The guard MUST use
// `slugVar` (underscore form) because the insertion below writes
// `${slugVar}:` as the key; for hyphenated slugs (e.g.
// `sales-dashboard` → `sales_dashboard`) matching on `slug`
// (hyphen form) would never hit on a re-run and would duplicate
// the outputs+filters entries every time. Mirrors the same fix
// applied to the `build-<slugVar>:` idempotency regex below.
const outputRe = new RegExp(`^\\s+${escapeRegex(slugVar)}:`, "m");
if (!outputRe.test(deploy)) {
// Add output (match only lines containing ${{ to avoid matching `steps:`).
// The indent is captured from the last existing output entry and
// reused below so YAML reflows don't desync the appended line.
const beforeOutputs = deploy;
deploy = deploy.replace(
/(outputs:\n(?:(\s+)\w+:.*\$\{\{.*\n)+)/,
`$1$2${slugVar}: \${{ steps.changes.outputs.${slugVar} }}\n`,
);
if (deploy === beforeOutputs) {
throw new Error(
`updateWorkflows: failed to locate the 'outputs:' block in ${deployPath}. ` +
"The workflow file layout may have changed; update the regex in updateWorkflows().",
);
}
// Add filter. Two indents matter here: the filter key's own indent
// (captured at the `<slug>:` line via `$2`) and the nested list
// entry indent (captured via `$3`). Both are pulled from the last
// existing filter block so the new entry tracks any reflow.
const beforeFilters = deploy;
deploy = deploy.replace(
/(filters: \|\n(?:(\s+)\w+:\n(?:(\s+)- .+\n)+)+)/,
`$1$2${slugVar}:\n$3- 'showcase/integrations/${slug}/**'\n`,
);
if (deploy === beforeFilters) {
throw new Error(
`updateWorkflows: failed to locate the 'filters:' block in ${deployPath}. ` +
"The workflow file layout may have changed; update the regex in updateWorkflows().",
);
}
}
// Add build job if not present. Anchor to `^\s*build-<slugVar>:` at
// start of a line so a longer sibling slug (e.g. `build-foo_bar` when
// adding `foo`) or an incidental `build-foo` mention inside a
// comment, env var, or step name doesn't silently skip the insert.
// Mirrors the anchored idempotency checks above at lines 1852/1871.
const buildJobRe = new RegExp(`^\\s*build-${escapeRegex(slugVar)}:`, "m");
if (!buildJobRe.test(deploy)) {
const buildJob = `
build-${slugVar}:
name: Build & Push ${args.name}
needs: [detect-changes, check-lockfile]
if: |
needs.detect-changes.outputs.${slugVar} == 'true' ||
github.event.inputs.service == '${slug}' ||
github.event.inputs.service == 'all'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: \${{ github.actor }}
password: \${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: showcase/integrations/${slug}
push: true
tags: |
ghcr.io/copilotkit/showcase-${slug}:latest
ghcr.io/copilotkit/showcase-${slug}:\${{ github.sha }}
cache-from: type=gha,scope=${slugVar}
cache-to: type=gha,scope=${slugVar},mode=max
- name: Trigger Railway deploy
run: |
if [ "\${SERVICE_ID:-RAILWAY_SERVICE_ID}" = "RAILWAY_SERVICE_ID" ] \\
|| [ "\${ENV_ID:-RAILWAY_ENVIRONMENT_ID}" = "RAILWAY_ENVIRONMENT_ID" ]; then
echo "::error::Railway placeholders not replaced" >&2
exit 1
fi
curl -sf -X POST https://backboard.railway.app/graphql/v2 \\
-H "Authorization: Bearer \${{ secrets.RAILWAY_TOKEN }}" \\
-H "Content-Type: application/json" \\
-d '{"query":"mutation { serviceInstanceRedeploy(serviceId: \\"RAILWAY_SERVICE_ID\\", environmentId: \\"RAILWAY_ENVIRONMENT_ID\\") }"}' \\
&& echo "${slug} deploy triggered"
`;
deploy += buildJob;
console.warn(
` [WARN] Appended build-${slugVar} job to showcase_deploy.yml with placeholder serviceId "RAILWAY_SERVICE_ID" and environmentId "RAILWAY_ENVIRONMENT_ID".`,
);
console.warn(
' Replace "RAILWAY_SERVICE_ID" and "RAILWAY_ENVIRONMENT_ID" with the real Railway ids before committing.',
);
}
fs.writeFileSync(deployPath, deploy);
console.log(" Updated showcase_deploy.yml");
}
// 2. showcase_drift-detection.yml was replaced by the showcase-harness
// service's aimock_wiring / image-drift probes; no per-integration
// workflow edit is needed anymore. The probes enumerate services from
// Railway at runtime.
// 3. Update test_smoke-starter.yml — add to matrix (block sequence format)
const smokePath = path.join(workflowsDir, "test_smoke-starter.yml");
const smokeState = probePath(smokePath);
if (smokeState === "unreadable") {
console.error(
`Error: ${smokePath} exists but is unreadable; refusing to silently skip workflow update.`,
);
process.exit(1);
}
if (smokeState === "missing") {
console.warn(
` [WARN] ${smokePath} not found; skipping test_smoke-starter.yml update.`,
);
}
if (smokeState === "exists") {
let smoke = fs.readFileSync(smokePath, "utf-8");
const slug = args.slug;
// The matrix uses YAML block sequence format:
// starter:
// - langgraph-python
// - mastra
// Find the last `- <slug>` entry after `starter:` under `matrix:` and append.
const lines = smoke.split("\n");
let lastEntryIndex = -1;
let inStarterBlock = false;
let sawStarterBlock = false;
let slugAlreadyPresent = false;
let entryIndent = "";
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^\s+starter:\s*$/.test(line)) {
inStarterBlock = true;
sawStarterBlock = true;
continue;
}
if (inStarterBlock) {
// Skip blank lines and YAML comments inside the block rather
// than treating them as the end of the sequence — either one
// can appear between list items without actually closing the
// block, and bailing early produced an "append to previous"
// placement that subtly broke formatting (and, for a comment
// line at the top of the block, no-op'd the insertion).
if (line.trim() === "" || /^\s*#/.test(line)) {
continue;
}
const entryMatch = line.match(/^(\s+- )\S/);
if (entryMatch) {
lastEntryIndex = i;
entryIndent = entryMatch[1];
if (line.trim() === `- ${slug}`) {
// Idempotent re-run — the slug is already wired into the
// matrix. Flag it distinctly from the "no entries" /
// "no block" error branches below; previously this path
// set `lastEntryIndex = -1` and fell into the same else
// branch as the zero-entries degenerate case, producing
// a misleading "block has zero entries" error on the
// second generator run against the same slug.
slugAlreadyPresent = true;
break;
}
} else {
// End of block sequence — a non-indented key (or any other
// line that isn't a list entry, blank, or comment) closes
// the sequence.
break;
}
}
}
if (slugAlreadyPresent) {
console.info(
` test_smoke-starter.yml: slug "${slug}" already present in starter matrix, skipping.`,
);
} else if (lastEntryIndex >= 0) {
lines.splice(lastEntryIndex + 1, 0, `${entryIndent}${slug}`);
smoke = lines.join("\n");
fs.writeFileSync(smokePath, smoke);
console.log(" Updated test_smoke-starter.yml");
} else if (!sawStarterBlock) {
// The file exists but our regex-based parser could not find a
// `starter:` block to append to. Symmetric with the deploy/drift
// branches above: throw so a starter can't silently ship without
// smoke-test coverage. Operators can set
// CREATE_INTEGRATION_SKIP_WORKFLOWS=1 to skip the workflow-edit
// pass entirely, or edit the workflow file manually after.
throw new Error(
`updateWorkflows: failed to locate the 'starter:' block in ${smokePath}. ` +
"The workflow file layout may have changed; update the parser in updateWorkflows().",
);
} else {
// Degenerate case: the `starter:` block was located but contains
// zero entries, so we have no existing `- <slug>` line to derive
// indentation from and no insertion point to append after.
// Without this guard the append silently no-ops and the new
// starter ships without smoke coverage. Auto-seeding the first
// entry is intentionally out of scope — the indentation for a
// fresh block depends on workflow layout we shouldn't guess at.
// The operator hand-seeds the first entry once; subsequent
// starters append normally.
throw new Error(
`updateWorkflows: found 'starter:' block in ${smokePath} but it has zero entries; ` +
"cannot determine indentation or insertion point. Hand-seed at least one " +
"existing entry in the block before running create-integration.",
);
}
}
}
// Only run main() when executed directly (tsx / node). Importing this
// module for tests must not trigger argv parsing / filesystem writes.
// Resolve both sides through path.resolve so symlinks introduced by tsx
// shims, pnpm, and bin links don't break the comparison (a raw ===
// check compares the physical path from import.meta.url against argv[1]
// which can be a symlink — they never match and main() silently
// no-ops). Mirrors the pattern used in validate-parity.ts. argv[1] is
// `undefined` when the module is imported via `node -e` / dynamic
// import without an entrypoint script, so short-circuit in that case
// to avoid a TypeError out of path.resolve.
if (process.argv[1]) {
const invokedAs = path.resolve(process.argv[1]);
const modulePath = path.resolve(fileURLToPath(import.meta.url));
if (invokedAs === modulePath) {
// Attach a top-level .catch so any rejected promise out of main()
// surfaces with a diagnostic prefix and a stable exit code rather
// than an UnhandledPromiseRejection warning (or, under
// --unhandled-rejections=strict, a silent non-zero exit with no
// stderr). Mirrors the [INTERNAL ERROR] / exit-4 convention used
// by audit.ts and validate-parity.ts.
main().catch((err) => {
const message =
err instanceof Error ? (err.stack ?? err.message) : String(err);
console.error(`[INTERNAL ERROR] create-integration crashed: ${message}`);
process.exitCode = 4;
});
}
}