feat(cli): add @langchain/langgraph-cli new command (#1685)

This commit is contained in:
David Duong
2025-09-20 22:53:24 +02:00
committed by GitHub
parent 0f2cc2d597
commit 7770c6a3db
10 changed files with 288 additions and 258 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"create-langgraph": patch
"@langchain/langgraph-cli": patch
---
Add `new` command to `@langchain/langgraph-cli` CLI
+8 -2
View File
@@ -1,9 +1,9 @@
{
"name": "create-langgraph",
"version": "0.0.3",
"description": "",
"description": "Create a new LangGraph project",
"license": "MIT",
"main": "dist/cli.mjs",
"main": "dist/index.mjs",
"bin": "dist/cli.mjs",
"files": [
"dist"
@@ -37,5 +37,11 @@
"typescript": "^4.9.5 || ^5.4.5",
"vitest": "^3.1.2"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"keywords": []
}
+1 -253
View File
@@ -1,247 +1,8 @@
#!/usr/bin/env node
import { Command } from "@commander-js/extra-typings";
import {
intro,
outro,
select,
text,
isCancel,
cancel,
confirm,
} from "@clack/prompts";
import * as fs from "fs/promises";
import * as path from "path";
import zipExtract from "extract-zip";
import { version } from "./utils/version.mjs";
import color from "picocolors";
import dedent from "dedent";
import { withAnalytics } from "./utils/analytics.mjs";
import { spawn } from "child_process";
const TEMPLATES = {
"New LangGraph Project": {
description: "A simple, minimal chatbot with memory.",
python:
"https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
},
"ReAct Agent": {
description: "A simple agent that can be flexibly extended to many tools.",
python:
"https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
},
"Memory Agent": {
description:
"A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
python:
"https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
},
"Retrieval Agent": {
description:
"An agent that includes a retrieval-based question-answering system.",
python:
"https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
},
"Data-enrichment Agent": {
description:
"An agent that performs web searches and organizes its findings into a structured format.",
python:
"https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
},
};
const getPmSpecificCommands = (): { install: string; exec: string } => {
const npmExecPath = process.env.npm_execpath as string | undefined;
// default to yarn, as most examples include yarn.lock
if (!npmExecPath) return { install: "yarn install", exec: "npx" };
const npmBin = path
.basename(npmExecPath)
.substring(0, -path.extname(npmExecPath).length);
if (npmBin === "yarn") return { install: "yarn install", exec: "npx" };
if (npmBin === "pnpm") return { install: "pnpm install", exec: "pnpm dlx" };
if (npmBin === "bun") return { install: "bun install", exec: "bunx" };
return { install: "npm install", exec: "npx" };
};
// Generate template IDs programmatically
const TEMPLATE_ID_TO_CONFIG = Object.entries(TEMPLATES).reduce(
(acc, [name, versions]) => {
Object.entries(versions)
.filter(([lang]) => lang === "python" || lang === "js")
.forEach(([lang, url]) => {
const id = `${name.toLowerCase().replace(/ /g, "-")}-${lang}`;
acc[id] = [name, lang, url];
});
return acc;
},
{} as Record<string, [string, string, string]>
);
const TEMPLATE_IDS = Object.keys(TEMPLATE_ID_TO_CONFIG);
async function downloadAndExtract(url: string, targetPath: string) {
try {
const response = await fetch(url);
if (!response.ok)
throw new Error(`Failed to download: ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await fs.mkdir(targetPath, { recursive: true });
// Create a temporary file to store the zip
const tempFile = path.join(targetPath, "temp.zip");
await fs.writeFile(tempFile, buffer);
// Extract the zip file
await zipExtract(tempFile, { dir: targetPath });
// Clean up temp file
await fs.unlink(tempFile);
// Move files from the extracted directory to target path
const extractedDir = (await fs.readdir(targetPath)).find((f) =>
f.endsWith("-main")
);
if (extractedDir) {
const fullExtractedPath = path.join(targetPath, extractedDir);
const files = await fs.readdir(fullExtractedPath);
await Promise.all(
files.map((file) =>
fs.rename(
path.join(fullExtractedPath, file),
path.join(targetPath, file)
)
)
);
await fs.rmdir(fullExtractedPath);
}
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(
`Failed to download and extract template: ${error.message}`
);
}
throw new Error("Failed to download and extract template");
}
}
async function createNew(projectPath?: string, templateId?: string) {
intro(`${color.bgCyan(color.black(" 🦜 create-langgraph "))}`);
let resolvedPath = projectPath;
if (!resolvedPath) {
const result = await text({
message: "Where would you like to create your project?",
placeholder: ".",
defaultValue: ".",
});
if (isCancel(result)) {
cancel("Operation cancelled");
process.exit(0);
}
resolvedPath = result;
}
const absolutePath = path.resolve(resolvedPath);
try {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
const files = await fs.readdir(absolutePath);
if (files.length > 0) throw new Error("Directory is not empty");
}
} catch (error: unknown) {
if (
error instanceof Error &&
(error as NodeJS.ErrnoException).code !== "ENOENT"
) {
throw error;
}
}
let language: "js" | "python" = "js";
let templateUrl: string;
if (templateId) {
const config = TEMPLATE_ID_TO_CONFIG[templateId];
if (!config) throw new Error("Invalid template ID.");
templateUrl = config[2];
} else {
const templateChoice = await select({
message: "Select a template",
options: Object.entries(TEMPLATES).map(([name, info]) => ({
value: name,
label: name,
hint: info.description,
})),
});
if (isCancel(templateChoice)) {
cancel("Operation cancelled");
process.exit(0);
}
const template = TEMPLATES[templateChoice as keyof typeof TEMPLATES];
if (!template) throw new Error("Invalid template choice");
templateUrl = template[language];
}
await downloadAndExtract(templateUrl, absolutePath);
// delete yarn.lock
try {
await fs.unlink(path.join(absolutePath, "yarn.lock"));
} catch {
// do nothing
}
const shouldInitGit = await confirm({
message: "Would you like to run `git init`?",
initialValue: true,
active: "Yes",
inactive: "No",
});
if (shouldInitGit) {
await new Promise((resolve, reject) => {
const proc = spawn("git", ["init"], { cwd: absolutePath });
proc.on("close", (code) => {
if (code === 0) resolve(undefined);
else reject(new Error(`git init failed with code ${code}`));
});
});
}
const pm = getPmSpecificCommands();
const guide =
language === "js"
? color.cyan(
dedent`
Next steps:
- cd ${path.relative(process.cwd(), absolutePath)}
- ${pm.install}
- ${pm.exec} @langchain/langgraph-cli@latest dev
`
)
: null;
outro(
[`Project created successfully at ${color.green(absolutePath)}`, guide]
.filter(Boolean)
.join("\n\n")
);
}
import { createNew } from "./index.mjs";
const program = new Command()
.name("create-langgraph")
@@ -251,19 +12,6 @@ const program = new Command()
.option("-t, --template <template>", "Template to use", "")
.hook("preAction", withAnalytics())
.action((path, options) => {
if (options.template) {
const config = TEMPLATE_ID_TO_CONFIG[options.template];
if (!config) {
console.error(`Invalid template ID "${options.template}"`);
console.error(
`Available options:\n${TEMPLATE_IDS.map((id) => `- ${id}`).join(
"\n"
)}`
);
process.exit(1);
}
}
createNew(path, options.template).catch((error) => {
console.error("Error:", error.message);
process.exit(1);
+251
View File
@@ -0,0 +1,251 @@
import {
intro,
outro,
select,
text,
isCancel,
cancel,
confirm,
} from "@clack/prompts";
import * as fs from "fs/promises";
import * as path from "path";
import zipExtract from "extract-zip";
import color from "picocolors";
import dedent from "dedent";
import { spawn } from "child_process";
const TEMPLATES = {
"New LangGraph Project": {
description: "A simple, minimal chatbot with memory.",
python:
"https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
},
"ReAct Agent": {
description: "A simple agent that can be flexibly extended to many tools.",
python:
"https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
},
"Memory Agent": {
description:
"A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
python:
"https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
},
"Retrieval Agent": {
description:
"An agent that includes a retrieval-based question-answering system.",
python:
"https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
},
"Data-enrichment Agent": {
description:
"An agent that performs web searches and organizes its findings into a structured format.",
python:
"https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
},
};
const getPmSpecificCommands = (): { install: string; exec: string } => {
const npmExecPath = process.env.npm_execpath as string | undefined;
// default to yarn, as most examples include yarn.lock
if (!npmExecPath) return { install: "yarn install", exec: "npx" };
const npmBin = path
.basename(npmExecPath)
.substring(0, -path.extname(npmExecPath).length);
if (npmBin === "yarn") return { install: "yarn install", exec: "npx" };
if (npmBin === "pnpm") return { install: "pnpm install", exec: "pnpm dlx" };
if (npmBin === "bun") return { install: "bun install", exec: "bunx" };
return { install: "npm install", exec: "npx" };
};
// Generate template IDs programmatically
const TEMPLATE_ID_TO_CONFIG = Object.entries(TEMPLATES).reduce(
(acc, [name, versions]) => {
Object.entries(versions)
.filter(([lang]) => lang === "python" || lang === "js")
.forEach(([lang, url]) => {
const id = `${name.toLowerCase().replace(/ /g, "-")}-${lang}`;
acc[id] = [name, lang, url];
});
return acc;
},
{} as Record<string, [string, string, string]>
);
const TEMPLATE_IDS = Object.keys(TEMPLATE_ID_TO_CONFIG);
async function downloadAndExtract(url: string, targetPath: string) {
try {
const response = await fetch(url);
if (!response.ok)
throw new Error(`Failed to download: ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await fs.mkdir(targetPath, { recursive: true });
// Create a temporary file to store the zip
const tempFile = path.join(targetPath, "temp.zip");
await fs.writeFile(tempFile, buffer);
// Extract the zip file
await zipExtract(tempFile, { dir: targetPath });
// Clean up temp file
await fs.unlink(tempFile);
// Move files from the extracted directory to target path
const extractedDir = (await fs.readdir(targetPath)).find((f) =>
f.endsWith("-main")
);
if (extractedDir) {
const fullExtractedPath = path.join(targetPath, extractedDir);
const files = await fs.readdir(fullExtractedPath);
await Promise.all(
files.map((file) =>
fs.rename(
path.join(fullExtractedPath, file),
path.join(targetPath, file)
)
)
);
await fs.rmdir(fullExtractedPath);
}
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(
`Failed to download and extract template: ${error.message}`
);
}
throw new Error("Failed to download and extract template");
}
}
export async function createNew(projectPath?: string, templateId?: string) {
if (templateId) {
const config = TEMPLATE_ID_TO_CONFIG[templateId];
if (!config) {
console.error(`Invalid template ID "${templateId}"`);
console.error(
`Available options:\n${TEMPLATE_IDS.map((id) => `- ${id}`).join("\n")}`
);
process.exit(1);
}
}
intro(`${color.bgCyan(color.black(" 🦜 create-langgraph "))}`);
let resolvedPath = projectPath;
if (!resolvedPath) {
const result = await text({
message: "Where would you like to create your project?",
placeholder: ".",
defaultValue: ".",
});
if (isCancel(result)) {
cancel("Operation cancelled");
process.exit(0);
}
resolvedPath = result;
}
const absolutePath = path.resolve(resolvedPath);
try {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
const files = await fs.readdir(absolutePath);
if (files.length > 0) throw new Error("Directory is not empty");
}
} catch (error: unknown) {
if (
error instanceof Error &&
(error as NodeJS.ErrnoException).code !== "ENOENT"
) {
throw error;
}
}
let language: "js" | "python" = "js";
let templateUrl: string;
if (templateId) {
const config = TEMPLATE_ID_TO_CONFIG[templateId];
if (!config) throw new Error("Invalid template ID.");
templateUrl = config[2];
} else {
const templateChoice = await select({
message: "Select a template",
options: Object.entries(TEMPLATES).map(([name, info]) => ({
value: name,
label: name,
hint: info.description,
})),
});
if (isCancel(templateChoice)) {
cancel("Operation cancelled");
process.exit(0);
}
const template = TEMPLATES[templateChoice as keyof typeof TEMPLATES];
if (!template) throw new Error("Invalid template choice");
templateUrl = template[language];
}
await downloadAndExtract(templateUrl, absolutePath);
// delete yarn.lock
try {
await fs.unlink(path.join(absolutePath, "yarn.lock"));
} catch {
// do nothing
}
const shouldInitGit = await confirm({
message: "Would you like to run `git init`?",
initialValue: true,
active: "Yes",
inactive: "No",
});
if (shouldInitGit) {
await new Promise((resolve, reject) => {
const proc = spawn("git", ["init"], { cwd: absolutePath });
proc.on("close", (code) => {
if (code === 0) resolve(undefined);
else reject(new Error(`git init failed with code ${code}`));
});
});
}
const pm = getPmSpecificCommands();
const guide =
language === "js"
? color.cyan(
dedent`
Next steps:
- cd ${path.relative(process.cwd(), absolutePath)}
- ${pm.install}
- ${pm.exec} @langchain/langgraph-cli@latest dev
`
)
: null;
outro(
[`Project created successfully at ${color.green(absolutePath)}`, guide]
.filter(Boolean)
.join("\n\n")
);
}
+1
View File
@@ -9,6 +9,7 @@
"allowJs": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": true,
"declaration": true,
"outDir": "dist"
},
"exclude": ["scripts/*", "dist/*"]
+1
View File
@@ -36,6 +36,7 @@
"@langchain/langgraph-api": "workspace:*",
"chokidar": "^4.0.3",
"commander": "^13.0.0",
"create-langgraph": "workspace:*",
"dedent": "^1.5.3",
"dotenv": "^16.4.7",
"execa": "^9.5.2",
+1
View File
@@ -7,6 +7,7 @@ import "./dev.mjs";
import "./docker.mjs";
import "./build.mjs";
import "./up.mjs";
import "./new.mjs";
import "./sysinfo.mjs";
builder.exitOverride((error) => gracefulExit(error.exitCode));
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { builder } from "./utils/builder.mjs";
import { withAnalytics } from "./utils/analytics.mjs";
import { createNew } from "create-langgraph";
import { gracefulExit } from "exit-hook";
builder
.command("new")
.description("Create a new LangGraph project")
.argument("[path]", "Path to create the project")
.option("-t, --template <template>", "Template to use", "")
.hook("preAction", withAnalytics())
.exitOverride((error) => gracefulExit(error.exitCode))
.action((path, options) => createNew(path, options.template));
@@ -34,7 +34,8 @@ async function logData(data: LogData): Promise<void> {
let analyticsPromise = Promise.resolve();
export function withAnalytics<TCommand extends Command<any, any, any>>(
fn?: (command: TCommand) => Record<string, boolean>
fn?: (command: TCommand) => Record<string, boolean>,
options?: { name?: string }
) {
if (process.env.LANGGRAPH_CLI_NO_ANALYTICS === "1") {
return () => void 0;
@@ -47,7 +48,7 @@ export function withAnalytics<TCommand extends Command<any, any, any>>(
os_version: os.release(),
node_version: process.version,
cli_version: version,
cli_command: actionCommand.name(),
cli_command: options?.name ?? actionCommand.name(),
params: fn?.(actionCommand) ?? {},
}).catch(() => {})
);
+2 -1
View File
@@ -1765,6 +1765,7 @@ __metadata:
"@types/node": "npm:^18.15.11"
chokidar: "npm:^4.0.3"
commander: "npm:^13.0.0"
create-langgraph: "workspace:*"
dedent: "npm:^1.5.3"
dotenv: "npm:^16.4.7"
execa: "npm:^9.5.2"
@@ -5707,7 +5708,7 @@ __metadata:
languageName: node
linkType: hard
"create-langgraph@workspace:libs/create-langgraph":
"create-langgraph@workspace:*, create-langgraph@workspace:libs/create-langgraph":
version: 0.0.0-use.local
resolution: "create-langgraph@workspace:libs/create-langgraph"
dependencies: