Building an Internal Developer CLI for Common Workflows
The same five chores are copied into every repository's Makefile or justfile with small differences: fetch development secrets, log in to the container registry, create a new service from the template, check toolchain versions, open the service's dashboards. Some copies were fixed when the registry moved and some were not, so make registry-login fails with Error response from daemon: Get "https://old-registry.acme.dev/v2/": dial tcp: lookup old-registry.acme.dev: no such host in half the repositories. An internal developer CLI puts those shared operations in one versioned tool, while each repository keeps its own tasks. This page builds one, as part of task runners and an internal developer CLI.
The goal is a small tool — often a few hundred lines — not a platform. It should do a handful of things well and delegate everything repository-specific.
Diagnostic
Find the operations duplicated across repositories and how far the copies have diverged:
#!/usr/bin/env bash
set -euo pipefail
root="${1:-$HOME/src}"
for f in "$root"/*/{Makefile,justfile,Taskfile.yml}; do
[ -f "$f" ] || continue
grep -hoE '^(registry-login|secrets-pull|new-service|doctor|dashboards)[: ]' "$f" 2>/dev/null | tr -d ': ' | sed "s|^|$(basename "$(dirname "$f")") |"
done | sort -k2 | awk '{c[$2]++} END {for (t in c) print c[t], t}' | sort -rn
grep -lr 'old-registry.acme.dev' "$root"/*/{Makefile,justfile,Taskfile.yml} 2>/dev/null | wc -l | xargs echo "files still using old registry:"
Expected bad output across 23 repositories:
21 doctor
19 registry-login
14 secrets-pull
6 new-service
4 dashboards
files still using old registry: 11
The same task exists in up to 21 places, and eleven copies still point at a registry that no longer exists.
Root cause
Task runners are scoped to one repository by design, which is right for repository-specific work and wrong for organisation-wide operations. Without a place to put shared operations, each team copies the nearest working example and adapts it, and from that moment the copies evolve independently. Infrastructure changes — a new registry, a new secret manager path, a renamed SSO profile — then require a coordinated change across every repository, which never quite completes. The fix is structural: move shared operations into one tool with one owner and one release process, and have repositories call it rather than reimplement it.
The opposite failure is also common: an internal CLI that tries to replace repository task runners entirely, forcing every project's build and test logic into a central codebase. That makes the platform team a bottleneck for every repository change. Keeping the CLI to shared operations plus delegation avoids it.
Resolution
- Start with a small, typed codebase. Python with Typer, Go with Cobra, or Node with Commander all work; pick the language the platform team maintains best. A Python skeleton:
import os
import shutil
import subprocess
import typer
app = typer.Typer(help="acme: shared developer workflows")
REGISTRY = os.environ.get("ACME_REGISTRY", "registry.acme.dev")
def run(*cmd: str) -> None:
subprocess.run(cmd, check=True)
@app.command()
def registry_login() -> None:
"""Log in to the container registry with your SSO session."""
token = subprocess.run(["acme-sso", "token", "--audience", REGISTRY], check=True, capture_output=True, text=True).stdout.strip()
subprocess.run(["docker", "login", REGISTRY, "-u", "oauth2", "--password-stdin"], input=token, text=True, check=True)
@app.command()
def doctor() -> None:
"""Check shared tools, then run the repository's own doctor task."""
for tool in ("docker", "git", "just"):
typer.echo(f"{tool:8} {'ok' if shutil.which(tool) else 'MISSING'}")
for runner in (("just", "doctor"), ("task", "doctor"), ("make", "doctor")):
if shutil.which(runner[0]) and any(os.path.exists(f) for f in ("justfile", "Taskfile.yml", "Makefile")):
run(*runner)
break
if __name__ == "__main__":
app()
Delegate repository work instead of reimplementing it.
acme doctorruns shared checks and then the repository's owndoctortask, so each repository extends the check rather than copying it.Distribute it like any tool, with a pinned version. Publish to an internal package index or a Homebrew tap, and pin the version in each repository's toolchain file so upgrades are deliberate:
#!/usr/bin/env bash
set -euo pipefail
pipx install --index-url https://pypi.acme.dev/simple "acme-cli==1.8.2"
acme --help
mise use "pipx:[email protected]"
- Replace the copies. In each repository, change the duplicated tasks to one-line calls, then delete them once documentation points at
acmedirectly:
# Log in to the container registry (shared implementation)
registry-login:
acme registry-login
- Measure what fails. Record anonymous command names, durations and exit codes to an internal endpoint (with an opt-out), so the platform team sees which commands fail most and where onboarding breaks:
import json
import time
import urllib.request
def report(command: str, started: float, code: int) -> None:
if os.environ.get("ACME_TELEMETRY") == "off":
return
body = json.dumps({"cmd": command, "ms": int((time.time() - started) * 1000), "exit": code}).encode()
req = urllib.request.Request("https://telemetry.acme.dev/cli", data=body, headers={"content-type": "application/json"})
try:
urllib.request.urlopen(req, timeout=2)
except OSError:
pass
Expected output
$ acme --help
Usage: acme [OPTIONS] COMMAND [ARGS]...
acme: shared developer workflows
Commands:
doctor Check shared tools, then run the repository's own doctor task.
registry-login Log in to the container registry with your SSO session.
secrets-pull Fetch development secrets for this repository.
new-service Create a new service from the golden template.
$ acme registry-login
Login Succeeded
The registry login works identically in every repository, and fixing it again in future means one release rather than twenty-three pull requests.
Telemetry gives the platform team a feedback loop that task files never had. A dashboard of the last week's commands, sorted by failure rate, usually reveals one or two operations that fail for a specific group — a region with a different SSO endpoint, a laptop model missing a dependency — long before anyone files a ticket. Treat that list as the CLI's backlog.
Prevention
Give the CLI an owner and a changelog. Shared tools without an owner become the next set of stale copies.
Test commands in CI against a clean container, including
acme doctorandacme new-serviceend to end, on every release.Warn on old versions. At startup, compare the running version with the version pinned in the current repository and print the exact upgrade command when they differ.
Platform caveats
Windows (native):
pipxand Python work natively, but commands that shell out to Bash do not. Keep the CLI's own logic in Python or Go rather than embedded shell, and test on a Windows runner.
macOS: Homebrew taps are the most familiar distribution for macOS developers;
brew install acme/tools/acmeplus a pinned formula version works well alongside mise or Devbox.
Apple Silicon (ARM64): Go and Rust CLIs need separate
darwin/arm64builds; Python CLIs are architecture-independent unless they bundle native extensions.
Rollback
Repositories that still have their forwarding tasks can revert them to the previous inline implementation from git history; the CLI itself can be uninstalled without affecting anything else:
#!/usr/bin/env bash
set -euo pipefail
git revert --no-edit "$(git log -1 --format=%H -S 'acme registry-login' -- justfile)"
pipx uninstall acme-cli
Frequently Asked Questions
Should the internal CLI replace each repository's task runner?
No. Keep repository-specific build, test and run tasks in each repository. The CLI should own only operations that are the same everywhere and delegate the rest, so teams are not blocked on the platform team for their own tasks.
Which language should we build it in?
The one the owning team maintains best. Python with Typer is quick to write and easy to distribute with pipx; Go produces single static binaries that suit Windows and minimal environments.
Is telemetry acceptable for an internal tool?
Usually, if it is limited to command names, durations and exit codes, documented, and has an opt-out. Never record arguments, paths or environment variables, which may contain secrets.
How do we keep developers on current versions?
Pin the version in each repository's toolchain file and bump it deliberately. Add a startup check that prints the upgrade command when the installed version differs from the pinned one.