Skip to content

Overview

Goals

  • Fix all known correctness bugs from the original boilr v1
  • Replace the prompt layer with huh (Charm)
  • Replace output styling with lipgloss
  • Use configurable template delimiters (default {{ }})
  • Replace project.json with project.yml
  • Add hooks, --values/--arg, XDG config, .specsverbatim, conditional files
  • Add computed values (post-prompt derived context keys)
  • Add specs use one-step command
  • Maintain backward compatibility with v1 templates

Package Structure

specs-cli/
├── main.go                       # main() — XDG init, cmd.Execute()
├── go.mod
├── docs/
│   ├── content/                  # the documentation site (Hugo)
│   ├── demo/                     # VHS tapes — one per documentation GIF
│   └── static/demo/              # the recorded GIFs, served at /demo/<name>.gif
└── internal/
    ├── specs/                    # global config & constants
    │   ├── configuration.go      # XDG paths, file name constants
    │   └── errors.go             # sentinel errors
    ├── cmd/                      # one file per Cobra command
    │   ├── root.go
    │   ├── app.go                # App struct, shared dependencies
    │   ├── use.go                # specs use <source> <target-dir>
    │   ├── template.go           # specs template subcommand group
    │   ├── template_download.go
    │   ├── template_save.go
    │   ├── template_use.go       # --values, --arg, --no-hooks
    │   ├── template_list.go
    │   ├── template_validate.go
    │   ├── template_rename.go
    │   ├── template_delete.go
    │   ├── template_update.go
    │   ├── template_upgrade.go
    │   └── version.go
    ├── registry/                 # on-disk template store operations
    │   └── registry.go           # Entry, UpgradeResult, Load(), Upgrade()
    ├── template/                 # template loading & execution engine
    │   ├── template.go           # Get(), Execute(), configurable delimiters
    │   ├── context.go            # project.yml parsing, LoadProjectFile(), referenced defaults, computed values
    │   ├── verbatim.go           # .specsverbatim loading & matching
    │   ├── functions.go          # FuncMap (custom + Sprout)
    │   ├── specsregistry.go      # custom Sprout registry (hostname, password, etc.)
    │   ├── analysis.go           # AST-based conditional variable analysis
    │   ├── cond.go               # Cond interface and implementations
    │   ├── metadata.go           # Metadata struct, JSONTime, LoadMetadata(), SaveMetadata()
    │   ├── status.go             # TemplateStatus — status caching (stale + version + local/remote)
    │   └── validate.go           # template validation helpers
    ├── hooks/                    # hook execution
    │   └── hooks.go              # Load(), Run(), context → env vars
    ├── host/                     # source URL parsing
    │   └── source.go             # owner/repo, HTTPS URL, SSH URL, local path
    └── util/
        ├── exit/                 # exit codes
        ├── git/                  # go-git wrapper, SSH auth, remote check
        ├── osutil/               # file operations (CopyDir, etc.)
        ├── output/               # Writer implementations, table renderer, slog setup, IsTTY
        ├── validate/             # Name() validator and argument validators
        └── values/               # --values file (JSON/YAML) + --arg flag parsing

docs/demo/ holds the VHS tapes behind the documentation GIFs — see Demo Recordings for how re-recording works and why the GIFs are snapshots rather than tests.


CLI Command Tree

specs [--version|-v]
      [--debug]                             enable debug output
      [--safe-mode]                         disable env/filesystem template functions + hooks
      [--no-env-prefix]                     disable SPECS_ prefix on hook env vars
      [--non-interactive]                   never prompt; fail naming the missing values
      [--output/-o pretty|json]             output format (default: pretty)
├── use <source> <target-dir>               one-step, no registry entry
│     [--values file.yaml|json]
│     [--arg Key=Value]...
│     [--use-defaults]
│     [--no-hooks]
├── template
│   ├── download [--force] <source> <name>
│   ├── save     [--force] <path> <name>
│   ├── use      <name> <target-dir>
│   │     [--values file.yaml|json]
│   │     [--arg Key=Value]...
│   │     [--use-defaults]
│   │     [--no-hooks]
│   ├── list|ls
│   ├── update   [name]                     refresh status cache (all if no name)
│   ├── upgrade  [name]                     re-clone remote / re-copy local to latest (all if no name)
│   ├── delete|remove|rm|del <name>...
│   ├── validate <path>
│   └── rename|mv <old> <new>
├── init    [--force]
└── version [--dont-prettify]

