Metadata-Version: 2.4
Name: ansible-help
Version: 1.1.0
Summary: CLI helpers for discovering Ansible playbooks, roles, and command-line options.
Author: Daniel Davis
Maintainer: Daniel Davis
License: MIT
Project-URL: Homepage, https://github.com/fullsteam-corp-shared/pypi-ansible-help
Project-URL: Documentation, https://github.com/fullsteam-corp-shared/pypi-ansible-help#readme
Project-URL: Repository, https://github.com/fullsteam-corp-shared/pypi-ansible-help.git
Project-URL: Issues, https://github.com/fullsteam-corp-shared/pypi-ansible-help/issues
Keywords: ansible,cli,playbooks
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.10
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: build; extra == "dev"

# Turn comments into help screens: Ansible playbooks and roles

> **`ansible-help` reads the comments, argument specs, and role metadata a repo already has, and prints what each playbook and role does, what it takes, and what is still open.**
> For engineers who run or maintain playbooks somebody else wrote.

<p align="center">
  <img src="images/01_hero.svg" alt="A terminal running ansible-help cert_rotation against the demo repo, with five numbered callouts: the description, the include_role invocation, the inputs table from argument_specs, the first header section with a count of the eleven more it prints, and the hazards list with file and line for each marked comment." width="850">
</p>

To learn what a role expects, you read its `meta/argument_specs.yml`, its `defaults/`, the play that calls it, and every comment that says "do not remove this".
In the demo repo that ships with this README, the `cert_rotation` role keeps 7 of those comments in 5 different directories.
`ansible-help cert_rotation` prints all of them, with the inputs table and the header's intent, on one screen.
**The documentation is already in the files; the work this tool does is reading it back out.**

---

## Install

```bash
pipx install ansible-help                  # or: python -m pip install ansible-help
ansible-help                               # run from anywhere inside an Ansible repo
ansible-help <playbook-or-role>            # show one entry
ansible-help -a <playbook-or-role>         # also every other comment and the README section
ansible-help --check                       # report comments and docs that drifted from the code
ansible-help --help                        # the guide: what it reads, why, how to add docs it picks up
```

House-style header support, play argument specs, `-a`, `--check`, and the `--help` guide (below) are newer than the 1.0.5 release on PyPI.
Until the next release ships, install from a checkout to get it:

