coxswain 0.1.0b1__tar.gz
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.
- coxswain-0.1.0b1/.github/workflows/docs.yml +27 -0
- coxswain-0.1.0b1/.github/workflows/publish.yml +16 -0
- coxswain-0.1.0b1/.github/workflows/test.yml +16 -0
- coxswain-0.1.0b1/.gitignore +4 -0
- coxswain-0.1.0b1/.pre-commit-config.yaml +8 -0
- coxswain-0.1.0b1/PKG-INFO +5 -0
- coxswain-0.1.0b1/README.md +7 -0
- coxswain-0.1.0b1/coxswain/__init__.py +5 -0
- coxswain-0.1.0b1/docs/_cli.py +71 -0
- coxswain-0.1.0b1/docs/_pull.py +215 -0
- coxswain-0.1.0b1/docs/_shots.py +99 -0
- coxswain-0.1.0b1/docs/assets/palette.css +61 -0
- coxswain-0.1.0b1/docs/assets/shell-sprite.svg +204 -0
- coxswain-0.1.0b1/docs/assets/shots/doctor.svg +97 -0
- coxswain-0.1.0b1/docs/assets/shots/install-dry-run.svg +77 -0
- coxswain-0.1.0b1/docs/assets/shots/route-status.svg +118 -0
- coxswain-0.1.0b1/docs/assets/shots/versions.svg +87 -0
- coxswain-0.1.0b1/docs/assets/sprite.css +61 -0
- coxswain-0.1.0b1/docs/components/cartridges.md +28 -0
- coxswain-0.1.0b1/docs/components/crew.md +28 -0
- coxswain-0.1.0b1/docs/components/desktop.md +27 -0
- coxswain-0.1.0b1/docs/components/graphs/docs/graphs/coxswain.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/docs/graphs/epic-reconcile.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/docs/graphs/epic-swarm.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/docs/graphs/initiative-decompose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/docs/graphs/lifecycle-propose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/docs/graphs/retro-propose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/docs/graphs/triage-propose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/graphs/delivery/epic-swarm.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/graphs/delivery/initiative-decompose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/graphs/delivery/lifecycle-propose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/graphs/ops/coxswain.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/graphs/ops/epic-reconcile.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/graphs/ops/retro-propose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs/graphs/ops/triage-propose.md +1 -0
- coxswain-0.1.0b1/docs/components/graphs.md +26 -0
- coxswain-0.1.0b1/docs/components/hud.md +28 -0
- coxswain-0.1.0b1/docs/components/index.md +18 -0
- coxswain-0.1.0b1/docs/components/tools.md +27 -0
- coxswain-0.1.0b1/docs/customize/fragments.md +36 -0
- coxswain-0.1.0b1/docs/customize/index.md +34 -0
- coxswain-0.1.0b1/docs/customize/policy.md +39 -0
- coxswain-0.1.0b1/docs/customize/providers.md +45 -0
- coxswain-0.1.0b1/docs/customize/skills.md +39 -0
- coxswain-0.1.0b1/docs/customize/team-cartridge.md +36 -0
- coxswain-0.1.0b1/docs/customize/write-a-graph.md +33 -0
- coxswain-0.1.0b1/docs/customize/write-a-skill.md +35 -0
- coxswain-0.1.0b1/docs/index.md +14 -0
- coxswain-0.1.0b1/docs/install/index.md +70 -0
- coxswain-0.1.0b1/docs/install/machines.md +45 -0
- coxswain-0.1.0b1/docs/install/providers.md +15 -0
- coxswain-0.1.0b1/docs/methodology/budgets.md +41 -0
- coxswain-0.1.0b1/docs/methodology/cartridges.md +61 -0
- coxswain-0.1.0b1/docs/methodology/crew-and-seats.md +44 -0
- coxswain-0.1.0b1/docs/methodology/graphs/coxswain.md +10 -0
- coxswain-0.1.0b1/docs/methodology/graphs/epic-reconcile.md +11 -0
- coxswain-0.1.0b1/docs/methodology/graphs/epic-swarm.md +10 -0
- coxswain-0.1.0b1/docs/methodology/graphs/initiative-decompose.md +11 -0
- coxswain-0.1.0b1/docs/methodology/graphs/lifecycle-propose.md +11 -0
- coxswain-0.1.0b1/docs/methodology/graphs/retro-propose.md +10 -0
- coxswain-0.1.0b1/docs/methodology/graphs/triage-propose.md +11 -0
- coxswain-0.1.0b1/docs/methodology/graphs.md +64 -0
- coxswain-0.1.0b1/docs/methodology/index.md +45 -0
- coxswain-0.1.0b1/docs/methodology/landing.md +43 -0
- coxswain-0.1.0b1/docs/methodology/records.md +37 -0
- coxswain-0.1.0b1/docs/methodology/review.md +50 -0
- coxswain-0.1.0b1/docs/overrides/main.html +20 -0
- coxswain-0.1.0b1/docs/reference/cli/index.md +6 -0
- coxswain-0.1.0b1/docs/releases/0.1.0-beta.1.md +96 -0
- coxswain-0.1.0b1/docs/releases/index.md +35 -0
- coxswain-0.1.0b1/docs/start/index.md +71 -0
- coxswain-0.1.0b1/docs/start/loop.md +58 -0
- coxswain-0.1.0b1/manifest.toml +34 -0
- coxswain-0.1.0b1/mkdocs.yml +90 -0
- coxswain-0.1.0b1/pyproject.toml +32 -0
- coxswain-0.1.0b1/tests/test_docs.py +491 -0
- coxswain-0.1.0b1/tests/test_manifest.py +44 -0
- coxswain-0.1.0b1/tests/test_packaging.py +42 -0
- coxswain-0.1.0b1/uv.lock +709 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: docs
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
permissions:
|
|
6
|
+
contents: write
|
|
7
|
+
jobs:
|
|
8
|
+
docs:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
with:
|
|
13
|
+
fetch-depth: 0
|
|
14
|
+
- uses: astral-sh/setup-uv@v5
|
|
15
|
+
- run: uv sync --group dev
|
|
16
|
+
- run: uv run python docs/_pull.py
|
|
17
|
+
- run: |
|
|
18
|
+
tag=$(uv run python -c 'import tomllib; print(tomllib.load(open("manifest.toml", "rb"))["components"]["tools"].get("tag", ""))')
|
|
19
|
+
uv tool install "git+https://github.com/ppfenning/coxswain-tools@${tag:-main}"
|
|
20
|
+
- run: uv run python docs/_cli.py
|
|
21
|
+
- id: version
|
|
22
|
+
run: echo "version=$(uv run python -c 'import tomllib; print(tomllib.load(open("manifest.toml", "rb"))["coxswain"]["version"])')" >> "$GITHUB_OUTPUT"
|
|
23
|
+
- run: |
|
|
24
|
+
git config user.name "github-actions[bot]"
|
|
25
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
26
|
+
- run: uv run mike deploy --push --update-aliases ${{ steps.version.outputs.version }} latest
|
|
27
|
+
- run: uv run mike set-default --push latest || true
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
jobs:
|
|
6
|
+
publish:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
environment: pypi
|
|
9
|
+
permissions:
|
|
10
|
+
id-token: write
|
|
11
|
+
contents: read
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: astral-sh/setup-uv@v5
|
|
15
|
+
- run: uv build
|
|
16
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
jobs:
|
|
7
|
+
test:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: astral-sh/setup-uv@v5
|
|
12
|
+
- run: uv sync --group dev
|
|
13
|
+
- run: uv run pytest -q
|
|
14
|
+
- name: ruff (rules in pyproject.toml; pre-commit mirrors it)
|
|
15
|
+
run: uv run ruff check .
|
|
16
|
+
- run: test ! -f mkdocs.yml || uv run mkdocs build --strict
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Local mirror of the CI lint job: `pre-commit install` once, then ruff runs on
|
|
2
|
+
# each commit with the same rules pyproject.toml / ruff.toml declares.
|
|
3
|
+
repos:
|
|
4
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
5
|
+
rev: v0.16.6
|
|
6
|
+
hooks:
|
|
7
|
+
- id: ruff
|
|
8
|
+
args: [--fix]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Coxswain
|
|
2
|
+
|
|
3
|
+
The umbrella for the agent platform: methodology docs, a version manifest that pins every component in lockstep, and a one-line installer. **Beta.**
|
|
4
|
+
|
|
5
|
+
The coxswain steers and calls the stroke for the crew and never rows.
|
|
6
|
+
|
|
7
|
+
Design: workspace `plans/agent-platform/2026-09-05-coxswain-design.md`.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Generate docs/reference/cli/ from `cox --help` at build time.
|
|
2
|
+
|
|
3
|
+
The core (`parse_subcommands`) is pure: given a block of argparse `--help`
|
|
4
|
+
text, it decides which names are subcommands, with no I/O. The edge (`main`)
|
|
5
|
+
runs `cox --help` and one `--help` per group and subcommand it discovers, and
|
|
6
|
+
writes nothing at all if `cox` isn't on PATH.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
REFERENCE_DIR = Path("docs/reference/cli")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_subcommands(help_text: str) -> list[str]:
|
|
20
|
+
"""Return the subcommand names listed under "positional arguments:".
|
|
21
|
+
|
|
22
|
+
Argparse indents the `{a,b,c}` placeholder line at one depth and each
|
|
23
|
+
named subcommand one depth deeper; only the deeper indent is a name.
|
|
24
|
+
"""
|
|
25
|
+
lines = help_text.splitlines()
|
|
26
|
+
starts = [i for i, line in enumerate(lines) if line.strip().startswith("positional arguments")]
|
|
27
|
+
if not starts:
|
|
28
|
+
return []
|
|
29
|
+
names = []
|
|
30
|
+
for line in lines[starts[0] + 1 :]:
|
|
31
|
+
if line and not line[:1].isspace():
|
|
32
|
+
break
|
|
33
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
34
|
+
stripped = line.strip()
|
|
35
|
+
if indent == 4 and stripped and not stripped.startswith("{"):
|
|
36
|
+
names.append(stripped.split()[0])
|
|
37
|
+
return names
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _help(*args: str) -> str:
|
|
41
|
+
return subprocess.run(["cox", *args, "--help"], capture_output=True, text=True, check=True).stdout
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _group_page(name: str, group_help: str, subcommands: list[tuple[str, str]]) -> str:
|
|
45
|
+
lines = [f"# {name}", "", "```", group_help.rstrip(), "```", ""]
|
|
46
|
+
for sub_name, sub_help in subcommands:
|
|
47
|
+
lines += [f"## {sub_name}", "", "```", sub_help.rstrip(), "```", ""]
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _index_page(names: list[str]) -> str:
|
|
52
|
+
lines = ["# CLI reference", "", "Generated from `cox --help` at build time.", ""]
|
|
53
|
+
lines += [f"- [{name}]({name}.md)" for name in names]
|
|
54
|
+
return "\n".join(lines) + "\n"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> None:
|
|
58
|
+
if shutil.which("cox") is None:
|
|
59
|
+
print("cox is not on PATH; skipping CLI reference generation")
|
|
60
|
+
return
|
|
61
|
+
names = parse_subcommands(_help())
|
|
62
|
+
REFERENCE_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
for name in names:
|
|
64
|
+
group_help = _help(name)
|
|
65
|
+
subcommands = [(sub, _help(name, sub)) for sub in parse_subcommands(group_help)]
|
|
66
|
+
(REFERENCE_DIR / f"{name}.md").write_text(_group_page(name, group_help, subcommands))
|
|
67
|
+
(REFERENCE_DIR / "index.md").write_text(_index_page(names))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
sys.exit(main())
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Pull each component's declared docs into docs/components/<name>/<path>.
|
|
2
|
+
|
|
3
|
+
The core (`plan`) is pure: given a parsed manifest, it decides which URLs to
|
|
4
|
+
fetch and where to write them, with no I/O. A `*.md` glob entry cannot be
|
|
5
|
+
expanded without asking GitHub what is in the directory, so `plan` returns it
|
|
6
|
+
as a single unresolved item; the edge (`_run`, called from `main`) expands it
|
|
7
|
+
via the GitHub contents API and does the actual fetching and writing.
|
|
8
|
+
|
|
9
|
+
Dest paths preserve each doc's full relative path (not just its basename)
|
|
10
|
+
under docs/components/<component>/, e.g. `graphs/delivery/epic-swarm.md`
|
|
11
|
+
lands at `docs/components/graphs/graphs/delivery/epic-swarm.md`. This is
|
|
12
|
+
deliberate: the graphs component pulls both a generated page
|
|
13
|
+
`docs/graphs/<name>.md` and a hand-written prose doc `graphs/<phase>/<name>.md`
|
|
14
|
+
for the same graph, and the two share a basename. Collapsing to the basename
|
|
15
|
+
would make the second overwrite the first.
|
|
16
|
+
|
|
17
|
+
A doc that fails to fetch is a build failure, not a quiet skip: `_run`
|
|
18
|
+
counts failures and `main` exits nonzero if any planned doc did not land, so
|
|
19
|
+
a stale pinned tag fails the docs workflow instead of publishing the
|
|
20
|
+
placeholder text in its place. A directory listing that resolves but holds
|
|
21
|
+
no `.md` files counts the same way; a glob that silently expands to nothing
|
|
22
|
+
is indistinguishable from success unless something says so. `_list_dir`
|
|
23
|
+
returns the ref that actually resolved (the pinned tag, or `main` on a 404)
|
|
24
|
+
alongside the file names, and `_glob_docs` (pure) turns that ref and those
|
|
25
|
+
names into concrete `doc` items the same shape `plan` already produces for
|
|
26
|
+
listed paths; `_run` fetches and writes that one shape without caring which
|
|
27
|
+
kind planned it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
import tomllib
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.request
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def raw_url(repo: str, ref: str, entry: str) -> str:
|
|
41
|
+
"""Pure: the raw.githubusercontent URL for one file at one ref."""
|
|
42
|
+
return f"https://raw.githubusercontent.com/{repo}/{ref}/{entry}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fallback_url(doc: dict) -> str | None:
|
|
46
|
+
"""Pure: the same document at `main`, or None when there is nowhere to fall back to.
|
|
47
|
+
|
|
48
|
+
One ref rule for every path: the pinned tag when it carries the file, else
|
|
49
|
+
`main`. The glob listing has always had this fallback because a tag may be
|
|
50
|
+
cut before a document exists. A listed path had the tag and nothing else,
|
|
51
|
+
so the same tag took the whole docs build down with it — and because the
|
|
52
|
+
pull step is required, down means publishing nothing.
|
|
53
|
+
"""
|
|
54
|
+
if not doc.get("repo") or not doc.get("entry") or doc.get("ref") == "main":
|
|
55
|
+
return None
|
|
56
|
+
return raw_url(doc["repo"], "main", doc["entry"])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _item(name: str, repo: str, tag: str, entry: str) -> dict:
|
|
60
|
+
if entry.endswith("/*.md"):
|
|
61
|
+
directory = entry[: -len("/*.md")]
|
|
62
|
+
return {
|
|
63
|
+
"kind": "glob",
|
|
64
|
+
"repo": repo,
|
|
65
|
+
"tag": tag,
|
|
66
|
+
"dir": directory,
|
|
67
|
+
"dest_dir": f"docs/components/{name}/{directory}",
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
"kind": "doc",
|
|
71
|
+
"repo": repo,
|
|
72
|
+
"entry": entry,
|
|
73
|
+
"ref": tag,
|
|
74
|
+
"url": raw_url(repo, tag, entry),
|
|
75
|
+
"dest": f"docs/components/{name}/{entry}",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def plan(manifest: dict) -> list[dict]:
|
|
80
|
+
"""Return one item per declared doc: a resolved `doc` or an unresolved `glob`.
|
|
81
|
+
|
|
82
|
+
Components declared with a `path` (in-repo, e.g. `desktop`) have no docs
|
|
83
|
+
to pull and are skipped, as does any repo component with no `docs` list.
|
|
84
|
+
"""
|
|
85
|
+
components = manifest.get("components", {})
|
|
86
|
+
return [
|
|
87
|
+
_item(name, info["repo"], info["tag"], entry)
|
|
88
|
+
for name, info in components.items()
|
|
89
|
+
if "repo" in info
|
|
90
|
+
for entry in info.get("docs", [])
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fetch(url: str) -> bytes:
|
|
95
|
+
with urllib.request.urlopen(url) as response:
|
|
96
|
+
return response.read()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write(dest: str, content: bytes) -> None:
|
|
100
|
+
destination = Path(dest)
|
|
101
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
destination.write_bytes(content)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _list_dir(repo: str, tag: str, directory: str) -> tuple[str, list[str]]:
|
|
106
|
+
"""Return the ref that resolved and the .md file names in `directory`.
|
|
107
|
+
|
|
108
|
+
The contents API 404s for a ref that does not exist yet (a tag not
|
|
109
|
+
pushed at the time docs build); `main` always resolves. The ref is
|
|
110
|
+
returned alongside the names because the raw fetch for each name must
|
|
111
|
+
use the ref the listing actually came from, not the ref that 404'd. A
|
|
112
|
+
non-404 response that isn't a directory listing (e.g. `directory` names
|
|
113
|
+
a file, not a folder) is a fetch failure too, not a crash: the caller
|
|
114
|
+
treats `URLError` as one skip, same as a 404.
|
|
115
|
+
"""
|
|
116
|
+
for ref in (tag, "main"):
|
|
117
|
+
url = f"https://api.github.com/repos/{repo}/contents/{directory}?ref={ref}"
|
|
118
|
+
try:
|
|
119
|
+
listing = json.loads(_fetch(url))
|
|
120
|
+
except urllib.error.HTTPError as exc:
|
|
121
|
+
if exc.code == 404 and ref == tag:
|
|
122
|
+
continue
|
|
123
|
+
raise
|
|
124
|
+
else:
|
|
125
|
+
if not isinstance(listing, list):
|
|
126
|
+
raise urllib.error.URLError(f"{url} did not return a directory listing")
|
|
127
|
+
return ref, [entry["name"] for entry in listing if entry["name"].endswith(".md")]
|
|
128
|
+
raise AssertionError("unreachable: main always resolves or raises")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _glob_docs(item: dict, ref: str, names: list[str]) -> list[dict]:
|
|
132
|
+
"""Turn a resolved glob directory listing into concrete `doc` items.
|
|
133
|
+
|
|
134
|
+
Pure: takes the ref and names `_list_dir` already resolved and returns
|
|
135
|
+
plain (url, dest) data in the same shape `plan` produces for a listed
|
|
136
|
+
path, so `_run` fetches and writes one shape regardless of which kind
|
|
137
|
+
planned it.
|
|
138
|
+
"""
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
"kind": "doc",
|
|
142
|
+
"url": f"https://raw.githubusercontent.com/{item['repo']}/{ref}/{item['dir']}/{name}",
|
|
143
|
+
"dest": f"{item['dest_dir']}/{name}",
|
|
144
|
+
}
|
|
145
|
+
for name in names
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve(items: list[dict]) -> tuple[list[dict], int]:
|
|
150
|
+
"""Expand every `glob` item into `doc` items; count each that failed to resolve.
|
|
151
|
+
|
|
152
|
+
A glob that lists zero `.md` files is counted as a failure: a directory
|
|
153
|
+
that is supposed to hold generated pages and comes back empty means the
|
|
154
|
+
pages were not generated yet, not that there is nothing to pull.
|
|
155
|
+
"""
|
|
156
|
+
docs: list[dict] = []
|
|
157
|
+
failures = 0
|
|
158
|
+
for item in items:
|
|
159
|
+
if item["kind"] != "glob":
|
|
160
|
+
docs.append(item)
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
ref, names = _list_dir(item["repo"], item["tag"], item["dir"])
|
|
164
|
+
except urllib.error.URLError as exc:
|
|
165
|
+
print(f"skip: {item['dir']}: {exc}")
|
|
166
|
+
failures += 1
|
|
167
|
+
continue
|
|
168
|
+
if not names:
|
|
169
|
+
print(f"skip: {item['dir']}: no .md files at {ref}")
|
|
170
|
+
failures += 1
|
|
171
|
+
continue
|
|
172
|
+
docs.extend(_glob_docs(item, ref, names))
|
|
173
|
+
return docs, failures
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _fetch_with_fallback(doc: dict, fetch=None) -> bytes:
|
|
177
|
+
"""Edge: fetch `doc` at its ref, and on a 404 at `main` instead.
|
|
178
|
+
|
|
179
|
+
`fetch` is injected so the ref rule is testable without a network.
|
|
180
|
+
"""
|
|
181
|
+
fetch = fetch or _fetch
|
|
182
|
+
try:
|
|
183
|
+
return fetch(doc["url"])
|
|
184
|
+
except urllib.error.HTTPError as exc:
|
|
185
|
+
alternate = fallback_url(doc) if exc.code == 404 else None
|
|
186
|
+
if alternate is None or alternate == doc["url"]:
|
|
187
|
+
raise
|
|
188
|
+
print(f"note: {doc['url']} is not at that ref; falling back to main")
|
|
189
|
+
return fetch(alternate)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _run(items: list[dict]) -> int:
|
|
193
|
+
"""Fetch and write every planned doc; return how many did not land."""
|
|
194
|
+
docs, failures = _resolve(items)
|
|
195
|
+
for doc in docs:
|
|
196
|
+
try:
|
|
197
|
+
content = _fetch_with_fallback(doc)
|
|
198
|
+
except urllib.error.URLError as exc:
|
|
199
|
+
print(f"skip: {doc['url']}: {exc}")
|
|
200
|
+
failures += 1
|
|
201
|
+
continue
|
|
202
|
+
_write(doc["dest"], content)
|
|
203
|
+
return failures
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main() -> int:
|
|
207
|
+
manifest = tomllib.loads(Path("manifest.toml").read_text())
|
|
208
|
+
failures = _run(plan(manifest))
|
|
209
|
+
if failures:
|
|
210
|
+
print(f"failed: {failures} doc(s) did not pull")
|
|
211
|
+
return 1 if failures else 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
sys.exit(main())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Render terminal screenshots of `cox` commands to SVG for the docs.
|
|
2
|
+
|
|
3
|
+
The core (`shot_plan`) is pure: it names each command to capture and the
|
|
4
|
+
argv to run it with, with no I/O. The edge (`main`) runs a maintainer's own
|
|
5
|
+
machine, capturing real output and writing SVGs; it does nothing but say so
|
|
6
|
+
and exit 0 if `cox` isn't on PATH. Rich is imported inside the edge function
|
|
7
|
+
so importing this module never requires it to be installed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SHOTS_DIR = Path("docs/assets/shots")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def shot_plan() -> list[tuple[str, list[str]]]:
|
|
21
|
+
"""Return the (name, argv) pairs to capture, one per screenshot."""
|
|
22
|
+
return [
|
|
23
|
+
("versions", ["cox", "versions", "--manifest", "manifest.toml"]),
|
|
24
|
+
("doctor", ["cox", "setup", "doctor"]),
|
|
25
|
+
("route-status", ["cox", "route", "status"]),
|
|
26
|
+
(
|
|
27
|
+
"install-dry-run",
|
|
28
|
+
["cox", "install", "--dry-run", "--manifest", "manifest.toml", "--root", "/tmp/x"],
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
MAX_LINES = 14
|
|
34
|
+
MAX_COLS = 100
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clip(output: str, max_lines: int = MAX_LINES, max_cols: int = MAX_COLS) -> str:
|
|
38
|
+
"""Pure: `output` bounded to a screenshot's worth of terminal.
|
|
39
|
+
|
|
40
|
+
A screenshot is an illustration, not a dump. Two things have to be bounded,
|
|
41
|
+
not one: `cox route status` prints a line per run the workspace has ever
|
|
42
|
+
recorded, and each of those lines carries a whole quarantine reason, so a
|
|
43
|
+
line cap alone still wraps into hundreds of rendered rows. Lines past
|
|
44
|
+
`max_lines` and columns past `max_cols` are cut, and each cut is marked so
|
|
45
|
+
the reader knows the terminal said more than the picture shows.
|
|
46
|
+
"""
|
|
47
|
+
lines = output.splitlines()
|
|
48
|
+
kept = [line if len(line) <= max_cols else line[: max_cols - 1] + "\u2026" for line in lines[:max_lines]]
|
|
49
|
+
if len(lines) > max_lines:
|
|
50
|
+
kept.append(f"... {len(lines) - max_lines} more lines")
|
|
51
|
+
return "\n".join(kept)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def redact(output: str, home: str) -> str:
|
|
55
|
+
"""Pure: the maintainer's home directory replaced by `~`.
|
|
56
|
+
|
|
57
|
+
These SVGs ship on a public site. The commands print real profile and
|
|
58
|
+
workspace paths, which carry a username; `~` is what a reader needs and
|
|
59
|
+
all they should get. `home` is passed in rather than read, so the rule is
|
|
60
|
+
testable and this function has no side door.
|
|
61
|
+
"""
|
|
62
|
+
return output.replace(home.rstrip("/"), "~") if home else output
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _capture(argv: list[str], home: str) -> str:
|
|
66
|
+
result = subprocess.run(argv, capture_output=True, text=True, check=False)
|
|
67
|
+
return clip(redact(result.stdout + result.stderr, home))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
if shutil.which("cox") is None:
|
|
72
|
+
print("cox is not on PATH; skipping screenshot capture")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
from rich.color_triplet import ColorTriplet
|
|
76
|
+
from rich.console import Console
|
|
77
|
+
from rich.terminal_theme import TerminalTheme
|
|
78
|
+
|
|
79
|
+
background = ColorTriplet(0x0B, 0x16, 0x22)
|
|
80
|
+
foreground = ColorTriplet(0xE8, 0xEE, 0xF2)
|
|
81
|
+
primary = ColorTriplet(0x2A, 0x9D, 0x8F)
|
|
82
|
+
accent = ColorTriplet(0xE9, 0xC4, 0x6A)
|
|
83
|
+
muted = ColorTriplet(0x9F, 0xB3, 0xC8)
|
|
84
|
+
cox_dark_theme = TerminalTheme(
|
|
85
|
+
background,
|
|
86
|
+
foreground,
|
|
87
|
+
[background, accent, primary, accent, primary, accent, muted, foreground],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
for name, argv in shot_plan():
|
|
92
|
+
console = Console(record=True, width=100)
|
|
93
|
+
console.print(_capture(argv, str(Path.home())))
|
|
94
|
+
svg = console.export_svg(title=" ".join(argv), theme=cox_dark_theme)
|
|
95
|
+
(SHOTS_DIR / f"{name}.svg").write_text(svg)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Coxswain palette: dark by default (slate), a light toggle (default). */
|
|
2
|
+
/* Contrast pairs used for body text against the page background: */
|
|
3
|
+
/* slate: text #e8eef2 on background #0b1622 -> ~16.9:1 */
|
|
4
|
+
/* default: text #14202b on background #f6f9fb -> ~16.3:1 */
|
|
5
|
+
/* Both clear the 7:1 body-text bar with headroom for the muted tone too. */
|
|
6
|
+
|
|
7
|
+
[data-md-color-scheme="slate"] {
|
|
8
|
+
--cox-color-background: #0b1622;
|
|
9
|
+
--cox-color-surface: #10202e;
|
|
10
|
+
--cox-color-primary: #2a9d8f;
|
|
11
|
+
--cox-color-accent: #e9c46a;
|
|
12
|
+
--cox-color-text: #e8eef2;
|
|
13
|
+
--cox-color-muted: #9fb3c8;
|
|
14
|
+
|
|
15
|
+
--md-primary-fg-color: var(--cox-color-primary);
|
|
16
|
+
--md-accent-fg-color: var(--cox-color-accent);
|
|
17
|
+
--md-default-bg-color: var(--cox-color-background);
|
|
18
|
+
--md-default-fg-color: var(--cox-color-text);
|
|
19
|
+
--md-code-bg-color: var(--cox-color-surface);
|
|
20
|
+
--md-typeset-a-color: var(--cox-color-primary);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
[data-md-color-scheme="default"] {
|
|
24
|
+
--cox-color-background: #f6f9fb;
|
|
25
|
+
--cox-color-surface: #ffffff;
|
|
26
|
+
--cox-color-primary: #1d6f66;
|
|
27
|
+
--cox-color-accent: #b8860b;
|
|
28
|
+
--cox-color-text: #14202b;
|
|
29
|
+
--cox-color-muted: #4a5b6b;
|
|
30
|
+
|
|
31
|
+
--md-primary-fg-color: var(--cox-color-primary);
|
|
32
|
+
--md-accent-fg-color: var(--cox-color-accent);
|
|
33
|
+
--md-default-bg-color: var(--cox-color-background);
|
|
34
|
+
--md-default-fg-color: var(--cox-color-text);
|
|
35
|
+
--md-code-bg-color: var(--cox-color-surface);
|
|
36
|
+
--md-typeset-a-color: var(--cox-color-primary);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* The beta banner: accent text over a translucent wash of the primary colour. */
|
|
40
|
+
.md-banner {
|
|
41
|
+
background-color: color-mix(in srgb, var(--cox-color-primary) 25%, transparent);
|
|
42
|
+
color: var(--cox-color-accent);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The prev/next bar at the top of each page body. */
|
|
46
|
+
.cox-page-nav {
|
|
47
|
+
display: flex;
|
|
48
|
+
gap: 1rem;
|
|
49
|
+
padding: 0.5rem 0 1rem;
|
|
50
|
+
font-size: 0.75rem;
|
|
51
|
+
color: var(--cox-color-muted);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.cox-page-nav a {
|
|
55
|
+
color: var(--cox-color-primary);
|
|
56
|
+
text-decoration: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.cox-page-nav__next {
|
|
60
|
+
margin-left: auto;
|
|
61
|
+
}
|