@tanlinfeng/project-docs-workflow 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +73 -152
  2. package/bin/install.js +9 -3
  3. package/package.json +1 -1
  4. package/skills/docs-html-view/SKILL.md +119 -0
  5. package/skills/docs-html-view/agents/openai.yaml +5 -0
  6. package/skills/docs-html-view/assets/conf.py.template +44 -0
  7. package/skills/docs-html-view/assets/guard.py +128 -0
  8. package/skills/docs-html-view/references/technical.md +151 -0
  9. package/skills/docs-html-view/requirements.txt +5 -0
  10. package/skills/docs-html-view/scripts/gen_nav.py +306 -0
  11. package/skills/docs-html-view/scripts/prepare_env.py +154 -0
  12. package/skills/project-docs-workflow/SKILL.md +15 -6
  13. package/skills/project-docs-workflow/assets/templates/entries/current-module.md +19 -0
  14. package/skills/project-docs-workflow/assets/templates/entries/pdr-spec.md +1 -1
  15. package/skills/project-docs-workflow/assets/templates/project/adr-readme.md +2 -1
  16. package/skills/project-docs-workflow/assets/templates/project/agents-block.md +1 -1
  17. package/skills/project-docs-workflow/assets/templates/project/agents-issues.md +6 -5
  18. package/skills/project-docs-workflow/assets/templates/project/agents-workflow.md +9 -6
  19. package/skills/project-docs-workflow/assets/templates/project/current-00-readme.md +42 -0
  20. package/skills/project-docs-workflow/assets/templates/project/pdr-00-readme.md +6 -7
  21. package/skills/project-docs-workflow/references/adr.md +5 -1
  22. package/skills/project-docs-workflow/references/current.md +39 -0
  23. package/skills/project-docs-workflow/references/issues.md +3 -2
  24. package/skills/project-docs-workflow/references/pdr.md +15 -10
  25. package/skills/project-docs-workflow/references/rendering.md +29 -0
  26. package/skills/project-docs-workflow/references/workflow.md +6 -5
  27. package/skills/project-docs-workflow/assets/templates/entries/pdr-current.md +0 -19
