How to write real devctl plugins in Python or shell: patterns, pitfalls, testing loops, and dynamic commands.
This guide is the "how do I actually ship this?" companion to the protocol reference. It focuses on practical patterns: how to structure a plugin, how to debug it when it breaks, and how to turn repo knowledge into a predictable devctl up/status/logs/down loop.
Prerequisites: This guide assumes you've read the user guide (devctl help user-guide) and understand the basic devctl workflow.
If you're starting from a big startdev.sh, the most important mindset shift is: your plugin computes facts (config, validation, and a plan), and devctl owns the lifecycle (starting processes, tracking state, capturing logs).
devctl plugins are NDJSON-over-stdio programs. That’s deliberately boring: if you can write to stdin/stdout, you can write a plugin in almost any language.
Two rules matter more than everything else:
If you violate rule (2), devctl will fail with an error like:
E_PROTOCOL_STDOUT_CONTAMINATION: ... invalid character ...
A good plugin starts strict and small. It should flush output, return E_UNSUPPORTED for unknown ops, and keep all logs on stderr.
Create plugins/myrepo.py:
#!/usr/bin/env python3
import json
import sys
def emit(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def log(msg):
sys.stderr.write(msg + "\n")
sys.stderr.flush()
emit({
"type": "handshake",
"protocol_version": "v2",
"plugin_name": "myrepo",
"capabilities": {"ops": ["config.mutate", "validate.run", "launch.plan"]},
})
for line in sys.stdin:
line = line.strip()
if not line:
continue
req = json.loads(line)
rid = req.get("request_id", "")
op = req.get("op", "")
ctx = req.get("ctx", {}) or {}
inp = req.get("input", {}) or {}
try:
if op == "config.mutate":
emit({
"type": "response",
"request_id": rid,
"ok": True,
"output": {"config_patch": {"set": {"services.api.port": 8080}, "unset": []}},
})
elif op == "validate.run":
# Use ctx.get("repo_root") to locate files.
emit({
"type": "response",
"request_id": rid,
"ok": True,
"output": {"valid": True, "errors": [], "warnings": []},
})
elif op == "launch.plan":
dry_run = bool(ctx.get("dry_run", False))
if dry_run:
log("dry-run: computing plan without side effects")
emit({
"type": "response",
"request_id": rid,
"ok": True,
"output": {
"services": [
{"name": "api", "command": ["bash", "-lc", "python3 -m http.server 8080"]},
]
},
})
else:
emit({
"type": "response",
"request_id": rid,
"ok": False,
"error": {"code": "E_UNSUPPORTED", "message": f"unsupported op: {op}"},
})
except Exception as e:
emit({
"type": "response",
"request_id": rid,
"ok": False,
"error": {"code": "E_PLUGIN", "message": str(e)},
})
Every request includes a ctx object. This is how devctl passes “where am I?” and “how much time do you have?” information to your plugin.
In practice:
ctx.repo_root: the repo root chosen by the user (via --repo-root, or CWD by default).ctx.cwd: the current working directory of the devctl process.ctx.dry_run: best-effort “no side effects”.ctx.deadline_ms: how long until devctl will cancel this operation.The simplest safe behavior is:
ctx.repo_rootctx.dry_run is truectx.deadline_msdevctl’s pipeline is intentionally consistent across repos. You can implement any subset, and devctl will only call what you declare in the handshake.
Common ops:
config.mutate: return a config patch (dotted keys) that devctl applies.validate.run: return errors/warnings that make failures actionable.build.run: run named build steps (return artifacts and step results).prepare.run: run named prepare steps (same “step result” pattern).launch.plan: return the list of services devctl should supervise.If you want the full schema for each op’s input/output, use the protocol guide:
devctl help plugin-authoring
devctl <cmd>Dynamic commands are for “repo helpers” that you want to standardize and ship alongside the rest of the dev environment knowledge (for example, db-reset, seed-data, or gen-certs).
To expose a dynamic command:
command.run to capabilities.opscapabilities.commandscommand.run to execute the command and return an exit_codeExample handshake snippet:
{
"type": "handshake",
"protocol_version": "v2",
"plugin_name": "myrepo",
"capabilities": {
"ops": ["command.run"],
"commands": [
{ "name": "db-reset", "help": "Reset the dev database", "args_spec": [] }
]
}
}
Example command.run response:
{ "type": "response", "request_id": "x", "ok": true, "output": { "exit_code": 0 } }
Practical guidance:
argv should behave like a normal CLI argv: treat it as untrusted user input.ctx.dry_run to implement “no side effects” modes when reasonable.config object included in the command.run input (it’s the merged config after config.mutate).Shell plugins are totally viable, but they require discipline because stdout is the protocol. The safest pattern is:
jq (recommended; assume it’s available on developer machines)Minimal sketch:
#!/usr/bin/env bash
set -euo pipefail
emit() { jq -c . <<<""; }
log() { printf '%s\n' "$*" >&2; }
emit '{"type":"handshake","protocol_version":"v2","plugin_name":"bash","capabilities":{"ops":["launch.plan"]}}'
while IFS= read -r line; do
[ -z "$line" ] && continue
rid="$(jq -r '.request_id // ""' <<<"$line")"
op="$(jq -r '.op // ""' <<<"$line")"
if [ "$op" = "launch.plan" ]; then
emit "$(jq -nc --arg rid "$rid" '{
type:"response", request_id:$rid, ok:true,
output:{services:[{name:"api", command:["bash","-lc","echo api && sleep 3600"]}]}
}')"
else
emit "$(jq -nc --arg rid "$rid" --arg op "$op" '{
type:"response", request_id:$rid, ok:false,
error:{code:"E_UNSUPPORTED", message:("unsupported op: "+$op)}
}')"
fi
done
Good plugin testing is less about unit tests and more about tight feedback loops: validate handshake, validate pipeline behavior, validate timeouts and failure reporting.
A practical progression:
devctl plugins listdevctl plandevctl updevctl statusdevctl logs --service <name> --followdevctl downWhen debugging protocol issues, run with a higher log level:
devctl --log-level debug plugins list
If your repository uses profiles, test each mode explicitly. Profiles select plugins by ID, so this catches misspellings and missing local overrides before someone tries to run the full environment.
devctl profiles list
devctl profiles active
devctl plugins list --profile backend
devctl plan --profile backend
devctl up --profile backend --dry-run
For local experiments, put personal profile choices in .devctl.override.yaml:
profile:
active: local-debug
profiles:
local-debug:
plugins: [api]
env:
LOG_LEVEL: trace
For the complete profile model, see devctl help profiles-guide.
The failure modes are predictable. If you build guardrails into your plugin from day one, you’ll avoid most of them.
ctx.deadline_ms to bound subprocess waits.validate.run output.If you want the complete protocol details (schemas, more examples, and deeper guidance on merging/strictness), use the authoring guide:
devctl help plugin-authoring
If you want to understand devctl as a user first (before writing plugins), start with:
devctl help user-guide