README
ΒΆ
Your Darkube apps, from the terminal. List, inspect, tail, shell into and create apps on the Hamravesh platform β without opening the console.
Why
Darkube is a good PaaS with one gap: everything runs through the web console. There is no kubectl for it, no way to grep a log, no way to script a deploy check, no way to see that a deleted app left its Helm release running in your cluster.
darkubectl is that missing CLI. It speaks the Hamravesh API (https://api.hamravesh.com) and its websockets directly, so the mental model is the one you already have: tenant = organization, namespace = project, app = workload.
darkubectl get apps -o wide # instead of five clicks
darkubectl logs app api -f # instead of the log panel
darkubectl terminal app api # instead of the web terminal
darkubectl get orphans # something the console cannot tell you at all
Highlights
| π Familiar verbs | get, describe, logs, exec, create, delete β the kubectl muscle memory carries over. |
| π§ Real terminals | terminal app <name> opens an interactive shell over the exec websocket, resize and all. exec runs one-off commands. |
| π Logs that pipe | logs -f to follow, --previous for the container that just crashed, --timestamps for correlation. |
| π Tunnels, not exposure | tunnel up runs a chisel server as an app and tunnel connect forwards local ports through it, so you can reach a ClusterIP database from a laptop without a public LoadBalancer and without cluster credentials. The client is built in, and connect --host β¦ --auth β¦ works for someone with no Darkube account at all. |
| π» Orphan detection | get orphans reconciles your tenant against a live cluster and finds the Helm releases Darkube left behind on delete. Nothing else surfaces these. |
| π¨ Readable output | Colorized tables and a describe -i interactive viewer with search, degrading to plain text the moment you pipe it. |
| π€ Scriptable | -o json, -o yaml, -o name on everything, config via flags, env or file, and get deploy-token to wire a CI pipeline without the console. |
| π§ Agent-ready | Non-interactive, JSON-emitting and credentialed from the environment, so a coding agent can run it as a tool and manage Darkube for you. |
| π Two auth modes | An account API key for scripting, or a full 2FA Console login (TOTP) for terminals and app creation. |
| π¦ One static binary | Go, no client-go, no runtime deps. Linux, macOS and Windows on amd64 and arm64. |
Install
Homebrew (macOS):
brew install rahacloud/tap/darkubectl
Go:
go install github.com/rahacloud/darkubectl@latest
Binaries β grab a tarball for your platform from the latest release:
curl -sSfL https://github.com/rahacloud/darkubectl/releases/latest/download/darkubectl_$(curl -s https://api.github.com/repos/rahacloud/darkubectl/releases/latest | grep -o '"tag_name": "v[^"]*' | cut -d'v' -f2)_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz | tar xz darkubectl
sudo install darkubectl /usr/local/bin/
From source:
git clone https://github.com/rahacloud/darkubectl && cd darkubectl && go build -o darkubectl .
Quickstart
darkubectl config use-tenant <org-slug> # e.g. rahacloud
darkubectl config set-token <your-api-key> # from the Darkube console
darkubectl get apps
That covers everything read-only. For pod terminals and app creation, add a Console login:
darkubectl login # email + password + TOTP
darkubectl terminal app <name>
Let an AI agent run it
Darkube has no MCP server and no CLI of its own, which leaves an agent with nothing to hold. darkubectl closes that gap: it is one static binary, every subcommand is non-interactive, every read speaks JSON, and credentials come from the environment β the four properties a coding agent (Claude Code, Codex, Cursor, or anything that can run a shell) needs to use a tool unattended. Give it the binary and it can answer "is anything unhealthy in talaland-dev?" or "why did the api pod restart last night?" without a human opening the console.
export DARKUBE_TOKEN=<api-key> DARKUBE_ORG=<org-slug>
darkubectl get apps -o json | jq -r '.[] | select(.state.text != "Healthy") | "\(.namespace.name)/\(.name): \(.state.text)"'
darkubectl logs app api --previous --tail 200 # what the container said before it died
darkubectl get orphans --context <ctx> # releases left behind by deletes
A few lines in your CLAUDE.md or AGENTS.md are usually enough to teach an agent the tool:
Our apps run on Darkube. Use
darkubectl get apps -o jsonto inspect state,darkubectl describe app <name> -o yamlfor configuration, anddarkubectl logs app <name> --tail 200to read logs. Rundarkubectl get orphansbefore recreating an app whose name is taken. Never rundeleteand never pass-y.
Give the agent an API key rather than a Console login. The Api-key cannot open pod terminals or create apps, which bounds an agent's blast radius to reads. Keep delete out of its reach: the deletion is asynchronous and routinely orphans the Helm release, and no flag in the API can undo that β see Orphaned releases.
Authentication
Almost every request is scoped to an active tenant (organization) via the X-Organization: <tenant-slug> header, and carries one of two credentials:
- an account API key β
Authorization: Api-key <token>; or - a Console JWT from
darkubectl loginβAuthorization: Bearer <jwt>.
Either credential drives the whole REST API. The Api-key is the simplest for scripting; a login is required additionally for the terminal/exec websocket (the Api-key cannot open it). If both are configured, the Api-key is used for REST and the JWT for the terminal.
Three commands need no tenant at all, because the endpoints behind them are account-wide rather than tenant-scoped: whoami (the account and its organizations), get notifications (a feed that spans every organization) and get plans (a global catalogue). That is what makes the first run work β whoami is how you find out which tenant slugs exist before selecting one. Everything else fails fast with no tenant selected rather than sending a request the API would answer with 403 permission_denied.
Config is stored at ~/.darkube/config.yaml (override with $DARKUBE_CONFIG), written 0600. Values can also be supplied via environment or flags, which take precedence:
| Setting | Flag | Environment | Config key |
|---|---|---|---|
| Token | --token |
DARKUBE_TOKEN |
token |
| Tenant | --org/-n |
DARKUBE_ORG |
current-tenant |
| Base URL | --base-url |
DARKUBE_BASE_URL |
base-url |
| Config | --config |
DARKUBE_CONFIG |
β |
Usage
# Tenants (organizations)
darkubectl whoami # no tenant needed: the account, its tenants, their numeric ids
darkubectl get tenants # the tenants in the local config
darkubectl config use-tenant talaland
# Apps
darkubectl get apps # table
darkubectl get apps -o wide # + type, cluster, RAM, CPU, domain, id
darkubectl get apps --managed # only managed services (Redis, Postgres, Grafana, β¦)
darkubectl get apps --type redisnew # only apps with this creation_method
darkubectl get apps -o json
darkubectl describe app <name|id> # colorized key/value view
darkubectl describe app <name|id> -i # interactive: scroll + / search
darkubectl describe app <name|id> -o yaml
# Other resources
darkubectl get namespaces # projects (derived from apps)
darkubectl get certificates
darkubectl get plans # no tenant needed: the plan catalogue is global
# App configuration
darkubectl get env <name|id> # environment variables (secrets listed by name)
darkubectl get domains <name|id> # custom domains + the CNAME target to point DNS at
# Notifications and monitoring
darkubectl get notifications # no tenant needed: account feed, spans every tenant
darkubectl get notifications --unread
darkubectl get alerts # monitoring alerts for the current tenant
darkubectl get alerts --firing # only what has not resolved
# CI credentials β the pair `darkube deploy` needs in a pipeline
darkubectl get deploy-token <name|id> # app id + trigger deploy token
darkubectl get deploy-token <name|id> -o name # the bare token, for a CI variable
# Reconcile Darkube against a cluster (needs kubectl on PATH)
darkubectl get orphans # every namespace in the current context
darkubectl get orphans --context <ctx> --namespace <ns>
# Mutations (all prompt for confirmation; pass -y to skip)
darkubectl scale app <name|id> --replicas 3
darkubectl set env <name|id> LOG_LEVEL=debug PORT=8080
darkubectl set env <name|id> --remove LOG_LEVEL
darkubectl set domain <name|id> --add api.example.com # a domain you own
darkubectl set domain <name|id> --remove old.example.com
darkubectl set subdomain <name|id> my-api # -> my-api.darkube.app, with a cert
darkubectl set subdomain <name|id> --remove
darkubectl set svc-type <name|id> LoadBalancer # expose it; ports are preserved
darkubectl set svc-type <name|id> ClusterIP --dry-run # show the diff, send nothing
darkubectl patch app <name|id> -p '{"ram_limit": "1024M"}'
darkubectl patch app <name|id> -p '{"replicas": 3}' --dry-run # show the diff, send nothing
darkubectl delete app <name|id>
# Block until a deploy has actually landed, instead of sleeping and hoping
darkubectl wait app <name|id> --for ready --timeout 10m
darkubectl wait app <name|id> --for deleted
# Reach a ClusterIP service from your laptop, without exposing it (needs the
# chisel client on PATH for `connect`; the server side installs nothing)
darkubectl tunnel up --namespace talaland-dev --subdomain tld-tunnel
darkubectl tunnel connect 1433:mssql-dev.talaland-dev.svc:1433 5432:postgres-dev.talaland-dev.svc:5432
darkubectl tunnel down
# Create an app from a Docker image (needs a JWT login; see below)
darkubectl get plans # pick a plan (NAME column β --plan)
darkubectl get namespaces # pick a namespace (ID column β --namespace)
darkubectl create app my-api --namespace <ns> --plan 1 --image nginx:latest
darkubectl create app -f spec.yaml # from a YAML spec (ports, disk, env)
darkubectl create app -i # interactive prompts
# Or let Darkube build the image from a repository (creation_method git_repo_url).
# Needs the git provider connected to the Hamravesh account under Integration.
darkubectl create app my-api --namespace <ns> --plan 1 \
--git-repo https://github.com/acme/my-api --git-branch main
# Logs
darkubectl logs <name> # last 100 lines (pod auto-detected; `logs app <name>` also works)
darkubectl logs <name> --tail 500 -f # follow
darkubectl logs <name> --previous # the container instance that crashed
darkubectl logs <name> --timestamps --pod <p> -c <container>
# Terminal / exec β needs a JWT login (separate from the Api-key)
darkubectl login # email + password + TOTP β stores a refresh token
darkubectl get pods <name> # an app's pods: READY, STATUS, RESTARTS, AGE
darkubectl get pods <name> -o wide # plus the containers and why they last died
darkubectl exec app <name> -- ls -la # run a command in a pod
darkubectl terminal app <name> # interactive shell (auto-detects the pod; alias: shell)
darkubectl terminal app <name> --pod <p> -c <container>
Output format is controlled by -o/--output: table (default), wide, json, yaml, or name. Scope any single command to a different tenant with -n <org>.
The app spec file
Flags cover the flat fields. Ports, persistent storage and environment variables are nested, so they are --file only:
name: masstransit-dev
namespace: "175864" # id, or a name β ids always work, see below
plan: "1" # plan NAME from `get plans`, or its id
image: masstransit/rabbitmq:3.13.1
replicas: 1
svcType: ClusterIP # or LoadBalancer; defaults to ClusterIP
ports: # keyed by name; "main" is the one ingress targets
amqp: {containerPort: 5672, servicePort: 5672, protocol: TCP}
main: {containerPort: 15672, servicePort: 15672, protocol: TCP}
disk:
sizeInGi: 4
setFsGroup: true
storageClassName: rawfile-btrfs # required β omitting it makes the API 500
partitions:
- {name: data, mountPath: /var/lib/rabbitmq/mnesia, subPath: data}
envs:
- {name: RabbitMq__Host, value: masstransit-dev.talaland-dev.svc}
secretEnvs:
- {name: RabbitMq__Password, value: hunter2}
command is split on whitespace; args is not. This is the sharpest edge in the API and it is not documented anywhere upstream:
command: /bin/sh -c # SPLIT -> ["/bin/sh", "-c"]
args: echo$IFS'hi' # NOT split -> ["echo$IFS'hi'"], one single argument
The arrangement everyone writes first β command: /bin/sh with args: -c echo hi β hands the container "-c echo hi" as one token, and busybox reads the space as another flag: /bin/sh: illegal option -, crash-looping, with nothing in the API response pointing at the cause. Put the words that need splitting in command; because that is split, the script in args must then contain no whitespace at all, which is what $IFS is doing above. create app warns when args contains whitespace and refuses a multi-element args list.
Both fields accept a string or a list, so command: ["/bin/sh", "-c"] works too.
For an app Darkube builds itself, replace image with a git block:
git:
repoUrl: https://github.com/acme/my-api
branch: main
dockerfile: ./Dockerfile # default
provider: Github # default: inferred from the URL
These can also be changed after the fact. The API has no partial update β PATCH is unimplemented and returns 500 β so every mutation is a read-modify-write of the whole app, which darkubectl does for you. Environment and domains have dedicated commands (set env, set domain); anything else goes through patch app, which merges your JSON into the current object and writes it back. The one caveat of that approach: a console edit made between the read and the write is lost.
Namespaces resolve by name when they already contain an app; a brand-new empty project has to be referenced by id, which get namespaces prints.
A multi-port spec is not proven to work. The example above is the real masstransit-dev spec, and the app it produced has no container ports and no Service at all β darkube deploy reported success and said nothing. Every single-port app created alongside it got its Service, so main on its own is the shape known to work. Treat the second port as unverified rather than broken: that app was later deleted and its release orphaned (see below), which is a second candidate explanation nobody has separated from the first.
Reaching a service from outside
There are two ways, and they are not equivalent.
set svc-type <app> LoadBalancer asks the cluster for a public address. It is one command, needs nothing installed, and changes the running app in place β the type is a field on the app object like any other, so there is no delete-and-recreate and no risk of stranding the Helm release. Use it for something that is meant to be public.
Two things about it are worth knowing before you reach for it. The first is that the port that answers is the allocated nodePort, not the servicePort. Hamravesh fronts LoadBalancer services with a shared gateway, so an app whose servicePort is 5432 is reached on something like :30410, and the servicePort itself refuses connections β which reads as a broken exposure rather than a wrong port. The command prints the endpoint that actually works, and describe app reports it as svc.externalAddress alongside the per-port nodePort:
darkubectl set svc-type postgres-dev LoadBalancer
app/postgres-dev service type set to LoadBalancer
reachable at:
c41ac067-β¦.hsvc.ir:30410 (main, container port 5432)
note: that is the nodePort, not the service port. The gateway is shared, so the
service port itself refuses connections.
The second is that there is now nothing between the internet and the app but whatever authentication the app itself asks for. For a database reachable with a superuser account, that is usually the wrong trade, which is what the other option is for.
tunnel runs a chisel server as an ordinary app and forwards local ports through it. Nothing is published: the tunnel rides the HTTP ingress the app already has, and only someone holding the credential minted at tunnel up can use it.
darkubectl tunnel up --namespace talaland-dev --subdomain tld-tunnel
darkubectl tunnel connect 1433:mssql-dev.talaland-dev.svc:1433
# -> localhost:1433 reaches the in-cluster database
REMOTEHOST is resolved inside the cluster, so it is the in-cluster address β describe app reports it as svc.internalAddress. Forwarding to localhost is rejected, because it would resolve to the tunnel pod's own (empty) loopback and produce a tunnel that connects and then refuses everything.
The credential is stored in the config file at creation, and that is the only copy: secret envs are write-only, so the API will not give it back. --auth and $DARKUBE_TUNNEL_AUTH override it, which is what a CI job or a second machine wants.
The point of the tunnel is that it needs no cluster credentials at all. kubectl port-forward is the obvious alternative and often is not available: on Hamravesh it means an OIDC exec plugin and a browser login, and the RBAC a Darkube user is given is frequently read-only or absent β so the person who can deploy the app cannot necessarily reach it. Neither side of the tunnel installs anything: the server is an app, and the chisel client is linked into this binary. --chisel-binary runs an external one instead, for a different version or a patched build.
Handing a tunnel to someone else is --host, and it is the flag that makes this useful beyond your own laptop:
darkubectl tunnel connect --host tld-tunnel.darkube.app --auth tunnel:β¦ \
27017:mongodb-stage.talaland-stage.svc:27017
With --host the command makes no API call, so whoever runs it needs no Darkube account, no tenant and no login β only this binary, the hostname and the credential. Without it the tunnel app is looked up by name, which needs all three. tunnel up prints the exact --host line to pass on, credential included.
The hostname has to be one the other side can actually resolve. A platform subdomain (--subdomain, *.darkube.app) answers with a private address and is reachable only from inside Hamravesh's network, so for an outside collaborator the tunnel needs either a real domain CNAMEd at the cluster (--host on tunnel up, pointed at the ingress_cname_address from get domains) or a LoadBalancer on the tunnel app β which exposes chisel rather than the database, and chisel's own SSH layer keeps the forwarded traffic encrypted even over plain http://. --host accepts host, host:port or a full URL, and keeps an explicit scheme.
Orphaned releases
Deleting an app removes it from the API at once and tears its Helm release down separately β and the release is routinely left behind. It keeps running, keeps its darkube.hamravesh.com/app-id label, and keeps the name, so recreating under that name fails with SameHelmReleaseNameExists indefinitely. Neither side shows this on its own: get apps cannot list an app that no longer exists, and the cluster looks entirely normal.
get orphans is the reconciliation, comparing the tenant's apps against the Deployments in a cluster:
darkubectl get orphans --context <ctx> --namespace <ns>
NAME NAMESPACE KIND APP-ID
masstransit-dev talaland-dev orphaned a5895ea3-ebea-4356-af55-e60835fd47f0
redis-dev talaland-dev orphaned 72050779-4c43-4c29-90c1-86d0ca066d5f
orphaned is a workload whose app is gone; no-workload is the reverse, an app with nothing in the cluster carrying its id. Only namespaces the cluster returns are compared, because a tenant's apps span several clusters while one kubeconfig context reaches one of them.
Clearing an orphan needs the console or Hamravesh support β there is no force/adopt flag on create and no release endpoint in the API. The command tells you the name is taken and why; it cannot free it.
This shells out to kubectl rather than linking client-go, which keeps the dependency tree small and inherits whatever kubeconfig, context and OIDC exec-plugin credentials already work for you.
Logging in
darkubectl login obtains a Console JWT and stores the (long-lived) refresh token, from which access tokens are minted automatically. There are several ways to provide it β a refresh token is as powerful as a full login:
darkubectl login # interactive: email + password + TOTP (2FA)
darkubectl login --refresh-token <token> # store an existing refresh token (no 2FA)
some-vault get token | darkubectl login --refresh-token-stdin
export DARKUBE_REFRESH_TOKEN=<token> # refresh token from the environment
export DARKUBE_ACCESS_TOKEN=<jwt> # a ready access token (used verbatim)
The account API key cannot open a pod terminal or create apps β the exec websocket (wss://β¦/ws/aexec/) and app creation require the JWT. Force the JWT even when an Api-key is configured by unsetting it: DARKUBE_TOKEN= darkubectl β¦.
What works, and what does not
The Darkube API has no public documentation; every endpoint here was reverse-engineered against a live account and is noted as confirmed or not in CLAUDE.md. Two things to know up front:
- There is no partial update β
PATCHis not implemented and returns a bodyless 500 on both API versions, so every write is a read-modify-write of the entire app viaPUT.darkubectlhandles that; the visible consequence is that a console edit racing your command is lost. - Delete is asynchronous and orphans releases β see Orphaned releases.
get orphansexists because this is the common case, not the edge case. Editing an app in place is safer than delete-and-recreate, which is what strands releases.
Managed services are ordinary apps. A oneclick Redis, Postgres, Grafana or Prometheus is not a separate resource with a separate endpoint β it is an app in the same list, distinguished only by its creation_method (redisnew, postgresqlnew, grafana, β¦). Probing for a dedicated managed-services API in 2026-08 found nothing but 404s. get apps -o wide shows the type, --type and --managed filter on it, and logs, describe and get env work on them like any other app. The caveat is access: a service provisioned under an organization your account cannot see will not appear at all, no matter what you know its URL to be.
Two things the API will not let you do, whatever the CLI offers:
- Secret environment values are write-only. They are vault-backed and always read back empty, so
get envlists secrets by name only, andset envrefuses to shadow one. Changing a secret's value needs the console. - You cannot mount an arbitrary config file into a docker-image app.
custom_configis the app's Helm chart values and is silently filtered against the chart's schema β for a docker-image app onlyhpaandcontainer(probes) survive. Theconfig.filesmechanism that would render a ConfigMap exists only on marketplace charts like redis and postgres. Use environment variables, or bake the file into the image.
Everything else β listing, describing, logs, exec, terminals, create, edit, deploy tokens, notifications, alerts β is confirmed working.
Development
go build ./...
go test -race ./...
golangci-lint run ./...
The demo GIF is generated with VHS:
brew install vhs
vhs demo/demo.tape # runs against your configured tenant β assets/demo.gif
Architecture, the reverse-engineered API/auth details, and contributor conventions live in CLAUDE.md.
Contributing
Issues and pull requests are welcome β especially new endpoint findings, since the API surface is mapped by observation. Keep golangci-lint at zero issues and go test -race ./... green, and the CI will agree with you.
If darkubectl saved you a trip to the console, a β helps other Darkube users find it.
Documentation
ΒΆ
Overview ΒΆ
Command darkubectl provides kubectl-like access to the Hamravesh Darkube platform.
Directories
ΒΆ
| Path | Synopsis |
|---|---|
|
Package cmd implements the darkubectl command tree on urfave/cli/v3.
|
Package cmd implements the darkubectl command tree on urfave/cli/v3. |
|
internal
|
|
|
appstate
Package appstate reads an app's live pods from the Darkube app-pods websocket.
|
Package appstate reads an app's live pods from the Darkube app-pods websocket. |
|
auth
Package auth mints and refreshes Console JWTs for the terminal/exec websocket.
|
Package auth mints and refreshes Console JWTs for the terminal/exec websocket. |
|
client
Package client is a thin wrapper over the Hamravesh Darkube REST API.
|
Package client is a thin wrapper over the Hamravesh Darkube REST API. |
|
config
Package config loads and persists darkubectl's kubeconfig-style settings.
|
Package config loads and persists darkubectl's kubeconfig-style settings. |
|
kube
Package kube reads Darkube-managed workloads out of a Kubernetes cluster and compares them with what the Darkube API believes exists.
|
Package kube reads Darkube-managed workloads out of a Kubernetes cluster and compares them with what the Darkube API believes exists. |
|
output
Package output renders API results as kubectl-style tables or as JSON/YAML.
|
Package output renders API results as kubectl-style tables or as JSON/YAML. |
|
tui
Package tui provides the interactive Bubble Tea viewers for darkubectl.
|
Package tui provides the interactive Bubble Tea viewers for darkubectl. |
|
wsexec
Package wsexec opens the Darkube exec websocket and relays a terminal session.
|
Package wsexec opens the Darkube exec websocket and relays a terminal session. |