--- name: atmos-scaffold description: "Scaffold templates: authoring scaffold.yaml, form fields (types, validation, conditional when:), conditional file generation, step-backed hooks (pre/post-generate), update-safe 3-way merge, and atmos scaffold generate/list/validate" metadata: copyright: Copyright Cloud Posse, LLC 2026 version: "1.0.0" category: scaffolding references: - references/scaffold-yaml-schema.md - references/merge-strategy.md --- # Atmos Scaffold Use this skill for generating boilerplate (components, configs, directory structures) from templates via `atmos scaffold generate`, for authoring new templates (`scaffold.yaml`), and for updating previously-generated output from a changed template via `--update`. For bootstrapping a brand-new Atmos *project* from the built-in template catalog, load `atmos-init` instead — it shares this exact engine but has its own command surface and built-in template list. ## Quick Shape ```yaml apiVersion: atmos/v1 kind: AtmosScaffoldConfig metadata: name: terraform-component description: Standard Terraform component structure spec: fields: - name: component_name label: Name of the component type: input required: true ``` ```shell atmos scaffold generate terraform-component ./components/terraform/vpc atmos scaffold list atmos scaffold validate ./components/terraform/vpc/scaffold.yaml ``` `atmos scaffold` ships experimental — behavior may change between releases. ## Creating a Template A template is a directory containing `scaffold.yaml` (the questionnaire and optional conditional-generation/hooks config) plus the files to generate. **Files are auto-discovered by walking the template directory** — there is no `files:` manifest listing every file (`spec.files:` exists only for the optional conditional-generation overlay, see below). Mark a file as a Go template (rendered with the collected answers) either by: - Naming it with a `.tmpl` extension, or - Adding an `atmos:template` magic comment in the first 10 lines, in the comment style matching the file type: `# atmos:template` (shell/YAML/Python), `// atmos:template` (Go/JS/C++), `/* atmos:template */` (C-style block), `` (HTML/XML/Markdown) Template sources: embedded (built into the Atmos binary), custom (declared under `scaffold.templates` in `atmos.yaml`), or catalog/remote (git/https/s3/oci — advertised as stubs, fetched on selection). An OCI source (`oci://ghcr.io/org/template:v1`) is pulled via the same `pkg/oci` client `atmos vendor pull` reuses (load `atmos-vendoring` for the URL syntax and auth precedence). `--ref` only applies to git sources; OCI/S3/local sources address a version through the source string itself. ## Form Fields `spec.fields` is an ordered questionnaire; fields prompt in the order declared. | Type | Prompt widget | |---|---| | `input` / `text` / `string` | Free-form text (huh Input) | | `select` | Single choice from `options:` | | `multiselect` | Multiple choices from `options:` (filterable) | | `confirm` / `bool` / `boolean` | Yes/no | Common field keys: `name` (required, used as the template variable — access via `{{ .Config. }}`), `label`, `description`, `required`, `default`, `options` (select/multiselect), `placeholder` (input), `validation.pattern`/`message` (regex, input fields only). ### Dynamic and label/value `options:` (select/multiselect) `options:` accepts a plain string list, a list of `{label, value}` objects, a dot-path into an earlier answer, or a Go-template expression: ```yaml spec: fields: - name: envs type: multiselect options: # {label, value} objects — value is required, label optional - label: Development value: dev - label: Production value: prod - name: default_env type: select options: answers.envs # dot-path: only the environments actually picked above - name: csv_owners type: input default: "platform-team,security-team" - name: primary_owner type: select options: '{{ splitList "," answers.csv_owners }}' # Go-template expression ``` The dot-path and template-expression forms resolve correctly once the referenced earlier field has been answered — interactively (fields prompt one at a time, so a later field is only ever shown after the ones before it) or headlessly against `--set`/`--defaults` — the same `answers.`-prefix convention `spec.files[].matrix` axes use. A dot-path may also point at a `spec.values` preset or a `--set`-supplied value never declared as a field at all; there's no field-declaration-order check at load time, so a forward/self/typo'd reference degrades gracefully at runtime instead of failing to load. When a dot-path (not a template expression) sources from a field using `{label, value}` pairs, those labels are recovered for the filtered subset of values present in the answer — only values ever flow into `answers`/templates, never labels. Full details: [references/scaffold-yaml-schema.md](references/scaffold-yaml-schema.md). ### Conditional prompts (`when:`) A field can declare `when:` to be shown only if a condition on **earlier-declared fields'** answers holds true: ```yaml spec: fields: - name: enable_monitoring type: confirm default: false - name: alert_email type: input when: "answers.enable_monitoring == true" # only asked if confirmed above ``` `when:` accepts a predicate keyword (`always`, `never`, `ci`, `local`), a CEL string, or a list (implicit `all`). Reference collected answers via the `answers` map — e.g. `"'dev' in answers.environments"` for a multiselect, `"answers.x == true"` for a confirm (a bare `answers.x` is **not** valid CEL here — it's typed `dyn`, not `bool`; compare explicitly). Use CEL's `&&`/`||`/`!` for compound conditions — the `{all:/any:/not:}` map form is not accepted for scaffold `when:` (see [references/scaffold-yaml-schema.md](references/scaffold-yaml-schema.md) for why). A `when:` can only see fields declared *before* it in the list. Full field/validation reference: [references/scaffold-yaml-schema.md](references/scaffold-yaml-schema.md). ## Conditional File Generation `spec.files:` is an optional overlay gating specific auto-discovered files, keyed by their path in the template tree. Files not listed always generate. ```yaml spec: files: - path: stacks/deploy/dev.yaml when: "'dev' in answers.environments" - path: stacks/deploy/staging.yaml when: "'staging' in answers.environments" ``` This is *static* gating over a fixed, enumerable set of files the template author already created — one file stays one file. For generating a variable number of files (one per selected value, or one per resolved combination of several axes), see `spec.files[].matrix` below. `path:` can also be a glob (doublestar syntax: `*`, `?`, `[...]`, `**` for any depth, `{a,b}`), matching every discovered file under it, so one entry gates or skips an entire directory recursively instead of listing every file it contains: ```yaml spec: files: - path: "docs/legacy/**" when: "answers.include_legacy_docs == true" # gates the whole directory at once ``` Always use forward slashes in the pattern — a backslash is normalized to `/` regardless of authoring OS. A malformed pattern (unclosed `[`/`{`) fails scaffold load and `atmos scaffold validate` immediately, not silently at generation time. When more than one entry's `path:` matches the same file, the **last** declared entry wins (`.gitignore`/`CODEOWNERS` precedence — write broad patterns first, specific overrides after). This is distinct from the older path-templating trick: if a file's *path itself* is a Go template that renders to `""`, `"false"`, `"null"`, or `""`, the engine skips it too (`ShouldSkipFile`). Prefer declarative `when:` for new templates — it's evaluated before any rendering and doesn't require crafting a path template. ## Dynamic File Generation (matrix) `spec.files[].matrix` expands one discovered file into one generated file per resolved combination of one or more axes — the same `map[axis][]values` shape workflow `matrix:` steps use. Requires `target:` (a Go-template string overriding the discovered `path:`), since a single `path:` can't serve as the output for more than one file. ```yaml spec: files: - path: templates/deploy.yaml target: "deploy/{{ .matrix.environment }}/{{ .matrix.region }}.yaml" matrix: environment: answers.environments # a list-shaped answer region: [us-east-1, us-west-2] # a literal list when: "matrix.region in answers.environments[matrix.environment].regions" ``` Each axis's value is a literal list, a dot-path into `answers.*` referencing an already list-shaped answer, or a Go-template expression computing the list from nested/structured or free-text answer data (e.g. `'{{ collectKeys answers.environments "regions" }}'` for a computed axis, or `'{{ splitList "," answers.environments_csv }}'` for a free-text one — see `atmos-templates` for `collectKeys`). The resolved combination is available as `.matrix.` in `target:`, in `when:` (pruning combinations that don't apply), and in the file's own rendered content. **Directory-level matrix**: a glob `path:` (see above) plus `matrix:` duplicates every file it matches once per combination, not just one file: ```yaml spec: files: - path: "components/**" target: "environments/{{ .matrix.env }}/{{ .file.RelPath }}" matrix: env: [dev, staging, production] ``` `.file.Path` (the matched file's own discovered path) and `.file.RelPath` (that path with the glob's literal prefix stripped, e.g. `vpc/main.tf` for `components/**` matching `components/vpc/main.tf`) are available in `target:` and content alongside `.matrix.` — required here since every matched file otherwise shares the same `.matrix.` values and would render to the same path. A `target:` that omits `.file.Path`/`.file.RelPath` when its `path:` matches more than one file fails before any file is written, not mid-run. `.file.*` is Go-template-only — it is not exposed to CEL `when:`. Full schema: [references/scaffold-yaml-schema.md](references/scaffold-yaml-schema.md#specfilesmatrix--dynamic-file-generation). ## Hooks `spec.hooks:` runs step-backed actions before/after generation, keyed by hook name, reusing the **exact vocabulary stack-level lifecycle hooks use** — load `atmos-hooks` for the full `events`/`kind`/`when`/`type`/`with` reference and `atmos-steps` for the step types available in `with:`. Events are `before.scaffold.generate` and `after.scaffold.generate`; a hook with no `events:` matches both. ```yaml spec: hooks: git-add: events: - after.scaffold.generate kind: step type: shell when: "size(answers.environments) > 0" with: command: "git add ." ``` Only `kind: step`/`kind: steps` are supported today. Stack-level `command`, scanner, `store`, `git`, and CI kinds require stack/component context that scaffold generation does not have. `kind: step` takes one registered step type in `type:` and its payload in `with:`; `kind: steps` takes an ordered `with:` list. Answers reach `when:` through the `answers` CEL variable and reach step bodies through `{{ .Answers. }}` Go-template syntax. **Security**: use `--skip-hooks` (skip all) or `--skip-hooks=name1,name2` (skip specific hooks) to bypass hooks for a diagnostic or untrusted-template run — the same flag semantics `terraform` already has. `ATMOS_SCAFFOLD_SKIP_HOOKS` is the matching env var. ## Updating Existing Projects (3-Way Merge) ```shell atmos scaffold generate my-template ./target --update atmos scaffold generate my-template ./target --update --base-ref=v1.2.0 atmos scaffold generate my-template ./target --update --merge-strategy=theirs atmos scaffold generate my-template ./target --update --dry-run ``` `--update` performs a real 3-way merge (base = the git ref the target was generated from, defaulting to `HEAD`) instead of failing on a non-empty target directory. `--merge-strategy` controls conflict resolution: `manual` (surface conflicts, default), `ours` (keep your version), `theirs` (use the template's version). Full mechanics (base storage, conflict markers, the "offer to update instead of failing" interactive prompt): [references/merge-strategy.md](references/merge-strategy.md). ## Commands and Flags `atmos scaffold generate [template] [target]`: `--force`, `--update`, `--base-ref`, `--dry-run`, `--interactive`/`-i` (default true), `--defaults` (use defaults/`--set` without prompting), `--set key=value` (repeatable), `--scaffold-source-override`, `--ref` (git ref for a template source), `--git`/`--no-git` (default **false** — see `atmos-init` for the opposite default), `--merge-strategy`, `--skip-hooks`. `atmos scaffold list`: templates from `scaffold.templates` in `atmos.yaml` (plus embedded/catalog). `atmos scaffold validate [path]`: validates `scaffold.yaml` against the JSON Schema. ## Routing | Need | Skill | |---|---| | Stack hook kinds, lifecycle events, envelope (`events`/`when`/`retry`/`on_failure`) | `atmos-hooks` | | Every registered step type and aliases usable in a hook's `with:` | `atmos-steps` | | Go-template/Gomplate/Sprig functions available in file content | `atmos-templates` | | Project bootstrap from the built-in template catalog | `atmos-init` | | OCI registry URL syntax, auth precedence, full source-type reference | `atmos-vendoring` | | Generated JSON Schema for IDE validation | `atmos-schemas` | | `when:`/CEL syntax reference | `atmos-workflows` | ## Guardrails - Prefer declarative `spec.fields[].when:`/`spec.files[].when:` over hand-rolled path templates or post-generation `sed`/shell cleanup. - Keep destructive `post_generate` hooks (deleting files, force-pushing, etc.) opt-in and visible in `scaffold.yaml`, mirroring `atmos-hooks`' guidance for stack hooks. - A `when:` can only reference fields/files declared earlier — referencing a not-yet-declared field silently sees its zero value, not an error; order fields deliberately. - Don't confuse the path-sentinel skip trick with declarative `when:` — use `when:` for new templates; the sentinel trick remains for backward compatibility.