specs use <source> <target-dir>

One-step command — downloads, executes, discards. No registry entry created.

FormatExample
GitHub shorthandIlyes512/boilr-laravel-project
GitHub with branchIlyes512/boilr-laravel-project:main
Full HTTPS URLhttps://github.com/Ilyes512/boilr-laravel-project
SCP-style SSHgit@github.com:Ilyes512/boilr-laravel-project
SSH URLssh://git@github.com/Ilyes512/boilr-laravel-project
Local path (explicit)file:./my-template
Local path (implicit)./my-template or /absolute/path

Source validation rules (enforced at parse time, before any network call):

  • GitHub shorthand — owner and repo must match GitHub’s naming rules: alphanumeric, dots, hyphens, underscores; must start and end with an alphanumeric character; max 39 chars for owner, 100 for repo; exactly one / separator. Branch (if given) must be non-empty, contain no whitespace, and must not include ...
  • HTTPS / SSH URLs — must have a non-empty host and a path with at least two non-empty segments (/owner/repo).

SSH clones are authenticated automatically via SSH agent or standard key files (~/.ssh/id_ed25519, id_rsa, id_ecdsa). Host key verification uses ~/.ssh/known_hosts.


Template Structure

<template-root>/
├── project.yml              # variable schema, defaults, optional inline hooks
├── .specsverbatim            # verbatim-copy glob patterns (opt-out from rendering)
├── __metadata.json           # written by specs on download/save
├── __status.json             # update-status cache (remote or local source; written by specs template list/update)
├── hooks/                    # script-based hooks (mutually exclusive with hooks: in project.yml)
│   ├── pre-use.sh
│   └── post-use.sh
└── template/
    ├── {{ if .UseSonarQube }}sonar-project.properties{{ end }}
    ├── composer.json
    ├── composer.lock         # matched by .specsverbatim → verbatim copy
    └── .github/
        └── workflows/
            └── ci.yml        # configure __delimiters: "[[ ]]" to pass ${{ }} through untouched

Configuration

$XDG_CONFIG_HOME/specs/          (default: ~/.config/specs/)
└── templates/
    └── <name>/
        ├── project.yml
        ├── .specsverbatim
        ├── __metadata.json
        ├── __status.json
        ├── hooks/
        └── template/

Data Flow — specs template use

    flowchart TD
    A[validate args & flags] --> B[check registry initialised + name exists]
    B --> C["template.Get(registry/name)\nparses project.yml, resolves referenced defaults,\nloads .specsverbatim, analyses AST for conditionals"]
    C --> D["hooks.Load(templateRoot, rawConfig)"]
    D --> E[merge --values + --arg overrides into context]
    E --> F["huh form: iterative prompting\n(unconditional vars first, then conditional by dependency layer)"]
    F --> G["ApplyComputed — resolve computed: values post-prompt"]
    G --> H["hooks.Run(pre-use) if defined"]
    H --> I["Execute(tmpDir) — render template/ into temp dir"]
    I --> J["osutil.CopyDir(tmpDir → targetDir)"]
    J --> K["hooks.Run(post-use, env=SPECS_-prefixed context)"]
    K --> L[output success]
  

Data Flow — specs use <source> <target>

    flowchart TD
    A[parse source format] --> B{source type?}
    B -->|github shorthand / URL| C["git.Clone(tmpDir, url)"]
    B -->|local path| D["copy local path to tmpDir"]
    C & D --> E["template.Get(tmpDir)"]
    E --> F[same flow as specs template use]
    F --> G[discard tmpDir — no registry entry]
  

Template Execute — File Walk

    flowchart TD
    A["filepath.WalkDir(template/)"] --> B{ignoredFile?}
    B -->|yes| Skip1[skip]
    B -->|no| C[render path as template]
    C --> D{render error or result empty?}
    D -->|yes| Skip2[skip]
    D -->|no| E{any path segment empty?}
    E -->|yes| Skip3[skip dir tree]
    E -->|no| F{is directory?}
    F -->|yes| Mkdir[os.MkdirAll]
    F -->|no| G{matches .specsverbatim?}
    G -->|yes| Copy1[copy verbatim]
    G -->|no| H{isBinary?}
    H -->|yes| Copy2[copy verbatim]
    H -->|no| I[render content as template]
    I --> J{whitespace-only result?}
    J -->|yes| Skip4[skip — do not create file]
    J -->|no| Write[write to dest]
  

