Files
2026-04-08 05:40:59 -07:00

1495 lines
39 KiB
Go

package root
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/langchain-ai/langgraph/libs/cli/internal/config"
"github.com/langchain-ai/langgraph/libs/cli/internal/deploy"
"github.com/langchain-ai/langgraph/libs/cli/internal/docker"
lgexec "github.com/langchain-ai/langgraph/libs/cli/internal/exec"
"github.com/langchain-ai/langgraph/libs/cli/internal/templates"
"github.com/langchain-ai/langgraph/libs/cli/internal/version"
)
const helpText = `Usage: langgraph [OPTIONS] COMMAND [ARGS]...
LangGraph CLI
Options:
--version Show the version and exit.
--help Show this message and exit.
Commands:
build Build LangGraph API server Docker image.
deploy Build and deploy to LangSmith.
dev Run LangGraph API server in development mode.
dockerfile Generate a Dockerfile for the LangGraph API server.
new Create a new LangGraph project from a template.
up Launch LangGraph API server.
validate Validate the LangGraph configuration file.`
func Run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
_, _ = fmt.Fprintln(stdout, helpText)
return 0
}
switch args[0] {
case "help", "--help", "-h":
_, _ = fmt.Fprintln(stdout, helpText)
return 0
case "version", "--version", "-V":
_, _ = fmt.Fprintf(
stdout,
"langgraph %s (commit: %s, built: %s)\n",
version.Version,
version.Commit,
version.Date,
)
return 0
case "validate":
return runValidate(args[1:], stdout, stderr)
case "build":
return runBuild(args[1:], stdout, stderr)
case "dockerfile":
return runDockerfile(args[1:], stdout, stderr)
case "up":
return runUp(args[1:], stdout, stderr)
case "dev":
return runDev(args[1:], stdout, stderr)
case "new":
return runNew(args[1:], stdout, stderr)
case "deploy":
return runDeploy(args[1:], stdout, stderr)
default:
_, _ = fmt.Fprintf(
stderr,
"langgraph: %q is not a langgraph command. See 'langgraph --help'.\n",
args[0],
)
return 1
}
}
// ---------------------------------------------------------------------------
// ANSI colors
// ---------------------------------------------------------------------------
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorCyan = "\033[36m"
)
func errPrint(w io.Writer, msg string) {
_, _ = fmt.Fprintf(w, "%sError: %s%s\n", colorRed, msg, colorReset)
}
// ---------------------------------------------------------------------------
// Common flag parsing helpers
// ---------------------------------------------------------------------------
type commonFlags struct {
configPath string
baseImage string
apiVersion string
tag string
port int
pull bool
verbose bool
watch bool
engineRuntimeMode string
installCommand string
buildCommand string
dockerCompose string
postgresURI string
debuggerPort int
debuggerBaseURL string
image string
wait bool
passthrough []string
}
func newCommonFlags() commonFlags {
return commonFlags{
configPath: "langgraph.json",
port: 8123,
pull: true,
engineRuntimeMode: "combined_queue_worker",
}
}
// parseFlags is a minimal flag parser. Unknown flags after "--" or positional
// args are collected into passthrough.
func parseFlags(args []string, flags *commonFlags) []string {
var positional []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "-c", "--config":
if i+1 < len(args) {
flags.configPath = args[i+1]
i++
}
case "-t", "--tag":
if i+1 < len(args) {
flags.tag = args[i+1]
i++
}
case "-p", "--port":
if i+1 < len(args) {
if n, err := strconv.Atoi(args[i+1]); err == nil {
flags.port = n
}
i++
}
case "--base-image":
if i+1 < len(args) {
flags.baseImage = args[i+1]
i++
}
case "--api-version":
if i+1 < len(args) {
flags.apiVersion = args[i+1]
i++
}
case "--engine-runtime-mode":
if i+1 < len(args) {
flags.engineRuntimeMode = args[i+1]
i++
}
case "--install-command":
if i+1 < len(args) {
flags.installCommand = args[i+1]
i++
}
case "--build-command":
if i+1 < len(args) {
flags.buildCommand = args[i+1]
i++
}
case "--docker-compose", "-d":
if i+1 < len(args) {
flags.dockerCompose = args[i+1]
i++
}
case "--postgres-uri":
if i+1 < len(args) {
flags.postgresURI = args[i+1]
i++
}
case "--debugger-port":
if i+1 < len(args) {
if n, err := strconv.Atoi(args[i+1]); err == nil {
flags.debuggerPort = n
}
i++
}
case "--debugger-base-url":
if i+1 < len(args) {
flags.debuggerBaseURL = args[i+1]
i++
}
case "--image":
if i+1 < len(args) {
flags.image = args[i+1]
i++
}
case "--pull":
flags.pull = true
case "--no-pull":
flags.pull = false
case "--verbose":
flags.verbose = true
case "--watch":
flags.watch = true
case "--wait":
flags.wait = true
case "--recreate", "--no-recreate":
// accepted but ignored in Go CLI (compose handles it)
default:
positional = append(positional, args[i])
}
}
return positional
}
// loadAndValidateConfig reads and validates langgraph.json. Returns the raw
// and validated config, or writes an error and returns nil.
func loadAndValidateConfig(configPath string, stderr io.Writer) (map[string]any, map[string]any, bool) {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
errPrint(stderr, fmt.Sprintf("Path '%s' does not exist.", configPath))
return nil, nil, false
}
data, err := os.ReadFile(configPath)
if err != nil {
errPrint(stderr, err.Error())
return nil, nil, false
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
errPrint(stderr, fmt.Sprintf("Invalid JSON in %s: %s", configPath, err.Error()))
return nil, nil, false
}
validated, err := config.ValidateConfig(raw)
if err != nil {
errPrint(stderr, err.Error())
return nil, nil, false
}
return raw, validated, true
}
// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------
func runValidate(args []string, stdout, stderr io.Writer) int {
configPath := "langgraph.json"
for i := 0; i < len(args); i++ {
switch args[i] {
case "-c", "--config":
if i+1 < len(args) {
configPath = args[i+1]
i++
} else {
_, _ = fmt.Fprintln(stderr, "Error: --config requires a path argument")
return 1
}
case "--help", "-h":
_, _ = fmt.Fprintln(stdout, `Usage: langgraph validate [OPTIONS]
Validate the LangGraph configuration file.
Options:
-c, --config PATH Path to configuration file (default: langgraph.json)
--help Show this message and exit.`)
return 0
default:
_, _ = fmt.Fprintf(stderr, "Error: unexpected argument %q\n", args[i])
return 1
}
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
_, _ = fmt.Fprintf(stderr, "Error: Path '%s' does not exist.\n", configPath)
return 1
}
data, err := os.ReadFile(configPath)
if err != nil {
_, _ = fmt.Fprintf(stderr, "%sError: %s%s\n", colorRed, err, colorReset)
return 1
}
var rawConfig map[string]any
if err := json.Unmarshal(data, &rawConfig); err != nil {
_, _ = fmt.Fprintf(stderr, "Error: Invalid JSON in %s: %s\n", configPath, err.Error())
return 1
}
unknownWarnings := config.GetUnknownKeys(rawConfig)
validated, validErr := config.ValidateConfig(rawConfig)
if validErr != nil {
_, _ = fmt.Fprintf(stderr, "%sError: %s%s\n", colorRed, validErr, colorReset)
if len(unknownWarnings) > 0 {
_, _ = fmt.Fprintln(stderr)
for _, w := range unknownWarnings {
_, _ = fmt.Fprintf(stderr, " %swarning: %s%s\n", colorYellow, w, colorReset)
}
}
return 1
}
graphs, _ := validated["graphs"].(map[string]any)
numGraphs := len(graphs)
plural := "s"
if numGraphs == 1 {
plural = ""
}
_, _ = fmt.Fprintf(stdout,
"%sConfiguration file %s is valid. (%d graph%s found)%s\n",
colorGreen, configPath, numGraphs, plural, colorReset)
if len(unknownWarnings) > 0 {
_, _ = fmt.Fprintln(stdout)
for _, w := range unknownWarnings {
_, _ = fmt.Fprintf(stdout, " %swarning: %s%s\n", colorYellow, w, colorReset)
}
}
return 0
}
// ---------------------------------------------------------------------------
// build
// ---------------------------------------------------------------------------
func runBuild(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph build [OPTIONS] [DOCKER_BUILD_ARGS]...
Build LangGraph API server Docker image.
Options:
-c, --config PATH Path to configuration file (default: langgraph.json)
-t, --tag TEXT Tag for the docker image. [required]
--pull / --no-pull Pull latest images. (default: pull)
--base-image TEXT Base image for the LangGraph API server.
--api-version TEXT API server version for the base image.
--engine-runtime-mode TEXT Runtime mode (combined_queue_worker or distributed).
--install-command TEXT Custom install command.
--build-command TEXT Custom build command.
--help Show this message and exit.`)
return 0
}
}
flags := newCommonFlags()
passthrough := parseFlags(args, &flags)
flags.passthrough = passthrough
if flags.tag == "" {
errPrint(stderr, "Missing option '--tag' / '-t'.")
return 1
}
_, validated, ok := loadAndValidateConfig(flags.configPath, stderr)
if !ok {
return 1
}
baseImage := flags.baseImage
if baseImage == "" {
baseImage = config.DefaultBaseImage(validated, flags.engineRuntimeMode)
}
// Pull base image
if flags.pull {
tag := config.DockerTag(validated, baseImage, flags.apiVersion)
_, _ = fmt.Fprintf(stdout, "Pulling %s...\n", tag)
if err := lgexec.Run("docker", []string{"pull", tag}, lgexec.RunOpts{Verbose: true}); err != nil {
_, _ = fmt.Fprintf(stderr, "%sWarning: failed to pull image: %s%s\n", colorYellow, err, colorReset)
}
}
_, _ = fmt.Fprintln(stdout, "Building...")
configJSON := deepCopyMap(validated)
dockerfile, contexts, err := config.ConfigToDocker(flags.configPath, configJSON, config.DockerOpts{
BaseImage: baseImage,
APIVersion: flags.apiVersion,
InstallCommand: flags.installCommand,
BuildCommand: flags.buildCommand,
})
if err != nil {
errPrint(stderr, err.Error())
return 1
}
buildArgs := []string{"build", "-f", "-", "-t", flags.tag}
for k, v := range contexts {
buildArgs = append(buildArgs, "--build-context", fmt.Sprintf("%s=%s", k, v))
}
buildArgs = append(buildArgs, flags.passthrough...)
buildContext := filepath.Dir(absPath(flags.configPath))
buildArgs = append(buildArgs, buildContext)
if err := lgexec.Run("docker", buildArgs, lgexec.RunOpts{
Stdin: dockerfile,
Verbose: true,
}); err != nil {
errPrint(stderr, fmt.Sprintf("Docker build failed: %s", err))
return 1
}
_, _ = fmt.Fprintf(stdout, "%sSuccessfully built image: %s%s\n", colorGreen, flags.tag, colorReset)
return 0
}
// ---------------------------------------------------------------------------
// dockerfile
// ---------------------------------------------------------------------------
func runDockerfile(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph dockerfile [OPTIONS] SAVE_PATH
Generate a Dockerfile for the LangGraph API server.
Options:
-c, --config PATH Path to configuration file (default: langgraph.json)
--base-image TEXT Base image for the LangGraph API server.
--api-version TEXT API server version for the base image.
--engine-runtime-mode TEXT Runtime mode (combined_queue_worker or distributed).
--add-docker-compose Add docker-compose.yml and supporting files.
--help Show this message and exit.`)
return 0
}
}
flags := newCommonFlags()
addCompose := false
var positional []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "-c", "--config":
if i+1 < len(args) {
flags.configPath = args[i+1]
i++
}
case "--base-image":
if i+1 < len(args) {
flags.baseImage = args[i+1]
i++
}
case "--api-version":
if i+1 < len(args) {
flags.apiVersion = args[i+1]
i++
}
case "--engine-runtime-mode":
if i+1 < len(args) {
flags.engineRuntimeMode = args[i+1]
i++
}
case "--add-docker-compose":
addCompose = true
default:
positional = append(positional, args[i])
}
}
if len(positional) < 1 {
errPrint(stderr, "Missing argument 'SAVE_PATH'.")
return 1
}
savePath := positional[0]
_, validated, ok := loadAndValidateConfig(flags.configPath, stderr)
if !ok {
return 1
}
baseImage := flags.baseImage
if baseImage == "" {
baseImage = config.DefaultBaseImage(validated, flags.engineRuntimeMode)
}
configJSON := deepCopyMap(validated)
dockerfile, _, err := config.ConfigToDocker(flags.configPath, configJSON, config.DockerOpts{
BaseImage: baseImage,
APIVersion: flags.apiVersion,
})
if err != nil {
errPrint(stderr, err.Error())
return 1
}
if err := os.WriteFile(savePath, []byte(dockerfile), 0644); err != nil {
errPrint(stderr, err.Error())
return 1
}
_, _ = fmt.Fprintf(stdout, "%sDockerfile written to %s%s\n", colorGreen, savePath, colorReset)
if addCompose {
dir := filepath.Dir(savePath)
composeStr, cerr := config.ConfigToCompose(flags.configPath, validated, config.ComposeOpts{
BaseImage: baseImage,
APIVersion: flags.apiVersion,
EngineRuntimeMode: flags.engineRuntimeMode,
})
if cerr != nil {
errPrint(stderr, cerr.Error())
return 1
}
composePath := filepath.Join(dir, "docker-compose.yml")
if err := os.WriteFile(composePath, []byte(composeStr), 0644); err != nil {
errPrint(stderr, err.Error())
return 1
}
_, _ = fmt.Fprintf(stdout, "%sDocker compose written to %s%s\n", colorGreen, composePath, colorReset)
}
return 0
}
// ---------------------------------------------------------------------------
// up
// ---------------------------------------------------------------------------
func runUp(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph up [OPTIONS]
Launch LangGraph API server.
Options:
-c, --config PATH Path to configuration file (default: langgraph.json)
-p, --port INTEGER Port to expose (default: 8123)
--pull / --no-pull Pull latest images (default: pull)
--recreate / --no-recreate Recreate containers
--verbose Show more output
--watch Restart on file changes
--wait Wait for services to start
--postgres-uri TEXT Postgres URI for database
--debugger-port INTEGER Port for the debugger UI
--debugger-base-url TEXT URL for debugger to access LangGraph API
--base-image TEXT Base image for the LangGraph API server
--api-version TEXT API server version for the base image
--engine-runtime-mode TEXT Runtime mode
--image TEXT Pre-built Docker image to use
--help Show this message and exit.`)
return 0
}
}
flags := newCommonFlags()
parseFlags(args, &flags)
caps, err := docker.CheckCapabilities()
if err != nil {
errPrint(stderr, err.Error())
return 1
}
_, validated, ok := loadAndValidateConfig(flags.configPath, stderr)
if !ok {
return 1
}
baseImage := flags.baseImage
if baseImage == "" {
baseImage = config.DefaultBaseImage(validated, flags.engineRuntimeMode)
}
// Generate compose YAML
composeSnippet, cerr := config.ConfigToCompose(flags.configPath, validated, config.ComposeOpts{
BaseImage: baseImage,
APIVersion: flags.apiVersion,
Image: flags.image,
Watch: flags.watch,
EngineRuntimeMode: flags.engineRuntimeMode,
})
if cerr != nil {
errPrint(stderr, cerr.Error())
return 1
}
infraYAML := docker.Compose(caps, docker.ComposeOpts{
Port: flags.port,
DebuggerPort: flags.debuggerPort,
DebuggerBaseURL: flags.debuggerBaseURL,
PostgresURI: flags.postgresURI,
Image: flags.image,
EngineRuntimeMode: flags.engineRuntimeMode,
})
// Wrap the app config snippet into a valid compose overlay YAML.
// ConfigToCompose returns content indented at the service-property level
// (8 spaces), so we wrap it in the correct compose structure.
appOverlayYAML := "services:\n langgraph-api:" + composeSnippet
// Pull
if flags.pull && flags.image == "" {
tag := config.DockerTag(validated, baseImage, flags.apiVersion)
_, _ = fmt.Fprintf(stdout, "Pulling %s...\n", tag)
_ = lgexec.Run("docker", []string{"pull", tag}, lgexec.RunOpts{Verbose: flags.verbose})
}
// Write infrastructure compose and app overlay to separate temp files.
// Docker compose handles merging when given multiple -f flags.
tmpInfra, err := os.CreateTemp("", "langgraph-infra-*.yml")
if err != nil {
errPrint(stderr, err.Error())
return 1
}
defer os.Remove(tmpInfra.Name())
_, _ = tmpInfra.WriteString(infraYAML)
tmpInfra.Close()
tmpApp, err := os.CreateTemp("", "langgraph-app-*.yml")
if err != nil {
errPrint(stderr, err.Error())
return 1
}
defer os.Remove(tmpApp.Name())
_, _ = tmpApp.WriteString(appOverlayYAML)
tmpApp.Close()
composeCmd := "docker"
composeArgs := []string{"compose"}
if caps.ComposeType == "standalone" {
composeCmd = "docker-compose"
composeArgs = nil
}
upArgs := append(composeArgs, "-f", tmpInfra.Name(), "-f", tmpApp.Name(), "up")
if flags.wait {
upArgs = append(upArgs, "--wait")
} else {
upArgs = append(upArgs, "-d")
}
_, _ = fmt.Fprintf(stdout, "%sStarting LangGraph API server...%s\n", colorCyan, colorReset)
if err := lgexec.Run(composeCmd, upArgs, lgexec.RunOpts{
Verbose: true,
Dir: filepath.Dir(absPath(flags.configPath)),
}); err != nil {
errPrint(stderr, fmt.Sprintf("docker compose up failed: %s", err))
return 1
}
_, _ = fmt.Fprintf(stdout, "%sLangGraph API server is running at http://localhost:%d%s\n",
colorGreen, flags.port, colorReset)
return 0
}
// ---------------------------------------------------------------------------
// dev
// ---------------------------------------------------------------------------
func runDev(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph dev [OPTIONS]
Run LangGraph API server in development mode with hot reloading.
Options:
--host TEXT Host to bind to (default: 127.0.0.1)
--port INTEGER Port to bind to (default: 2024)
--config PATH Path to configuration file (default: langgraph.json)
--no-reload Disable auto-reloading
--no-browser Skip opening the browser
--debug-port INTEGER Enable remote debugging on port
--allow-blocking Allow blocking I/O operations
--tunnel Expose via public tunnel
--help Show this message and exit.`)
return 0
}
}
// Dev command: subprocess back into Python.
// The Go CLI handles argument parsing, but the actual dev server runs in Python.
pythonExe := os.Getenv("LANGGRAPH_CALLING_PYTHON")
if pythonExe == "" {
// Try to find python in the environment
for _, name := range []string{"python3", "python"} {
if p, _, err := lgexec.RunCollect("which", []string{name}); err == nil && strings.TrimSpace(p) != "" {
pythonExe = strings.TrimSpace(p)
break
}
}
}
if pythonExe == "" {
errPrint(stderr, "Could not find Python interpreter. Set LANGGRAPH_CALLING_PYTHON.")
return 1
}
// Forward all args to the Python dev command
pyArgs := append([]string{"-m", "langgraph_cli.cli", "dev"}, args...)
if err := lgexec.Run(pythonExe, pyArgs, lgexec.RunOpts{Verbose: true}); err != nil {
return 1
}
return 0
}
// ---------------------------------------------------------------------------
// new
// ---------------------------------------------------------------------------
func runNew(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph new [OPTIONS] [PATH]
Create a new LangGraph project from a template.
Options:
--template TEXT Template ID to use.
--help Show this message and exit.
Available templates:
`+templates.TemplateHelp())
return 0
}
}
var path, template string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--template":
if i+1 < len(args) {
template = args[i+1]
i++
}
default:
if path == "" {
path = args[i]
}
}
}
if path == "" {
errPrint(stderr, "Missing argument 'PATH'. Usage: langgraph new [--template ID] PATH")
return 1
}
if template == "" {
errPrint(stderr, "Missing option '--template'. Available templates:\n"+templates.TemplateHelp())
return 1
}
if err := templates.CreateNew(path, template); err != nil {
errPrint(stderr, err.Error())
return 1
}
_, _ = fmt.Fprintf(stdout, "%sCreated new LangGraph project at %s%s\n", colorGreen, path, colorReset)
return 0
}
// ---------------------------------------------------------------------------
// deploy
// ---------------------------------------------------------------------------
func runDeploy(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
return runDeployMain(args, stdout, stderr)
}
switch args[0] {
case "--help", "-h":
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy [OPTIONS] COMMAND [ARGS]...
[Beta] Build and deploy a LangGraph image to LangSmith Deployment.
Commands:
list List LangSmith Deployments.
revisions Manage deployment revisions.
delete Delete a LangSmith Deployment.
logs Fetch LangSmith Deployment logs.
Run 'langgraph deploy COMMAND --help' for more information on a command.
If no subcommand is given, deploys the current project.`)
return 0
case "list":
return runDeployList(args[1:], stdout, stderr)
case "revisions":
return runDeployRevisions(args[1:], stdout, stderr)
case "delete":
return runDeployDelete(args[1:], stdout, stderr)
case "logs":
return runDeployLogs(args[1:], stdout, stderr)
default:
// No subcommand → main deploy flow
return runDeployMain(args, stdout, stderr)
}
}
func resolveDeployClient(args []string, stderr io.Writer) (apiKey, hostURL string, extra []string) {
hostURL = deploy.DefaultHostURL
for i := 0; i < len(args); i++ {
switch args[i] {
case "--api-key":
if i+1 < len(args) {
apiKey = args[i+1]
i++
}
case "--host-url":
if i+1 < len(args) {
hostURL = args[i+1]
i++
}
default:
extra = append(extra, args[i])
}
}
if apiKey == "" {
apiKey = deploy.ResolveAPIKey("", nil)
}
if apiKey == "" {
errPrint(stderr, "API key required. Set --api-key or LANGSMITH_API_KEY environment variable.")
}
return
}
func runDeployMain(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy [OPTIONS]
Build and deploy a LangGraph image to LangSmith.
Options:
--api-key TEXT LangSmith API key
--name TEXT Deployment name
--deployment-id TEXT Existing deployment ID
--deployment-type TEXT dev or prod (default: dev)
-c, --config PATH Path to config (default: langgraph.json)
--no-wait Skip waiting for deployment
--verbose Show more output
--remote / --no-remote Force remote or local build
-t, --tag TEXT Image tag (default: latest)
--base-image TEXT Base image
--help Show this message and exit.`)
return 0
}
}
// Parse deploy-specific flags
var (
apiKey, hostURL, name, deploymentID, deploymentType, tag string
noWait, verbose, remote bool
)
flags := newCommonFlags()
deploymentType = "dev"
tag = "latest"
for i := 0; i < len(args); i++ {
switch args[i] {
case "--api-key":
if i+1 < len(args) {
apiKey = args[i+1]
i++
}
case "--host-url":
if i+1 < len(args) {
hostURL = args[i+1]
i++
}
case "--name":
if i+1 < len(args) {
name = args[i+1]
i++
}
case "--deployment-id":
if i+1 < len(args) {
deploymentID = args[i+1]
i++
}
case "--deployment-type":
if i+1 < len(args) {
deploymentType = args[i+1]
i++
}
case "-t", "--tag":
if i+1 < len(args) {
tag = args[i+1]
i++
}
case "--no-wait":
noWait = true
case "--verbose":
verbose = true
case "--remote":
remote = true
default:
// Parse common flags
switch args[i] {
case "-c", "--config":
if i+1 < len(args) {
flags.configPath = args[i+1]
i++
}
case "--base-image":
if i+1 < len(args) {
flags.baseImage = args[i+1]
i++
}
case "--api-version":
if i+1 < len(args) {
flags.apiVersion = args[i+1]
i++
}
case "--pull", "--no-pull":
// accept
}
}
}
if hostURL == "" {
hostURL = deploy.DefaultHostURL
}
if apiKey == "" {
apiKey = deploy.ResolveAPIKey("", nil)
}
if apiKey == "" {
errPrint(stderr, "API key required. Set --api-key or LANGSMITH_API_KEY environment variable.")
return 1
}
_, validated, ok := loadAndValidateConfig(flags.configPath, stderr)
if !ok {
return 1
}
_ = tag
_ = deploymentType
_ = verbose
_ = remote
_ = name
_ = deploymentID
_ = validated
client := deploy.NewClient(hostURL, apiKey)
// Resolve deployment name
if name == "" && deploymentID == "" {
// Default to current directory name
absConfig, _ := filepath.Abs(flags.configPath)
name = filepath.Base(filepath.Dir(absConfig))
}
// Find or create deployment
var depID string
if deploymentID != "" {
depID = deploymentID
} else {
found, err := deploy.FindDeploymentIDByName(client, name)
if err != nil {
errPrint(stderr, err.Error())
return 1
}
if found != "" {
depID = found
_, _ = fmt.Fprintf(stdout, "Found existing deployment: %s\n", depID)
} else {
_, _ = fmt.Fprintf(stdout, "Creating deployment '%s'...\n", name)
resp, err := client.CreateDeployment(name, deploymentType, "internal_docker", "", nil)
if err != nil {
errPrint(stderr, err.Error())
return 1
}
depID, _ = resp["id"].(string)
_, _ = fmt.Fprintf(stdout, "Created deployment: %s\n", depID)
}
}
// Build locally
baseImage := flags.baseImage
if baseImage == "" {
baseImage = config.DefaultBaseImage(validated, "combined_queue_worker")
}
imgTag := fmt.Sprintf("langgraph-%s:%s", deploy.NormalizeImageName(name), tag)
_, _ = fmt.Fprintln(stdout, "Building image...")
buildConfig := deepCopyMap(validated)
dockerfile, contexts, err := config.ConfigToDocker(flags.configPath, buildConfig, config.DockerOpts{
BaseImage: baseImage,
APIVersion: flags.apiVersion,
})
if err != nil {
errPrint(stderr, err.Error())
return 1
}
buildArgs := []string{"build", "-f", "-", "-t", imgTag}
for k, v := range contexts {
buildArgs = append(buildArgs, "--build-context", fmt.Sprintf("%s=%s", k, v))
}
buildArgs = append(buildArgs, filepath.Dir(absPath(flags.configPath)))
if err := lgexec.Run("docker", buildArgs, lgexec.RunOpts{Stdin: dockerfile, Verbose: verbose}); err != nil {
errPrint(stderr, fmt.Sprintf("Build failed: %s", err))
return 1
}
// Push
_, _ = fmt.Fprintln(stdout, "Requesting push token...")
tokenResp, err := client.RequestPushToken(depID)
if err != nil {
errPrint(stderr, err.Error())
return 1
}
registryURL, _ := tokenResp["registry_url"].(string)
token, _ := tokenResp["token"].(string)
remoteTag := fmt.Sprintf("%s:%s", registryURL, tag)
_ = lgexec.Run("docker", []string{"tag", imgTag, remoteTag}, lgexec.RunOpts{})
_ = lgexec.Run("docker", []string{"login", "-u", "oauth2accesstoken", "-p", token, registryURL}, lgexec.RunOpts{})
_, _ = fmt.Fprintln(stdout, "Pushing image...")
if err := lgexec.Run("docker", []string{"push", remoteTag}, lgexec.RunOpts{Verbose: verbose}); err != nil {
errPrint(stderr, fmt.Sprintf("Push failed: %s", err))
return 1
}
// Update deployment
_, _ = fmt.Fprintln(stdout, "Updating deployment...")
envVars := deploy.ParseEnvFromConfig(validated, flags.configPath)
secrets := deploy.SecretsFromEnv(envVars)
_, err = client.UpdateDeployment(depID, remoteTag, secrets)
if err != nil {
errPrint(stderr, err.Error())
return 1
}
if !noWait {
_, _ = fmt.Fprintln(stdout, "Waiting for deployment...")
finalStatus, pollErr := deploy.PollDeploymentStatus(client, depID, 300, 2, func(status string) {
_, _ = fmt.Fprintf(stdout, " Status: %s\n", status)
})
if pollErr != nil {
errPrint(stderr, pollErr.Error())
return 1
}
if finalStatus != "DEPLOYED" {
errPrint(stderr, fmt.Sprintf("Deployment failed with status: %s", finalStatus))
return 1
}
}
_, _ = fmt.Fprintf(stdout, "%sDeployment updated successfully!%s\n", colorGreen, colorReset)
return 0
}
func runDeployList(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy list [OPTIONS]
List LangSmith Deployments.
Options:
--api-key TEXT API key
--name-contains TEXT Filter by name
--help Show this message and exit.`)
return 0
}
}
var nameContains string
apiKey, hostURL, extra := resolveDeployClient(args, stderr)
if apiKey == "" {
return 1
}
for i := 0; i < len(extra); i++ {
if extra[i] == "--name-contains" && i+1 < len(extra) {
nameContains = extra[i+1]
i++
}
}
client := deploy.NewClient(hostURL, apiKey)
resp, err := client.ListDeployments(nameContains)
if err != nil {
errPrint(stderr, err.Error())
return 1
}
deployments, _ := resp["deployments"].([]any)
if len(deployments) == 0 {
_, _ = fmt.Fprintln(stdout, "No deployments found.")
return 0
}
// Format table
_, _ = fmt.Fprintf(stdout, "%-38s %-30s %s\n", "Deployment ID", "Name", "URL")
_, _ = fmt.Fprintf(stdout, "%-38s %-30s %s\n", strings.Repeat("-", 38), strings.Repeat("-", 30), strings.Repeat("-", 40))
for _, d := range deployments {
dep, _ := d.(map[string]any)
id, _ := dep["id"].(string)
name, _ := dep["name"].(string)
url := "-"
if sc, ok := dep["source_config"].(map[string]any); ok {
if u, ok := sc["custom_url"].(string); ok && u != "" {
url = u
}
}
_, _ = fmt.Fprintf(stdout, "%-38s %-30s %s\n", id, name, url)
}
return 0
}
func runDeployRevisions(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy revisions COMMAND [ARGS]...
Commands:
list List revisions for a deployment.`)
return 0
}
switch args[0] {
case "list":
return runDeployRevisionsList(args[1:], stdout, stderr)
case "--help", "-h":
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy revisions COMMAND [ARGS]...
Commands:
list List revisions for a deployment.`)
return 0
default:
errPrint(stderr, fmt.Sprintf("Unknown revisions command: %s", args[0]))
return 1
}
}
func runDeployRevisionsList(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy revisions list [OPTIONS] DEPLOYMENT_ID
List revisions for a LangSmith Deployment.
Options:
--api-key TEXT API key
--limit INTEGER Max revisions (default: 10)
--help Show this message and exit.`)
return 0
}
}
limit := 10
apiKey, hostURL, extra := resolveDeployClient(args, stderr)
if apiKey == "" {
return 1
}
var deploymentID string
for i := 0; i < len(extra); i++ {
if extra[i] == "--limit" && i+1 < len(extra) {
if n, err := strconv.Atoi(extra[i+1]); err == nil {
limit = n
}
i++
} else if !strings.HasPrefix(extra[i], "-") && deploymentID == "" {
deploymentID = extra[i]
}
}
if deploymentID == "" {
errPrint(stderr, "Missing argument 'DEPLOYMENT_ID'.")
return 1
}
client := deploy.NewClient(hostURL, apiKey)
resp, err := client.ListRevisions(deploymentID, limit)
if err != nil {
errPrint(stderr, err.Error())
return 1
}
revisions, _ := resp["revisions"].([]any)
if len(revisions) == 0 {
_, _ = fmt.Fprintln(stdout, "No revisions found.")
return 0
}
_, _ = fmt.Fprintf(stdout, "%-38s %-15s %s\n", "Revision ID", "Status", "Created At")
_, _ = fmt.Fprintf(stdout, "%-38s %-15s %s\n", strings.Repeat("-", 38), strings.Repeat("-", 15), strings.Repeat("-", 25))
for _, r := range revisions {
rev, _ := r.(map[string]any)
id, _ := rev["id"].(string)
status, _ := rev["status"].(string)
created, _ := rev["created_at"].(string)
_, _ = fmt.Fprintf(stdout, "%-38s %-15s %s\n", id, status, created)
}
return 0
}
func runDeployDelete(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy delete [OPTIONS] DEPLOYMENT_ID
Delete a LangSmith Deployment.
Options:
--api-key TEXT API key
--force Delete without confirmation
--help Show this message and exit.`)
return 0
}
}
force := false
apiKey, hostURL, extra := resolveDeployClient(args, stderr)
if apiKey == "" {
return 1
}
var deploymentID string
for i := 0; i < len(extra); i++ {
if extra[i] == "--force" {
force = true
} else if !strings.HasPrefix(extra[i], "-") && deploymentID == "" {
deploymentID = extra[i]
}
}
if deploymentID == "" {
errPrint(stderr, "Missing argument 'DEPLOYMENT_ID'.")
return 1
}
if !force {
_, _ = fmt.Fprintf(stdout, "Are you sure you want to delete deployment %s? [y/N] ", deploymentID)
var answer string
_, _ = fmt.Fscanln(os.Stdin, &answer)
if answer != "y" && answer != "Y" {
_, _ = fmt.Fprintln(stdout, "Aborted.")
return 0
}
}
client := deploy.NewClient(hostURL, apiKey)
if err := client.DeleteDeployment(deploymentID); err != nil {
errPrint(stderr, err.Error())
return 1
}
_, _ = fmt.Fprintf(stdout, "%sDeployment %s deleted.%s\n", colorGreen, deploymentID, colorReset)
return 0
}
func runDeployLogs(args []string, stdout, stderr io.Writer) int {
for _, a := range args {
if a == "--help" || a == "-h" {
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy logs [OPTIONS]
Fetch LangSmith Deployment logs.
Options:
--api-key TEXT API key
--name TEXT Deployment name
--deployment-id TEXT Deployment ID
--type TEXT Log type: deploy or build (default: deploy)
--revision-id TEXT Specific revision ID
--level TEXT Filter by log level
--limit INTEGER Max entries (default: 100)
--query TEXT Search string
--follow Continuously poll for new logs
--help Show this message and exit.`)
return 0
}
}
var (
name, deploymentID, logType, revisionID, level, query string
limit int
follow bool
)
logType = "deploy"
limit = 100
apiKey, hostURL, extra := resolveDeployClient(args, stderr)
if apiKey == "" {
return 1
}
for i := 0; i < len(extra); i++ {
switch extra[i] {
case "--name":
if i+1 < len(extra) {
name = extra[i+1]
i++
}
case "--deployment-id":
if i+1 < len(extra) {
deploymentID = extra[i+1]
i++
}
case "--type":
if i+1 < len(extra) {
logType = extra[i+1]
i++
}
case "--revision-id":
if i+1 < len(extra) {
revisionID = extra[i+1]
i++
}
case "--level":
if i+1 < len(extra) {
level = extra[i+1]
i++
}
case "--limit":
if i+1 < len(extra) {
if n, err := strconv.Atoi(extra[i+1]); err == nil {
limit = n
}
i++
}
case "--query", "-q":
if i+1 < len(extra) {
query = extra[i+1]
i++
}
case "--follow", "-f":
follow = true
}
}
if deploymentID == "" && name == "" {
errPrint(stderr, "Provide --deployment-id or --name.")
return 1
}
client := deploy.NewClient(hostURL, apiKey)
if deploymentID == "" {
found, err := deploy.FindDeploymentIDByName(client, name)
if err != nil {
errPrint(stderr, err.Error())
return 1
}
if found == "" {
errPrint(stderr, fmt.Sprintf("No deployment found with name '%s'.", name))
return 1
}
deploymentID = found
}
payload := map[string]any{
"limit": limit,
"order": "desc",
}
if level != "" {
payload["level"] = level
}
if query != "" {
payload["query"] = query
}
if follow {
payload["order"] = "asc"
seen := make(map[string]bool)
for {
var resp map[string]any
var err error
if logType == "build" {
if revisionID == "" {
revResp, rerr := client.ListRevisions(deploymentID, 1)
if rerr != nil {
errPrint(stderr, rerr.Error())
return 1
}
revisions, _ := revResp["revisions"].([]any)
if len(revisions) > 0 {
rev, _ := revisions[0].(map[string]any)
revisionID, _ = rev["id"].(string)
}
}
if revisionID == "" {
time.Sleep(2 * time.Second)
continue
}
resp, err = client.GetBuildLogs(deploymentID, revisionID, payload)
} else {
resp, err = client.GetDeployLogs(deploymentID, payload, revisionID)
}
if err != nil {
errPrint(stderr, err.Error())
return 1
}
logs, _ := resp["logs"].([]any)
for _, l := range logs {
entry, _ := l.(map[string]any)
id, _ := entry["id"].(string)
if id == "" {
// Fall back to timestamp+message as key
ts, _ := entry["timestamp"].(string)
msg, _ := entry["message"].(string)
id = ts + "|" + msg
}
if seen[id] {
continue
}
seen[id] = true
ts, _ := entry["timestamp"].(string)
lvl, _ := entry["level"].(string)
msg, _ := entry["message"].(string)
if ts != "" {
_, _ = fmt.Fprintf(stdout, "[%s] ", ts)
}
if lvl != "" {
_, _ = fmt.Fprintf(stdout, "[%s] ", lvl)
}
_, _ = fmt.Fprintln(stdout, msg)
}
time.Sleep(2 * time.Second)
}
}
var resp map[string]any
var err error
if logType == "build" {
if revisionID == "" {
// Get latest revision
revResp, rerr := client.ListRevisions(deploymentID, 1)
if rerr != nil {
errPrint(stderr, rerr.Error())
return 1
}
revisions, _ := revResp["revisions"].([]any)
if len(revisions) > 0 {
rev, _ := revisions[0].(map[string]any)
revisionID, _ = rev["id"].(string)
}
}
if revisionID == "" {
errPrint(stderr, "No revisions found for build logs.")
return 1
}
resp, err = client.GetBuildLogs(deploymentID, revisionID, payload)
} else {
resp, err = client.GetDeployLogs(deploymentID, payload, revisionID)
}
if err != nil {
errPrint(stderr, err.Error())
return 1
}
logs, _ := resp["logs"].([]any)
for i := len(logs) - 1; i >= 0; i-- {
entry, _ := logs[i].(map[string]any)
ts, _ := entry["timestamp"].(string)
lvl, _ := entry["level"].(string)
msg, _ := entry["message"].(string)
if ts != "" {
_, _ = fmt.Fprintf(stdout, "[%s] ", ts)
}
if lvl != "" {
_, _ = fmt.Fprintf(stdout, "[%s] ", lvl)
}
_, _ = fmt.Fprintln(stdout, msg)
}
return 0
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func absPath(p string) string {
abs, err := filepath.Abs(p)
if err != nil {
return p
}
return abs
}
func deepCopyMap(m map[string]any) map[string]any {
data, _ := json.Marshal(m)
var out map[string]any
_ = json.Unmarshal(data, &out)
return out
}