Run a repository's services locally from one panel: each on its own ports, with
its status, its logs, its dependencies and a key to restart it. Everything
devctl knows comes from one file at the root of your repository, devctl.yaml.
It is a terminal panel, not a daemon. No state is kept between runs, and quitting stops everything it started.
go install github.com/road-labs/devctl@latest
devctl
A repository with more than a couple of services accumulates a README section that says: export these variables, start this, then this, in that order, and if port 8080 is taken, good luck. That text goes stale, and every developer keeps a slightly different version of it in their shell history.
devctl replaces it with a file. The file is checked at start, so a mistake in it is an error message rather than a service that half-runs, and the ports are allocated once so two things cannot silently fight over one number.
Install it once and it is on your PATH, which is what makes devctl a plain
command in every repository, and what shell completion needs:
go install github.com/road-labs/devctl@latest
devctl
go.mod declares Go 1.27.1, so that is what building it needs.
You can pin it as a tool of a single repository instead, with
go get -tool github.com/road-labs/devctl@latest and go tool devctl, but then
it is not on your PATH and completion cannot hook it, so the plain install is
the recommendation.
devctl.yaml sits at the root of your repository. devctl finds it by walking up
from the working directory, the way git finds .git, so it runs from anywhere
in the tree. Every relative path inside it resolves against the directory that
holds it.
dependencies:
- name: db
description: Postgres holding every service's data
env: DATABASE_URL
example: postgres://localhost:5432/shop
kind: tcp
services:
- name: catalogue
description: Product catalogue, the API everything else reads
depends_on: [db]
ports:
- {name: http, port: 7200, kind: http, fixed: true}
- {name: grpc, port: 7201, kind: grpc}
autostart: true
dir: services/catalogue
watch: [services/catalogue, libs]
cmd: go run ./cmd/catalogue
listen: {http: HTTP_ADDR, grpc: GRPC_ADDR}
env:
DATABASE_URL: "{{ db.address }}"
- name: storefront
depends_on: [catalogue]
ports:
- {name: web, port: 7300, kind: http, fixed: true}
autostart: true
dir: services/storefront
cmd: npm run dev
listen: {web: {env: PORT, format: number}}
env:
CATALOGUE_URL: "{{ catalogue.http.url }}"
tasks:
- name: seed
description: Wipe the database and seed a shop to click around in
depends_on: [db]
dir: services/catalogue
cmd: go run ./cmd/seeddocs/manifest.md documents every key.
testdata/devctl.yaml is a worked example that the test
suite loads, so it cannot drift from the format. example/ is two
small projects you can actually run, to see one devctl peer with another and a
dependency switch between modes.
Ports are declared once and allocated when devctl starts. A free default is
kept. A default something else already holds is replaced by a free port and
reported, marked * in the panel. A port marked fixed: true refuses to start
instead, which is what you want for anything written down outside the process:
a URL persisted in a database, an OAuth redirect, a browser bookmark.
Anything that needs an address refers to it, and follows it if it moves:
| Reference | Expands to |
|---|---|
{{ catalogue.grpc }} |
localhost:7201 |
{{ catalogue.grpc.number }} |
7201 |
{{ catalogue.http.url }} |
http://localhost:7200 |
{{ db.address }} |
whatever DATABASE_URL holds |
A reference that names something undeclared is an error at load, so a typo fails before anything starts.
What a run needs from outside the repository is a dependency, and there are two kinds.
Provided by the machine. A database, usually. env names the variable
holding its address, read from your environment or the repository's git-ignored
.env. If it is missing, devctl prints the line to add and exits. If it does
not answer, devctl says so and exits, so nothing starts against a database that
is not there.
# .env at the repository root
DATABASE_URL=postgres://localhost:5432/shop
.env is not loaded into your services. devctl reads it only for the addresses
of dependencies, under the exact names they declare in env, and your own
environment beats the file. A service's own variables belong in its env in the
manifest; putting them in .env does nothing.
The one thing besides an address that belongs in .env is a secret a forwarded
dependency needs, a password or a token: declare it as a dependency with
kind: secret and devctl reads it from .env the same way, never dials it, and
masks it in the panel. The manifest composes it into the address a service
reads, so the secret stays out of the committed file.
What a service is given is your shell's environment, then its listeners, then
its declared env with every reference resolved. Later wins, so the manifest
overrides anything you happened to have exported.
Forwarded by devctl. Something in a remote cluster, reached through a
tunnel. It is a row with a local port and the command that opens it; select it
and press s. Nothing waits for a tunnel, so without one only the screens that
need it fail.
- name: payments
kind: tcp
port: 7100
forward: {cmd: "kubectl -n shop port-forward svc/payments {{ payments.port.number }}:8080"}The command states the local port once, by referring to the port devctl gave it.
Peered with a sibling devctl. When you run devctl from several repositories at
once and some depend on others, a peer reads another devctl's live ports rather
than repeating a number that moves. It names that devctl by its id and the
service and port it declares. The sibling already listens on localhost, so there
is no tunnel; services reach it as {{ name.port }}.
- name: platform-api
description: The platform gateway, read live from the platform repo
peer: {id: platform, service: gateway, port: http}See Peering devctls together for the id that makes
a devctl readable.
Mark a dependency optional: true and an unconfigured or unreachable one warns
rather than stops the run. Forwarded and peered ones are optional already.
Running a UI, you often want to point a dependency at a local service one minute
and a port-forwarded environment the next. A dependency declares those sources as
named modes and switches between them live: select its row, press m, and
pick one. The services that read it restart, so they come back pointed at the new source.
- name: platform
description: The platform API
default: local
modes:
- {name: local, peer: {id: platform, service: gateway, port: http}}
- {name: staging, port: 7100, forward: {cmd: "kubectl -n plat port-forward svc/gateway {{ platform.port.number }}:8080"}}
- {name: shared, env: PLATFORM_URL, example: https://platform.staging}Each mode is one of the three source kinds. default names the one live at
start; devctl.mine.yaml can set a different one per machine, since which source
a developer uses day to day is their own business. Whatever reads the dependency
refers to it as {{ platform.address }}, which follows whichever mode is live.
Some config comes with a mode rather than being an address, an auth bundle that
differs local vs staging, say. Put it on the mode as provides, and every
service that depends on the dependency is handed it, swapped when the mode
switches:
- name: identity
default: staging
modes:
- name: local
peer: {id: platform, service: identity, port: grpc}
provides: {OIDC_PROVIDER_URL: http://localhost:24700/, OIDC_CLIENT_ID: finance}
- name: staging
port: 9490
forward: {cmd: "kubectl ... svc/identity {{ identity.port.number }}:9090"}
provides: {OIDC_PROVIDER_URL: https://id.public.road.dev/, OIDC_CLIENT_ID: d99d247a-...}One m on the identity row flips the address and the bundle together. A service
can still override a provided value in its own env.
Give a devctl an id and it publishes its allocated ports for its siblings:
id: platformAnother repository's devctl then reads them by that id with a peer dependency,
so billing finds platform on whatever port it actually got, with nobody
writing a number down. The socket lives under the system temp directory and is
removed when devctl quits. Start order does not matter: a devctl that peers with a
sibling not yet running shows waiting, and picks it up the moment it comes up,
restarting the running services that read it so they never stay on an empty
endpoint.
Two devctls cannot share one id; the second refuses to start, which catches a
stray copy.
A published devctl also carries the config its services provides, so a peer
inherits it. If platform's identity service publishes the OIDC issuer and
client id, billing peering that service gets them without restating anything.
The service that owns a value states it once; everyone downstream reads it live.
A repository with thirty services rarely wants all thirty up. Name the subsets people actually work on:
profiles:
- name: fraud
description: The fraud console and what it reads
include: [fraud-ui, fraud-review]devctl fraud # the profile
devctl fraud-ui # any single name works too
devctl # everything
What a profile lists are the roots. What they need comes with them, following
depends_on and any {{ reference }}, because a service that reads another's
address needs it up whether or not it declared so.
Naming a target runs it. devctl fraud-ui starts fraud-ui, and devctl fraud
starts the profile's roots, without anything needing autostart: true in the
manifest; the backing services follow through depends_on. A bare devctl with
no target is the exception: there the manifest's own autostart flags decide
what comes up, so a repository can leave everything down on a bare run and still
have devctl fraud bring the fraud console up. A profile's autostart list, when
it sets one, stays definitive and overrides both.
The narrowing happens before anything else reads the manifest, so a profile neither resolves dependencies it has no use for nor has its run refused over a port belonging to a service it is not starting.
A profile can also say how its dependencies are wired, not just which things run.
Give it modes to start a multi-mode dependency in a chosen source:
profiles:
- name: ui-staging
description: The UI, with the platform API forwarded from staging
include: [ui]
modes:
platform: stagingdevctl ui-staging runs the UI and starts platform forwarded rather than in
its default mode. m still switches at runtime; this only sets where it starts.
A profile picks each dependency's mode independently and can add env for the
run, so "auth against staging, data local" is one profile:
profiles:
- name: dev
include: [ui]
modes: {identity: staging, billing: local, pricing: local}
env: {APP_ENVIRONMENT: local}The auth bundle comes with identity's staging mode (its provides), so it is
written once and every run that chooses that mode inherits it. The profile's
env is for config that belongs to the run rather than to one dependency.
devctl.mine.yaml, beside the manifest and git-ignored, is merged over the top
when it exists. The committed manifest says what the repository needs; this says
how your machine provides it.
It exists because those are different questions. Everyone on a project needs a database. Whether it arrives from Docker, a container runtime or a box under someone's desk is nobody else's business, and a committed manifest that picks one forces it on everybody.
# devctl.mine.yaml
services:
- name: catalogue
ports:
- {name: grpc, port: 9999} # 7201 is taken on this machine, permanently
autostart: false # I start this one by hand
env:
LOG_LEVEL: debugMerging is by name and by key, so you change one thing without restating what surrounds it: the other ports stay, the other environment variables stay, the command stays. An entry the manifest does not have is added, so you can keep a scratch service or a personal task without committing it.
Because the merge happens on the file rather than on parsed values, false and
0 in the overlay mean what they say rather than reading as "not set".
One table. Dependencies, then services with a line per listener under each, then
tasks, separated by a rule. TYPE says which is which, ENDPOINT carries a
dependency's address or a listener's port, RESTARTS counts how many times a
process has come back, and WATCH says whether it restarts itself. The header
above it carries what devctl is running for, the keys, and what the markers
mean.
s |
start a service, run a task, open a forward |
x |
stop |
r |
restart |
w |
auto-restart on or off for this service |
m |
pick a dependency mode (opens a picker) |
d |
describe the selected row |
l |
logs for this row |
L |
logs for everything |
j k |
move |
pgup pgdn |
to the band above or below |
q |
quit, stopping everything devctl started |
d opens everything devctl knows about a row: its status and uptime, the
command it runs and the directory it runs in, its listeners with declared
against actual ports, the environment it is handed with every reference
resolved, and the dependency graph both ways.
Both directions matter. What a row depends on is in the manifest; what depends on it is not, and that is the question a dependency's owner has, which is who breaks if this is down.
The environment is shown even when it cannot be resolved, because a reference that does not resolve is exactly why the row will not start.
l opens the selected row's output, L every process merged in arrival order
with each line prefixed by its service. ← → or tab move between them, g
and G jump to the ends, f follows the tail, which also stops when you scroll
up and resumes when you reach the bottom. esc returns.
That is the last 2000 lines of each process, kept in memory and gone when devctl is. To keep them, give the manifest a log directory:
logs:
dir: .devlogs
max_size: 2MBOne <name>.log per row, appended to across restarts, so a service that died
while you were reading something else can still be read afterwards. A file
already over the cap when its process starts is emptied and begun again. Both
d and the log view print the path. Gitignore the directory.
A service with a watch list restarts when a Go file, go.mod or go.sum
under those directories changes, after a short quiet period so a multi-file save
restarts once. The WATCH column says on or off; w toggles it. Leave the
list empty for anything that reloads itself, such as a Next.js dev server.
Services marked autostart in the manifest come up when devctl does, so there
is no key for it.
go install leaves you a binary and nothing else, so the two things that help
you write a manifest are printed by the binary rather than shipped as files.
devctl -example
A worked example of every feature, which is also the manifest devctl's own
tests run against, so it cannot drift from what the code does. Redirect it into
devctl.yaml and cut it down, or read it beside your own.
devctl -skill
An agent skill for writing one, with the full schema reference and a worked example appended, so it is one document with nothing left to go and find. The skill itself covers where in a repository the answers already are, starting with the Makefile and the Taskfile, then the handful of questions only a person can answer, then the mistakes worth naming.
The skill and the reference are fetched, so they are whatever the project says today rather than whatever your binary was built with. Save it where your agent looks:
mkdir -p .claude/skills/devctl-manifest
devctl -skill > .claude/skills/devctl-manifest/SKILL.md
devctl -check
Validates the manifest, resolves and pings the dependencies, prints the allocation and exits without opening the panel. Useful in CI to keep the manifest honest.
devctl -manifest path/to/devctl.yaml
Points at a manifest explicitly, rather than walking up to find one.
devctl <TAB> completes the run targets, the profiles and every single service,
task and dependency, read from the manifest in the current directory. It follows
you between repositories, since it reads whichever manifest you are standing in.
Load it for your shell (needs devctl on your PATH, so go install it):
# bash, in ~/.bashrc
source <(devctl -completion bash)
# zsh, in ~/.zshrc (after compinit)
source <(devctl -completion zsh)
# fish
devctl -completion fish > ~/.config/fish/completions/devctl.fish
Each script asks devctl -complete for the target list, so a new profile shows
up the moment it is in the manifest, with nothing to regenerate.
MIT. See LICENSE.