Context Resolution

    flowchart TD
    A[load project.yml] --> B["strip computed: and hooks: sections from user input map"]
    B --> C["resolve referenced defaults\n(topological sort on template expressions in string defaults)"]
    C --> D[merge --values file overrides]
    D --> E[merge --arg flag overrides]
    E --> F["iterative prompting via huh\n(unreferenced variables skipped entirely)"]
    F --> G["resolve computed: values post-prompt\n(topological sort; each result merged before next)"]
    G --> H["run hooks with full context\n(user inputs + computed values)"]
    H --> I["Execute — render template files"]
  

Error Handling (internal/specs/errors.go)

Sentinel errors are declared in internal/specs/errors.go and should always be wrapped with %w so that callers can use errors.Is to distinguish them:

SentinelKind stringRaised when
ErrTemplateNotFoundtemplate_not_foundNamed template does not exist in the registry
ErrTemplateAlreadyExiststemplate_already_existsTemplate name is already in use (save/download/rename without --force)
ErrTemplateDirMissingtemplate_dir_missingTemplate root exists but has no template/ subdirectory
ErrBothHookSourcesboth_hook_sourcesBoth inline hooks and a hooks/ directory are present
ErrAmbiguousProjectFileambiguous_project_fileBoth project.yaml and project.yml exist in the template root
ErrInvalidDelimitersinvalid_delimiters__delimiters in project.yaml is malformed
ErrProjectFileMissingproject_file_missingNo project.yaml, project.yml, or project.json found
ErrLocalSourcelocal_sourceLocal path given to a command that requires a remote URL
ErrInvalidComputedDefinvalid_computed_defcomputed: entry in project.yaml has wrong type, value type mismatch, or key conflict
ErrCyclicDependencycyclic_dependencyCycle detected among computed or referenced-default keys
ErrInvalidSpecsVersioninvalid_specs_version__specs__version in project.yaml is not a string or not a parseable semver constraint
ErrSpecsVersionUnsatisfiedspecs_version_unsatisfiedRunning CLI version does not satisfy the template’s __specs__version constraint
ErrReservedVariableNamereserved_variable_nameA variable or computed name uses the reserved __ prefix without being a recognised configuration key
ErrCannotPromptcannot_promptA value is missing and cannot be asked for: stdin is not a terminal, or --non-interactive is set

specs.KindOf(err error) string returns the stable kind string for any error in the chain, or "" when no known sentinel is wrapped.


Output System (internal/util/output)

All user-facing output goes through the output.Writer interface, on the rule that stdout carries the product and stderr the narration: Table and WriteResult write the answer a caller would redirect or pipe, while Info, Warn, Error and WriteErr narrate on stderr. Two implementations are selected at startup via --output: PrettyWriter (lipgloss-styled text) and JSONWriter (NDJSON, useful for scripting or CI pipelines) — genuinely NDJSON, one object per line, for a table as much as for a single result.

See Output for the full contract, the colour and width decisions and the golden-file tests.


Logging

specs uses log/slog for structured diagnostic output. All packages emit logs via the package-level slog.Debug/Info/Warn/Error functions, which route through the global default logger.

slog is a debug-only diagnostic channel on stderr — silent on a normal run, and distinct from the two output.Writer formats (pretty/json) that produce user-facing output on stdout. Do not use slog for user-facing reporting; use output.Writer.

That silence is enforced, not conventional. output.LevelSilent is slog.LevelError + 1, above every level slog defines, and it is the level a run without --debug gets — so a slog.Info or slog.Warn added anywhere in the tree still writes nothing. It does not depend on every log point happening to be Debug.

One constructor, called twice

output.SetupLogger(w io.Writer, format Format, debug bool) *slog.LevelVar is the only place a handler is built. It installs the process-wide default and returns the LevelVar gating it:

CallerStreamWhy
NewApp()os.StderrCobra parses persistent flags only after the tree is built, so a failure before that still needs a logger. Silent, pretty.
PersistentPreRunEcmd.ErrOrStderr()The flags are now resolved. Writing to the command’s own stderr is what lets a test assert on --debug output.

Flags

FlagEffect
(neither)Level LevelSilent — nothing is emitted at any level
--debugLevel Debug, text records on stderr
--debug + --output=jsonLevel Debug, JSON records on stderr, so that stream is JSON all the way down

--output alone does not change logging: without --debug there is nothing to format.

Log points

