feat: add langship CLI (agents, envs, pipelines, creds, runs)

Python/Typer/Rich/httpx CLI in langship-cli/. Talks to the Langship API
over HTTP; config in ~/.langship/config.toml (no built-in default URL —
requires `langship login` or LANGSHIP_API_URL).

Command groups:
  agents      list/get/create/delete/trigger/follow-env/unfollow-env/test-auth/webhook
  envs        list/get/create/update/delete/add-pipeline/remove-pipeline/reorder
  pipelines   list/get(-o json|yaml)/push(create-or-update from file)/delete
  creds       list/get/create(--type aws|gcp|kv)/delete
  runs        list/get/logs(-f streams the execution SSE feed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
co-authored by Claude Opus 4.7
parent 6b1a13ecdc
commit dd2c0e171a
14 changed files with 1229 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
+109
View File
@@ -0,0 +1,109 @@
# langship
CLI for the Langship control plane. Drive agents, environments, pipelines,
credentials, and runs from your terminal — same API the web UI uses.
## Install
```bash
pip install -e /Users/khushpatel2002/langship-restate/langship-cli
# (once published: pip install langship)
```
Optional: `pip install pyyaml` to use `-o yaml` and YAML pipeline files.
## Setup
```bash
langship login --api-url http://localhost:8090
# or just: langship login (prompts; default http://localhost:8090)
```
Saved to `~/.langship/config.toml`. Override per-invocation:
```bash
LANGSHIP_API_URL=http://localhost:8090 langship agents list
LANGSHIP_TOKEN=... langship agents list # if/when the API requires auth
```
There is no built-in default URL — you must `login` (or set `LANGSHIP_API_URL`)
before any command other than `login`.
## Quick tour
```bash
# Agents
langship agents create --repo https://github.com/me/agent --pat ghp_...
langship agents list
langship agents get <agentId>
langship agents trigger <agentId> # dispatch across followed envs
# Environments (named, ORDERED list of pipelines = the promotion sequence)
langship envs create dev -d "Auto-deploy on push"
langship pipelines push pipeline.json # -> prints the new pipeline id
langship envs add-pipeline dev <pipelineId>
langship envs reorder dev <pid1> <pid2> <pid3>
langship envs get dev
# Agent follows an environment -> triggering it runs that env's pipelines
langship agents follow-env <agentId> dev
langship agents trigger <agentId>
# Credentials (global pool — server needs FLOW_SECRET_KEY set)
langship creds create prod-aws --type aws \
--aws-region us-east-1 --aws-account 123456789012 \
--aws-role-arn arn:aws:iam::123456789012:role/FlowDeployRole
langship creds list
# Runs
langship runs list
langship runs logs <executionId> -f # stream live
langship runs get <executionId>
```
## Commands
```
langship login [--api-url URL] [--token TOK]
langship config-show
langship version
langship agents list [-o json|yaml]
langship agents get <agentId> [-o ...]
langship agents create --repo <url> [--pat ...] [--name ...] [--ref ...]
langship agents delete <agentId> [-y]
langship agents trigger <agentId>
langship agents follow-env <agentId> <env>
langship agents unfollow-env <agentId> <env>
langship agents test-auth <agentId>
langship agents webhook install <agentId>
langship agents webhook uninstall <agentId>
langship envs list [-o ...]
langship envs get <name> [-o ...]
langship envs create <name> [-d <description>]
langship envs update <name> -d <description>
langship envs delete <name> [-y]
langship envs add-pipeline <name> <pipelineId>
langship envs remove-pipeline <name> <pipelineId>
langship envs reorder <name> <pid> <pid> ...
langship pipelines list [-o ...]
langship pipelines get <pipelineId> [-o json|yaml|table]
langship pipelines push <file.json|.yaml> [--id <pipelineId>] [--name ...]
langship pipelines delete <pipelineId> [-y]
langship creds list [-o ...]
langship creds get <name> [-o ...]
langship creds create <name> --type aws|gcp|kv [provider flags...]
langship creds delete <name> [-y]
langship runs list [--limit N] [--pipeline <id>] [-o ...]
langship runs get <executionId> [-o ...]
langship runs logs <executionId> [-f]
```
## Env vars
- `LANGSHIP_API_URL` — overrides the saved API URL
- `LANGSHIP_TOKEN` — overrides the saved auth token
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "langship"
version = "0.1.0"
description = "langship — CLI for the Langship control plane (agents, environments, pipelines, runs)."
requires-python = ">=3.10"
authors = [{ name = "Lyzr" }]
readme = "README.md"
license = { text = "MIT" }
dependencies = [
"typer>=0.12.0",
"rich>=13.0.0",
"httpx>=0.27.0",
"tomli>=2.0.0; python_version<'3.11'",
"tomli-w>=1.0.0",
]
[project.scripts]
langship = "langship.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/langship"]
+3
View File
@@ -0,0 +1,3 @@
"""langship — CLI for the Langship control plane."""
__version__ = "0.1.0"
+125
View File
@@ -0,0 +1,125 @@
"""langship — CLI for the Langship control plane."""
from __future__ import annotations
import typer
from rich.console import Console
from . import __version__
from . import config as cfg
from .client import APIError
from .utils import handle_api_error, die
from .commands import (
agents as agents_cmd,
environments as envs_cmd,
pipelines as pipelines_cmd,
credentials as creds_cmd,
runs as runs_cmd,
)
console = Console()
app = typer.Typer(
name="langship",
help="langship — drive the Langship control plane from your terminal.",
no_args_is_help=True,
add_completion=False,
rich_markup_mode="rich",
pretty_exceptions_enable=False,
)
app.add_typer(agents_cmd.app, name="agents", help="Manage agents (repos + env subscriptions).")
app.add_typer(envs_cmd.app, name="envs", help="Manage environments (named, ordered pipeline lists).")
app.add_typer(pipelines_cmd.app, name="pipelines", help="Manage pipeline definitions (push from file, dump).")
app.add_typer(creds_cmd.app, name="creds", help="Manage the global credential pool (aws / gcp / kv).")
app.add_typer(runs_cmd.app, name="runs", help="Inspect executions and stream logs.")
@app.command()
def login(
api_url: str = typer.Option(None, "--api-url", help="Langship API URL, e.g. http://localhost:8090.", show_default=False),
token: str = typer.Option(None, "--token", help="API auth token (optional).", show_default=False),
) -> None:
"""Save API URL (and optional token) to ~/.langship/config.toml.
Fully non-interactive when --api-url is passed. With no flags it
prompts for the URL (and optionally a token).
"""
current = cfg.load()
interactive = api_url is None
if api_url is None:
api_url = typer.prompt("API URL", default=cfg.api_url_or_none() or "http://localhost:8090")
if token is None and interactive:
token = typer.prompt("Token (optional, blank for none)", default=current.get("token", ""), show_default=False)
current["api_url"] = api_url.rstrip("/")
if token:
current["token"] = token
elif token == "" and "token" in current:
# explicit empty input clears it
del current["token"]
cfg.save(current)
console.print(f"[green]✓[/green] saved {cfg.CONFIG_PATH}")
console.print(f" api_url = {current['api_url']}")
if "token" in current:
console.print(" token = ****")
@app.command(name="config-show")
def config_show() -> None:
"""Print the current local config."""
console.print(f"[dim]path:[/dim] {cfg.CONFIG_PATH}")
url = cfg.api_url_or_none()
console.print(f"api_url = {url or '(unset — run `langship login`)'}")
console.print("token = ****" if cfg.token() else "token = (unset)")
@app.command(name="version")
def version_cmd() -> None:
"""Print version."""
console.print(f"langship {__version__}")
def _version_callback(value: bool) -> None:
if value:
console.print(f"langship {__version__}")
raise typer.Exit()
@app.callback()
def _root(
version: bool = typer.Option(
False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
),
) -> None:
pass
def main() -> None:
import sys
import click
try:
app()
except SystemExit:
raise
except click.exceptions.Exit as e:
sys.exit(getattr(e, "exit_code", 0))
except click.exceptions.Abort:
from .utils import err_console
err_console.print("[red]error:[/red] aborted")
sys.exit(2)
except APIError as e:
handle_api_error(e)
except RuntimeError as e:
from .utils import err_console
err_console.print(f"[red]error:[/red] {e}")
sys.exit(1)
except KeyboardInterrupt:
die("interrupted", code=130)
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
"""Thin httpx wrapper around the Langship API.
The Langship server exposes its routes under /api/... (see pkg/api). All
methods here prepend nothing — callers pass the full path, e.g.
client.get("/api/agents").
"""
from __future__ import annotations
from typing import Any, Iterator, Optional
import httpx
from . import config
class APIError(Exception):
def __init__(self, status: int, message: str, body: Any = None):
self.status = status
self.message = message
self.body = body
super().__init__(f"{status} {message}")
def _client(timeout: float = 30.0) -> httpx.Client:
headers = {"Content-Type": "application/json"}
tok = config.token()
if tok:
headers["Authorization"] = f"Bearer {tok}"
return httpx.Client(base_url=config.api_url(), timeout=timeout, headers=headers)
def _raise_for(resp: httpx.Response) -> None:
if resp.is_success:
return
try:
body = resp.json()
msg = body.get("error") or body.get("message") or resp.text
except Exception:
body = resp.text
msg = resp.text or resp.reason_phrase
raise APIError(resp.status_code, msg, body)
def get(path: str, **kwargs: Any) -> Any:
with _client() as c:
r = c.get(path, **kwargs)
_raise_for(r)
return r.json() if r.content else None
def post(path: str, json: Optional[dict] = None, **kwargs: Any) -> Any:
with _client() as c:
r = c.post(path, json=json or {}, **kwargs)
_raise_for(r)
return r.json() if r.content else None
def put(path: str, json: Optional[dict] = None, **kwargs: Any) -> Any:
with _client() as c:
r = c.put(path, json=json or {}, **kwargs)
_raise_for(r)
return r.json() if r.content else None
def patch(path: str, json: Optional[dict] = None, **kwargs: Any) -> Any:
with _client() as c:
r = c.patch(path, json=json or {}, **kwargs)
_raise_for(r)
return r.json() if r.content else None
def delete(path: str, **kwargs: Any) -> Any:
with _client() as c:
r = c.delete(path, **kwargs)
_raise_for(r)
return r.json() if r.content else None
def stream_sse(path: str, timeout: float = 600.0) -> Iterator[dict]:
"""Yield parsed `data:` JSON objects from an SSE endpoint until the
server closes the stream (e.g. /api/executions/{id}/stream). Non-JSON
data lines yield {"raw": "..."}.
"""
headers = {"Accept": "text/event-stream"}
tok = config.token()
if tok:
headers["Authorization"] = f"Bearer {tok}"
import json as _json
with httpx.Client(base_url=config.api_url(), timeout=timeout, headers=headers) as c:
with c.stream("GET", path) as r:
_raise_for(r)
for line in r.iter_lines():
if not line or not line.startswith("data:"):
continue
payload = line[len("data:"):].strip()
if not payload:
continue
try:
yield _json.loads(payload)
except Exception:
yield {"raw": payload}
@@ -0,0 +1,173 @@
"""agents list / get / create / delete / trigger / follow-env / unfollow-env / webhook / test-auth."""
from __future__ import annotations
import typer
from .. import client
from ..utils import confirm, console, die, emit, make_table, print_kv, relative_time, truncate
app = typer.Typer(no_args_is_help=True)
@app.command("list")
def list_agents(output: str = typer.Option("table", "--output", "-o", help="table | json | yaml")) -> None:
"""List all agents."""
agents = client.get("/api/agents") or []
if emit(agents, output):
return
if not agents:
console.print("[dim]no agents yet.[/dim]")
return
t = make_table("AGENT ID", "NAME", "REPO", "PAT", "WEBHOOK", "ENVS", "UPDATED")
for a in agents:
t.add_row(
a["id"],
a["name"],
truncate(a.get("repoUrl"), 44),
"yes" if a.get("hasPat") else "no",
"ok" if a.get("webhookInstalled") else "",
", ".join(a.get("environments") or []) or "",
relative_time(a.get("updatedAt")),
)
console.print(t)
@app.command("get")
def get_agent(
agent_id: str = typer.Argument(..., help="Agent ID."),
output: str = typer.Option("table", "--output", "-o", help="table | json | yaml"),
) -> None:
"""Show an agent: repo, auth, webhook, followed environments, credentials."""
a = client.get(f"/api/agents/{agent_id}")
if emit(a, output):
return
print_kv(
{
"id": a["id"],
"name": a["name"],
"repo": a.get("repoUrl"),
"ref": a.get("ref"),
"pat_set": a.get("hasPat", False),
"auth": a.get("authStatus") or "untested",
"auth_checked": a.get("authCheckedAt"),
"webhook_installed": a.get("webhookInstalled", False),
"webhook_url": a.get("webhookUrl"),
"environments": ", ".join(a.get("environments") or []) or "",
"created": a.get("createdAt"),
"updated": a.get("updatedAt"),
},
title=f"agent · {a['name']}",
)
creds = a.get("credentials") or []
if creds:
t = make_table("CRED NAME", "TYPE", "DETAIL", title="agent credential overrides")
for c in creds:
if c["type"] == "aws":
detail = f"{c.get('awsAccountId','')} / {c.get('awsRegion','')}"
elif c["type"] == "gcp":
detail = f"project={c.get('gcpProjectId','')}"
else:
detail = ",".join(c.get("kvKeys") or [])
t.add_row(c["name"], c["type"], detail)
console.print(t)
@app.command("create")
def create_agent(
repo: str = typer.Option(..., "--repo", "-r", help="Git repository URL."),
pat: str = typer.Option("", "--pat", help="Personal access token (for private repos)."),
name: str = typer.Option("", "--name", "-n", help="Override the auto-derived name."),
ref: str = typer.Option("", "--ref", help="Default git ref (branch). Defaults to main."),
) -> None:
"""Register a new agent repository."""
body: dict = {"repoUrl": repo}
if pat:
body["pat"] = pat
if name:
body["name"] = name
if ref:
body["ref"] = ref
a = client.post("/api/agents", json=body)
console.print(f"[green]✓[/green] created agent [bold]{a['name']}[/bold] · id [cyan]{a['id']}[/cyan]")
@app.command("delete")
def delete_agent(
agent_id: str = typer.Argument(...),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
) -> None:
"""Delete an agent (best-effort uninstalls its webhook first)."""
if not yes and not confirm(f"delete agent {agent_id}?", default=False):
die("aborted", code=2)
client.delete(f"/api/agents/{agent_id}")
console.print(f"[green]✓[/green] deleted {agent_id}")
@app.command("trigger")
def trigger_agent(
agent_id: str = typer.Argument(...),
output: str = typer.Option("table", "--output", "-o", help="table | json"),
) -> None:
"""Dispatch a run across the agent's followed environments' pipelines."""
res = client.post(f"/api/agents/{agent_id}/trigger")
if emit(res, output):
return
ids = res.get("executionIds") or []
fails = res.get("failures") or []
for eid in ids:
console.print(f"[green]✓[/green] run [cyan]{eid}[/cyan] (langship runs logs {eid})")
for f in fails:
where = "/".join(x for x in (f.get("environment"), f.get("pipelineId")) if x) or "?"
console.print(f"[yellow]·[/yellow] skipped {where}: {f.get('reason')}" + (f"{f['error']}" if f.get("error") else ""))
if not ids and not fails:
console.print("[dim]nothing dispatched.[/dim]")
@app.command("follow-env")
def follow_env(
agent_id: str = typer.Argument(...),
env: str = typer.Argument(..., help="Environment name."),
) -> None:
"""Subscribe the agent to an environment."""
client.post(f"/api/agents/{agent_id}/environments/{env}")
console.print(f"[green]✓[/green] {agent_id} now follows env [bold]{env}[/bold]")
@app.command("unfollow-env")
def unfollow_env(
agent_id: str = typer.Argument(...),
env: str = typer.Argument(..., help="Environment name."),
) -> None:
"""Unsubscribe the agent from an environment."""
client.delete(f"/api/agents/{agent_id}/environments/{env}")
console.print(f"[green]✓[/green] {agent_id} no longer follows env [bold]{env}[/bold]")
@app.command("test-auth")
def test_auth(agent_id: str = typer.Argument(...)) -> None:
"""Probe the agent's PAT against its repo."""
res = client.post(f"/api/agents/{agent_id}/test-auth")
status = res.get("authStatus") if isinstance(res, dict) else "?"
if status == "ok":
console.print(f"[green]✓[/green] auth ok")
else:
console.print(f"[red]✗[/red] auth {status}" + (f"{res.get('error')}" if isinstance(res, dict) and res.get("error") else ""))
webhook_app = typer.Typer(no_args_is_help=True, help="Install / uninstall the GitHub webhook.")
app.add_typer(webhook_app, name="webhook")
@webhook_app.command("install")
def webhook_install(agent_id: str = typer.Argument(...)) -> None:
"""Install the GitHub push webhook for this agent (server must have FLOW_PUBLIC_URL set)."""
a = client.post(f"/api/agents/{agent_id}/webhook")
console.print(f"[green]✓[/green] webhook installed: {a.get('webhookUrl')}")
@webhook_app.command("uninstall")
def webhook_uninstall(agent_id: str = typer.Argument(...)) -> None:
"""Remove the GitHub webhook for this agent."""
client.delete(f"/api/agents/{agent_id}/webhook")
console.print(f"[green]✓[/green] webhook uninstalled")
@@ -0,0 +1,110 @@
"""creds list / get / create / delete — the global credential pool.
Note: the server refuses credential writes unless FLOW_SECRET_KEY is set
(secrets are AES-GCM sealed at rest). Secret values you pass here travel
to the server over HTTP and are never returned by the API afterwards.
"""
from __future__ import annotations
from pathlib import Path
import typer
from .. import client
from ..utils import confirm, console, die, emit, make_table, print_kv, relative_time
app = typer.Typer(no_args_is_help=True)
@app.command("list")
def list_creds(output: str = typer.Option("table", "--output", "-o", help="table | json | yaml")) -> None:
"""List credentials in the global pool."""
creds = client.get("/api/credentials") or []
if emit(creds, output):
return
if not creds:
console.print("[dim]no credentials yet.[/dim]")
return
t = make_table("NAME", "TYPE", "DETAIL", "UPDATED")
for c in creds:
if c["type"] == "aws":
detail = f"{c.get('awsAccountId','')} / {c.get('awsRegion','')} role={c.get('awsCrossAccountRoleArn','')}"
elif c["type"] == "gcp":
detail = f"project={c.get('gcpProjectId','')} loc={c.get('gcpLocation','') or ''} sa={'set' if c.get('hasServiceAccount') else 'unset'}"
else:
detail = "keys: " + (", ".join(c.get("kvKeys") or []) or "")
t.add_row(c["name"], c["type"], detail, relative_time(c.get("updatedAt")))
console.print(t)
@app.command("get")
def get_cred(
name: str = typer.Argument(...),
output: str = typer.Option("table", "--output", "-o", help="table | json | yaml"),
) -> None:
"""Show one credential (non-secret fields + flags only)."""
c = client.get(f"/api/credentials/{name}")
if emit(c, output):
return
print_kv(c, title=f"credential · {c['name']}")
@app.command("create")
def create_cred(
name: str = typer.Argument(..., help="Credential name, e.g. prod-aws."),
type_: str = typer.Option(..., "--type", "-t", help="aws | gcp | kv"),
# AWS
aws_region: str = typer.Option("", "--aws-region"),
aws_account: str = typer.Option("", "--aws-account", help="12-digit account id"),
aws_role_arn: str = typer.Option("", "--aws-role-arn", help="cross-account role ARN to assume"),
# GCP
gcp_project: str = typer.Option("", "--gcp-project"),
gcp_location: str = typer.Option("", "--gcp-location"),
gcp_sa_key_file: Path = typer.Option(None, "--gcp-sa-key-file", help="path to service-account JSON"),
# KV
kv: list[str] = typer.Option(None, "--kv", help="KEY=value (repeatable)"),
) -> None:
"""Create a credential in the global pool."""
t = type_.lower()
body: dict = {"name": name, "type": t}
if t == "aws":
if not (aws_region and aws_account and aws_role_arn):
die("aws credential needs --aws-region, --aws-account, and --aws-role-arn")
body["awsRegion"] = aws_region
body["awsAccountId"] = aws_account
body["awsCrossAccountRoleArn"] = aws_role_arn
elif t == "gcp":
if not gcp_project:
die("gcp credential needs --gcp-project")
body["gcpProjectId"] = gcp_project
if gcp_location:
body["gcpLocation"] = gcp_location
if gcp_sa_key_file:
body["gcpServiceAccountJson"] = Path(gcp_sa_key_file).read_text()
elif t == "kv":
if not kv:
die("kv credential needs at least one --kv KEY=value")
m: dict[str, str] = {}
for pair in kv:
if "=" not in pair:
die(f"--kv must be KEY=value, got {pair!r}")
k, v = pair.split("=", 1)
m[k.strip()] = v
body["kv"] = m
else:
die("--type must be aws, gcp, or kv")
c = client.post("/api/credentials", json=body)
console.print(f"[green]✓[/green] created credential [bold]{c['name']}[/bold] ({c['type']})")
@app.command("delete")
def delete_cred(
name: str = typer.Argument(...),
yes: bool = typer.Option(False, "--yes", "-y"),
) -> None:
"""Delete a credential from the global pool."""
if not yes and not confirm(f"delete credential {name}? pipelines referencing it will fail until replaced.", default=False):
die("aborted", code=2)
client.delete(f"/api/credentials/{name}")
console.print(f"[green]✓[/green] deleted {name}")
@@ -0,0 +1,127 @@
"""envs list / get / create / update / delete / add-pipeline / remove-pipeline / reorder."""
from __future__ import annotations
import typer
from .. import client
from ..utils import confirm, console, die, emit, make_table, print_kv, relative_time
app = typer.Typer(no_args_is_help=True)
def _pipeline_names() -> dict[str, str]:
"""Map pipeline id -> name (best effort; falls back to id)."""
try:
flows = client.get("/api/workflows") or []
return {f["id"]: f.get("name") or f["id"] for f in flows}
except Exception:
return {}
@app.command("list")
def list_envs(output: str = typer.Option("table", "--output", "-o", help="table | json | yaml")) -> None:
"""List all environments."""
envs = client.get("/api/environments") or []
if emit(envs, output):
return
if not envs:
console.print("[dim]no environments yet.[/dim]")
return
names = _pipeline_names()
t = make_table("NAME", "DESCRIPTION", "PIPELINES (ORDER)", "UPDATED")
for e in envs:
pids = e.get("pipelineIds") or []
chain = "".join(names.get(p, p) for p in pids) or ""
t.add_row(e["name"], e.get("description") or "", chain, relative_time(e.get("updatedAt")))
console.print(t)
@app.command("get")
def get_env(
name: str = typer.Argument(..., help="Environment name."),
output: str = typer.Option("table", "--output", "-o", help="table | json | yaml"),
) -> None:
"""Show an environment and its ordered pipelines."""
e = client.get(f"/api/environments/{name}")
if emit(e, output):
return
print_kv(
{"name": e["name"], "description": e.get("description"), "created": e.get("createdAt"), "updated": e.get("updatedAt")},
title=f"environment · {e['name']}",
)
pids = e.get("pipelineIds") or []
if not pids:
console.print("[dim]no pipelines in this environment.[/dim]")
return
names = _pipeline_names()
t = make_table("#", "PIPELINE ID", "NAME", title="pipelines (promotion order)")
for i, pid in enumerate(pids, 1):
t.add_row(str(i), pid, names.get(pid, pid))
console.print(t)
@app.command("create")
def create_env(
name: str = typer.Argument(..., help="Environment name, e.g. dev."),
description: str = typer.Option("", "--description", "-d"),
) -> None:
"""Create an environment."""
body: dict = {"name": name}
if description:
body["description"] = description
e = client.post("/api/environments", json=body)
console.print(f"[green]✓[/green] created environment [bold]{e['name']}[/bold]")
@app.command("update")
def update_env(
name: str = typer.Argument(...),
description: str = typer.Option(..., "--description", "-d", help="New description (use \"\" to clear)."),
) -> None:
"""Update an environment's description (name is immutable)."""
e = client.put(f"/api/environments/{name}", json={"name": name, "description": description})
console.print(f"[green]✓[/green] updated [bold]{e['name']}[/bold]")
@app.command("delete")
def delete_env(
name: str = typer.Argument(...),
yes: bool = typer.Option(False, "--yes", "-y"),
) -> None:
"""Delete an environment."""
if not yes and not confirm(f"delete environment {name}? agents following it stop dispatching its pipelines.", default=False):
die("aborted", code=2)
client.delete(f"/api/environments/{name}")
console.print(f"[green]✓[/green] deleted {name}")
@app.command("add-pipeline")
def add_pipeline(
name: str = typer.Argument(..., help="Environment name."),
pipeline_id: str = typer.Argument(..., help="Pipeline ID to append."),
) -> None:
"""Bring a pipeline into the environment (appended to the end of the order)."""
e = client.post(f"/api/environments/{name}/pipelines/{pipeline_id}")
console.print(f"[green]✓[/green] {pipeline_id} added to [bold]{name}[/bold] ({len(e.get('pipelineIds') or [])} total)")
@app.command("remove-pipeline")
def remove_pipeline(
name: str = typer.Argument(...),
pipeline_id: str = typer.Argument(...),
) -> None:
"""Remove a pipeline from the environment."""
client.delete(f"/api/environments/{name}/pipelines/{pipeline_id}")
console.print(f"[green]✓[/green] {pipeline_id} removed from [bold]{name}[/bold]")
@app.command("reorder")
def reorder(
name: str = typer.Argument(...),
pipeline_ids: list[str] = typer.Argument(..., help="The full pipeline id list in the new order."),
) -> None:
"""Set the promotion order. Must be a permutation of the env's current pipelines."""
e = client.put(f"/api/environments/{name}/pipelines", json={"pipelineIds": pipeline_ids})
names = _pipeline_names()
console.print(f"[green]✓[/green] reordered [bold]{name}[/bold]: " + "".join(names.get(p, p) for p in e.get("pipelineIds") or []))
@@ -0,0 +1,121 @@
"""pipelines list / get / push / delete.
`push` takes a local file containing the pipeline definition (n8n-format
JSON, or YAML if PyYAML is installed). If the file (or the top-level
object) carries an `id`, the pipeline is updated; otherwise a new one is
created and its id printed. The file is the source of truth — GitOps.
"""
from __future__ import annotations
import json
from pathlib import Path
import typer
from .. import client
from ..utils import confirm, console, die, emit, make_table, print_kv, relative_time, truncate
app = typer.Typer(no_args_is_help=True)
def _load_file(path: Path) -> dict:
text = path.read_text()
if path.suffix in (".yaml", ".yml"):
try:
import yaml # type: ignore
return yaml.safe_load(text)
except ImportError:
die("PyYAML not installed — install it or pass a .json file")
return json.loads(text)
@app.command("list")
def list_pipelines(output: str = typer.Option("table", "--output", "-o", help="table | json | yaml")) -> None:
"""List pipeline definitions."""
flows = client.get("/api/workflows") or []
if emit(flows, output):
return
if not flows:
console.print("[dim]no pipelines yet.[/dim]")
return
t = make_table("PIPELINE ID", "NAME", "NODES", "STATUS", "UPDATED")
for f in flows:
t.add_row(f["id"], f.get("name") or "", str(f.get("nodeCount", 0)), f.get("status") or "draft", relative_time(f.get("updatedAt")))
console.print(t)
@app.command("get")
def get_pipeline(
pipeline_id: str = typer.Argument(...),
output: str = typer.Option("json", "--output", "-o", help="json | yaml | table"),
) -> None:
"""Dump a pipeline. Default output is JSON (the definition is the point)."""
p = client.get(f"/api/workflows/{pipeline_id}")
if output.lower() in ("json", "yaml", "yml"):
emit(p, output)
return
# table summary
defn = p.get("definition") or {}
nodes = defn.get("nodes") or []
print_kv(
{"id": p.get("id"), "name": p.get("name"), "nodes": len(nodes), "status": p.get("status"), "updated": p.get("updatedAt")},
title=f"pipeline · {p.get('name')}",
)
if nodes:
t = make_table("NODE", "TYPE", title="nodes")
for n in nodes:
t.add_row(n.get("name") or "", n.get("type") or "")
console.print(t)
@app.command("push")
def push_pipeline(
file: Path = typer.Argument(..., exists=True, readable=True, help="JSON/YAML file with the pipeline definition."),
pipeline_id: str = typer.Option("", "--id", help="Update this pipeline id (overrides any id in the file)."),
name: str = typer.Option("", "--name", "-n", help="Override the pipeline name."),
) -> None:
"""Create or update a pipeline from a local definition file."""
doc = _load_file(file)
if not isinstance(doc, dict):
die("file must contain a JSON/YAML object")
# Two accepted shapes: {id?, name?, definition:{...}} or a bare
# n8n-format definition {name?, nodes:[...], connections:{...}}.
if "definition" in doc:
definition = doc["definition"]
file_id = doc.get("id")
file_name = doc.get("name")
else:
definition = doc
file_id = doc.get("id")
file_name = doc.get("name")
pid = pipeline_id or (file_id or "")
body: dict = {"definition": definition}
if name:
body["name"] = name
elif file_name:
body["name"] = file_name
if pid:
client.put(f"/api/workflows/{pid}", json=body)
console.print(f"[green]✓[/green] updated pipeline [cyan]{pid}[/cyan]")
else:
res = client.post("/api/workflows", json=body)
new_id = res.get("id") if isinstance(res, dict) else None
console.print(f"[green]✓[/green] created pipeline [cyan]{new_id}[/cyan]")
console.print(f" tip: add \"id\": \"{new_id}\" to {file} so the next push updates it in place")
@app.command("delete")
def delete_pipeline(
pipeline_id: str = typer.Argument(...),
yes: bool = typer.Option(False, "--yes", "-y"),
) -> None:
"""Delete a pipeline definition."""
if not yes and not confirm(f"delete pipeline {pipeline_id}? (envs referencing it will lose it)", default=False):
die("aborted", code=2)
client.delete(f"/api/workflows/{pipeline_id}")
console.print(f"[green]✓[/green] deleted {pipeline_id}")
+160
View File
@@ -0,0 +1,160 @@
"""runs list / get / logs.
`logs -f` streams the execution's SSE event feed (/api/executions/{id}/stream)
until the run is done. Without -f it dumps the archived per-node logs from
/api/executions/{id}/logs/{node} for whatever nodes the run record knows about.
"""
from __future__ import annotations
import typer
from .. import client
from ..utils import console, die, emit, make_table, print_kv, relative_time, truncate
app = typer.Typer(no_args_is_help=True)
_LEVEL_COLOR = {
"error": "red",
"stderr": "yellow",
"warn": "yellow",
"cmd": "cyan",
"status": "magenta",
}
def _status_mark(s: str | None) -> str:
s = (s or "").lower()
if s in ("success", "completed"):
return "[green]✓[/green]"
if s in ("failed", "error", "partial_error"):
return "[red]✗[/red]"
if s in ("running", "pending"):
return "[cyan]◌[/cyan]"
if s in ("paused",):
return "[yellow]⏸[/yellow]"
if s in ("cancelled",):
return "[dim]∅[/dim]"
return "·"
@app.command("list")
def list_runs(
limit: int = typer.Option(20, "--limit", "-l"),
pipeline: str = typer.Option("", "--pipeline", "-p", help="Filter by pipeline id."),
output: str = typer.Option("table", "--output", "-o", help="table | json | yaml"),
) -> None:
"""List recent runs."""
params: dict = {"limit": str(limit)}
if pipeline:
params["pipeline_id"] = pipeline
runs = client.get("/api/executions", params=params) or []
if emit(runs, output):
return
if not runs:
console.print("[dim]no runs yet.[/dim]")
return
t = make_table("", "EXECUTION ID", "PIPELINE", "STATUS", "STARTED")
for r in runs:
t.add_row(
_status_mark(r.get("status")),
r["id"],
truncate(r.get("pipelineName") or r.get("pipelineId"), 32),
r.get("status") or "",
relative_time(r.get("startedAt")),
)
console.print(t)
@app.command("get")
def get_run(
execution_id: str = typer.Argument(...),
output: str = typer.Option("table", "--output", "-o", help="table | json | yaml"),
) -> None:
"""Show a run: status + per-node outcomes (from the orchestrator)."""
r = client.get(f"/api/executions/{execution_id}")
if emit(r, output):
return
if not isinstance(r, dict):
die("unexpected response")
print_kv(
{"execution_id": r.get("execution_id") or execution_id, "status": r.get("status")},
title=f"run · {execution_id}",
)
node_outputs = r.get("node_outputs") or {}
if node_outputs:
t = make_table("NODE", "OUTPUTS (keys)", title="node outputs")
for node, outs in node_outputs.items():
keys = set()
for items in (outs or {}).values():
for it in items or []:
if isinstance(it, dict):
keys.update(it.keys())
t.add_row(node, ", ".join(sorted(keys)) or "")
console.print(t)
errs = r.get("errors") or []
for e in errs:
console.print(f"[red]error:[/red] {e}")
@app.command("logs")
def logs(
execution_id: str = typer.Argument(...),
follow: bool = typer.Option(False, "--follow", "-f", help="Stream the SSE event feed until the run is done."),
) -> None:
"""Print logs for a run. -f streams live; otherwise dumps archived node logs."""
if follow:
_stream(execution_id)
return
# Non-follow: dump archived per-node logs for whatever nodes we know.
try:
r = client.get(f"/api/executions/{execution_id}")
node_outputs = (r or {}).get("node_outputs") or {}
nodes = list(node_outputs.keys())
except Exception:
nodes = []
if not nodes:
console.print("[dim]no node logs available — try `-f` while the run is in progress.[/dim]")
return
for node in nodes:
try:
text = client.get(f"/api/executions/{execution_id}/logs/{node}")
except Exception:
continue
if not text:
continue
console.print(f"[bold]── {node} ──[/bold]")
# The node-log endpoint returns the raw archived text/JSON; print as-is.
if isinstance(text, str):
console.print(text, end="" if text.endswith("\n") else "\n")
else:
console.print_json(data=text)
def _stream(execution_id: str) -> None:
last_status: str | None = None
for ev in client.stream_sse(f"/api/executions/{execution_id}/stream"):
if "raw" in ev:
console.print(ev["raw"])
continue
et = ev.get("type")
node = ev.get("node")
if et == "node_started":
console.print(f"[cyan]▶[/cyan] [bold]{node}[/bold] [dim]({ev.get('node_type','')})[/dim]")
elif et == "node_log":
line = ev.get("content") or ""
color = _LEVEL_COLOR.get((ev.get("status") or "").lower(), "white")
console.print(f" [bold]{node}[/bold] [{color}]{line}[/{color}]")
elif et == "node_completed":
dur = ev.get("duration_ms")
console.print(f"[green]✓[/green] [bold]{node}[/bold] {ev.get('status','')}" + (f" [dim]({dur}ms)[/dim]" if dur else ""))
elif et == "node_error":
console.print(f"[red]✗[/red] [bold]{node}[/bold] {ev.get('error','')}")
elif et == "done":
s = ev.get("status")
if s != last_status:
console.print(f"[bold]·[/bold] run {s}")
return
else:
# Unknown event type — show it raw so nothing's silently dropped.
console.print(f"[dim]{ev}[/dim]")
+55
View File
@@ -0,0 +1,55 @@
"""Local config persisted to ~/.langship/config.toml.
There is no default API URL — the CLI requires `langship login` (or the
LANGSHIP_API_URL env var) so it never silently talks to the wrong server.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any, Optional
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover
import tomli as tomllib # type: ignore
import tomli_w
CONFIG_DIR = Path.home() / ".langship"
CONFIG_PATH = CONFIG_DIR / "config.toml"
def load() -> dict[str, Any]:
if not CONFIG_PATH.exists():
return {}
with CONFIG_PATH.open("rb") as f:
return tomllib.load(f)
def save(cfg: dict[str, Any]) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with CONFIG_PATH.open("wb") as f:
tomli_w.dump(cfg, f)
def api_url_or_none() -> Optional[str]:
"""Resolve API URL: env var > config file > None."""
return os.environ.get("LANGSHIP_API_URL") or load().get("api_url")
def api_url() -> str:
"""Resolve API URL or raise — used everywhere except `login`."""
url = api_url_or_none()
if not url:
raise RuntimeError(
"no API URL configured — run `langship login --api-url <url>` "
"or set LANGSHIP_API_URL"
)
return url.rstrip("/")
def token() -> Optional[str]:
return os.environ.get("LANGSHIP_TOKEN") or load().get("token")
+112
View File
@@ -0,0 +1,112 @@
"""Output helpers — Rich tables, error formatting, time formatting."""
from __future__ import annotations
import json as _json
from datetime import datetime, timezone
from typing import Any
import typer
from rich.console import Console
from rich.table import Table
from .client import APIError
console = Console()
err_console = Console(stderr=True)
def die(msg: str, code: int = 1) -> None:
err_console.print(f"[red]error:[/red] {msg}")
raise typer.Exit(code=code)
def handle_api_error(err: APIError) -> None:
msg = err.message
if err.status == 404:
die(f"not found: {msg}")
if err.status in (401, 403):
die(f"unauthorized: {msg}")
if err.status == 409:
die(f"conflict: {msg}")
if err.status == 503:
die(f"unavailable: {msg}")
if err.status >= 500:
die(f"server error ({err.status}): {msg}")
die(f"{err.status}: {msg}")
def relative_time(iso: str | None) -> str:
if not iso:
return ""
try:
s = iso.replace("Z", "+00:00")
t = datetime.fromisoformat(s)
if t.tzinfo is None:
t = t.replace(tzinfo=timezone.utc)
except ValueError:
return iso[:19]
delta = datetime.now(timezone.utc) - t
secs = int(delta.total_seconds())
if secs < 0:
return "now"
if secs < 60:
return "just now"
mins = secs // 60
if mins < 60:
return f"{mins}m ago"
hours = mins // 60
if hours < 24:
return f"{hours}h ago"
days = hours // 24
if days < 30:
return f"{days}d ago"
return f"{days // 30}mo ago"
def make_table(*columns: str, title: str | None = None) -> Table:
t = Table(title=title, show_header=True, header_style="bold", box=None, pad_edge=False)
for c in columns:
t.add_column(c, overflow="fold")
return t
def print_kv(d: dict[str, Any], title: str | None = None) -> None:
t = Table(show_header=False, box=None, pad_edge=False, title=title)
t.add_column(style="dim")
t.add_column(overflow="fold")
for k, v in d.items():
if isinstance(v, (dict, list)):
v = _json.dumps(v)
t.add_row(k, "" if v is None else str(v))
console.print(t)
def confirm(msg: str, *, default: bool = False) -> bool:
return typer.confirm(msg, default=default)
def truncate(s: str | None, n: int) -> str:
if not s:
return ""
return s if len(s) <= n else s[: n - 1] + ""
def emit(obj: Any, fmt: str) -> bool:
"""If fmt is json/yaml, print obj and return True (caller should
return). Otherwise return False so the caller renders a table.
"""
fmt = (fmt or "").lower()
if fmt == "json":
console.print_json(data=obj)
return True
if fmt in ("yaml", "yml"):
try:
import yaml # type: ignore
console.print(yaml.safe_dump(obj, sort_keys=False), end="")
except ImportError:
# No PyYAML — fall back to pretty JSON rather than failing.
console.print_json(data=obj)
return True
return False