**Prereqs:** [Python 3.10+](https://www.python.org/downloads/).

```bash
git clone https://github.com/fullsteam-corp-shared/pypi-ansible-help.git
cd pypi-ansible-help
python -m pip install -e .                 # console script ansible-help -> ansible_help.cli:main
cd examples/demo-repo && ansible-help      # try it on the demo repo
```

That is the entire setup.
The package has no runtime dependencies, no configuration file, and two flags besides help: `-a` / `--all` and `--check`.
`-h` prints the option list; `--help` prints a guide to what the tool reads, why, how to use it, and how to add docs it picks up, ending with where to extend `cli.py`.

---

## Why this exists

<p align="center">
  <img src="images/02_why.svg" alt="Two cards for the same file, playbooks/rotate_certs.yml. On the left, amber, ansible-help 1.0.5 prints the title as rotate_certs and dumps every header section as raw text with no inputs parsed. On the right, green and the same size, the current version prints the real title, the purpose, a three-row inputs table, examples, and the first header section; its count line reads 67 lines, 3 inputs, 1 hazard." width="780">
</p>

Comment headers in this house style have 14 fixed sections, and they are the first thing in every file.
To `ansible-help` 1.0.5 they were noise: it read the first sentence of `rotate_certs.yml: renew the ...` as `rotate_certs.`, found no `Inputs:` section, and printed 57 lines of raw header with no inputs table.
The low-leverage fix is a second, shorter docblock written for the tool, which drifts from the real header within a release.
**The high-leverage fix is a parser that reads the header people already maintain, so the help screen and the code review read the same text.**

<!-- src: expected/v1.0.5/show_rotate_certs.txt -->
```text
$ ansible-help playbooks/rotate_certs.yml      # ansible-help 1.0.5
rotate_certs

 PURPOSE
 Provides the operator entry point for certificate renewal, so nobody renews a certificate by
 hand on one host and forgets the others.

 INTENDED BEHAVIOR
 Dry-run by default: report which certificates are inside the renewal window and stop. With
 cert_dry_run=false, request new certificates, install them, and reload nginx once per host.
```

---

## Where it looks

<p align="center">
  <img src="images/03_search_roots.svg" alt="ansible.cfg at the top, with collections_path set to ./collections, fanning out to four cards: local playbooks under playbooks/*.yml, local roles under roles/*/, each collections_path root's ansible_collections tree, and the ~/.ansible/collections fallback used only when collections_path is unset." width="780">
</p>

`ansible-help` walks up from the current directory until it finds `ansible.cfg`, and treats that directory as the repo root.
Without an `ansible.cfg` it stops with `error: Could not locate repo root (expected ansible.cfg).`

It then looks in these places, in this order:

| Order | Where | When |
|---|---|---|
| 1 | `playbooks/*.yml` and `roles/*/` under the repo root | always |
| 2 | each entry of `collections_path` in `ansible.cfg` (`:` or `;` separated; relative entries resolve against the repo root, `~` expands) | when `collections_path` is set |
| 3 | `~/.ansible/collections` | only when `collections_path` is unset, matching Ansible's default |
| 4 | `./collections` | when it exists, for older repo-local layouts |

In rows 2 to 4, playbooks and roles are read from `<root>/ansible_collections/<ns>/<coll>/playbooks/` and `.../roles/`.
Row 2 is the one most repos depend on: the tool reads the same `collections_path` that `ansible-playbook` does, so a name that resolves for one resolves for the other.

Each item is listed under one name, and show mode accepts a few more:

| Item | Listed as | Also accepted by show mode |
|---|---|---|
| Local playbook | `playbooks.<file>.yml` | `playbooks.<file>`, the file path, and the bare `<file>` when no role has that name |
| Local role | `roles.<name>` | `<name>` |
| Collection playbook or role | `<ns>.<coll>.<name>` | nothing else |

Whichever name you type for a local playbook, show mode prints the path `ansible-playbook` accepts, so the usage line can be pasted as is:

<!-- src: expected/show_scale_web_bare.txt -->
```text
$ ansible-help scale_web
Playbook: playbooks/scale_web.yml

ansible-playbook playbooks/scale_web.yml [-e "[replicas=]"]

  replicas      OPTIONAL        Defaults to 3. Aliases: count. Inferred from set_fact input-resolution task (heuristic).
```

`ansible-help playbooks.scale_web.yml` prints the same lines.
A bare name is tried only after every playbook and role form has failed, so a role named `scale_web` would win.

---

## What the list shows

<p align="center">
  <img src="images/04_list_filter.svg" alt="The demo repo's 10 playbook files and 3 roles pass through an amber filter into the real ansible-help list output, which shows 7 playbooks. Three red pills sit outside it: acme.ops.smoke_test, hidden by cli_help_hidden: true; acme.ops.run_named, a wrapper with a templated import and no description; and playbooks/purge_cache.yml, shown once as acme.ops.purge_cache." width="780">
</p>

Run with no arguments, `ansible-help` lists every playbook and role it can resolve, sorted, with the one-line description it would print in show mode.

<!-- src: expected/list.txt -->
```text
$ ansible-help
For more information on a specific playbook or role, type ansible-help <name>

Playbooks:
---------------
acme.ops.backup_db           Take a compressed logical backup of one database.
acme.ops.purge_cache         Purge the CDN cache for the web tier.
playbooks.deploy_web.yml     Deploy a release of the web tier to one environment.
playbooks.provision_web.yml  Provision a new web host and serve static files from one document root.
playbooks.restart_web.yml    
playbooks.rotate_certs.yml   Renew the web tier's TLS certificates before they expire.
playbooks.scale_web.yml      

Roles
---------------
acme.ops.db_backup           Dump, restore-test, and upload a database backup.
roles.cert_rotation          Renew and install TLS certificates for the web tier.
roles.webserver              Install and configure the nginx web tier.
```

| Left out | Rule | Why |
|---|---|---|
| A collection playbook whose top docblock has a line `cli_help_hidden: true` (or `cli_help_hidden=true`) | explicit opt-out | CI fixtures and internal plays stay runnable but unlisted |
| A collection playbook that only does `import_playbook` and has no description | wrapper-only, nothing to say | a templated target such as `"{{ target_playbook }}.yml"` cannot be followed |
| **A local playbook that a collection playbook imports** | **listed once, under the collection name** | **the collection wrapper shows the local file's description, one hop deep** |

Descriptions longer than the terminal are cut with `...`, so a narrow terminal shows less of each line.

---

## Docblocks: the original convention

<p align="center">
  <img src="images/05_legacy_docblock.svg" alt="Five lines from the docblock of playbooks/deploy_web.yml on the left, each connected by an arrow to the line ansible-help prints for it on the right: the summary sentence becomes the description, and four input bullets become four rows of name, REQUIRED or OPTIONAL, and detail." width="780">
</p>

A top-of-file `#` comment block is read as a docblock.
Its first sentence is the description, `Name:` lines start sections, and the input sections become a table.
Every convention here predates house-style support and still works; a file is only read the new way when it has a house header.

<!-- src: playbooks/deploy_web.yml -->
```yaml
---
# Deploy a release of the web tier to one environment.
#
# Rolls hosts in batches behind the load balancer, so a bad release stops at the
# first batch instead of reaching every host.
#
# Inputs:
#   - web_env (required, staging or production)
#   - release_tag or tag (required, git tag to deploy)
#   - batch_size (optional, defaults to 1, hosts updated per batch)
#   - overwrite (optional, replace an existing release directory)
#
# Examples:
#   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0"
#   ansible-playbook playbooks/deploy_web.yml \
#     -e "web_env=staging release_tag=v2.4.0 overwrite=true"
#
# Tags:
#   --tags config
#   --skip-tags restart
#
# Rollback:
#   Re-run with the previous release_tag. Old release directories are kept.
```

<!-- src: expected/show_deploy_web.txt -->
```text
$ ansible-help playbooks/deploy_web.yml
Deploy a release of the web tier to one environment

Rolls hosts in batches behind the load balancer, so a bad release stops at the
first batch instead of reaching every host.

ansible-playbook playbooks/deploy_web.yml [-e "[web_env=] [release_tag=] [batch_size=] [overwrite=true]"]

  web_env       REQUIRED        Staging or production
  release_tag   REQUIRED        Alias: tag. Git tag to deploy
  batch_size    OPTIONAL        Defaults to 1. Hosts updated per batch
  overwrite     OPTIONAL        Replace an existing release directory

Examples:

   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0"
   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0 overwrite=true"

Options (tags):

   --tags config
   --skip-tags restart

Rollback:

     Re-run with the previous release_tag. Old release directories are kept.
```

| Docblock element | Read as | Printed as |
|---|---|---|
| First sentence-like line | description, up to the first `.` followed by a space or the line end, so `24.04`, `README.md`, and `3.5` do not cut it short | the first output line, trailing `.` dropped |
| `====` banners, `ns.coll.name` identifier lines, single-word headings, `CLI_HELP_INPUTS` / `END_CLI_HELP_INPUTS` / `Canonical CLI` lines | skipped when picking the description | |
| **`Inputs:`, `Required inputs:`, `Command Line Arguments:`, `Parameters:`** | **the first of these present** | **the inputs table and the `ansible-playbook ... [-e "..."]` line** |
| `Examples:`, `Example:`, `Run:`, `Run from repo root (.env and tool resolution rely on $PWD):` | `ansible-playbook` commands, wrapped lines joined | `Examples:` |
| `Tags:` | lines starting `--` | `Options (tags):` |
| Any other `Name:` section | context | printed under its own heading |
| `cli_help_hidden: true` | list opt-out | nothing; see the list rules above |

Input lines take two forms, and a section should use one of them:

| Form | Example | Row |
|---|---|---|
| Bullet with qualifiers | `- release_tag or tag (required, git tag to deploy)` | `release_tag  REQUIRED  Alias: tag. Git tag to deploy` |
| Name, qualifiers, then ` - description` (bullet optional) | `db_name (required) - Database to dump.` | `db_name  REQUIRED  Database to dump` |

Qualifiers are comma separated: `required` or `optional` (an entry is `REQUIRED` unless it says `optional`), `defaults to <value>`, and free text that becomes detail.
An input named `overwrite` is shown as `[overwrite=true]` in the invocation line.

### When a playbook has no docblock

A play argument spec, when the playbook has one, comes before the input section (see [Play argument specs](#play-argument-specs-validate_argspec)).
With no input section, show mode falls back to heuristics, in this order:

| Heuristic | Trigger | Detail column says |
|---|---|---|
| **Role argument specs** | **the play's only task is `ansible.builtin.include_role`** | **the role's `meta/argument_specs.yml` options** |
| `set_fact` input resolution | a task sets `<name>_input` and matches `item.key \| lower == '...'` or `in [...]` | `Inferred from set_fact input-resolution task (heuristic).` |
| `include_role` var mapping | `selectattr('key', 'match', '(?i)^name$')` in a thin wrapper | `Mapped to include_role vars from CLI/runtime vars (heuristic).` |
| `roles:` list and play `vars:` | none of the above found an input, and the play lists roles or sets literal vars | each listed role's `argument_specs` options, then its `meta/main.yml` dependencies' options (each row ending `From role <name>`), then play vars as `Defaults to <value> (set by the play)` |

The last row only fills a table that would otherwise be empty, so a playbook that already printed inputs prints the same ones.
A trailing comment on a play var line becomes part of its row: `common_action: plan  # override with -e 'common_action=apply'` reads `Defaults to plan (set by the play): override with -e 'common_action=apply'`.
Play vars whose names start with `_` are play-private helpers and are not listed as inputs.
Vars that an `include_role` task in the play passes itself are dropped, since the caller cannot set them.

<!-- src: expected/show_restart_web.txt -->
```text
$ ansible-help playbooks/restart_web.yml
Playbook: playbooks/restart_web.yml

ansible-playbook playbooks/restart_web.yml [-e "[web_env=] [web_port=] [web_worker_count=]"]

  web_env               REQUIRED        Environment the host serves, staging or production
  web_port              OPTIONAL        Defaults to 8080. Port nginx listens on behind the load balancer
  web_worker_count      OPTIONAL        Defaults to 4. nginx worker processes per host
```

<!-- src: expected/show_scale_web.txt -->
```text
$ ansible-help playbooks/scale_web.yml
Playbook: playbooks/scale_web.yml

ansible-playbook playbooks/scale_web.yml [-e "[replicas=]"]

  replicas      OPTIONAL        Defaults to 3. Aliases: count. Inferred from set_fact input-resolution task (heuristic).
```

### Roles

A role's description is `meta/main.yml` `galaxy_info.description` when present.
Otherwise it is the first line of the docblock in `tasks/main.yml` (or `meta/main.yml`), and `meta/argument_specs.yml` `short_description` only when both are missing.
Inputs come from `meta/argument_specs.yml` options first, including multi-line, folded, and list descriptions, then from a docblock input section.
A role that documents no inputs of its own gets the options of its `meta/main.yml` dependencies, followed depth first, with each row ending in `From role <name>`.
Examples come from the docblock; with none, the tool prints a generic `ansible-playbook <playbook> -e <first input>=<value>` line.

<!-- src: expected/show_webserver.txt -->
```text
$ ansible-help roles.webserver
Install and configure the nginx web tier

ansible.builtin.include_role name=webserver vars: [[web_env=] [web_port=] [web_worker_count=]]

  web_env               REQUIRED        Environment the host serves, staging or production
  web_port              OPTIONAL        Defaults to 8080. Port nginx listens on behind the load balancer
  web_worker_count      OPTIONAL        Defaults to 4. nginx worker processes per host

Renders one server block per host and reloads nginx only when the rendered
configuration changed.

Examples:

   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0"
```

---

## House-style headers

<p align="center">
  <img src="images/06_house_header.svg" alt="The header of playbooks/rotate_certs.yml on the left rebuilds the ansible-help output on the right line by line: the title loses its rotate_certs.yml: prefix and trailing period and gains a capital, the PURPOSE paragraph becomes the summary, the inputs table is filled from the cert_rotation role's argument_specs, both ansible-playbook commands lift out of MAINTENANCE into Examples with the backslash continuation joined into one line, the other sections cross over in header order as printed headings while the three placeholder sections (UNKNOWN, NOT APPLICABLE, NOT APPLICABLE) dissolve as skipped, and the warning comment on line 69 flies into Hazards and open items as playbooks/rotate_certs.yml:69." width="780">
</p>

A file is read as house style when its top comment block contains at least 2 of the 14 section headings, each followed by a dashed underline.
For a role, the header is looked for in `tasks/main.yml`, then `meta/main.yml`, and the description prefers `galaxy_info.description`, then `argument_specs` `short_description`, then the header title.
Everything else goes through the docblock parser above, so a repo can migrate one file at a time.

| Header part | Printed as | Notes |
|---|---|---|
| `<file> (context): responsibility.` title line | the description | file prefix removed, first letter capitalized |
| `PURPOSE` | the paragraph under the description | |
| Inputs table | the `ansible-playbook` or `include_role` line and table | from a play argument spec (below), `argument_specs`, bullet lines under `INPUTS`, the heuristics above, or the roles in a play's `roles:` list and their `meta/main.yml` dependencies; literal play `vars:` become optional inputs with the play's default, and vars an `include_role` task passes itself are dropped |
| **Any `ansible-playbook ...` line, in any section** | **`Examples:`** | **house headers keep commands in `MAINTENANCE`, so the tool looks everywhere** |
| Every other section, in header order | its own sentence-case heading (`Intended behavior:`, `Implementation overview:`, `Key elements:`, ...), body verbatim, after the examples; `INPUTS` prints as `Inputs (from header):` | a section whose body is only `UNKNOWN`, `NOT APPLICABLE`, or `REQUIRES FURTHER REPOSITORY CONTEXT` is not printed |

<!-- src: expected/show_rotate_certs.txt -->
```text
$ ansible-help playbooks/rotate_certs.yml
Renew the web tier's TLS certificates before they expire

Provides the operator entry point for certificate renewal, so nobody renews a certificate by
hand on one host and forgets the others.

ansible-playbook playbooks/rotate_certs.yml [-e "[cert_domain=] [cert_renew_days_before=] [cert_dry_run=]"]

  cert_domain                   REQUIRED        Domain the certificate is issued for
  cert_renew_days_before        OPTIONAL        Defaults to 21. Renew when fewer than this many days remain
  cert_dry_run                  OPTIONAL        Defaults to true. Report what would be renewed and change nothing

Examples:

   ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com"
   ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com cert_dry_run=false"

Intended behavior:

   Dry-run by default: report which certificates are inside the renewal window and stop. With
   cert_dry_run=false, request new certificates, install them, and reload nginx once per host.

Implementation overview:

   One play over the web group runs the cert_rotation role, which checks expiry, renews, installs,
   and notifies the reload handler.

Inputs (from header):

   cert_domain is required. cert_renew_days_before and cert_dry_run have role defaults.

Outputs and side effects:

   In dry-run, prints the certificates due for renewal and changes nothing. Otherwise writes new
   certificate and key files under /etc/ssl/web and reloads nginx.

Key elements:

   The play "Rotate web tier certificates" and its cert_rotation role.

Callers and consumers:

   Operators run this with ansible-playbook. A scheduled job is planned but does not exist yet.

Internal dependencies:

   roles/cert_rotation.

External dependencies:

   The ACME endpoint named in roles/cert_rotation/vars/main.yml, and nginx on every web host.

Constraints and assumptions:

   The web hosts must reach the ACME endpoint on 443. Run against one environment at a time.

Maintenance:

   Keep these operator commands aligned with the role defaults:
     ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com"
     ansible-playbook playbooks/rotate_certs.yml \
       -e "cert_domain=shop.example.com cert_dry_run=false"

Hazards and open items:

   playbooks/rotate_certs.yml:69
     ⚠ serial: 1 is NOT a leftover from debugging. Renewing every host at once reloads every nginx
     at once, and the load balancer has no healthy backend for the length of the reload.
```

The header's `INTEGRATION AND LINKAGE`, `DATA FLOW`, and `DEPENDENCY RELATIONSHIPS` sections hold only `UNKNOWN` or `NOT APPLICABLE`, so they are the three left out.
Compare it with the 1.0.5 output under [Why this exists](#why-this-exists): same file, 67 lines instead of 57, with the inputs table and examples the old output never found and without the placeholder sections it printed.

### Hazards and open items

<p align="center">
  <img src="images/07_hazards.svg" alt="A rail listing the cert_rotation role's tasks, handlers, defaults, vars, and meta directories feeds seven cards, one per marked comment, each showing its file and line and the first line of the comment, colored by marker: amber for warnings and NOT OPTIONAL, green for ANSWERED, cyan for DELIBERATE, rust for PARKED." width="750">
</p>

In a house-style playbook or role, comments that start with a status marker are collected under `Hazards and open items:`, each with its `path:line`.
For a role, the tool reads every YAML file under `tasks/`, `handlers/`, `defaults/`, `vars/`, and `meta/`.
For a playbook, it reads the playbook file itself.
Files without a house header get no hazards section, which keeps their output as it was.

| Marker | Means |
|---|---|
| **`⚠`** (including `⚠ STILL OPEN`) | **a hazard, or a guard that must not be removed** |
| `✅` (`✅ ANSWERED`, `✅ HALF-ANSWERED`) | a question closed, with the evidence |
| `PARKED` | a known gap, deferred on purpose |
| `DELIBERATE` | looks like an omission, is not |
| `NOT OPTIONAL` | a step the system is broken without |

A marker only counts at the start of a comment line, so a `✅` inside a task's `msg:` string is not a hazard.
An entry runs until a blank `#` line, the next marker, a banner, or code.

<!-- src: expected/show_cert_rotation.txt -->
```text
$ ansible-help cert_rotation
Hazards and open items:

   roles/cert_rotation/tasks/main.yml:70
     ⚠ STILL OPEN (2026-09-30): hosts with no certificate yet fail the task above instead of being
     treated as due. Decide whether first issuance belongs in this role before relying on it for a
     new host.

   roles/cert_rotation/tasks/renew.yml:7
     ✅ ANSWERED (2026-09-22): the ACME client keeps the old key when asked to, so a renewal does not
     invalidate any pinned public key. Verified against the staging endpoint.

   roles/cert_rotation/tasks/renew.yml:18
     NOT OPTIONAL: nginx reads the key as a non-root worker. Without this mode the reload succeeds
     and the next TLS handshake fails.

   roles/cert_rotation/handlers/main.yml:2
     DELIBERATE: reload, not restart. A restart drops in-flight connections; a reload picks up the
     new certificate without dropping them.
```

---

## Play argument specs: `validate_argspec`

<p align="center">
  <img src="images/10_play_argspec.svg" alt="Five source lines from playbooks/provision_web.meta.yml and playbooks/provision_web.yml, each on a cyan card with its file and line number, and under each the line ansible-help prints for it on a green card: the spec description becomes the summary, the web_env description becomes a REQUIRED row, default: /srv/www becomes the web_doc_root row's Defaults to /srv/www, the play's webserver role adds the web_port row, and validate_argspec: true becomes the line naming the provision web host spec in provision_web.meta.yml." width="780">
</p>

ansible-core 2.20 added argument validation for plays, as a tech preview.
A play opts in with the `validate_argspec` keyword, and its spec lives in `<playbook>.meta.yml` next to the playbook.
`ansible-playbook` validates the play's variables against that spec before the first task, so the spec cannot quietly disagree with what a run accepts.
**`ansible-help` reads the same file, so the inputs table it prints is the one ansible-core enforces.**

<!-- src: playbooks/provision_web.yml -->
```yaml
---
- name: provision web host
  hosts: web
  validate_argspec: true
  roles:
    - webserver
  tasks:
    - name: Create the document root
      ansible.builtin.file:
        path: "{{ web_doc_root }}"
        state: directory
        mode: "0755"
```

<!-- src: playbooks/provision_web.meta.yml -->
```yaml
---
argument_specs:
  provision web host:
    description: Provision a new web host and serve static files from one document root.
    options:
      web_env:
        description: Environment the host serves, staging or production.
        type: str
        required: true
        choices: [staging, production]
      web_doc_root:
        description: Directory nginx serves static files from.
        type: str
        default: /srv/www
```

<!-- src: expected/show_provision_web.txt -->
```text
$ ansible-help playbooks/provision_web.yml
Playbook: playbooks/provision_web.yml

Provision a new web host and serve static files from one document root.

ansible-playbook playbooks/provision_web.yml [-e "[web_env=] [web_doc_root=] [web_port=] [web_worker_count=]"]

  web_env         	REQUIRED	Environment the host serves, staging or production
  web_doc_root    	OPTIONAL	Defaults to /srv/www. Directory nginx serves static files from
  web_port        	OPTIONAL	Defaults to 8080. Port nginx listens on behind the load balancer
  web_worker_count	OPTIONAL	Defaults to 4. nginx worker processes per host

Validated by ansible-core 2.20+ before the first task: validate_argspec "provision web host" in playbooks/provision_web.meta.yml
```

| In the files | Read as |
|---|---|
| `validate_argspec: true` | the spec named after the play's `name`, as ansible-core does |
| `validate_argspec: <name>` | that spec; a templated value (`{{ ... }}`) cannot be resolved without a run, so it is skipped |
| `<playbook>.meta.yml`, then `.meta.yaml`, then `.meta` | the meta file, in ansible-core's lookup order |
| **The selected spec's `options`** | **the inputs table, ahead of a header `INPUTS` section or a docblock `Inputs:` section; rows for the roles in the play's `roles:` list follow, because `-e` still sets them** |
| The spec's `description`, else the meta file's top-level `description` | the description, when the header or docblock gives none; list mode uses it too |
| A selected spec that is missing or has no `options` mapping | a `Warning:` line under the table, because `ansible-playbook` stops with a parser error at that play |
| The meta file itself | never listed as a playbook; with `-a`, its comments are printed after the playbook's |

`--check` reports a bad selector as `argspec-missing` on its `validate_argspec` line, and an option with no description as `spec-no-description`.
A play spec's options count as defined, so a role var that the play spec declares is not reported as `undeclared-input`.
Playbooks without `validate_argspec` print exactly what they printed before.

---

## Every other comment: `-a`

<p align="center">
  <img src="images/08_all_comments.svg" alt="An animated terminal runs ansible-help -a playbooks/rotate_certs.yml: the 67 lines of default output scroll past, then a scan line sweeps rotate_certs.yml, stamping the house header on lines 2 to 67 as already on screen and the marked comment on line 69 as already under Hazards, and keeps the trailing comment on line 74, which travels along a wire into the output under All comments. A second scan finds the heading rotate_certs.yml in playbooks/README.md and sends that section, fenced code included, under From playbooks/README.md. A counter rolls from 67 to 86 lines, and a tally lists 66 header lines and 2 marked lines not printed again and 0 lint directives such as noqa and yamllint." width="780">
</p>

The default screen is for running a playbook; the comments that explain why a task looks the way it does stay in the file.
`ansible-help -a <playbook-or-role>` (or `--all`, before or after the name) prints the default output unchanged, then appends two parts.
`All comments:` is every comment in the files, verbatim, under its `path:line`: for a playbook, the playbook file; for a role, every YAML file under `tasks/`, `handlers/`, `defaults/`, `vars/`, and `meta/`.
After it come the README docs that describe the entry, under `From <path>:`.

| In the files | With `-a` |
|---|---|
| A run of `#` lines | printed verbatim from its first line; a bare `#` keeps the paragraph break, banner lines are dropped |
| A trailing comment on a code line | printed with its code line, so the note keeps its subject |
| The house header and marked comments | skipped, because they are already on screen |
| Lint directives (`# noqa`, `# yamllint`, `# nosec`, `# pylint`, `# type:`, `# pragma`, `# fmt:`) | dropped; they are for tools, not readers |
| **`README.md` next to the playbook, then the repo-root `README.md`** | **the section whose heading names the playbook file, down to the next heading at its level or above; `#` lines inside a fenced block are not headings** |
| A role's `README.md` | printed in full |

In the demo playbook every header line and the one marked comment are already on screen, so `All comments:` holds a single inline comment, followed by the `rotate_certs.yml` section of `playbooks/README.md`.
The first 62 lines are the default output shown above:

<!-- src: expected/show_rotate_certs_all.txt -->
````text
$ ansible-help -a playbooks/rotate_certs.yml
Hazards and open items:

   playbooks/rotate_certs.yml:69
     ⚠ serial: 1 is NOT a leftover from debugging. Renewing every host at once reloads every nginx
     at once, and the load balancer has no healthy backend for the length of the reload.

All comments:

   playbooks/rotate_certs.yml:74
     gather_facts: false  # the role reads expiry with openssl on the host, so facts are never used

From playbooks/README.md:

   ## rotate_certs.yml

   Run it once without `cert_dry_run=false` and read the list of certificates due. Then run it
   again with `cert_dry_run=false` inside a change window:

   ```bash
   ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com cert_dry_run=false"
   ```

   A host that has never had a certificate fails the expiry check. First issuance is still an open
   question in the `cert_rotation` role, so issue that host's first certificate by hand.
````

A file without a house header skips nothing, so its docblock appears twice: once parsed at the top, once verbatim under `All comments:`.

---

## Find drift: `--check`

<p align="center">
  <img src="images/09_check.svg" alt="ansible-help --check against the demo repo: a scan beam sweeps a tree of the 10 playbooks and 3 roles it checked, and each drifted comment it catches lights amber and travels to the terminal as a compiler-style finding: two documented-unused inputs in acme.ops backup_db.yml at lines 11 and 12, two in playbooks/deploy_web.yml at lines 9 and 11, and a header-placeholder at playbooks/rotate_certs.yml:45 and roles/cert_rotation/tasks/main.yml:43, while the other files get green checks, a counter ticks to 6 findings, and the exit code flips to 1." width="780">
</p>

Help text read from comments is only as good as the comments, and comments drift: a renamed input stays in the docblock, a deleted file stays in a note, a header section stays `UNKNOWN`.
`ansible-help --check` reads the same files the help screens come from and reports each place where the comments or docs no longer match the code.
**It exits 1 on any finding and 0 when clean, so it can gate a pull request the same way a linter does.**

With no target it checks every local and collection playbook and role.
`ansible-help --check <playbook-or-role>` checks one entry; a playbook target also checks the roles in its `roles:` list.
Each finding is one compiler-style line, `<path>:<line>: <kind>: <message>`, so editors and CI annotators can jump to it.

| Kind | Reported when |
|---|---|
| **`undeclared-input`** | **a role's YAML files or templates read a var with `\| default(...)`, and nothing in the repo defines it (vars and defaults keys, inventory keys, `set_fact`, `register`, `loop_var`, `vars:` blocks, any role's `argument_specs`, any play argument spec) and it is not an Ansible magic var: only `-e` or inventory can set it, and `argument_specs` does not declare it. Reported once per var per role** |
| `documented-unused` | an input named in a house `INPUTS` bullet, a docblock `Inputs:` / `Parameters:` bullet, or the backticked first column of a parameters table in the playbook's README section appears in no YAML or `.j2` code |
| `stale-path` | a comment names a file path, or a bare `*.yml` file name, that exists nowhere in the git repo; `state/`, `.tofu-data/`, `.terraform/`, `node_modules/`, and virtualenvs are skipped without being opened |
| `spec-no-description` | an option in a role's `argument_specs` or a play argument spec has no description |
| `argspec-missing` | a play's `validate_argspec` selects a spec that `<playbook>.meta.yml` does not have, or that has no `options` mapping, or the meta file is missing; `ansible-playbook` stops with a parser error at that play |
| `header-missing-section`, `header-placeholder` | a house header lacks one of the 14 sections, or a section still says `UNKNOWN` or `REQUIRES FURTHER REPOSITORY CONTEXT`; `NOT APPLICABLE` is an answer and is not reported |

The demo repo is not clean, on purpose: its playbooks are stubs, so two docblocks list inputs that no task reads, and two headers still have an unanswered section.

<!-- src: expected/check_all.txt -->
```text
$ ansible-help --check
Checked 10 playbook(s), 3 role(s)

collections/ansible_collections/acme/ops/playbooks/backup_db.yml:11: documented-unused: backup_bucket is documented as an input in the docblock but no code uses it
collections/ansible_collections/acme/ops/playbooks/backup_db.yml:12: documented-unused: retention_days is documented as an input in the docblock but no code uses it
playbooks/deploy_web.yml:9: documented-unused: release_tag is documented as an input in the docblock but no code uses it
playbooks/deploy_web.yml:11: documented-unused: overwrite is documented as an input in the docblock but no code uses it
playbooks/rotate_certs.yml:45: header-placeholder: INTEGRATION AND LINKAGE is still a placeholder (UNKNOWN)
roles/cert_rotation/tasks/main.yml:43: header-placeholder: INTEGRATION AND LINKAGE is still a placeholder (REQUIRES FURTHER REPOSITORY CONTEXT)

6 finding(s)
```

A clean entry prints the count and exits 0:

<!-- src: expected/check_webserver.txt -->
```text
$ ansible-help --check roles.webserver
Checked 0 playbook(s), 1 role(s)

0 finding(s)
```

`check.py` checks those exit codes along with the output: 1 for the whole demo repo and for `--check playbooks/rotate_certs.yml`, 0 for `--check roles.webserver`.

---

## The loop

Every block of output in this README, and every line of output in the figures, is a capture from a real run against [`examples/demo-repo/`](examples/demo-repo/).
`check.py` re-runs them and fails when anything drifted.

```bash
python3 examples/demo-repo/expected/check.py              # re-run every capture; check figure text and README blocks
python3 examples/demo-repo/expected/check.py --update     # accept new output after an intended change
python3 images/build_figures.py                           # redraw the figures from the captures
python3 -m build                                          # the package build CI runs on every PR
```

After a change to `src/ansible_help/cli.py`, run `check.py` first.
A changed capture is either the change you meant, which you accept with `--update` and then rebuild the figures, or a regression in a file format you did not mean to touch.
The legacy files in the demo repo (`deploy_web.yml`, `backup_db.yml`, the heuristic playbooks, `webserver`) are the backward-compatibility fixture: their captures should not move when house-style parsing changes.

Releases are automated.
One workflow, `version-control.yml`, runs [action-version-control](https://github.com/fullsteam-corp-shared/action-version-control) v2, which picks its step from the pull request event.
On a pull request to `main` it checks that every commit is a conventional commit.
Each merge rebuilds a `release/next` pull request from the merged commits, with the changelog and the version in `pyproject.toml` and `src/ansible_help/__init__.py` bumped.
Merging `release/next` tags the release, creates the GitHub Release, and publishes to [PyPI](https://pypi.org/p/ansible-help).
`ci.yml` runs the tests and the package build on every pull request.

---

## Folder structure

```
pypi-ansible-help/
├── README.md                          # this file; also the PyPI long description
├── pyproject.toml                     # package metadata, console script, version
├── CHANGELOG.md                       # generated by the release workflow
├── .changes/                          # per-version changelog fragments
├── .github/workflows/                 # ci (tests, build), version-control (commit gate, release PR, PyPI)
├── src/ansible_help/
│   ├── __init__.py                    # __version__
│   └── cli.py                         # the entire tool
├── examples/demo-repo/                # a small Ansible repo every README capture comes from
│   ├── ansible.cfg                    # collections_path = ./collections
│   ├── playbooks/                     # docblock, house-style, no-docblock, and validate_argspec playbooks; README.md for -a
│   ├── roles/                         # webserver (docblock), cert_rotation (house style)
│   ├── collections/                   # acme.ops: wrappers, a hidden playbook, a role
│   └── expected/                      # captured output and check.py
└── images/                            # animated SVG figures and the script that draws them
```

---

## Make it your own

Treat this as a reader for whatever conventions your repo already follows.
Four places to extend it: add a section name to the docblock tables in `cli.py` when your team uses a different heading for inputs; add a marker to `STATUS_MARKER_RE` when your team flags hazards with another word; add a prefix to `LINT_DIRECTIVE_RE` when `-a` prints a tool directive your repo uses; copy `examples/demo-repo/` and its `check.py` pattern into your own repo so your playbooks' help output is checked the same way this README is.
`ansible-help --help` ends with where in `cli.py` a new input source or a new `--check` finding goes.
**What transfers when the tool changes is the comments: a header and a marked hazard still document the file for the next person who opens it, with or without `ansible-help`.**

---

## Where it can still fail

- **It reads files, not runs.** Variables set in inventory, `group_vars`, or at runtime are invisible. Treat the inputs table as what the author documented, and `ansible-playbook --syntax-check` plus the role's `argument_specs` validation as the truth.
- **YAML is read line by line, with regular expressions, not with a YAML parser.** Unusual indentation, flow-style mappings, or anchors in `argument_specs.yml` and `meta/main.yml` can be missed. If an input is missing from the table, check that file's layout first.
- **Mixing the two input-line forms in one section reorders the table.** Plain bullets keep their place at the top, and lines in the `name (required) - description` form are appended after them. Use one form per section to control the order.
- **Imports are followed one hop.** A wrapper that imports a wrapper, or imports a templated path, shows its own docblock or nothing.
- **`cli_help_hidden: true` only hides collection playbooks.** Local playbooks under `playbooks/` are always listed.
- **A playbook's hazards and `-a` comments come only from the playbook file.** Markers and comments in the roles it runs show up under `ansible-help <role>` and `ansible-help -a <role>`, not under the playbook.
- **`-a` finds playbook docs only under a heading that names the file.** A section titled "Certificate rotation" is not matched; put `rotate_certs.yml` in the heading, as `playbooks/README.md` in the demo repo does.
- **`--check` must be the first argument.** `ansible-help --check cert_rotation` works; `ansible-help cert_rotation --check` exits 2 with `error: unsupported arguments`.
- **`--check` exits 1 for an unresolvable target as well as for findings.** A CI step cannot tell the two apart by exit code; the target error goes to stderr as `error: could not resolve playbook or role target: <name>`.
- **`undeclared-input` treats a var defined anywhere in the repo as defined.** A var that an unrelated role or an inventory file happens to define hides a real gap in this role's `argument_specs`. Read the role's `| default(...)` reads yourself when an input looks missing.
- **`documented-unused` is word-based.** A documented name that appears anywhere in YAML or template code, as a key, in a string, or in another role, counts as used, so it misses an input that only shares a name with something else.
- **`stale-path` matches by path suffix or file name across the whole git repo.** A comment naming `vars/main.yml` passes as long as any `vars/main.yml` exists, even in another role. The repo is the nearest directory with `.git`, so inside a monorepo that is the whole monorepo.
- **A playbook target checks only the roles in its `roles:` list.** Roles a play reaches through `include_role` or `import_role` are not checked with it; `ansible-help --check playbooks/restart_web.yml` checks 0 roles. Run `--check` on the role, or with no target.
- **Play argument validation is a tech preview in ansible-core 2.20.** The keyword or the meta file layout can still change upstream. `ansible-help` does not read a `.meta.json` file, and skips a `validate_argspec` value that is templated.
- **List mode clips descriptions to the terminal width.** Set `COLUMNS` for a stable width in scripts, as `check.py` does.

---

## License

MIT, as declared in [`pyproject.toml`](pyproject.toml).

---

Built so the help screen and the code review read the same comments.
