devctl User Guide (CLI + TUI + Plugins)

A practical, end-to-end guide to using devctl: from your first .devctl.yaml to the TUI and real plugins.

Sections

Terminology & Glossary
📖 Documentation
Navigation
6 sectionsv0.1
📄 devctl User Guide (CLI + TUI + Plugins) — glaze help user-guide
user-guide

devctl User Guide (CLI + TUI + Plugins)

A practical, end-to-end guide to using devctl: from your first .devctl.yaml to the TUI and real plugins.

Topicdevctldev-environmentclituipluginsscriptingprocess-supervision

devctl User Guide

What devctl is (and why you'd want it)

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:

  • Ordering: run build steps before prepare steps before launching services
  • Process supervision: start services, track PIDs, capture stdout/stderr
  • State management: know what's running, stop it cleanly, resume later
  • Consistency: same commands work across repos, machines, and CI

The core idea: your plugin knows your repo; devctl knows how to run things reliably.

The pipeline: how devctl works

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.

Quick start: 5 minutes to a working dev environment

Let's make this concrete. Say you have a repo with a backend API and a frontend dev server.

1. Create .devctl.yaml at your repo root

plugins:
  - id: myrepo
    path: python3
    args: ["./devctl-plugin.py"]
    priority: 10

2. Create 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

3. Run the core loop

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.

The CLI: your daily workflow

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.

Inspect before running

devctl plugins list   # What plugins are configured?
devctl plan           # What config and services would be created?

Run one pipeline phase

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.

Start and observe

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

Control one service at a time

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.

Stop and cleanup

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 and local overrides

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.

Common flags you'll use (command-local)

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
FlagPurpose
--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-runSkip side effects; plugins see ctx.dry_run=true
--forceStop existing state before starting
--skip-validateSkip validate.run
--skip-buildSkip build.run
--skip-prepareSkip prepare.run
--step <name>Select a named build/prepare step for devctl build or devctl prepare
--strictError on service/config collisions instead of "last wins"

The TUI: an always-on dashboard

The TUI gives you a persistent, interactive view of your dev environment. Start it with:

devctl tui
KeyAction
TabSwitch views: Dashboard → Events → Pipeline → Plugins
?Toggle help overlay
qQuit

Dashboard view (where you'll spend most time)

KeyAction
j/k or ↑/↓Select service
l or EnterOpen service logs
uStart (or restart if already running)
dStop (with confirmation)
rRestart the whole environment (with confirmation)
xKill selected service (with confirmation)

Service view (logs)

KeyAction
TabToggle stdout/stderr
fToggle follow mode
/Filter logs
sStop selected service
rRestart selected service
EscBack to dashboard

For the full TUI reference, see devctl help tui-guide.

Logs on disk

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.

Writing plugins: from shell script to devctl

If you have an existing setup script, converting it to a devctl plugin follows a pattern:

Your script does...devctl phasePlugin returns
Sets environment variablesconfig.mutateA patch with dotted keys
Checks if docker is runningvalidate.runErrors/warnings
Runs npm installprepare.runNamed steps
Runs go buildbuild.runNamed steps, artifacts
Starts processeslaunch.planService definitions

The key shift: don't start processes in your plugin. Return a service definition and let devctl handle process management.

Real-world example: a typical web app

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.

Where devctl stores things

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.

Troubleshooting

"No plugins configured"

Your .devctl.yaml isn't being found. Check --repo-root:

devctl plugins list --repo-root /path/to/repo

Plugin fails with "stdout contamination"

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)

"Unknown service" when using logs

The service name doesn't match what's in state. Check status first:

devctl status   # See actual service names
devctl logs --service <name-from-status>

"Read state: no such file"

No state exists—either up hasn't run or down already cleaned up. Run up first:

devctl up
devctl status

Timeout errors

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?

Next steps

Want to...Read
Use profiles and local overridesdevctl help profiles-guide
Write your first plugindevctl help scripting-guide
Understand the full protocoldevctl help plugin-authoring
Learn all TUI featuresdevctl help tui-guide