@@ -0,0 +1,151 @@
1
+ # docs-html-view technical reference
2
+
3
+ ## Pinned dependencies
4
+
5
+ Verified on PyPI 2026-10-14; the set is mutually compatible. Sphinx 9.1.0 requires Python >= 3.12.
6
+
7
+ | Package | Pin | Note |
8
+ | :--- | :--- | :--- |
9
+ | Sphinx | `Sphinx==9.1.0` | needs Python >= 3.12 |
10
+ | MyST parser | `myst-parser==5.1.0` | Markdown support |
11
+ | RTD theme | `sphinx-rtd-theme==3.1.0` | stock theme via `html_theme` |
12
+
13
+ `requirements.txt` ships this declaration. `prepare_env.py` installs nothing unless `--install` is passed; the caller runs it only after approval.
14
+
15
+ ## Layout
16
+
17
+ ```
18
+ <project root>/
19
+ .venv-docs/ # default render venv (or the project's docs env)
20
+ docs/ # Sphinx source root
21
+ pdr|current|adr/ # approved trees, never modified
22
+ issues/ agents/ ... # excluded
23
+ docsHtmlView/ # workspace
24
+ conf.py # from assets/conf.py.template
25
+ guard.py # copied from assets/guard.py
26
+ index.md # generated landing page (marked)
27
+ nav/<tree>/**/index.md # generated toctrees (marked)
28
+ docs-html-view.config.json # optional, user-authored
29
+ build/html/docsHtmlView/index.html # landing page (root_doc = docsHtmlView/index)
30
+ ```
31
+
32
+ `docsHtmlView` is the workspace directory name, not the site name.
33
+
34
+ ## Files shipped by this skill
35
+
36
+ - `scripts/prepare_env.py` — create/reuse a recognized venv; optional pinned install / freeze. Stdlib only.
37
+ - `scripts/gen_nav.py` — generate `index.md` + `nav/**` toctrees (marked files only).
38
+ - `assets/conf.py.template` — the managed `conf.py` for a new site.
39
+ - `assets/guard.py` — the parse/render guard extension.
40
+ - `requirements.txt` — the pins.
41
+
42
+ ## Commands
43
+
44
+ Inspect is done with normal read-only tools; there is no inspect subcommand and nothing runs the project interpreter before approval. After approval, define the prepared environment's absolute paths from the project root (override `--venv` to reuse a project docs env):
45
+
46
+ ```bash
47
+ SKILL="${SKILL:-$HOME/.agents/skills/docs-html-view}"
48
+ SITE_TITLE="APPROVED TITLE" # the confirmed project/site title
49
+ DOCS_PY="$PWD/.venv-docs/bin/python" # venv python
50
+ DOCS_SPHINXBUILD="$PWD/.venv-docs/bin/sphinx-build" # venv sphinx-build
51
+ ```
52
+
53
+ First-time setup (new site only; skip if `docs/docsHtmlView/` already exists and instead show a manual merge plan):
54
+
55
+ ```bash
56
+ python3 "$SKILL"/scripts/prepare_env.py --project-root . --venv .venv-docs
57
+ python3 "$SKILL"/scripts/prepare_env.py --project-root . --venv .venv-docs --install
58
+ mkdir -p docs/docsHtmlView
59
+ ( cd docs/docsHtmlView && "$DOCS_PY" -m sphinx.cmd.quickstart -q -p "$SITE_TITLE" -a "AUTHOR" -v "1.0" --no-sep )
60
+ # apply assets/conf.py.template -> docs/docsHtmlView/conf.py
61
+ # tokens are UNQUOTED; replace __SITE_TITLE__/__AUTHOR__/__RELEASE__ with repr(value)
62
+ # copy assets/guard.py -> docs/docsHtmlView/guard.py
63
+ # remove the generated docs/docsHtmlView/index.rst
64
+ # Makefile: SOURCEDIR = .. BUILDDIR = build SPHINXOPTS = -c . -w build/warnings.log
65
+ "$DOCS_PY" "$SKILL"/scripts/gen_nav.py --project-root . --title "$SITE_TITLE"
66
+ mkdir -p docs/docsHtmlView/build
67
+ ( cd docs/docsHtmlView && make html SPHINXBUILD="$DOCS_SPHINXBUILD" SPHINXOPTS='-c . -w build/warnings.log' )
68
+ ```
69
+
70
+ Repeat render (same approved scope and title, no re-ask):
71
+
72
+ ```bash
73
+ "$DOCS_PY" "$SKILL"/scripts/gen_nav.py --project-root . --title "$SITE_TITLE"
74
+ mkdir -p docs/docsHtmlView/build
75
+ ( cd docs/docsHtmlView && make html SPHINXBUILD="$DOCS_SPHINXBUILD" SPHINXOPTS='-c . -w build/warnings.log' )
76
+ ```
77
+
78
+ No `make`:
79
+
80
+ ```bash
81
+ mkdir -p docs/docsHtmlView/build
82
+ ( cd docs/docsHtmlView && "$DOCS_SPHINXBUILD" -b html -c . -d build/doctrees -w build/warnings.log .. build/html )
83
+ ```
84
+
85
+ `gen_nav.py` exits `1` when no approved tree contains documents, so a later `make` cannot silently build a stale site. Warnings (missing/empty trees) go to stderr; `--json` prints the summary on stdout. Without `--title` the site title defaults to `Documentation`; pass the approved `--title` (or set `site_title`) so repeat renders keep the agreed title.
86
+
87
+ Optional resolved-version snapshot (approved output):
88
+
89
+ ```bash
90
+ python3 "$SKILL"/scripts/prepare_env.py --project-root . --venv .venv-docs \
91
+ --freeze docs/docsHtmlView/requirements.lock.txt
92
+ ```
93
+
94
+ `prepare_env.py` flags: `--project-root` (default cwd), `--venv` (default `.venv-docs`, relative to project root unless absolute), `--base-python` (interpreter used to create the venv), `--requirements`, `--install`, `--freeze PATH`. It refuses to reuse a path that exists without `pyvenv.cfg`, refuses Python < 3.12, and refuses `--install`/`--freeze` unless the interpreter actually belongs to the target venv. It never installs Python/uv and never uses global/system pip.
95
+
96
+ `gen_nav.py` flags: `--project-root` (default cwd), `--title` (site title override), `--json`.
97
+
98
+
99
+ ## Sphinx configuration
100
+
101
+ `SOURCEDIR=..`, config dir `docs/docsHtmlView` (`-c .`), `BUILDDIR=build` → output `build/html`; `SPHINXOPTS = -c . -w build/warnings.log` (create `build/` first) so warnings are captured for reporting. `root_doc = docsHtmlView/index`, so the landing page is `build/html/docsHtmlView/index.html` (the `build/html/` layout is otherwise unchanged; no redirect or extra page).
102
+
103
+ `conf.py` uses:
104
+
105
+ - `include_patterns` — **enumerated**: `docsHtmlView/index.md`, `docsHtmlView/nav/**`, and one `<root>/**` per approved tree. Not a `docsHtmlView/**` wildcard.
106
+ - `exclude_patterns` — build, `.venv-docs`, workspace venvs, VCS internals.
107
+ - `myst_enable_extensions = ["colon_fence", "deflist", "fieldlist"]`, `myst_heading_anchors = 3`.
108
+ - `docs_html_view_allowed_roots` — the same roots, read by `guard.py`.
109
+
110
+ **Changing `allowed_dirs` is a manual, approved edit with no automatic sync.** Update all three by hand — `include_patterns` and `docs_html_view_allowed_roots` in `conf.py`, and `allowed_dirs` in `docs-html-view.config.json` — then re-run `gen_nav.py`; a mismatch between them must be resolved before `make`. Missing/empty trees are fine (reported and skipped).
111
+
112
+ ### Guard (`assets/guard.py`)
113
+
114
+ - `builder-inited`: registers a refusing directive for `include`, `literalinclude`, `raw` (case-insensitive; also hit via `eval-rst` and colon fences), and validates each allowed root is a simple relative name resolving inside `docs`.
115
+ - `doctree-read` (priority 100, before Sphinx's asset collectors at 500): rejects any `raw` node that is not purely HTML comment(s)+whitespace (so PDR-template comments pass, active markup fails); for images and `download_reference` nodes, resolves the original reference via `app.env.relfn2path`, requires it inside an approved root using `realpath`/`commonpath` (symlinks rejected), or raises `SphinxError` before any copy; remote targets are replaced with an inline placeholder and a Sphinx warning, never fetched.
116
+ - This is the authoritative boundary and the reason no regex-only scan is used. It guards trusted docs; `conf.py` is trusted executable code and is not sandboxed.
117
+
118
+ ## Optional project config
119
+
120
+ `docs/docsHtmlView/docs-html-view.config.json` is user-authored and never overwritten. It must be a JSON object; a parse error, a non-object, a non-string `site_title`, a non-string `navigation_order`, or an unsafe `allowed_dirs` entry is refused (not silently dropped).
121
+
122
+ | Key | Read by | Effect |
123
+ | :--- | :--- | :--- |
124
+ | `site_title` | `gen_nav.py` | site title (also settable via `gen_nav.py --title`) |
125
+ | `allowed_dirs` | `gen_nav.py` | explicit trees; one directory name each (no separators, not hidden, not `build`/`docsHtmlView`); defaults to `pdr`, `current`, `adr` |
126
+ | `navigation_order` | `gen_nav.py` | list of path strings relative to `docs/` placed first, in order; then numeric prefix, then case-insensitive name |
127
+ | `author`, `release` | human / agent only | template token values when applying `conf.py.template`; not read by the scripts |
128
+ | `python` | human / agent only | preferred interpreter when choosing the venv path; not read by the scripts |
129
+
130
+ ## Navigation rules
131
+
132
+ - Marked generated files only (`% docs-html-view: generated navigation; do not edit`, a MyST comment); unmarked collisions and symlinks are refused.
133
+ - **Preflight before any write:** approved dirs, source trees, and any existing `nav/` (including ancestors) are checked for symlinks; every output path and existing collision is validated before the first write or prune, so a refusal leaves the tree unchanged.
134
+ - `gen_nav.py` exits `1` when no approved tree has documents, and prints warnings (missing/empty trees) to stderr.
135
+ - Hierarchy mirrors the tree; each directory gets a `{toctree}` of its documents and child-index links; entry titles come from the source H1 (path-only toctree entries), so no source text can inject MyST.
136
+ - Directory/site headings are sanitized (no `<`, `>`, backticks, braces, brackets, pipes, newlines).
137
+ - The full ADR tree renders, including retired/deleted tombstones.
138
+ - Re-running prunes only marked stale nav files; user files are never touched.
139
+
140
+
141
+ ## Approval and consent
142
+
143
+ The Skill asks once, before any write/install/scaffold, covering packages, environment, files, `.gitignore` additions, output path, and title. There is no stored consent state or fingerprint. Repeated renders inside the approved scope do not re-ask; dependency upgrades, overwriting existing files, or changing the approved roots/range require a fresh ask. Missing trees or malformed required config/venv → report and stop, never guess. The template tokens are unquoted, so substitute each with `repr(value)` (valid, escaped Python literal), never raw interpolation into executable config.
144
+
145
+ ## Limitations
146
+
147
+ - `.md` / `.rst` only; no `.mdx`.
148
+ - `include` / `literalinclude` / `raw` refused.
149
+ - Remote assets become inline placeholders plus a warning; active HTML is refused (only HTML comments are allowed), and `conf.py` is trusted code.
150
+ - No custom theme, browser, server, or deployment.
151
+ - Failed `make` may leave stale/partial `build/` output; no publisher normalises it.
@@ -0,0 +1,5 @@
1
+ # docs-html-view — pinned render dependencies (verified on PyPI 2026-10-14)
2
+ # Sphinx 9.1.0 requires Python >= 3.12.
3
+ Sphinx==9.1.0
4
+ myst-parser==5.1.0
5
+ sphinx-rtd-theme==3.1.0
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env python3
2
+ """Generate docs-html-view navigation under docs/docsHtmlView.
3
+
4
+ Writes only marked files inside the workspace: ``docsHtmlView/index.md`` and
5
+ ``docsHtmlView/nav/**/index.md``. Never writes into source trees. Validates every
6
+ symlink and collision in a preflight pass *before* the first write or prune, so a
7
+ refusal leaves the tree unchanged. Removes only stale files carrying the marker.
8
+ Runs before ``make html``; it never invokes Sphinx itself.
9
+
10
+ Exit codes: 0 = generated (or nothing needed); 1 = refused, or no documents in
11
+ the approved trees (so a later ``make`` cannot silently build a stale site).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import posixpath
19
+ import re
20
+ import sys
21
+
22
+ MARK = "% docs-html-view: generated navigation; do not edit"
23
+ DEFAULT_ROOTS = ["pdr", "current", "adr"]
24
+ SUFFIXES = (".md", ".rst")
25
+ RESERVED = {"build", "docsHtmlView"}
26
+ BIG = float("inf")
27
+
28
+
29
+ def die(msg: str) -> None:
30
+ sys.exit("refusing: " + msg)
31
+
32
+
33
+ def warn(msg: str) -> None:
34
+ print("warning: " + msg, file=sys.stderr)
35
+
36
+
37
+ def sanitize(text: str) -> str:
38
+ text = re.sub(r"[<>`{}\[\]|]", "", text)
39
+ return re.sub(r"\s+", " ", text).strip()
40
+
41
+
42
+ def display_name(name: str) -> str:
43
+ name = re.sub(r"^\d+[-_. ]*", "", name).replace("-", " ").replace("_", " ").strip()
44
+ return sanitize(name) or "section"
45
+
46
+
47
+ def num_key(name: str):
48
+ match = re.match(r"^(\d+)", name)
49
+ return (0, int(match.group(1))) if match else (1, 0)
50
+
51
+
52
+ def has_docs(path: str) -> bool:
53
+ for _dirpath, _dirnames, filenames in os.walk(path, followlinks=False):
54
+ if any(f.lower().endswith(SUFFIXES) for f in filenames):
55
+ return True
56
+ return False
57
+
58
+
59
+ def file_owned(path: str) -> bool:
60
+ try:
61
+ with open(path, encoding="utf-8") as fh:
62
+ return fh.read().lstrip().startswith(MARK)
63
+ except OSError:
64
+ die("cannot read " + path)
65
+
66
+
67
+ def read_config(workspace: str) -> dict:
68
+ path = os.path.join(workspace, "docs-html-view.config.json")
69
+ if not os.path.lexists(path):
70
+ return {}
71
+ if os.path.islink(path):
72
+ die("config is a symlink: " + path)
73
+ try:
74
+ with open(path, encoding="utf-8") as fh:
75
+ data = json.load(fh)
76
+ except (OSError, ValueError) as exc:
77
+ die("cannot parse config %s: %s" % (path, exc))
78
+ if not isinstance(data, dict):
79
+ die("config must be a JSON object: " + path)
80
+ return data
81
+
82
+
83
+ def validate_dir_name(name: str) -> None:
84
+ if not name or name in (".", ".."):
85
+ die("invalid allowed_dirs entry: %r" % (name,))
86
+ if "/" in name or "\\" in name:
87
+ die("allowed_dirs entry must be one directory name, no separators: %r" % (name,))
88
+ if name.startswith("."):
89
+ die("allowed_dirs entry must not be hidden: %r" % (name,))
90
+ if name in RESERVED:
91
+ die("allowed_dirs entry may not be %r" % (name,))
92
+
93
+
94
+ def resolve_roots(cfg: dict, docs: str):
95
+ allowed = cfg.get("allowed_dirs")
96
+ if allowed is None:
97
+ candidates = list(DEFAULT_ROOTS)
98
+ else:
99
+ if not isinstance(allowed, list):
100
+ die("allowed_dirs must be a list of simple directory names")
101
+ for name in allowed:
102
+ if not isinstance(name, str):
103
+ die("allowed_dirs entries must be strings")
104
+ validate_dir_name(name)
105
+ candidates = list(dict.fromkeys(allowed))
106
+
107
+ present, missing = [], []
108
+ for name in candidates:
109
+ path = os.path.join(docs, name)
110
+ if os.path.islink(path):
111
+ die("approved dir is a symlink: " + path)
112
+ if os.path.isdir(path) and has_docs(path):
113
+ present.append(name)
114
+ else:
115
+ missing.append(name)
116
+ return present, missing
117
+
118
+
119
+ def assert_no_symlinks(base: str, label: str) -> None:
120
+ if not os.path.isdir(base):
121
+ return
122
+ for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
123
+ for name in list(dirnames) + filenames:
124
+ path = os.path.join(dirpath, name)
125
+ if os.path.islink(path):
126
+ die("symlink under %s: %s" % (label, path))
127
+
128
+
129
+ def toctree(entries, maxdepth=1) -> str:
130
+ if not entries:
131
+ return "<!-- (no documents) -->\n"
132
+ lines = ["```{toctree}", ":maxdepth: %d" % maxdepth, ""] + list(entries) + ["```"]
133
+ return "\n".join(lines) + "\n"
134
+
135
+
136
+ def build_outputs(docs, workspace, roots, order_map, site_title):
137
+ outputs = {}
138
+
139
+ def order_key(base_rel):
140
+ def key(name):
141
+ rel = posixpath.join(base_rel, name) if base_rel else name
142
+ return (order_map.get(rel, BIG), num_key(name), name.casefold())
143
+ return key
144
+
145
+ def generate(source_root, rel_parts, src_dir):
146
+ base_rel = posixpath.join(source_root, *rel_parts)
147
+ out_rel = posixpath.join("docsHtmlView", "nav", source_root, *rel_parts)
148
+ children = []
149
+ for name in sorted(os.listdir(src_dir)):
150
+ path = os.path.join(src_dir, name)
151
+ if os.path.islink(path):
152
+ die("symlink in source tree: " + path)
153
+ if os.path.isdir(path):
154
+ if has_docs(path):
155
+ children.append((name, "dir"))
156
+ elif name.lower().endswith(SUFFIXES):
157
+ children.append((name, "file"))
158
+ children.sort(key=lambda item: order_key(base_rel)(item[0]))
159
+
160
+ entries = []
161
+ for name, kind in children:
162
+ if kind == "dir":
163
+ entries.append(posixpath.join(name, "index"))
164
+ else:
165
+ target = posixpath.join(source_root, *rel_parts, name)
166
+ entries.append(posixpath.relpath(target, out_rel))
167
+
168
+ title = display_name(rel_parts[-1]) if rel_parts else display_name(source_root)
169
+ out_file = os.path.join(docs, *out_rel.split("/"), "index.md")
170
+ outputs[out_file] = MARK + "\n\n# " + title + "\n\n" + toctree(entries)
171
+
172
+ for name, kind in children:
173
+ if kind == "dir":
174
+ generate(source_root, list(rel_parts) + [name], os.path.join(src_dir, name))
175
+
176
+ for source_root in roots:
177
+ generate(source_root, [], os.path.join(docs, source_root))
178
+
179
+ index_entries = [posixpath.join("nav", r, "index") for r in roots]
180
+ outputs[os.path.join(workspace, "index.md")] = (
181
+ MARK + "\n\n# " + site_title + "\n\n" + toctree(index_entries, maxdepth=2)
182
+ )
183
+ return outputs
184
+
185
+
186
+ def collision_preflight(outputs, nav_root) -> None:
187
+ out_abs = {os.path.abspath(p) for p in outputs}
188
+ for path in sorted(out_abs):
189
+ if os.path.islink(path):
190
+ die("symlinked output: " + path)
191
+ if os.path.lexists(path) and not file_owned(path):
192
+ die("existing unmarked file: " + path)
193
+ if os.path.isdir(nav_root):
194
+ for dirpath, _dirnames, filenames in os.walk(nav_root, followlinks=False):
195
+ for name in filenames:
196
+ path = os.path.abspath(os.path.join(dirpath, name))
197
+ if path in out_abs:
198
+ continue
199
+ if os.path.islink(path):
200
+ die("symlinked nav file: " + path)
201
+ if not file_owned(path):
202
+ die("unmarked file under nav/: " + path)
203
+
204
+
205
+ def verify_inside(outputs, workspace) -> None:
206
+ workspace_real = os.path.realpath(workspace)
207
+ for path in outputs:
208
+ real = os.path.realpath(path)
209
+ if real != workspace_real and not real.startswith(workspace_real + os.sep):
210
+ die("output escapes workspace: " + path)
211
+
212
+
213
+ def write_outputs(outputs) -> None:
214
+ for path in sorted(outputs):
215
+ os.makedirs(os.path.dirname(path), exist_ok=True)
216
+ with open(path, "w", encoding="utf-8", newline="\n") as fh:
217
+ fh.write(outputs[path])
218
+
219
+
220
+ def prune(nav_root, outputs) -> None:
221
+ out_abs = {os.path.abspath(p) for p in outputs}
222
+ if not os.path.isdir(nav_root):
223
+ return
224
+ for dirpath, _dirnames, filenames in os.walk(nav_root, followlinks=False):
225
+ for name in filenames:
226
+ path = os.path.abspath(os.path.join(dirpath, name))
227
+ if path not in out_abs and file_owned(path):
228
+ os.remove(path)
229
+ for dirpath, _dirs, _files in os.walk(nav_root, topdown=False):
230
+ if dirpath == nav_root:
231
+ continue
232
+ try:
233
+ os.rmdir(dirpath) # only succeeds when empty
234
+ except OSError:
235
+ pass
236
+
237
+
238
+ def main() -> None:
239
+ ap = argparse.ArgumentParser(description="Generate docs-html-view navigation (marked files only).")
240
+ ap.add_argument("--project-root", default=os.getcwd())
241
+ ap.add_argument("--title", default=None, help="site title override")
242
+ ap.add_argument("--json", action="store_true", help="print a machine-readable summary on stdout")
243
+ args = ap.parse_args()
244
+
245
+ root = os.path.abspath(args.project_root)
246
+ docs = os.path.join(root, "docs")
247
+ if not os.path.isdir(docs):
248
+ die("no docs/ directory at " + root)
249
+ if os.path.islink(docs):
250
+ die("docs/ is a symlink")
251
+
252
+ workspace = os.path.join(docs, "docsHtmlView")
253
+ if not os.path.isdir(workspace):
254
+ die("no docs/docsHtmlView workspace; run quickstart/setup first")
255
+ if os.path.islink(workspace):
256
+ die("docsHtmlView/ is a symlink")
257
+
258
+ cfg = read_config(workspace)
259
+ site_title_cfg = cfg.get("site_title")
260
+ if site_title_cfg is not None and not isinstance(site_title_cfg, str):
261
+ die("site_title must be a string")
262
+ order = cfg.get("navigation_order")
263
+ if order is None:
264
+ order = []
265
+ elif not isinstance(order, list) or not all(isinstance(x, str) for x in order):
266
+ die("navigation_order must be a list of strings")
267
+ order_map = {rel.strip("/"): i for i, rel in enumerate(order)}
268
+
269
+ roots, missing = resolve_roots(cfg, docs)
270
+ if missing:
271
+ warn("missing/empty trees: " + ", ".join(missing))
272
+ if not roots:
273
+ print(
274
+ "no documents in the approved trees; nothing to scaffold (run after adding docs)",
275
+ file=sys.stderr,
276
+ )
277
+ sys.exit(1)
278
+
279
+ for name in roots:
280
+ assert_no_symlinks(os.path.join(docs, name), "source tree " + name)
281
+ nav_root = os.path.join(workspace, "nav")
282
+ if os.path.islink(nav_root):
283
+ die("nav/ is a symlink")
284
+ assert_no_symlinks(nav_root, "nav/")
285
+
286
+ site_title = sanitize(str(site_title_cfg or args.title or "Documentation"))
287
+ outputs = build_outputs(docs, workspace, roots, order_map, site_title)
288
+ collision_preflight(outputs, nav_root)
289
+ verify_inside(outputs, workspace)
290
+
291
+ write_outputs(outputs)
292
+ prune(nav_root, outputs)
293
+
294
+ if args.json:
295
+ print(
296
+ json.dumps(
297
+ {"roots": roots, "generated": sorted(os.path.relpath(p, root) for p in outputs)},
298
+ indent=2,
299
+ )
300
+ )
301
+ else:
302
+ print("generated %d nav file(s) for roots: %s" % (len(outputs), ", ".join(roots)))
303
+
304
+
305
+ if __name__ == "__main__":
306
+ main()
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare the docs render environment for docs-html-view.
3
+
4
+ Stdlib only. Never installs Python or uv, and never touches global/system
5
+ site-packages. It creates or reuses one *recognized* virtualenv and, only when
6
+ explicitly asked, installs the pinned requirements into that venv.
7
+
8
+ Run this only AFTER the user has approved the packages, the environment, and the
9
+ files (the Skill asks; this helper stores no consent state).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import subprocess
16
+ import sys
17
+
18
+ MIN_PY = (3, 12)
19
+
20
+
21
+ def venv_python(venv: str) -> str:
22
+ if os.name == "nt":
23
+ return os.path.join(venv, "Scripts", "python.exe")
24
+ return os.path.join(venv, "bin", "python")
25
+
26
+
27
+ def is_recognized_venv(venv: str) -> bool:
28
+ return os.path.isfile(os.path.join(venv, "pyvenv.cfg"))
29
+
30
+
31
+ def python_version(python: str):
32
+ code = "import sys;print('%d.%d' % sys.version_info[:2])"
33
+ try:
34
+ out = subprocess.run(
35
+ [python, "-I", "-c", code], capture_output=True, text=True, check=True
36
+ ).stdout.strip()
37
+ except (OSError, subprocess.CalledProcessError) as exc:
38
+ return None, str(exc)
39
+ try:
40
+ return tuple(int(x) for x in out.split(".")[:2]), out
41
+ except ValueError:
42
+ return None, out
43
+
44
+
45
+ def run(cmd, cwd=None):
46
+ print("+ " + " ".join(cmd))
47
+ return subprocess.run(cmd, cwd=cwd)
48
+
49
+
50
+ def has_pip(python: str) -> bool:
51
+ try:
52
+ return subprocess.run(
53
+ [python, "-m", "pip", "--version"], capture_output=True, text=True
54
+ ).returncode == 0
55
+ except OSError:
56
+ return False
57
+
58
+
59
+ def venv_prefix(python: str):
60
+ try:
61
+ out = subprocess.run(
62
+ [python, "-c", "import sys;print(sys.prefix)"], capture_output=True, text=True
63
+ )
64
+ except OSError:
65
+ return None
66
+ return out.stdout.strip() if out.returncode == 0 else None
67
+
68
+
69
+ def pip_in_venv(python: str, venv: str):
70
+ if not has_pip(python):
71
+ return False, "pip is not available in the interpreter"
72
+ prefix = venv_prefix(python)
73
+ if prefix is None or os.path.realpath(prefix) != os.path.realpath(venv):
74
+ return False, "interpreter does not belong to %s (sys.prefix=%s)" % (venv, prefix)
75
+ return True, ""
76
+
77
+
78
+ def main() -> None:
79
+ default_req = os.path.join(
80
+ os.path.dirname(os.path.abspath(__file__)), os.pardir, "requirements.txt"
81
+ )
82
+ ap = argparse.ArgumentParser(
83
+ description="Create/reuse the docs render venv and optionally install pinned requirements."
84
+ )
85
+ ap.add_argument("--project-root", default=os.getcwd())
86
+ ap.add_argument("--venv", default=".venv-docs", help="venv path (relative to project root unless absolute)")
87
+ ap.add_argument("--base-python", default=None, help="interpreter used to create the venv (default: this one)")
88
+ ap.add_argument("--requirements", default=default_req)
89
+ ap.add_argument("--install", action="store_true", help="install the pinned requirements (post-consent)")
90
+ ap.add_argument("--freeze", metavar="PATH", default=None, help="write `pip freeze` output to PATH (post-consent)")
91
+ args = ap.parse_args()
92
+
93
+ root = os.path.abspath(args.project_root)
94
+ venv = args.venv if os.path.isabs(args.venv) else os.path.join(root, args.venv)
95
+ requirements = os.path.abspath(args.requirements)
96
+
97
+ if os.path.lexists(venv) and not is_recognized_venv(venv):
98
+ sys.exit("refusing: %s exists but is not a virtualenv (no pyvenv.cfg); move it aside first" % venv)
99
+
100
+ if not is_recognized_venv(venv):
101
+ base = args.base_python or sys.executable
102
+ ver, raw = python_version(base)
103
+ if ver is None or ver < MIN_PY:
104
+ sys.exit("refusing: %s reports %s; need Python >= %d.%d" % (base, raw, *MIN_PY))
105
+ print("+ %s -m venv %s" % (base, venv))
106
+ created = subprocess.run(
107
+ [base, "-m", "venv", venv], capture_output=True, text=True
108
+ )
109
+ if created.returncode != 0:
110
+ detail = (created.stderr or created.stdout or "").strip()
111
+ sys.exit(
112
+ "failed to create virtualenv at %s (exit %d).\n%s\n"
113
+ "Ensure python3-venv/ensurepip is available, or pass --venv pointing at "
114
+ "an existing project docs environment." % (venv, created.returncode, detail)
115
+ )
116
+ else:
117
+ print("reusing virtualenv: " + venv)
118
+
119
+ py = venv_python(venv)
120
+ ver, raw = python_version(py)
121
+ if ver is None or ver < MIN_PY:
122
+ sys.exit("refusing: venv python reports %s; need Python >= %d.%d" % (raw, *MIN_PY))
123
+
124
+ if args.install or args.freeze:
125
+ ok, why = pip_in_venv(py, venv)
126
+ if not ok:
127
+ sys.exit("refusing: %s; point --venv at a working docs environment" % why)
128
+
129
+ if args.install:
130
+ if not os.path.isfile(requirements):
131
+ sys.exit("requirements not found: " + requirements)
132
+ result = run(
133
+ [py, "-m", "pip", "install", "--no-input", "--disable-pip-version-check", "-r", requirements],
134
+ cwd=root,
135
+ )
136
+ if result.returncode != 0:
137
+ sys.exit("pip install failed (exit %d)" % result.returncode)
138
+
139
+ if args.freeze:
140
+ result = subprocess.run([py, "-m", "pip", "freeze"], capture_output=True, text=True)
141
+ if result.returncode != 0:
142
+ sys.exit("pip freeze failed (exit %d)" % result.returncode)
143
+ dest = args.freeze if os.path.isabs(args.freeze) else os.path.join(root, args.freeze)
144
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
145
+ with open(dest, "w", encoding="utf-8", newline="\n") as fh:
146
+ fh.write(result.stdout)
147
+ print("wrote freeze: " + dest)
148
+
149
+ print("python: " + py)
150
+ print("install: " + ("yes" if args.install else "no"))
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()