A practical, end-to-end guide to using devctl: from your first .devctl.yaml to the TUI and real plugins.
Every repo has "how we run this locally" knowledge. Over time, this knowledge accumulates as scattered scripts, undocumented flags, and tribal knowledge that only a few people really understand. Onboarding new developers becomes slow. CI diverges from local dev. The startup script grows into something fragile.
devctl exists to solve this problem. It's a dev environment orchestrator that lets you capture "how we run this repo" in a testable, versionable plugin—while devctl itself handles the boring-but-hard parts:
The core idea: your plugin knows your repo; devctl knows how to run things reliably.
When you run devctl up, devctl executes a pipeline of phases. Each phase can be implemented by one or more plugins, and devctl merges their outputs.
┌──────────────────────────────────────────────────────────────────────┐
│ devctl up pipeline │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ config │───▶│ build │───▶│ prepare │ │
│ │ .mutate │ │ .run │ │ .run │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ Derive env │ Compile code │ Install deps │
│ │ vars, ports │ bundle assets │ run migrations │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ validate │───▶│ launch │───▶│ supervise │ │
│ │ .run │ │ .plan │ │ (devctl) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ Check prereqs Return service Start processes, │
│ (docker? deps?) definitions capture logs, track PIDs │
│ │
└──────────────────────────────────────────────────────────────────────┘
Key insight: your plugin computes what to do (config, build steps, services). devctl handles how to run it (processes, state, logs). This separation keeps plugins simple and testable.
Let's make this concrete. Say you have a repo with a backend API and a frontend dev server.
.devctl.yaml at your repo rootplugins:
- id: myrepo
path: python3
args: ["./devctl-plugin.py"]
priority: 10
devctl-plugin.py#!/usr/bin/env python3
import json, sys
def emit(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
# Handshake: tell devctl who we are and what we support
emit({
"type": "handshake",
"protocol_version": "v2",
"plugin_name": "myrepo",
"capabilities": {"ops": ["config.mutate", "validate.run", "launch.plan"]},
})
# Handle requests from devctl
for line in sys.stdin:
if not line.strip():
continue
req = json.loads(line)
rid, op = req.get("request_id", ""), req.get("op", "")
if op == "config.mutate":
# Derive config values (ports, URLs, env vars)
emit({"type": "response", "request_id": rid, "ok": True,
"output": {"config_patch": {
"set": {"env.API_PORT": "8080", "env.VITE_API_URL": "http://localhost:8080"},
"unset": []
}}})
elif op == "validate.run":
# Check prerequisites
import shutil
errors = []
if not shutil.which("node"):
errors.append({"code": "E_MISSING", "message": "node not found. Install: brew install node"})
emit({"type": "response", "request_id": rid, "ok": True,
"output": {"valid": len(errors) == 0, "errors": errors, "warnings": []}})
elif op == "launch.plan":
# Define services for devctl to supervise
emit({"type": "response", "request_id": rid, "ok": True,
"output": {"services": [
{"name": "api", "cwd": "backend", "command": ["go", "run", "."],
"env": {"PORT": "8080"},
"health": {"type": "http", "url": "http://localhost:8080/health", "timeout_ms": 30000}},
{"name": "web", "cwd": "frontend", "command": ["npm", "run", "dev"],
"env": {"VITE_API_URL": "http://localhost:8080"}}
]}})
else:
emit({"type": "response", "request_id": rid, "ok": False,
"error": {"code": "E_UNSUPPORTED", "message": f"unsupported op: {op}"}})
Make it executable: chmod +x devctl-plugin.py
devctl plugins list # Verify plugin loads correctly
devctl plan # See what would run (no services started)
devctl build # Optional: run build.run without starting services
devctl validate # Optional: check prerequisites without starting services
devctl up # Start everything
devctl status # What's running?
devctl logs --service api --follow # Tail logs
devctl down # Stop everything
That's it. You now have a dev environment that anyone can start with devctl up.
devctl commands are designed for a simple, repeatable workflow: plan → up → observe → down. When you need finer control, you can also run individual phases such as build, prepare, and validate before up.
devctl plugins list # What plugins are configured?
devctl plan # What config and services would be created?
Use standalone phase commands when a step is expensive, flaky, or useful in CI. Each command runs config.mutate first, then the requested phase, and prints JSON containing the mutated config and phase result.
devctl build --timeout 10m # Run build.run only
devctl build --step backend --timeout 10m # Ask for selected build steps
devctl prepare --step pnpm-install # Run prepare.run only
devctl validate # Run validate.run only; non-zero if invalid
For long-running builds, increase --timeout. Plugins should stream human-readable progress to stderr; protocol stdout must stay NDJSON-only.
devctl up # Run pipeline, start services
devctl status # Show running services, PIDs, health
devctl status --tail-lines 10 # Include stderr tails for dead services
devctl logs --service api # Show stdout for a service
devctl logs --service api --stderr # Show stderr
devctl logs --service api --follow # Live tail
Restarting: If state already exists from a previous up, devctl prompts before restarting. Use --force to skip the prompt:
devctl up --force # Stop existing services, then start fresh
Once an environment is running, you can stop, start, or restart one tracked service without tearing down the whole environment:
devctl restart api # Stop api, re-plan, then start api again
devctl stop-service web # Stop only web and keep the rest running
devctl start web # Start a stopped or crashed tracked service
start and restart re-run the planning phases config.mutate and launch.plan so devctl can recover the current service specification without storing raw service environments in .devctl/state.json. They do not run build.run, prepare.run, or validate.run.
start refuses to duplicate a service whose tracked PID is still alive. Use restart when you intentionally want to replace a running process.
devctl down # Stop all services, remove state
What down does: sends SIGTERM to each service process group, waits up to 3s, then SIGKILL if needed. Removes .devctl/state.json. The .devctl/logs/ directory is preserved.
Profiles select which plugins participate in a devctl run. They are useful when a repository has more than one valid local mode: frontend-only, backend-only, full-stack, or local debugging.
profile:
active: development
profiles:
development:
display_name: Development
plugins: [api, web]
env:
LOG_LEVEL: debug
backend:
display_name: Backend Only
plugins: [api, database]
plugins:
- id: api
path: python3
args: [./plugins/api.py]
- id: web
path: python3
args: [./plugins/web.py]
- id: database
path: python3
args: [./plugins/database.py]
Select a profile explicitly with --profile:
devctl up --profile backend
devctl plan --profile development
devctl plugins list --profile backend
Use .devctl.override.yaml for personal profile choices that should not change the shared project config:
profile:
active: local-debug
profiles:
local-debug:
plugins: [api, database]
env:
LOG_LEVEL: trace
If no profile is selected, devctl loads all top-level plugins. A profile named default is allowed, but it is not implicit; select it with profile.active: default or --profile default.
For the full profile reference, see devctl help profiles-guide.
devctl has a small set of “repo context” flags that apply to most verbs. These are command-local flags, which means they appear after the verb:
devctl status --repo-root /path/to/repo
devctl plan --repo-root /path/to/repo --timeout 10s
devctl build --repo-root /path/to/repo --timeout 10m
| Flag | Purpose |
|---|---|
--repo-root <path> | Override the repo root (default: cwd) |
--config <file> | Override config file (default: .devctl.yaml) |
--profile <name> | Select an active profile (overrides profile.active) |
--timeout <dur> | Per-operation timeout (default: 30s) |
--dry-run | Skip side effects; plugins see ctx.dry_run=true |
--force | Stop existing state before starting |
--skip-validate | Skip validate.run |
--skip-build | Skip build.run |
--skip-prepare | Skip prepare.run |
--step <name> | Select a named build/prepare step for devctl build or devctl prepare |
--strict | Error on service/config collisions instead of "last wins" |
The TUI gives you a persistent, interactive view of your dev environment. Start it with:
devctl tui
| Key | Action |
|---|---|
Tab | Switch views: Dashboard → Events → Pipeline → Plugins |
? | Toggle help overlay |
q | Quit |
| Key | Action |
|---|---|
j/k or ↑/↓ | Select service |
l or Enter | Open service logs |
u | Start (or restart if already running) |
d | Stop (with confirmation) |
r | Restart the whole environment (with confirmation) |
x | Kill selected service (with confirmation) |
| Key | Action |
|---|---|
Tab | Toggle stdout/stderr |
f | Toggle follow mode |
/ | Filter logs |
s | Stop selected service |
r | Restart selected service |
Esc | Back to dashboard |
For the full TUI reference, see devctl help tui-guide.
devctl writes service logs to .devctl/logs/ as timestamped files:
.devctl/logs/
├── api-20060102-150405.stdout.log
├── api-20060102-150405.stderr.log
├── api-20060102-150405.ready
└── api-20060102-150405.exit.json
devctl logs --service api reads the most recent stdout log. devctl logs --service api --follow tails it live. Even after devctl down, the log files remain for debugging.
If you have an existing setup script, converting it to a devctl plugin follows a pattern:
| Your script does... | devctl phase | Plugin returns |
|---|---|---|
| Sets environment variables | config.mutate | A patch with dotted keys |
| Checks if docker is running | validate.run | Errors/warnings |
Runs npm install | prepare.run | Named steps |
Runs go build | build.run | Named steps, artifacts |
| Starts processes | launch.plan | Service definitions |
The key shift: don't start processes in your plugin. Return a service definition and let devctl handle process management.
Here's what a plugin for a "backend + frontend + database" repo might look like:
# launch.plan response
"services": [
{
"name": "postgres",
"command": ["docker", "compose", "up", "postgres"],
"health": {"type": "tcp", "address": "localhost:5432", "timeout_ms": 30000}
},
{
"name": "api",
"cwd": "backend",
"command": ["go", "run", "./cmd/server"],
"env": {"DATABASE_URL": "postgres://localhost:5432/dev"},
"health": {"type": "http", "url": "http://localhost:8080/health"}
},
{
"name": "web",
"cwd": "frontend",
"command": ["npm", "run", "dev"],
"env": {"VITE_API_URL": "http://localhost:8080"}
}
]
For complete plugin authoring guidance, see devctl help plugin-authoring.
devctl writes to .devctl/ in your repo root:
.devctl/
├── state.json # What's running (PIDs, start times, health config)
└── logs/
├── api-20060102-150405.stdout.log # Service stdout
├── api-20060102-150405.stderr.log # Service stderr
├── api-20060102-150405.ready # Ready file (wrapper mode)
└── api-20060102-150405.exit.json # Exit info (wrapper mode)
Each service run gets a fresh set of timestamped log files. state.json is what devctl uses for status, logs, and down. You can safely rm -rf .devctl/ to reset state. Add .devctl/ to .gitignore.
Your .devctl.yaml isn't being found. Check --repo-root:
devctl plugins list --repo-root /path/to/repo
Your plugin is printing non-JSON to stdout. Move all logging to stderr:
# Wrong
print("Starting up...")
# Right
import sys
print("Starting up...", file=sys.stderr)
logsThe service name doesn't match what's in state. Check status first:
devctl status # See actual service names
devctl logs --service <name-from-status>
No state exists—either up hasn't run or down already cleaned up. Run up first:
devctl up
devctl status
A plugin is blocking too long. Debug by reducing scope:
devctl plugins list --timeout 5s # Does handshake work?
devctl plan --timeout 5s # Does planning work?
| Want to... | Read |
|---|---|
| Use profiles and local overrides | devctl help profiles-guide |
| Write your first plugin | devctl help scripting-guide |
| Understand the full protocol | devctl help plugin-authoring |
| Learn all TUI features | devctl help tui-guide |