A Go CLI for on-prem Azure DevOps Server (e.g. https://devops.example.com/devops). Microsoft's az devops extension officially supports the cloud only; azctl fills the gap for self-hosted servers and is built for automation from the ground up — every command emits a stable JSON envelope, every flag and output schema is discoverable at runtime, and exit codes map cleanly to failure classes.
- On-prem first. No cloud dependency; talks to ADO Server REST
api-version=7.1with automatic6.0fallback. - Scriptable.
--jsonon every command returns a versioned envelope (schemaVersion=1) with embedded, discoverable JSON Schemas. - Self-describing.
azctl describeemits a machine-readable catalog of commands, flags, and output schemas — the contract for agents and scripts. - Safe by default. Mutations prompt for confirmation;
--yesopts out for automation. - Stable exit codes. Distinct codes for auth, not-found, conflict, network, and server failures.
- Secret-aware. PAT resolved from stdin, env, or the OS keychain — never written to config files.
- Zero-config auth flow.
azctl loginbootstraps a profile, PAT, and connectivity check in one step.
- Install
- Quickstart
- Commands
- Agent mode
- Config file
- Environment variables
- Automation / CI
- Global flags
- Exit codes
- Known limitations
- Development
- Contributing
- Security
- License
Build from source (Go 1.26+):
git clone https://github.com/cyteger/azctl && cd azctl
make build
install -m 0755 bin/azctl ~/bin/azctl # or: make install
azctl --versionCross-compiled archives (darwin/linux/windows, amd64/arm64) are produced by make release into dist/ along with SHA256SUMS.
# 1. Configure server + collection (one-time)
mkdir -p ~/.config/azctl
cat > ~/.config/azctl/config.toml <<EOF
server = "https://devops.example.com/devops"
collection = "DefaultCollection"
project = "SampleProject"
EOF
# 2. Authenticate — paste your PAT (stored in OS keychain)
azctl auth login
# 3. Confirm
azctl auth status
# 4. List your active PRs in a repo
azctl pr list --repo mobile-app --status activeRun azctl <group> --help for flags, or azctl describe for the full machine-readable catalog.
| Group | Command | Description |
|---|---|---|
auth |
login |
Authenticate and (optionally) store a PAT in the keychain |
logout |
Remove the stored PAT for the current server | |
status |
Show server, user, and PAT source | |
pr |
list |
List pull requests in a repository |
view |
Show a PR, optionally with comments and commits | |
create |
Create a new pull request | |
complete |
Complete (merge) a pull request | |
abandon |
Abandon a pull request | |
vote |
Vote on a PR as the authenticated user | |
comment |
Post a comment (new thread or reply) | |
reviewer |
Manage PR reviewers | |
diff |
Summarize file changes in a PR | |
repo |
list |
List repositories |
view |
Show a repository | |
branch |
Branch operations (list, view) |
|
pipeline |
list |
List pipelines |
view |
Show a pipeline | |
run |
Queue a new run of a pipeline | |
builds |
List builds with optional filters | |
build |
Show a single build | |
logs |
List or stream build logs | |
profile |
add |
Add or update a profile |
list |
List all configured profiles | |
show |
Show one profile (defaults to the active profile) | |
use |
Set the default profile | |
remove |
Remove a profile | |
describe |
— | Emit the JSON catalog of commands, flags, and schemas |
login |
— | Interactive bootstrap: profile + PAT + verification |
Every command accepts --json and returns a stable envelope. Use azctl describe --json to enumerate commands, flags, and output schemas:
azctl describe --json | jq '.commands[].path'
azctl describe --schema pr-list # raw JSON schema for pr list
azctl pr list --repo mobile-app --json | jq '.data[].pullRequestId'Schemas covered by describe:
auth-statuspr-list,pr-view,pr-create,pr-complete,pr-abandon,pr-vote,pr-comment,pr-reviewer,pr-diffrepo-list,repo-view,repo-branch-list,repo-branch-viewpipeline-list,pipeline-view,pipeline-run,pipeline-builds,pipeline-build,pipeline-logs
Location: $XDG_CONFIG_HOME/azctl/config.toml, defaulting to ~/.config/azctl/config.toml. Override with --config <path>.
# Global defaults
server = "https://devops.example.com/devops"
collection = "DefaultCollection"
project = "SampleProject"
apiVersion = "7.1" # optional; otherwise probed
# Named profiles, selectable via --profile <name>
[profiles.staging]
server = "https://devops-staging.example.com/devops"
project = "SampleProject"
[profiles.legacy]
server = "https://old-tfs.example.com/tfs"
collection = "LegacyCollection"Per-command flags (--server, --collection, --project) always override profile and config values.
| Variable | Purpose |
|---|---|
ADO_PAT |
Personal Access Token. When set, bypasses the OS keychain entirely |
XDG_CONFIG_HOME |
Base directory for azctl/config.toml |
The PAT is resolved in this order: --pat-stdin (read from stdin), then ADO_PAT, then the OS keychain entry written by azctl auth login / azctl login.
The OS keychain ties access to a binary's code signature, so rebuilding (go run, go build, make build) produces a new binary identity and macOS prompts to re-authorize on every invocation. For scripts, CI jobs, or any non-interactive use, export the PAT once and the keychain is never queried:
export ADO_PAT='your-pat-here'
azctl pr list --repo mobile-app # no prompt
azctl pr list --all --json | jq ... # no promptPair with --yes to skip confirmation prompts on mutating commands:
azctl --yes pr complete 1234 --repo mobile-app --merge squashFor an installed binary at a stable path (e.g. ~/bin/azctl via make install), macOS' "Always Allow" sticks across runs — the prompt only returns when the binary is rebuilt or moved.
--server,--collection,--project,--profile--json— emit JSON envelopes on stdout--yes— skip confirmation prompts on destructive commands--debug— log every HTTP request to stderr (PAT redacted)--timeout <sec>— HTTP timeout, default 30--pat-stdin— read PAT from stdin instead of keychain/env--config <path>— alternate config file
| Code | Meaning | When |
|---|---|---|
| 0 | success | command completed |
| 1 | usage / generic | bad flags, missing required values, unclassified |
| 2 | auth failed | 401 / invalid PAT |
| 3 | not found | 404 on PR, repo, branch, pipeline, build, etc. |
| 4 | conflict | 409 — e.g. PR already completed |
| 5 | api error | other 4xx |
| 6 | network | dial/TLS failure, context timeout |
| 7 | server error | 5xx after retries |
An error envelope mirrors these codes via its code field (USAGE, AUTH_FAILED, NOT_FOUND, CONFLICT, API_ERROR, NETWORK, SERVER_ERROR).
pr diffreturns a file-level summary (additions/deletions per path). Inline hunks/blob content are not yet streamed.pipeline logsis fetched once at command time; there is no live tail/poll loop yet.- On headless Linux with no D-Bus / libsecret available, the keychain backend may fail to initialize. Fall back to
ADO_PATor--pat-stdin. - The api-version probe is implemented in the client but is not yet wired into an
azctl auth probesubcommand; servers that reject7.1needapiVersion = "6.0"set in config until that lands.
make vet # go vet ./...
make test # go test ./...
make test-race # go test -race ./...
make lint # golangci-lint run (config in .golangci.yml)
make smoke # tagged integration tests against a real server
make release # cross-compile dist/ + SHA256SUMSDesign notes and rationale live in docs/plans/2026-05-14-azctl-design.md.
Contributions are welcome. See CONTRIBUTING.md for the build, test, and commit conventions, and CODE_OF_CONDUCT.md for community expectations.
azctl handles Personal Access Tokens. If you find a vulnerability, please follow the disclosure process in SECURITY.md rather than opening a public issue.
MIT — see LICENSE.