Skip to content

Commit 64f2a2c

Browse files
committed
cbc566
1 parent ffe0b2b commit 64f2a2c

6 files changed

Lines changed: 567 additions & 34 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ system_audit.html
1717

1818
# OS
1919
.DS_Store
20+
21+
extras/

MANUAL.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,47 @@ When reading `[install nginx]`, the dependency keyword should answer "what does
252252

253253
`first [update cache]` reads as a natural instruction: "first, make sure the cache is updated." It's unambiguous about direction, and it matches how ops people actually talk: "first update the cache, then install the package."
254254

255+
### File documentation headers
256+
257+
A `.cgr` file can carry a documentation header that `cgr how FILE` renders for users and agents discovering the file for the first time. The header lives before any `set` or `target` declarations and consists of two optional parts:
258+
259+
**Title line** — a `--- text ---` line on the very first content line:
260+
261+
```
262+
--- deploy web application ---
263+
```
264+
265+
**Comment block** — contiguous `#` lines immediately following the title (or at the top if there is no title):
266+
267+
```
268+
--- authorize SSH key ---
269+
#
270+
# Copies your local public key to each host using ssh-copy-id.
271+
#
272+
# Usage:
273+
# cgr apply authorize_ssh_key.cgr --set hosts="web01,web02,db01"
274+
# cgr apply authorize_ssh_key.cgr --set hosts="host1" --set ssh_port=2222
275+
#
276+
# Variables:
277+
# hosts — comma-separated list of hostnames or IPs (required)
278+
# ssh_user — remote user (default: $USER from environment)
279+
# ssh_port — SSH port (default: 22)
280+
281+
set hosts = "192.168.1.10,192.168.1.11"
282+
set ssh_user = env("USER", "ubuntu")
283+
set ssh_port = "22"
284+
```
285+
286+
`cgr how` renders the header and then a **Defaults** table showing each `set` declaration with its raw expression (`env("USER", "ubuntu")` rather than the evaluated value), so users can see exactly what to override.
287+
288+
Conventions for the comment block:
289+
- Start with a one-line summary sentence.
290+
- Add a `Usage:` block with copy-pasteable `cgr apply` examples.
291+
- Add a `Variables:` block listing each user-facing `set` variable with a short description and whether it is required or has a default.
292+
- For graphs with manual resume steps, add a `Resuming after interruption:` block with `cgr state set` examples.
293+
294+
The parser strips the header before building the AST; it has no effect on execution.
295+
255296
---
256297

257298
## Parallel constructs (.cgr format)
@@ -2024,6 +2065,7 @@ This makes the HTML file a self-contained pitch: send someone `webserver.html`,
20242065
| `doctor` | Check environment: Python version, SSH client, sudo, template repo, Vault passphrase, graph files in CWD. |
20252066
| `secrets` | Manage encrypted `.secrets` files for offline secret storage. |
20262067
| `ping FILE` | Verify SSH connectivity to all targets defined in the graph. |
2068+
| `how FILE` | Show the file's documentation header and a **Defaults** table of all `set` declarations with their raw expressions. Works without resolving the graph. |
20272069
| `explain FILE STEP` | Show the full dependency chain for a step. |
20282070
| `why FILE STEP` | Show what steps depend on a step (reverse of explain). |
20292071
| `check FILE` | Re-run all check clauses against the live system and report drift. |
@@ -2256,7 +2298,7 @@ If you are an AI agent tasked with creating a CommandGraph dependency graph, fol
22562298

22572299
3. **Identify the goal.** What end state must be true on which host(s)?
22582300

2259-
4. **Check the repo.** Run or simulate `cgr repo index --repo ./repo`. Reuse existing templates wherever possible. Do not reinvent `apt-get install` — use `apt/install_package`.
2301+
4. **Check the repo.** Run or simulate `cgr repo index --repo ./repo`. Reuse existing templates wherever possible. Do not reinvent `apt-get install` — use `apt/install_package`. To understand an existing `.cgr` file's interface before running it, use `cgr how FILE` — it prints the documentation header and a table of all `set` variables with their default expressions.
22602302

22612303
5. **Decompose backwards.** Start from the end state and work backwards to roots. Draw the tree mentally or in comments.
22622304

cgr.py

Lines changed: 101 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cgr_src/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,10 @@ def main():
474474
sub.add_parser("doctor",help="Check environment for common issues")
475475
sub.add_parser("version",help="Show version information")
476476

477+
# ── How ──────────────────────────────────────────────────────────
478+
shw=sub.add_parser("how",help="Show documentation and variables for a graph file")
479+
shw.add_argument("file",nargs="?",default=None,help="Graph file (.cgr or .cg)")
480+
477481
# ── Shell completion generator ──────────────────────────────────────
478482
scmp=sub.add_parser("completion",help="Generate shell completion script")
479483
scmp.add_argument("shell",choices=["bash","zsh","fish"],help="Shell type")
@@ -497,7 +501,7 @@ def main():
497501
cmd_init(args.file); return
498502

