mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
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:
co-authored by
Claude Opus 4.7
parent
6b1a13ecdc
commit
dd2c0e171a
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""langship — CLI for the Langship control plane."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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]")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user