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.
Files changed (79) hide show
  1. coxswain-0.1.0b1/.github/workflows/docs.yml +27 -0
  2. coxswain-0.1.0b1/.github/workflows/publish.yml +16 -0
  3. coxswain-0.1.0b1/.github/workflows/test.yml +16 -0
  4. coxswain-0.1.0b1/.gitignore +4 -0
  5. coxswain-0.1.0b1/.pre-commit-config.yaml +8 -0
  6. coxswain-0.1.0b1/PKG-INFO +5 -0
  7. coxswain-0.1.0b1/README.md +7 -0
  8. coxswain-0.1.0b1/coxswain/__init__.py +5 -0
  9. coxswain-0.1.0b1/docs/_cli.py +71 -0
  10. coxswain-0.1.0b1/docs/_pull.py +215 -0
  11. coxswain-0.1.0b1/docs/_shots.py +99 -0
  12. coxswain-0.1.0b1/docs/assets/palette.css +61 -0
  13. coxswain-0.1.0b1/docs/assets/shell-sprite.svg +204 -0
  14. coxswain-0.1.0b1/docs/assets/shots/doctor.svg +97 -0
  15. coxswain-0.1.0b1/docs/assets/shots/install-dry-run.svg +77 -0
  16. coxswain-0.1.0b1/docs/assets/shots/route-status.svg +118 -0
  17. coxswain-0.1.0b1/docs/assets/shots/versions.svg +87 -0
  18. coxswain-0.1.0b1/docs/assets/sprite.css +61 -0
  19. coxswain-0.1.0b1/docs/components/cartridges.md +28 -0
  20. coxswain-0.1.0b1/docs/components/crew.md +28 -0
  21. coxswain-0.1.0b1/docs/components/desktop.md +27 -0
  22. coxswain-0.1.0b1/docs/components/graphs/docs/graphs/coxswain.md +1 -0
  23. coxswain-0.1.0b1/docs/components/graphs/docs/graphs/epic-reconcile.md +1 -0
  24. coxswain-0.1.0b1/docs/components/graphs/docs/graphs/epic-swarm.md +1 -0
  25. coxswain-0.1.0b1/docs/components/graphs/docs/graphs/initiative-decompose.md +1 -0
  26. coxswain-0.1.0b1/docs/components/graphs/docs/graphs/lifecycle-propose.md +1 -0
  27. coxswain-0.1.0b1/docs/components/graphs/docs/graphs/retro-propose.md +1 -0
  28. coxswain-0.1.0b1/docs/components/graphs/docs/graphs/triage-propose.md +1 -0
  29. coxswain-0.1.0b1/docs/components/graphs/graphs/delivery/epic-swarm.md +1 -0
  30. coxswain-0.1.0b1/docs/components/graphs/graphs/delivery/initiative-decompose.md +1 -0
  31. coxswain-0.1.0b1/docs/components/graphs/graphs/delivery/lifecycle-propose.md +1 -0
  32. coxswain-0.1.0b1/docs/components/graphs/graphs/ops/coxswain.md +1 -0
  33. coxswain-0.1.0b1/docs/components/graphs/graphs/ops/epic-reconcile.md +1 -0
  34. coxswain-0.1.0b1/docs/components/graphs/graphs/ops/retro-propose.md +1 -0
  35. coxswain-0.1.0b1/docs/components/graphs/graphs/ops/triage-propose.md +1 -0
  36. coxswain-0.1.0b1/docs/components/graphs.md +26 -0
  37. coxswain-0.1.0b1/docs/components/hud.md +28 -0
  38. coxswain-0.1.0b1/docs/components/index.md +18 -0
  39. coxswain-0.1.0b1/docs/components/tools.md +27 -0
  40. coxswain-0.1.0b1/docs/customize/fragments.md +36 -0
  41. coxswain-0.1.0b1/docs/customize/index.md +34 -0
  42. coxswain-0.1.0b1/docs/customize/policy.md +39 -0
  43. coxswain-0.1.0b1/docs/customize/providers.md +45 -0
  44. coxswain-0.1.0b1/docs/customize/skills.md +39 -0
  45. coxswain-0.1.0b1/docs/customize/team-cartridge.md +36 -0
  46. coxswain-0.1.0b1/docs/customize/write-a-graph.md +33 -0
  47. coxswain-0.1.0b1/docs/customize/write-a-skill.md +35 -0
  48. coxswain-0.1.0b1/docs/index.md +14 -0
  49. coxswain-0.1.0b1/docs/install/index.md +70 -0
  50. coxswain-0.1.0b1/docs/install/machines.md +45 -0
  51. coxswain-0.1.0b1/docs/install/providers.md +15 -0
  52. coxswain-0.1.0b1/docs/methodology/budgets.md +41 -0
  53. coxswain-0.1.0b1/docs/methodology/cartridges.md +61 -0
  54. coxswain-0.1.0b1/docs/methodology/crew-and-seats.md +44 -0
  55. coxswain-0.1.0b1/docs/methodology/graphs/coxswain.md +10 -0
  56. coxswain-0.1.0b1/docs/methodology/graphs/epic-reconcile.md +11 -0
  57. coxswain-0.1.0b1/docs/methodology/graphs/epic-swarm.md +10 -0
  58. coxswain-0.1.0b1/docs/methodology/graphs/initiative-decompose.md +11 -0
  59. coxswain-0.1.0b1/docs/methodology/graphs/lifecycle-propose.md +11 -0
  60. coxswain-0.1.0b1/docs/methodology/graphs/retro-propose.md +10 -0
  61. coxswain-0.1.0b1/docs/methodology/graphs/triage-propose.md +11 -0
  62. coxswain-0.1.0b1/docs/methodology/graphs.md +64 -0
  63. coxswain-0.1.0b1/docs/methodology/index.md +45 -0
  64. coxswain-0.1.0b1/docs/methodology/landing.md +43 -0
  65. coxswain-0.1.0b1/docs/methodology/records.md +37 -0
  66. coxswain-0.1.0b1/docs/methodology/review.md +50 -0
  67. coxswain-0.1.0b1/docs/overrides/main.html +20 -0
  68. coxswain-0.1.0b1/docs/reference/cli/index.md +6 -0
  69. coxswain-0.1.0b1/docs/releases/0.1.0-beta.1.md +96 -0
  70. coxswain-0.1.0b1/docs/releases/index.md +35 -0
  71. coxswain-0.1.0b1/docs/start/index.md +71 -0
  72. coxswain-0.1.0b1/docs/start/loop.md +58 -0
  73. coxswain-0.1.0b1/manifest.toml +34 -0
  74. coxswain-0.1.0b1/mkdocs.yml +90 -0
  75. coxswain-0.1.0b1/pyproject.toml +32 -0
  76. coxswain-0.1.0b1/tests/test_docs.py +491 -0
  77. coxswain-0.1.0b1/tests/test_manifest.py +44 -0
  78. coxswain-0.1.0b1/tests/test_packaging.py +42 -0
  79. 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,4 @@
1
+ .venv/
2
+ site/
3
+ __pycache__/
4
+ .pytest_cache/
@@ -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,5 @@
1
+ Metadata-Version: 2.5
2
+ Name: coxswain
3
+ Version: 0.1.0b1
4
+ Summary: Coxswain: the agent platform. The command is `cox`, from the coxswain-tools package.
5
+ Requires-Python: >=3.11
@@ -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,5 @@
1
+ """Coxswain: the agent platform. The command is `cox`, from the coxswain-tools package."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("coxswain")
@@ -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
+ }