2177 lines
78 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
}
|