Files
copilotkit__copilotkit/showcase/scripts/generate-starters.ts
Jordan Ritter fe7129fbcf fix: context-aware Python import rewriting for files inside tools/ dir
Files inside tools/ need "from tools import X" → "from . import X"
(current package), not "from .tools import X" (sub-module). The
previous rewrite rule was correct for files outside tools/ but caused
ModuleNotFoundError for crewai-crews custom_tool.py.
2026-04-15 16:46:14 -07:00

1081 lines
35 KiB
TypeScript

/**
* Generate self-contained starter projects for each showcase framework.
*
* Combines the canonical template frontend with per-framework agent backends
* to produce standalone clonable starters at showcase/starters/<slug>/.
*
* Usage:
* npx tsx generate-starters.ts [--slug langgraph-python] [--dry-run] [--check]
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { execSync } from "node:child_process";
import * as os from "node:os";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ROOT = path.resolve(import.meta.dirname, "..", "..");
const SHOWCASE = path.join(ROOT, "showcase");
const TEMPLATE_DIR = path.join(SHOWCASE, "starters", "template");
const STARTERS_DIR = path.join(SHOWCASE, "starters");
const PACKAGES_DIR = path.join(SHOWCASE, "packages");
const SHARED_PYTHON_DIR = path.join(SHOWCASE, "shared", "python");
const SHARED_TS_DIR = path.join(SHOWCASE, "shared", "typescript", "tools");
// ---------------------------------------------------------------------------
// Framework definitions
// ---------------------------------------------------------------------------
interface FrameworkDef {
slug: string;
name: string;
language: "python" | "typescript" | "java" | "csharp";
agentSourceDir: string;
agentDir: string; // Output dir name in generated starter
devScript: string;
extraFiles?: Record<string, string>; // destPath -> sourcePath (relative to package dir)
extraDependencies?: Record<string, string>; // Additional npm dependencies to merge into package.json
}
const FRAMEWORKS: FrameworkDef[] = [
{
slug: "langgraph-python",
name: "LangGraph Python",
language: "python",
agentSourceDir: "src/agents",
agentDir: "src/agents",
devScript:
'concurrently "next dev --turbopack" "python -m langgraph_cli dev --config langgraph.json --host 0.0.0.0 --port 8123 --no-browser"',
extraFiles: { "langgraph.json": "langgraph.json" },
},
{
slug: "langgraph-fastapi",
name: "LangGraph FastAPI",
language: "python",
agentSourceDir: "src/agents",
agentDir: "src/agents",
devScript:
'concurrently "next dev --turbopack" "python -m langgraph_cli dev --config langgraph.json --host 0.0.0.0 --port 8123 --no-browser"',
extraFiles: { "langgraph.json": "langgraph.json" },
},
{
slug: "langgraph-typescript",
name: "LangGraph TypeScript",
language: "typescript",
agentSourceDir: "src/agent",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "npx @langchain/langgraph-cli dev --config agent/langgraph.json --host 0.0.0.0 --port 8123 --no-browser"',
},
{
slug: "pydantic-ai",
name: "Pydantic AI",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "crewai-crews",
name: "CrewAI Crews",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "ag2",
name: "AG2",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "agno",
name: "Agno",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "google-adk",
name: "Google ADK",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "langroid",
name: "Langroid",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "llamaindex",
name: "LlamaIndex",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "strands",
name: "Strands",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "mastra",
name: "Mastra",
language: "typescript",
agentSourceDir: "src/mastra",
agentDir: "src/mastra",
devScript: 'concurrently "next dev --turbopack" "PORT=8123 npx mastra dev"',
extraDependencies: {
"@ag-ui/mastra": "beta",
"@ai-sdk/openai": "^2.0.42",
"@libsql/client": "^0.15.15",
"@mastra/client-js": "beta",
"@mastra/core": "beta",
"@mastra/libsql": "beta",
"@mastra/memory": "beta",
ai: "^4.0.0",
libsql: "^0.5.22",
mastra: "beta",
},
},
{
slug: "claude-sdk-python",
name: "Claude SDK Python",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "claude-sdk-typescript",
name: "Claude SDK TypeScript",
language: "typescript",
agentSourceDir: "src/agent",
agentDir: "agent",
devScript: 'concurrently "next dev --turbopack" "npx tsx agent/index.ts"',
extraDependencies: {
"@ag-ui/core": "^0.0.48",
"@ag-ui/encoder": "^0.0.48",
"@anthropic-ai/sdk": "^0.57.0",
dotenv: "^16.4.0",
express: "^4.21.0",
},
},
{
slug: "ms-agent-python",
name: "Microsoft Agent Python",
language: "python",
agentSourceDir: "src/agents",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 --reload"',
},
{
slug: "ms-agent-dotnet",
name: "Microsoft Agent .NET",
language: "csharp",
agentSourceDir: "agent",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "cd agent && dotnet run --urls http://0.0.0.0:8123"',
},
{
slug: "spring-ai",
name: "Spring AI",
language: "java",
agentSourceDir: "src/main",
agentDir: "agent",
devScript:
'concurrently "next dev --turbopack" "cd agent && ./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8123"',
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function copyDirSync(src: string, dest: string): void {
if (!fs.existsSync(src)) {
throw new Error(`copyDirSync: source directory missing: ${src}`);
}
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (
entry.name === "__pycache__" ||
entry.name === "node_modules" ||
entry.name === ".next" ||
entry.name === "__tests__"
) {
continue;
}
// Resolve symlinks — follow them and copy real content
let realPath: string;
let stat: fs.Stats;
try {
realPath = fs.realpathSync(srcPath);
stat = fs.statSync(realPath);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
console.warn(
` [warn] Broken symlink or missing: ${srcPath} — skipping`,
);
continue;
}
throw e;
}
if (stat.isDirectory()) {
copyDirSync(realPath, destPath);
} else if (stat.isFile()) {
fs.copyFileSync(realPath, destPath);
}
}
}
function substituteVars(content: string, vars: Record<string, string>): string {
let result = content;
for (const [key, value] of Object.entries(vars)) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
}
const remaining = result.match(/\{\{[A-Z_]+\}\}/g);
if (remaining) {
console.warn(
` [warn] Unreplaced template variables: ${remaining.join(", ")}`,
);
}
return result;
}
function rewritePythonImports(filePath: string, _agentDir: string): void {
if (!filePath.endsWith(".py")) return;
let content = fs.readFileSync(filePath, "utf-8");
// Line-based removal of sys.path.insert blocks.
// Track paren depth to handle multi-line calls.
const lines = content.split("\n");
const result: string[] = [];
let parenDepth = 0;
let inSysPathBlock = false;
// Track which line indices to skip (import sys/os before sys.path.insert)
const skipIndices = new Set<number>();
// First pass: find sys.path.insert blocks and mark surrounding import sys/os
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith("sys.path.insert(")) {
inSysPathBlock = true;
parenDepth = 0;
// Look back to mark import sys / import os
for (let j = i - 1; j >= 0; j--) {
const trimmed = lines[j].trim();
if (trimmed === "") continue;
if (trimmed === "import sys" || trimmed === "import os") {
skipIndices.add(j);
} else {
break;
}
}
}
if (inSysPathBlock) {
skipIndices.add(i);
for (const ch of lines[i]) {
if (ch === "(") parenDepth++;
if (ch === ")") parenDepth--;
}
if (parenDepth <= 0) {
inSysPathBlock = false;
}
}
}
for (let i = 0; i < lines.length; i++) {
if (!skipIndices.has(i)) {
result.push(lines[i]);
}
}
content = result.join("\n");
// Clean up multiple blank lines
content = content.replace(/\n{3,}/g, "\n\n");
// Rewrite "from tools import ..." — context-dependent:
// Files inside tools/ directory: "from tools import X" → "from . import X"
// Files outside tools/ directory: "from tools import X" → "from .tools import X"
const isInsideToolsDir = filePath.includes("/tools/");
if (isInsideToolsDir) {
content = content.replace(/^from tools import /gm, "from . import ");
content = content.replace(
/^from tools\.(\w+) import /gm,
"from .$1 import ",
);
} else {
content = content.replace(/^from tools import /gm, "from .tools import ");
content = content.replace(
/^from tools\.(\w+) import /gm,
"from .tools.$1 import ",
);
}
// Rewrite "from src.agents.X import ..." to "from .X import ..."
// This handles main.py style imports like "from src.agents.tools import ..."
content = content.replace(
/^(\s*)from src\.agents\.(\w+) import /gm,
"$1from .$2 import ",
);
// Rewrite "from agents.X import ..." to "from .X import ..."
// When the demo "agents/" dir is renamed to "agent/" in starters,
// intra-package absolute imports break. Convert to relative imports.
content = content.replace(
/^(\s*)from agents\.([\w.]+) import /gm,
"$1from .$2 import ",
);
fs.writeFileSync(filePath, content);
}
function rewriteTypeScriptSharedImports(
filePath: string,
agentDestDir: string,
starterOutDir?: string,
): void {
if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx")) return;
let content = fs.readFileSync(filePath, "utf-8");
// Compute correct relative path from file to shared-tools dir
const fileDir = path.dirname(filePath);
const sharedToolsDir = path.join(agentDestDir, "shared-tools");
let relativePath = path.relative(fileDir, sharedToolsDir);
// Ensure it starts with ./ for files at the same level
if (!relativePath.startsWith(".")) {
relativePath = "./" + relativePath;
}
// Rewrite @copilotkit/showcase-shared-tools to correct relative path
content = content.replace(
/@copilotkit\/showcase-shared-tools/g,
relativePath,
);
// Rewrite Next.js path aliases (@/...) to relative imports.
// In the source packages, @/ maps to src/ via tsconfig paths. But tools
// like mastra dev use rollup which doesn't resolve tsconfig aliases.
if (starterOutDir) {
const srcDir = path.join(starterOutDir, "src");
content = content.replace(
/from\s+["']@\/([^"']+)["']/g,
(_match, aliasPath) => {
const targetPath = path.join(srcDir, aliasPath);
let relPath = path.relative(fileDir, targetPath);
if (!relPath.startsWith(".")) {
relPath = "./" + relPath;
}
return `from "${relPath}"`;
},
);
}
fs.writeFileSync(filePath, content);
}
/**
* Extract the uvicorn module path (e.g. "agent.agent:app") from a framework's
* devScript. Falls back to "agent.main:app" if no uvicorn invocation is found.
*/
function extractUvicornModule(fw: FrameworkDef): string {
const match = fw.devScript.match(/uvicorn\s+([\w.:]+)/);
if (!match) {
console.warn(
` [warn] Could not extract uvicorn module from devScript for ${fw.slug}, using default "agent.main:app"`,
);
}
return match ? match[1] : "agent.main:app";
}
const AGENT_HEALTH_CHECK = `if kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent server started (PID: $AGENT_PID)"
else
echo "[entrypoint] ERROR: Agent server failed to start — exiting"
exit 1
fi`;
function getEntrypointBlock(fw: FrameworkDef): string {
switch (fw.language) {
case "python":
if (fw.slug === "langgraph-python" || fw.slug === "langgraph-fastapi") {
return `echo "[entrypoint] Starting LangGraph agent server on port 8123..."
python -m langgraph_cli dev \\
--config langgraph.json \\
--host 0.0.0.0 \\
--port 8123 \\
--no-browser 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
sleep 3
${AGENT_HEALTH_CHECK}`;
}
return `echo "[entrypoint] Starting Python agent server on port 8123..."
cd /app && python -m uvicorn agent_server:app --host 0.0.0.0 --port 8123 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
sleep 2
${AGENT_HEALTH_CHECK}`;
case "typescript":
if (fw.slug === "langgraph-typescript") {
return `echo "[entrypoint] Starting LangGraph TS agent on port 8123..."
npx @langchain/langgraph-cli dev \\
--config agent/langgraph.json \\
--host 0.0.0.0 \\
--port 8123 \\
--no-browser 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
sleep 3
${AGENT_HEALTH_CHECK}`;
}
if (fw.slug === "mastra") {
return `echo "[entrypoint] Starting Mastra agent on port 8123..."
PORT=8123 npx mastra dev 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
sleep 3
${AGENT_HEALTH_CHECK}`;
}
return `echo "[entrypoint] Starting TypeScript agent on port 8123..."
npx tsx agent/index.ts 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
sleep 2
${AGENT_HEALTH_CHECK}`;
case "java":
return `echo "[entrypoint] Starting Spring AI agent on port 8123..."
java -jar agent/app.jar --server.port=8123 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
sleep 5
${AGENT_HEALTH_CHECK}`;
case "csharp":
return `echo "[entrypoint] Starting .NET agent on port 8123..."
cd agent && dotnet ProverbsAgent.dll --urls http://0.0.0.0:8123 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
cd /app
sleep 3
${AGENT_HEALTH_CHECK}`;
}
}
// ---------------------------------------------------------------------------
// Copy shared Python tools into agent dir (making it self-contained)
// ---------------------------------------------------------------------------
function copySharedPythonTools(agentDestDir: string): void {
const toolsSrc = path.join(SHARED_PYTHON_DIR, "tools");
if (!fs.existsSync(toolsSrc)) {
throw new Error(`Shared Python tools directory missing: ${toolsSrc}`);
}
const toolsDest = path.join(agentDestDir, "tools");
copyDirSync(toolsSrc, toolsDest);
// Copy data/db.csv
const dataSrc = path.join(SHARED_PYTHON_DIR, "data");
const dataDest = path.join(agentDestDir, "data");
copyDirSync(dataSrc, dataDest);
// Remove test files from the copied tools
const testDir = path.join(toolsDest, "__tests__");
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
}
// ---------------------------------------------------------------------------
// Copy shared TypeScript tools into agent dir
// ---------------------------------------------------------------------------
function copySharedTypeScriptTools(agentDestDir: string): void {
const toolsSrc = SHARED_TS_DIR;
const toolsDest = path.join(agentDestDir, "shared-tools");
if (!fs.existsSync(toolsSrc)) {
throw new Error(`Shared TypeScript tools directory missing: ${toolsSrc}`);
}
fs.mkdirSync(toolsDest, { recursive: true });
const entries = fs.readdirSync(toolsSrc).sort();
for (const entry of entries) {
if (entry === "__tests__" || entry === "node_modules") continue;
const srcPath = path.join(toolsSrc, entry);
const destPath = path.join(toolsDest, entry);
if (fs.statSync(srcPath).isFile()) {
fs.copyFileSync(srcPath, destPath);
}
}
// Also copy the data/db.csv for queryData
const dataSrc = path.join(SHARED_PYTHON_DIR, "data");
const dataDest = path.join(agentDestDir, "data");
if (fs.existsSync(dataSrc)) {
copyDirSync(dataSrc, dataDest);
}
}
// ---------------------------------------------------------------------------
// Generate a single starter
// ---------------------------------------------------------------------------
/**
* Core generation logic shared by generateStarter() and generateStarterToDir().
* Writes a fully self-contained starter into `outDir`.
*/
function generateStarterImpl(fw: FrameworkDef, outDir: string): void {
if (fs.existsSync(outDir)) {
fs.rmSync(outDir, { recursive: true });
}
fs.mkdirSync(outDir, { recursive: true });
// Extra Dockerfile COPY lines for frameworks that need root-level config files
let dockerExtraCopy =
fw.extraFiles &&
Object.keys(fw.extraFiles).some((dest) => !dest.includes("/"))
? Object.keys(fw.extraFiles)
.filter((dest) => !dest.includes("/"))
.map((dest) => `\n# Framework config\nCOPY ${dest} ./`)
.join("")
: "";
// Non-langgraph Python starters need agent_server.py at the root
if (
fw.language === "python" &&
fw.slug !== "langgraph-python" &&
fw.slug !== "langgraph-fastapi"
) {
dockerExtraCopy +=
"\n# FastAPI agent server entrypoint\nCOPY agent_server.py ./";
}
const vars: Record<string, string> = {
SLUG: fw.slug,
NAME: fw.name,
LANGUAGE: fw.language,
AGENT_DIR: fw.agentDir,
DEV_SCRIPT: fw.devScript,
AGENT_PORT: "8123",
DEV_SCRIPT_BLOCK: getEntrypointBlock(fw),
DOCKER_EXTRA_COPY: dockerExtraCopy,
};
// 1. Copy frontend files into src/
const frontendSrc = path.join(TEMPLATE_DIR, "frontend");
const frontendDest = path.join(outDir, "src");
copyDirSync(frontendSrc, frontendDest);
processTemplateVarsInDir(frontendDest, vars);
// 2. Copy template config files
const templateConfigs: Array<[string, string]> = [
["package.template.json", "package.json"],
["next.config.template.ts", "next.config.ts"],
["tsconfig.template.json", "tsconfig.json"],
["postcss.config.template.mjs", "postcss.config.mjs"],
[".gitignore.template", ".gitignore"],
];
for (const [templateFile, outputFile] of templateConfigs) {
const templatePath = path.join(TEMPLATE_DIR, templateFile);
if (!fs.existsSync(templatePath)) {
throw new Error(`Required template config missing: ${templatePath}`);
}
let content = fs.readFileSync(templatePath, "utf-8");
content = substituteVars(content, vars);
if (outputFile === "package.json") {
const pkg = JSON.parse(content);
pkg.scripts.dev = fw.devScript;
if (fw.devScript.includes("concurrently")) {
pkg.devDependencies = pkg.devDependencies || {};
pkg.devDependencies.concurrently = "^9.1.0";
}
// Merge framework-specific dependencies
if (fw.extraDependencies) {
pkg.dependencies = pkg.dependencies || {};
Object.assign(pkg.dependencies, fw.extraDependencies);
// Sort dependencies keys for deterministic output
const sorted: Record<string, string> = {};
for (const key of Object.keys(pkg.dependencies).sort()) {
sorted[key] = pkg.dependencies[key];
}
pkg.dependencies = sorted;
}
// Sort devDependencies keys for deterministic output
if (pkg.devDependencies) {
const sorted: Record<string, string> = {};
for (const key of Object.keys(pkg.devDependencies).sort()) {
sorted[key] = pkg.devDependencies[key];
}
pkg.devDependencies = sorted;
}
// Pin floating dist-tags (like "beta", "next") to concrete versions
// so Docker builds get reproducible installs. The monorepo lockfile
// keeps the demo packages stable, but starters run `npm install`
// from scratch and can hit version drift.
const PIN_OVERRIDES: Record<string, Record<string, string>> = {
mastra: {
"@ag-ui/mastra": "0.2.1-beta.2",
"@mastra/client-js": "^1.13.4",
"@mastra/core": "^1.25.0",
"@mastra/libsql": "^1.8.1",
"@mastra/memory": "^1.15.1",
mastra: "^1.6.0",
},
};
const pins = PIN_OVERRIDES[fw.slug];
if (pins && pkg.dependencies) {
for (const [dep, version] of Object.entries(pins)) {
if (pkg.dependencies[dep]) {
pkg.dependencies[dep] = version;
}
}
}
content = JSON.stringify(pkg, null, 2) + "\n";
}
fs.writeFileSync(path.join(outDir, outputFile), content);
}
// 3. Copy agent code
const pkgDir = path.join(PACKAGES_DIR, fw.slug);
const agentSrc = path.join(pkgDir, fw.agentSourceDir);
const agentDest = path.join(outDir, fw.agentDir);
if (!fs.existsSync(agentSrc)) {
throw new Error(
`Agent source directory missing for ${fw.slug}: ${agentSrc}`,
);
} else {
copyDirSync(agentSrc, agentDest);
}
// For spring-ai: the source copies src/main/{java,resources} flattened into
// agent/{java,resources}, but Maven requires the standard src/main/ layout.
// Restructure: agent/java/ → agent/src/main/java/, agent/resources/ → agent/src/main/resources/
if (fw.slug === "spring-ai") {
const srcMainDir = path.join(agentDest, "src", "main");
fs.mkdirSync(srcMainDir, { recursive: true });
for (const sub of ["java", "resources"]) {
const flat = path.join(agentDest, sub);
if (fs.existsSync(flat)) {
fs.renameSync(flat, path.join(srcMainDir, sub));
}
}
}
// For Python: make self-contained by copying shared tools + rewriting imports
if (fw.language === "python") {
copySharedPythonTools(agentDest);
// Always rewrite: remove sys.path.insert and convert shared tool imports
rewritePythonImportsInDir(agentDest, fw.agentDir);
// Handle tools.py / tools/ naming collision:
// If both tools.py (wrapper) and tools/ (shared tools dir) exist,
// rename tools.py to tool_wrappers.py and update imports
const toolsPy = path.join(agentDest, "tools.py");
const toolsDir = path.join(agentDest, "tools");
if (fs.existsSync(toolsPy) && fs.existsSync(toolsDir)) {
const newName = path.join(agentDest, "tool_wrappers.py");
fs.renameSync(toolsPy, newName);
// Update imports in OTHER .py files (not tool_wrappers.py itself)
// to reference tool_wrappers instead of tools (the wrapper file, not the dir)
for (const pyFile of fs
.readdirSync(agentDest)
.filter((f) => f.endsWith(".py") && f !== "tool_wrappers.py")) {
const fp = path.join(agentDest, pyFile);
let content = fs.readFileSync(fp, "utf-8");
const agentMod = fw.agentDir.replace(/\//g, ".");
// from <agentMod>.tools import X -> from <agentMod>.tool_wrappers import X
content = content.replace(
new RegExp(`from ${agentMod}\\.tools import`, "g"),
`from ${agentMod}.tool_wrappers import`,
);
// from .tools import X -> from .tool_wrappers import X
// (but NOT from .tools.submodule — those reference the tools/ dir)
content = content.replace(
/^from \.tools import/gm,
"from .tool_wrappers import",
);
fs.writeFileSync(fp, content);
}
}
// For langgraph starters: convert relative imports to absolute
// because langgraph_cli loads modules standalone, not as packages
if (fw.slug.startsWith("langgraph-")) {
const agentMod = fw.agentDir.replace(/\//g, ".");
for (const pyFile of fs
.readdirSync(agentDest)
.filter((f) => f.endsWith(".py"))) {
const fp = path.join(agentDest, pyFile);
let content = fs.readFileSync(fp, "utf-8");
// from .X import -> from <agentMod>.X import
content = content.replace(
/^from \.([\w.]+) import/gm,
`from ${agentMod}.$1 import`,
);
fs.writeFileSync(fp, content);
}
}
const reqSrc = path.join(pkgDir, "requirements.txt");
if (fs.existsSync(reqSrc)) {
fs.copyFileSync(reqSrc, path.join(agentDest, "requirements.txt"));
}
const initPath = path.join(agentDest, "__init__.py");
if (!fs.existsSync(initPath)) {
fs.writeFileSync(initPath, "");
}
// Copy agent_server.py from demo package into starter root for non-langgraph starters
if (fw.slug !== "langgraph-python" && fw.slug !== "langgraph-fastapi") {
const agentServerSrc = path.join(pkgDir, "src", "agent_server.py");
if (fs.existsSync(agentServerSrc)) {
let serverContent = fs.readFileSync(agentServerSrc, "utf-8");
// Rewrite imports: demo packages use "agents/" dir, starters use "agent/"
serverContent = serverContent.replace(
/^from agents\./gm,
"from agent.",
);
fs.writeFileSync(path.join(outDir, "agent_server.py"), serverContent);
} else {
console.warn(
` [warn] agent_server.py missing for ${fw.slug}: ${agentServerSrc} — skipping`,
);
}
}
}
// For TypeScript: copy shared tools and rewrite imports
if (fw.language === "typescript") {
copySharedTypeScriptTools(agentDest);
rewriteTypeScriptImportsInDir(agentDest, undefined, outDir);
}
// 4. Copy extra files
if (fw.extraFiles) {
for (const [dest, src] of Object.entries(fw.extraFiles)) {
const srcPath = path.join(pkgDir, src);
const destPath = path.join(outDir, dest);
if (!fs.existsSync(srcPath)) {
console.warn(
` [warn] Extra file missing for ${fw.slug}: ${srcPath} — skipping`,
);
continue;
}
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(srcPath, destPath);
// Rewrite langgraph.json graph paths for the starter layout.
// Only rewrite paths when agents are flattened into a different dir
// (e.g. src/agents/ -> agent/). Skip rewriting when the starter
// preserves the same directory structure as the package.
if (
dest.endsWith("langgraph.json") &&
fw.agentDir !== fw.agentSourceDir
) {
let lgContent = fs.readFileSync(destPath, "utf-8");
lgContent = lgContent.replace(/\.\/src\/agents\//g, "./");
lgContent = lgContent.replace(/\.\/src\/agent\//g, "./");
fs.writeFileSync(destPath, lgContent);
}
}
}
// 5. Copy Dockerfile
const dockerfileKey = fw.language === "csharp" ? "dotnet" : fw.language;
const dockerfileSrc = path.join(
TEMPLATE_DIR,
"dockerfiles",
`Dockerfile.${dockerfileKey}`,
);
if (!fs.existsSync(dockerfileSrc)) {
throw new Error(`Dockerfile missing for ${fw.slug}: ${dockerfileSrc}`);
}
let dockerfileContent = fs.readFileSync(dockerfileSrc, "utf-8");
dockerfileContent = substituteVars(dockerfileContent, vars);
fs.writeFileSync(path.join(outDir, "Dockerfile"), dockerfileContent);
// 6. Generate entrypoint.sh
const entrypointTemplate = fs.readFileSync(
path.join(TEMPLATE_DIR, "entrypoint.template.sh"),
"utf-8",
);
const entrypoint = substituteVars(entrypointTemplate, vars);
fs.writeFileSync(path.join(outDir, "entrypoint.sh"), entrypoint, {
mode: 0o755,
});
// 7. Generate showcase.json
const showcaseJson = {
slug: fw.slug,
name: fw.name,
language: fw.language,
agentDir: fw.agentDir,
agentPort: 8123,
generated: true,
};
fs.writeFileSync(
path.join(outDir, "showcase.json"),
JSON.stringify(showcaseJson, null, 2) + "\n",
);
// 8. Copy flight-schema.json for A2UI
const flightSchemaSrc = path.join(
SHOWCASE,
"shared",
"frontend",
"src",
"a2ui",
"flight-schema.json",
);
if (fs.existsSync(flightSchemaSrc)) {
if (fw.language === "python") {
const flightDest = path.join(agentDest, "data", "flight-schema.json");
fs.mkdirSync(path.dirname(flightDest), { recursive: true });
fs.copyFileSync(flightSchemaSrc, flightDest);
}
}
// For spring-ai: copy pom.xml and Java source
if (fw.slug === "spring-ai") {
const pomSrc = path.join(pkgDir, "pom.xml");
if (fs.existsSync(pomSrc)) {
fs.copyFileSync(pomSrc, path.join(agentDest, "pom.xml"));
}
for (const f of ["mvnw", "mvnw.cmd"]) {
const src = path.join(pkgDir, f);
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(agentDest, f));
fs.chmodSync(path.join(agentDest, f), 0o755);
}
}
const mvnDir = path.join(pkgDir, ".mvn");
if (fs.existsSync(mvnDir)) {
copyDirSync(mvnDir, path.join(agentDest, ".mvn"));
}
// resources/ already placed at agent/src/main/resources/ by the restructure step above
}
}
function generateStarter(fw: FrameworkDef, dryRun: boolean): void {
const outDir = path.join(STARTERS_DIR, fw.slug);
if (dryRun) {
console.log(` [dry-run] Would generate: ${outDir}`);
return;
}
generateStarterImpl(fw, outDir);
}
function processTemplateVarsInDir(
dir: string,
vars: Record<string, string>,
): void {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
processTemplateVarsInDir(fullPath, vars);
} else {
const ext = path.extname(entry.name);
if ([".ts", ".tsx", ".json", ".css", ".html", ".mjs"].includes(ext)) {
let content = fs.readFileSync(fullPath, "utf-8");
const replaced = substituteVars(content, vars);
if (replaced !== content) {
fs.writeFileSync(fullPath, replaced);
}
}
}
}
}
function rewritePythonImportsInDir(dir: string, agentDir: string): void {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip data dirs but process everything else including tools/
if (entry.name !== "data") {
rewritePythonImportsInDir(fullPath, agentDir);
}
} else {
rewritePythonImports(fullPath, agentDir);
}
}
}
function rewriteTypeScriptImportsInDir(
dir: string,
agentDestDir?: string,
starterOutDir?: string,
): void {
if (!fs.existsSync(dir)) return;
const rootDir = agentDestDir ?? dir;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "shared-tools" && entry.name !== "data") {
rewriteTypeScriptImportsInDir(fullPath, rootDir, starterOutDir);
}
} else {
rewriteTypeScriptSharedImports(fullPath, rootDir, starterOutDir);
}
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void {
// Validate slug uniqueness at startup
const slugs = FRAMEWORKS.map((f) => f.slug);
const dupes = slugs.filter((s, i) => slugs.indexOf(s) !== i);
if (dupes.length) {
throw new Error(`Duplicate framework slugs: ${dupes.join(", ")}`);
}
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const checkMode = args.includes("--check");
const slugIdx = args.indexOf("--slug");
const filterSlug = slugIdx >= 0 ? args[slugIdx + 1] : null;
const targets = filterSlug
? FRAMEWORKS.filter((f) => f.slug === filterSlug)
: FRAMEWORKS;
if (targets.length === 0) {
console.error(`Unknown slug: ${filterSlug}`);
console.error(`Available: ${FRAMEWORKS.map((f) => f.slug).join(", ")}`);
process.exit(1);
}
if (checkMode) {
runCheckMode(targets);
return;
}
console.log(`Generating ${targets.length} starter(s)...`);
for (const fw of targets) {
console.log(`\n--- ${fw.slug} (${fw.language}) ---`);
generateStarter(fw, dryRun);
console.log(` Generated: showcase/starters/${fw.slug}/`);
}
console.log(`\nDone. Generated ${targets.length} starter(s).`);
}
/**
* --check mode: generate to a temp directory, diff against committed starters,
* and exit non-zero if any drift is detected.
*/
function runCheckMode(targets: FrameworkDef[]): void {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-check-"));
const tmpStartersDir = path.join(tmpDir, "starters");
fs.mkdirSync(tmpStartersDir, { recursive: true });
let drifted = false;
console.log(`[check] Generating ${targets.length} starter(s) to temp dir...`);
for (const fw of targets) {
const outDir = path.join(tmpStartersDir, fw.slug);
generateStarterToDir(fw, tmpStartersDir);
const committedDir = path.join(STARTERS_DIR, fw.slug);
if (!fs.existsSync(committedDir)) {
console.error(
`[check] DRIFT: ${fw.slug}/ does not exist in committed starters`,
);
drifted = true;
continue;
}
try {
execSync(
`diff -r --exclude=node_modules --exclude=.next --exclude=next-env.d.ts --exclude=package-lock.json "${committedDir}" "${outDir}"`,
{ stdio: "pipe" },
);
} catch (e: unknown) {
const stdout = (e as { stdout?: Buffer }).stdout?.toString() || "";
const stderr = (e as { stderr?: Buffer }).stderr?.toString() || "";
console.error(`[check] DRIFT in ${fw.slug}:`);
if (stdout.trim()) console.error(stdout.trim());
if (stderr.trim()) console.error(stderr.trim());
drifted = true;
}
}
// Clean up
fs.rmSync(tmpDir, { recursive: true });
if (drifted) {
console.error(
"\n[check] FAILED: Starters are out of date. Run: npx tsx generate-starters.ts",
);
process.exit(1);
} else {
console.log("[check] OK: All starters are up to date.");
}
}
/**
* Generate a single starter into a specified output base directory.
*/
function generateStarterToDir(fw: FrameworkDef, startersBase: string): void {
generateStarterImpl(fw, path.join(startersBase, fw.slug));
}
main();
export {
FRAMEWORKS,
generateStarter,
substituteVars,
rewritePythonImports,
extractUvicornModule,
getEntrypointBlock,
};