499503
# ── Auto-detect graph file if not provided ────────────────────────
500-
if hasattr(args, "file") and args.file is None and args.command not in ("serve", "init", "repo", "completion", "version", "doctor", "test"):
504+
if hasattr(args, "file") and args.file is None and args.command not in ("serve", "init", "repo", "completion", "version", "doctor", "test", "how"):
501505
args.file = _auto_detect_graph()
502506

503507
if args.command=="convert":
@@ -516,6 +520,9 @@ def main():
516520
if args.command=="fmt":
517521
cmd_fmt(args.file); return
518522

523+
if args.command=="how":
524+
cmd_how(args.file); return
525+
519526
if args.command=="secrets":
520527
cmd_secrets(args.action, args.file, key=args.key, value=args.value, vault_args=args); return
521528

cgr_src/commands.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,97 @@ def _build_apply_report(graph: Graph, results: list[ExecResult], wall_ms: int,
181181
return report
182182

183183

184+
# ── How command ───────────────────────────────────────────────────────
185+
186+
def _extract_file_header(source: str) -> tuple[str|None, str]:
187+
"""Return (title, doc_body) from the leading --- / # comment block."""
188+
title = None
189+
doc_lines: list[str] = []
190+
for raw in source.splitlines():
191+
stripped = raw.strip()
192+
if not stripped:
193+
if doc_lines or title:
194+
doc_lines.append("")
195+
continue
196+
if stripped.startswith("---") and stripped.endswith("---") and title is None and not doc_lines:
197+
title = stripped[3:-3].strip()
198+
continue
199+
if stripped.startswith("#"):
200+
body = stripped[1:]
201+
if body and body[0] in (" ", "\t"):
202+
body = body[1:]
203+
doc_lines.append(body)
204+
continue
205+
break # first non-comment, non-blank line ends the header
206+
while doc_lines and not doc_lines[-1]:
207+
doc_lines.pop()
208+
while doc_lines and not doc_lines[0]:
209+
doc_lines.pop(0)
210+
return title, "\n".join(doc_lines)
211+
212+
213+
def _extract_set_vars(source: str) -> list[tuple[str, str]]:
214+
"""Return [(name, raw_expr)] for top-level set declarations, preserving env() etc. un-evaluated."""
215+
result: list[tuple[str, str]] = []
216+
seen: set[str] = set()
217+
for raw in source.splitlines():
218+
stripped = raw.strip()
219+
if stripped.startswith("#"):
220+
continue
221+
m = re.match(r'^set\s+(\w+)\s*=\s*(.+)', stripped)
222+
if m:
223+
name, expr = m.group(1), m.group(2).strip()
224+
if name == "stateless" or name in seen:
225+
continue
226+
result.append((name, expr))
227+
seen.add(name)
228+
return result
229+
230+
231+
def cmd_how(filepath: str):
232+
"""Show header documentation and configurable variables for a graph file."""
233+
path = Path(filepath)
234+
if not path.exists():
235+
print(red(f"error: file not found: {filepath}")); sys.exit(1)
236+
237+
source = path.read_text()
238+
title, doc_body = _extract_file_header(source)
239+
raw_vars = _extract_set_vars(source)
240+
241+
# Best-effort parse to flag secrets
242+
secret_names: set[str] = set()
243+
try:
244+
ast = parse_cg(source, filepath) if filepath.endswith(".cg") else parse_cgr(source, filepath)
245+
secret_names = {v.name for v in ast.variables if v.is_secret}
246+
except Exception:
247+
pass
248+
249+
print()
250+
251+
if title:
252+
width = min(len(title), 60)
253+
print(f" {bold(title)}")
254+
print(f" {dim('─' * width)}")
255+
print()
256+
257+
if doc_body:
258+
for line in doc_body.splitlines():
259+
print(f" {line}" if line else "")
260+
print()
261+
elif not title:
262+
print(f" {dim('(no documentation header)')}")
263+
print()
264+
265+
if raw_vars:
266+
print(f" {bold('Defaults')} {dim('(override with --set NAME=VALUE)')}")
267+
print()
268+
max_len = max(len(n) for n, _ in raw_vars)
269+
for name, expr in raw_vars:
270+
secret_tag = f" {dim('[secret]')}" if name in secret_names else ""
271+
print(f" {cyan(name.ljust(max_len))} {dim('=')} {expr}{secret_tag}")
272+
print()
273+
274+
184275
# ── Check command (live drift detection without prior run) ─────────────
185276

186277
def cmd_check(graph, *, max_parallel=4, verbose=False, json_output=False):
@@ -2075,7 +2166,7 @@ def cmd_doctor():
20752166
# Python version
20762167
ver = sys.version.split()[0]
20772168
major, minor = sys.version_info[:2]
2078-
ok = major >= 3 and minor >= 9
2169+
ok = (major, minor) >= (3, 9)
20792170
checks.append((ok, f"Python {ver}", "Requires 3.9+" if not ok else ""))
20802171

20812172
# SSH client

0 commit comments

Comments
 (0)