devmachine

CLI Reference

The package format

A package is an Ansible role plus one extra file, package.yml. Nothing is translated on the way to the machine: what you write is what runs, so a failure points at the exact line you wrote.

This page and the validator agree. When they disagree, trust the validator — ask it with devmachine packages schema --json.

The layout

<name>/
  package.yml
  tasks/main.yml
  defaults/main.yml
  handlers/, files/, templates/, vars/   (optional, as in any role)

The directory name is the package name. devmachine packages new <name> writes a starting skeleton that already passes devmachine packages validate.

The fields

format (required)

The shape of the file. This CLI reads format 1. A validator that meets a format it cannot read says so and stops, instead of misreading fields it does not understand.

name (required)

Lower case letters, digits, dashes and underscores, matching the directory name — a package is found by its directory.

scope (required)

machine or workspace, nothing else. A fact about the software, not a preference: Docker installs once and serves everyone, so it is machine; a tool with a login per person is workspace, running once per workspace that asks for it.

summary (required)

One line saying what the package installs. packages list prints it.

requires.cli

Which version of the CLI can run this package: ">= 0.2.0", "> 0.2.0" or "= 0.2.0". Different from format: format says whether the CLI can read the file, requires.cli says whether it can run what it describes — the binary and the packages release on their own schedules, so the two can disagree. A CLI built from source calls itself dev, and every constraint allows it.

needs

Packages that must run before this one:

needs: [base, firewall]

The only thing that decides run order — the order packages are listed in your own configuration means nothing. A circular dependency is refused, naming the packages involved.

provides

Places other packages may write into, as a name and an absolute path on the machine:

provides:
  sites.d: /etc/caddy/sites.d

extends

Adds a file to a place another package opened:

extends:
  caddy.sites.d: files/sharing.caddy

The key is <package>.<place>, and the value is a path inside this package. It can only add a file there, never change what is already there or reach anywhere else. Extending a place nobody provides is refused while devmachine plans, before anything runs. The file lands as <extending package>-<basename>, so two packages adding a same-named file never collide.

variables

Values the package reads, each with a summary and a default:

variables:
  port:
    summary: The port the container listens on.
    default: 53842

The package’s Ansible role reads devmachine_<package>_<name>, so a package called tunnel that declares port uses devmachine_tunnel_port — the package name is part of it since Ansible has one shared namespace, and two packages might both want a port.

That is the same name used for a target’s settings: a setting is just a default someone overrode, and the package does not know or care where the value came from.

A dash works in a package name but never in a variable name, so - becomes _, as does the . a package name may contain. Names that collide this way are refused, rather than one silently winning.

credentials

What the package’s tool needs to authenticate, and how to get it — how belongs here because the package is the only thing that knows. Each entry has a name, a kind, and a scope (machine or workspace), plus what its kind needs:

kindalso needswhat it means
manualcommand, stored_atA person runs command; the tool leaves its session at stored_at.
secretenv or pathA value handed over once, delivered there.
filepathA file placed on the machine at that path.
credentials:
  - name: claude
    kind: manual
    scope: workspace
    command: claude /login
    stored_at: ~/.claude/.credentials.json

stored_at is a claim, not a guarantee — it is what lets doctor check whether the login worked.

A manual credential can also say shareable: true: a copy of stored_at works on another account, the way one GitHub login can serve every workspace. This is a fact about the tool, found by testing it — a session file copies fine, a token tied to one device or browser does not. Leave it out and it defaults to false. A credential recommending scope: machine must say shareable: true, since scope: machine means “one login, copied into every workspace” — recommending both without it asks for something the package itself says cannot work.

Only manual can be shareable. A secret or file is delivered fresh to each place that needs it, never copied, so shareable on either is refused. scope here is only a recommendation — whether a shareable credential is actually shared is the operator’s own choice, per workspace — see Configuration.

requires_files

Files that must already be on the machine before the package runs.

skills.path

A package can ship complete Agent Skill directories:

skills:
  path: skills

The path is relative to the package root, and cannot contain .., be absolute, or escape through a symlink. Each direct child must be a lower-case, dash-separated skill directory with a SKILL.md, whose frontmatter name matches the directory and description is not empty.

This does not replace the Ansible role — a package with skills still has tasks/main.yml, and may also have defaults, handlers, files and templates.

kind, entrypoint, commands

A package can ship an executable the CLI calls on the machine:

kind: dns
entrypoint: bin/provider
commands: [zones, list, upsert, delete, help]
  • entrypoint is a path inside the package. It must exist, be executable, and start with #!/usr/bin/env python3 — Ansible already needs Python on any machine this CLI sets up.
  • commands lists what it accepts: names, or ["*"] for anything. Mixing "*" with named commands is refused.
  • kind is a contract. The only one so far is dns, which must accept zones, list, upsert, delete and help.

Nothing calls an entrypoint in this version yet — it is validated now so the first real use cannot invent its own shape later.

The rules, and what each one says

RuleThe message
format missingevery package needs `format`, and this CLI reads 1
format unreadableformat 99, and this CLI reads 1. Upgrade with brew upgrade devmachine
name missingevery package needs a `name`
name malformedname "X": use lower case letters, digits, dashes and underscores
name is not the directoryname is "X" but the directory is "Y": a package is found by its directory
scope unknownscope must be "machine" or "workspace", got "X"
summary missingevery package needs a one-line `summary`
requires.cli unreadablerequires.cli "X": write it as ">= 0.2.0", "> 0.2.0" or "= 0.2.0"
extends key has no dotan extension point is written <package>.<place>
extends file is not thereextends "X" points at Y, which is not in the package
provides path is relativean extension point is an absolute path on the machine
no tasks/main.ymla package is an Ansible role, so it needs tasks/main.yml
the apt module is usedthe apt module is not allowed; use `package` so this works beyond Debian
a credential says too littleone line per credential, naming it and what it is missing
a machine login is not shareablecredential "X" recommends `scope: machine`, so it needs `shareable: true`
a secret or a file is shareablecredential "X" is a secret, so it cannot be `shareable`
an entrypoint is not executableentrypoint "X" is not executable: chmod +x it
an entrypoint is not Python 3an entrypoint is Python 3 and starts with #!/usr/bin/env python3
kind or commands with no entrypointkind` and `commands` describe an `entrypoint`, and this package declares none

devmachine packages validate reports every problem at once, not just the first.

Why apt is refused

A package that calls apt only works on Debian. package: picks the machine’s own package manager instead, turning a rule people have to remember into an error the validator catches.