Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 59 additions & 165 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,210 +6,104 @@
</p>
<!-- markdownlint-enable MD033 -->

A general-purpose developer CLI for scaffolding projects from templates. Define variables, write template files, run hooks — `specs` handles the rest.
Scaffold a project from a template — a git repository or a directory on disk.

![specs use — answering the prompts and running the template's hooks](docs/static/demo/use.gif)
A template is an ordinary tree of files plus a `project.yml` that declares the variables it needs.
`specs` asks for them, renders the tree, and runs the template's hooks. Templates you reuse can be
registered under a name, and `specs` tracks whether each one has moved ahead of what you have.

---
![specs use — answering the prompts and running the template's hooks](docs/static/demo/use.gif)

## Installation
```yaml
# project.yml — the value is the default, the type decides the prompt
projectName: "my-app"
useDocker: false
license:
- MIT
- Apache-2.0

**Homebrew (macOS):**
computed:
packagePath: "github.com/{{ username }}/{{ .projectName }}"

```sh
brew install specsnl/tap/specs
hooks:
post-use:
- git init
```

**Release candidates** — the `@rc` cask tracks every tag, prereleases included:

```sh
brew install specsnl/tap/specs@rc
specs use specsnl/my-template ./my-project # one-off: fetch, render, discard
specs template download specsnl/my-template mine # or register it once…
specs template use mine ./my-project # …and reuse it by name
specs template list # what's registered, and what has updates
```

Both casks install a binary called `specs`, so pick one: `brew uninstall specs` before installing
`specs@rc`, and the other way round.

**From source:**
What it does, in one list:

```sh
go install github.com/specsnl/specs-cli@latest
```

**Download a binary** from the [releases page](https://github.com/specsnl/specs-cli/releases).
- **Renders a whole tree**, file names included, with 200+
[Sprout](https://github.com/go-sprout/sprout) functions available. Files can be conditional,
copied verbatim, or given explicit permissions.
- **Prompts only where something can answer.** With stdin not a terminal, a missing value fails
immediately and names itself instead of hanging a CI job. `--arg`, `--values` and `--use-defaults`
make a run unattended.
- **Keeps templates current.** `template list` reports each one's status — remote templates against
their git remote, saved ones against their source directory — and `template upgrade` applies it.
- **Scripts cleanly.** stdout carries the answer, stderr the narration, so `2>/dev/null` leaves
exactly the data. `--output json` makes it NDJSON.

---

## Quick start

Use a template directly without registering it first:

```sh
specs use specsnl/my-template ./my-project
```

Or register a template and reuse it later:

```sh
specs template download specsnl/my-template my-template
specs template use my-template ./my-project
```

You can also register a local directory as a template:

```sh
specs template save ./my-template my-template
```

### Keeping templates up to date

`specs 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 available` means the source path has moved ahead of what was saved (`source missing`
if 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.

### Scripting

stdout carries the answer, stderr the narration — so discarding stderr leaves exactly the data,
in either format (`--output` / `-o` selects `pretty` or `json`):
## Install

```sh
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:

```console
$ 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-defaults
brew install specsnl/tap/specs
```

Supply 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.
Or `go install github.com/specsnl/specs-cli@latest`, or download a binary for your platform from the
[releases page](https://github.com/specsnl/specs-cli/releases).

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:
Release candidates are a separate, opt-in cask that tracks every tag, prereleases included:

```sh
specs template list -o json 2>/dev/null | jq -r .repository
brew install specsnl/tap/specs@rc
```

For 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`.
Both casks provide a `specs` command and cannot be installed side by side — `brew uninstall specs`
before installing `specs@rc`, and the other way round.

---

## The project file

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](https://github.com/Masterminds/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](https://cli.specs.dev) for the full project-file reference.

---

## Development environment

### Overview

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.
## Getting started

| 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](https://taskfile.dev) and Docker.

### Getting started

Build the images once before running any task:

```sh
task dc:build
```

Then use the standard tasks:

```sh
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 diff
```

List all available tasks:
Point `specs use` at any template and it will ask you the rest:

```sh
task --list
specs use specsnl/my-template ./my-project
```

### How it works
Writing your own starts with a `project.yml`. That file, every command and flag, the template
functions, scripting and CI, and how it is built are all on the same site:
**[cli.specs.dev](https://cli.specs.dev)**.

`task 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.
---

### Without Docker (escape hatch)
## Contributing

If you already have Go 1.26+ installed locally, you can bypass the container entirely:
Every command runs through [Task](https://taskfile.dev), which wraps the Docker Compose services
that pin the Go and tooling versions — so a check runs the same way locally as it does in CI. No
local Go installation needed. Run `task --list` for the full set.

```sh
go build ./...
go test ./...
task dc:build # build the images once
task build # build the binary for the current platform
task test # run the unit tests
```

CI always runs through Docker and the Taskfile. The container is the source of truth for reproducible builds.

### CI and agent execution
With Go 1.26+ installed you can bypass the container entirely — `go build ./...`, `go test ./...` —
but CI always runs through Docker and the Taskfile, so that is the source of truth.

All CI and agent workflows follow the same rule: use `task` commands, never call `docker compose` directly. See [`.github/instructions/executing-commands.md`](.github/instructions/executing-commands.md) for the authoritative execution rules.
Conventions, workflow, and the house rules that reviews are held to: [AGENTS.md](./AGENTS.md), and
the execution rules in
[`.github/instructions/executing-commands.md`](.github/instructions/executing-commands.md).

---

Expand Down
23 changes: 23 additions & 0 deletions docs/content/docs/commands/global-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,26 @@ $ specs version --output json 2>/dev/null
Commands that only act on the filesystem (`template save`, `template download`, `use`, …) narrate
what they did and write nothing to stdout. `--debug` logging is separate again and always goes to
stderr.

`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.

## Pretty tables

A pretty table is capped to the width of your terminal: when it 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:

```sh
COLUMNS=100 specs template list | less -R
```

Cells that stand for a URL are printed as a **label** and hyperlinked to the full value, using
[OSC 8](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda). Terminals that support
hyperlinks (iTerm2, WezTerm, kitty, Ghostty, GNOME Terminal, Windows Terminal, …) make the label
clickable, and it stays 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.
17 changes: 17 additions & 0 deletions docs/content/docs/commands/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ For machine-readable `list` and `update` output, use the global `--output json`
still yields `[]`, with the explanation narrated on stderr — while `validate` answers
`{"valid": true|false}` and `version` answers `{"version": "…"}`.

## The Repository column

`template list` prints a **label** in its `Repository` column, not the raw stored value. A GitHub
URL reads as `specsnl/specs-cli`, since GitHub is the default host; any other host keeps its name
(`gitlab.com/acme/tpl`); and a saved path collapses `$HOME` to `~`. The label is clickable in
terminals that support hyperlinks — see [Pretty tables](global-flags#pretty-tables).

`--output json` carries the value as stored, so scripts read the full URL:

```sh
specs template list -o json 2>/dev/null | jq -r .repository
```

For 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 older versions carry a
`local:` prefix instead; that form is still read, and migrates on the next `template upgrade`.

## Update status

The `Status` column reflects where each template's "source of truth" lives:
Expand Down