Documentation: cli.specs.dev
A general-purpose developer CLI for scaffolding projects from templates. Define variables, write template files, run hooks — specs handles the rest.
Homebrew (macOS):
brew install specsnl/tap/specsRelease candidates — the @rc cask tracks every tag, prereleases included:
brew install specsnl/tap/specs@rcBoth casks install a binary called specs, so pick one: brew uninstall specs before installing
specs@rc, and the other way round.
From source:
go install github.com/specsnl/specs-cli@latestDownload a binary from the releases page.
Use a template directly without registering it first:
specs use specsnl/my-template ./my-projectOr register a template and reuse it later:
specs template download specsnl/my-template my-template
specs template use my-template ./my-projectYou can also register a local directory as a template:
specs template save ./my-template my-templatespecs template list shows an update Status for each registered template:
- Remote templates (from
download) are checked against their git remote. - Local templates (from
save) are checked against their source directory on disk —update availablemeans the source path has moved ahead of what was saved (source missingif that path is gone). Uncommitted changes on the saved commit (a "dirty" working tree) are not treated as an update, so a dirty source is not reported as perpetually out of date.
specs template upgrade [name] applies available updates: remote templates are re-cloned, local
templates are re-copied from their source path. Cached statuses refresh automatically once older
than 24 hours or when written by a different specs version.
stdout carries the answer, stderr the narration — so discarding stderr leaves exactly the data,
in either format (--output / -o selects pretty or json):
specs template list -o json 2>/dev/null | jq -r .name
specs version -o json 2>/dev/null # {"version":"v0.0.13"}
specs template validate ./my-template -o json 2>/dev/null # {"valid":true}pretty and json are the only accepted values; anything else exits non-zero naming the flag,
rather than being silently treated as pretty.
json is NDJSON throughout: one object per line, a table row included, so a killed or failed
run still leaves every completed row readable. The keys are snake_case and independent of the
column headings the pretty table prints, and each value keeps its own type — a count is a number,
a timestamp is a timestamp, and a field with no value is absent rather than the - the table
shows.
Prompts only happen where something can answer them. With stdin not a terminal — a CI job, or
< /dev/null — a template still missing a value fails immediately and names it, instead of
blocking until the runner times out:
$ specs use specsnl/go-service ./out < /dev/null
error cannot prompt for values: stdin is not a terminal
missing values for: project_name
provide them with --arg Key=Value, with --values, or take the schema defaults with --use-defaultsSupply every variable and the command runs unattended. --non-interactive forces the same
refusal at a terminal, so you can check a command before CI does. A remote template's hook
confirmation is taken as "no" rather than an error: the template applies, the hooks are skipped,
and --yes opts in.
Commands that only change the filesystem (use, template save, template download, …) narrate
what they did on stderr and write nothing to stdout.
Pretty tables are capped to the width of your terminal: when a table does not fit, its widest
columns shrink and their cells wrap onto extra lines rather than the table breaking apart. Redirect
stdout to a file or a pipe and the full natural width is written instead; set COLUMNS to pin a
width there (COLUMNS=100 specs template list | less -R).
The Repository column shows a label, not the raw value: a GitHub URL reads as
specsnl/specs-cli since GitHub is the default host, another host keeps its name
(gitlab.com/acme/tpl), and a saved path collapses $HOME to ~. The label is clickable in
terminals that support hyperlinks (iTerm2, WezTerm, kitty, Ghostty, GNOME Terminal, Windows
Terminal, …) and opens the full URL, staying one link even when the column wraps it over several
lines. Terminals without support simply show the label, and a redirect to a file or a pipe writes
plain text.
--output json always carries the value as stored, never the label — so scripts read the full URL:
specs template list -o json 2>/dev/null | jq -r .repositoryFor a template registered with template save, that value is the source path, with your home
directory written as ~ (e.g. ~/code/my-template). Templates saved by versions before this
change carry a local: prefix instead; that form is still read, and migrates on the next
template upgrade.
A template's project.yml declares its variables, defaults, computed values, and hooks. A few __-prefixed keys are reserved by specs and never exposed as template variables:
| Key | Purpose |
|---|---|
__delimiters |
Override the default {{ }} template delimiters with a custom pair (e.g. [[ ]]). |
__specs__version |
Declare a semver constraint on the specs CLI version required to use the template, e.g. __specs__version: ^0.1.0. specs use and specs template use refuse to run the template unless the running binary satisfies the constraint (development builds are exempt; specs template save skips the check). |
See the documentation for the full project-file reference.
The repo ships a Dockerfile and a compose.yml that together define a self-contained build and test environment. Contributors don't need a local Go installation — all builds and tests run inside a Docker container that pins the exact Go version and tooling.
| File | Role |
|---|---|
Dockerfile |
Defines the build image — Go 1.26 + tooling, used by task build and task test |
compose.yml |
Wires the Dockerfile stages into named services consumed by the Taskfile |
Taskfile.dist.yml |
Orchestrates all developer workflows; wraps Docker Compose so you never call it directly |
Requirements: Task and Docker.
Build the images once before running any task:
task dc:buildThen use the standard tasks:
task build # Build the binary for the current platform
task test # Run unit tests
task test:update # Rewrite the output golden files, then review the diffList all available tasks:
task --listtask dc:build builds all Docker Compose services in the build profile. The key service is go-builder, built from the builder-download stage of the Dockerfile. It mounts the repository root and two Docker volumes — one for the Go module cache and one for the build cache — so subsequent runs are fast.
task test and task build spin up a one-off go-builder container (docker compose run --rm), run the Go command inside it, then discard the container. The service doesn't need to be started in advance — it is ephemeral by design.
task build also invokes docker buildx bake using the go-binary service to produce a statically linked binary and copy it out of the image into the project root.
If you already have Go 1.26+ installed locally, you can bypass the container entirely:
go build ./...
go test ./...CI always runs through Docker and the Taskfile. The container is the source of truth for reproducible builds.
All CI and agent workflows follow the same rule: use task commands, never call docker compose directly. See .github/instructions/executing-commands.md for the authoritative execution rules.
MIT — see LICENSE.