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:
kind | also needs | what it means |
|---|---|---|
manual | command, stored_at | A person runs command; the tool leaves its session at stored_at. |
secret | env or path | A value handed over once, delivered there. |
file | path | A 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]
entrypointis 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.commandslists what it accepts: names, or["*"]for anything. Mixing"*"with named commands is refused.kindis a contract. The only one so far isdns, which must acceptzones,list,upsert,deleteandhelp.
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
| Rule | The message |
|---|---|
format missing | every package needs `format`, and this CLI reads 1 |
format unreadable | format 99, and this CLI reads 1. Upgrade with brew upgrade devmachine |
name missing | every package needs a `name` |
name malformed | name "X": use lower case letters, digits, dashes and underscores |
name is not the directory | name is "X" but the directory is "Y": a package is found by its directory |
scope unknown | scope must be "machine" or "workspace", got "X" |
summary missing | every package needs a one-line `summary` |
requires.cli unreadable | requires.cli "X": write it as ">= 0.2.0", "> 0.2.0" or "= 0.2.0" |
extends key has no dot | an extension point is written <package>.<place> |
extends file is not there | extends "X" points at Y, which is not in the package |
provides path is relative | an extension point is an absolute path on the machine |
no tasks/main.yml | a package is an Ansible role, so it needs tasks/main.yml |
the apt module is used | the apt module is not allowed; use `package` so this works beyond Debian |
| a credential says too little | one line per credential, naming it and what it is missing |
a machine login is not shareable | credential "X" recommends `scope: machine`, so it needs `shareable: true` |
a secret or a file is shareable | credential "X" is a secret, so it cannot be `shareable` |
| an entrypoint is not executable | entrypoint "X" is not executable: chmod +x it |
| an entrypoint is not Python 3 | an entrypoint is Python 3 and starts with #!/usr/bin/env python3 |
kind or commands with no entrypoint | kind` 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.