PackageFunctionLevelAttributes
internal/templateGetDebugtemplate, keys, computed
internal/templateExecuteDebugpath, dest, action (render/verbatim/skip)
internal/templateExecuteDebugtemplate, dest, rendered, verbatim, skipped (summary)
internal/templateApplyComputedDebugkey, source=“computed”
internal/hooksHooks.RunDebugtrigger, commands, command
internal/cmdexecuteTemplateDebugkey, source (values_file/arg_flag/default/prompt) — one log per key, final source only
internal/registryUpgradeDebugtemplate, repo, branch, target_ref, latest_version
internal/util/gitCloneDebugrepo, dest, branch (start and complete)
internal/util/gitDescribeDebugdest, commit, version (or error on failure)
internal/util/gitCheckRemoteContextDebugrepo, branch, dest, up_to_date, latest_version, error_kind
internal/util/gitCheckLocalSourceDebugsource, saved_commit, saved_version, up_to_date, latest_version, error_kind

Consistent attribute keys

KeyMeaning
templateRegistered template name (e.g. "minimal") — primary user identifier
pathTemplate-relative source path of a file (e.g. "src/foo.go")
destAbsolute destination path on the filesystem
repoRemote repository URL
branchGit branch or tag ref
commitFull git commit SHA
versiongit-describe-style version string
triggerHook trigger name (pre-use, post-use)
keyContext variable name
sourceHow a context value was provided (default/prompt/values_file/arg_flag/computed)
actionFile decision (render/verbatim/skip)
errorUnderlying error (formatted as %v)

Example: structured debug output

# Text handler (default with --debug):
specs --debug template use minimal ./out

# NDJSON handler (--debug + --output=json):
specs --debug --output=json template ls 2>debug.ndjson

--output=json controls the data format on stdout; --debug + --output=json controls the log format on stderr. The two streams are independent.


Hooks Execution

// internal/hooks/hooks.go

type Hooks struct {
    PreUse    []string // each entry: single command or multiline bash script
    PostUse   []string
    EnvPrefix string   // prefix prepended to each context key in the env (e.g. "SPECS_")
}

// Load reads hook definitions from templateRoot.
// Sources (mutually exclusive — error if both are present):
//   - Inline: the "hooks" key in projectConfig (parsed from project.yml)
//   - Directory: hooks/pre-use.sh and hooks/post-use.sh under templateRoot
func Load(templateRoot string, projectConfig map[string]any, envPrefix string) (*Hooks, error)

// Run executes each command via bash -c.
// ctx is injected as SPECS_-prefixed uppercase env vars: ProjectName → SPECS_PROJECTNAME.
// {{ }} expressions in hook commands are rendered against ctx before execution.
// Stops and returns error on first non-zero exit.
func (h *Hooks) Run(trigger, cwd string, ctx map[string]any, funcMap template.FuncMap, delims specs.Delimiters) error

Packages Added / Changed vs boilr v1

PackageStatusChange
internal/specsnewXDG paths, file name constants, sentinel errors, KindOf() (replaces pkg/boilr)
internal/registrynewon-disk template store: Entry, Load(), Upgrade()
internal/cmdupdatednew use.go, template_update.go, template_upgrade.go, iterative conditional prompting; no longer reads project files or __metadata.json directly
internal/templateupdatedconfigurable delimiters (default {{ }}), context.go, verbatim.go, conditional skip, AST analysis, status; exports LoadProjectFile(), LoadMetadata(), SaveMetadata()
internal/hooksnewhook loading and execution
internal/util/outputupdatedlipgloss-based logger + table renderer; WriteErr with JSON error_kind for known sentinels (replaces tlog + tabular)
internal/util/valuesnew--values file (JSON/YAML) and --arg flag parsing
internal/hostupdatedsource format parsing (owner/repo, HTTPS, SSH, local path)
pkg/promptremovedreplaced by huh
pkg/util/tlogremovedreplaced by internal/util/output
pkg/util/tabularremovedreplaced by internal/util/output
pkg/util/execremovedno longer needed (hooks use os/exec directly)
internal/util/exitunchanged
internal/util/gitupdatedSSH auth, CheckRemoteContext() (context-aware), CheckLocalSource() (local-path status), Describe() for status tracking; RemoteCheckResult.Err() returns typed sentinel errors
internal/util/osutilupdatedCopyDir() recursive copy
internal/util/validateupdatedName() validator (alphanumeric + hyphens + underscores)