diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index 3d13e248a..e68c830e2 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -4,15 +4,16 @@ description: >- Governs Python code style for ptr727/ProjectTemplate fleet repos: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, src layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test - conventions, and versioning. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever running or choosing a Python formatting, lint, type-check, - or test command, whenever deciding whether a Python subtree is a shippable project or a lint-only - scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or whenever writing or - reviewing a Python test. Triggers even when the task looks like a small local fix ("just add a - helper function", "silence this lint warning", "add a dependency") or verification step ("run - the tests"), because choosing pytest before reading the profile turns an intentional unittest - suite into a false missing-dependency diagnosis. Applies only to a repo's Python side, a repo - with no Python has no use for this Skill. + conventions, versioning, and the --help and unknown-argument rule for a script. Use this + whenever writing, reviewing, or editing a .py file, a pyproject.toml, or a uv.lock, whenever + running or choosing a Python formatting, lint, type-check, or test command, whenever deciding + whether a Python subtree is a shippable project or a lint-only scripts tree, whenever choosing + pyright versus mypy for a repo's CI gate, or whenever writing or reviewing a Python test. + Triggers even when the task looks like a small local fix ("just add a helper function", "silence + this lint warning", "add a dependency") or verification step ("run the tests"), because choosing + pytest before reading the profile turns an intentional unittest suite into a false + missing-dependency diagnosis. Applies only to a repo's Python side, a repo with no Python has no + use for this Skill. --- # Python Codestyle @@ -247,6 +248,17 @@ Before pushing or opening a PR: - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + + [docs-link]: https://docs.pytest.org/ diff --git a/.agents/skills/shell-codestyle/SKILL.md b/.agents/skills/shell-codestyle/SKILL.md index 1708f1dfc..99f15b37e 100644 --- a/.agents/skills/shell-codestyle/SKILL.md +++ b/.agents/skills/shell-codestyle/SKILL.md @@ -4,16 +4,17 @@ description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the - shellcheck-plus-shfmt clean-compile, and the why-not-what comment rule. Use this whenever - writing, reviewing, or editing a shell script (a `.sh` file, or an extensionless bash/sh shebang - script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline - built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure, or - whenever a script or probe writes a wrapper or shim named after a command it also calls. - Triggers even when the task looks like a one-line tweak to an existing script, because a missing - `-e`/`pipefail`, or a reader piped straight from a producer that closes the pipe early, are each - invisible until the exact failure mode they guard against actually happens. Fleet-wide: a shell - script can appear in any repo (a bootstrap that installs the interpreter, a host tool that must - run before a toolchain exists), not only a repo whose primary language is shell. + shellcheck-plus-shfmt clean-compile, the --help and unknown-argument rule, and the why-not-what + comment rule. Use this whenever writing, reviewing, or editing a shell script (a `.sh` file, or + an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash + or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it + silently swallowed a failure, or whenever a script or probe writes a wrapper or shim named after + a command it also calls. Triggers even when the task looks like a one-line tweak to an existing + script, because a missing `-e`/`pipefail`, or a reader piped straight from a producer that + closes the pipe early, are each invisible until the exact failure mode they guard against + actually happens. Fleet-wide: a shell script can appear in any repo (a bootstrap that installs + the interpreter, a host tool that must run before a toolchain exists), not only a repo whose + primary language is shell. --- # Shell Codestyle @@ -75,3 +76,14 @@ depend on Python either. Everything else is Python, with a test under its own sc - **Comments say why, never what.** The code states what it does. A comment restating it goes stale silently, where a comment carrying a reason fails visibly when the reason stops being true. + +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle index 66bd085bd..3f2b2d701 100644 --- a/.claude-plugin/fleet-skills/.source-digests/python-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -1 +1 @@ -4a55b75c694378d2 +aee8b6541472e858 diff --git a/.claude-plugin/fleet-skills/.source-digests/shell-codestyle b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle index 188cb067b..b98d14882 100644 --- a/.claude-plugin/fleet-skills/.source-digests/shell-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle @@ -1 +1 @@ -52454c88dd2aedc0 +3880094e1fe1db20 diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index 3d13e248a..e68c830e2 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -4,15 +4,16 @@ description: >- Governs Python code style for ptr727/ProjectTemplate fleet repos: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, src layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test - conventions, and versioning. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever running or choosing a Python formatting, lint, type-check, - or test command, whenever deciding whether a Python subtree is a shippable project or a lint-only - scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or whenever writing or - reviewing a Python test. Triggers even when the task looks like a small local fix ("just add a - helper function", "silence this lint warning", "add a dependency") or verification step ("run - the tests"), because choosing pytest before reading the profile turns an intentional unittest - suite into a false missing-dependency diagnosis. Applies only to a repo's Python side, a repo - with no Python has no use for this Skill. + conventions, versioning, and the --help and unknown-argument rule for a script. Use this + whenever writing, reviewing, or editing a .py file, a pyproject.toml, or a uv.lock, whenever + running or choosing a Python formatting, lint, type-check, or test command, whenever deciding + whether a Python subtree is a shippable project or a lint-only scripts tree, whenever choosing + pyright versus mypy for a repo's CI gate, or whenever writing or reviewing a Python test. + Triggers even when the task looks like a small local fix ("just add a helper function", "silence + this lint warning", "add a dependency") or verification step ("run the tests"), because choosing + pytest before reading the profile turns an intentional unittest suite into a false + missing-dependency diagnosis. Applies only to a repo's Python side, a repo with no Python has no + use for this Skill. --- # Python Codestyle @@ -247,6 +248,17 @@ Before pushing or opening a PR: - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + + [docs-link]: https://docs.pytest.org/ diff --git a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md index 1708f1dfc..99f15b37e 100644 --- a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md @@ -4,16 +4,17 @@ description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the - shellcheck-plus-shfmt clean-compile, and the why-not-what comment rule. Use this whenever - writing, reviewing, or editing a shell script (a `.sh` file, or an extensionless bash/sh shebang - script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline - built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure, or - whenever a script or probe writes a wrapper or shim named after a command it also calls. - Triggers even when the task looks like a one-line tweak to an existing script, because a missing - `-e`/`pipefail`, or a reader piped straight from a producer that closes the pipe early, are each - invisible until the exact failure mode they guard against actually happens. Fleet-wide: a shell - script can appear in any repo (a bootstrap that installs the interpreter, a host tool that must - run before a toolchain exists), not only a repo whose primary language is shell. + shellcheck-plus-shfmt clean-compile, the --help and unknown-argument rule, and the why-not-what + comment rule. Use this whenever writing, reviewing, or editing a shell script (a `.sh` file, or + an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash + or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it + silently swallowed a failure, or whenever a script or probe writes a wrapper or shim named after + a command it also calls. Triggers even when the task looks like a one-line tweak to an existing + script, because a missing `-e`/`pipefail`, or a reader piped straight from a producer that + closes the pipe early, are each invisible until the exact failure mode they guard against + actually happens. Fleet-wide: a shell script can appear in any repo (a bootstrap that installs + the interpreter, a host tool that must run before a toolchain exists), not only a repo whose + primary language is shell. --- # Shell Codestyle @@ -75,3 +76,14 @@ depend on Python either. Everything else is Python, with a test under its own sc - **Comments say why, never what.** The code states what it does. A comment restating it goes stale silently, where a comment carrying a reason fails visibly when the reason stops being true. + +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + diff --git a/.github/actions/python-directories/python_directories.py b/.github/actions/python-directories/python_directories.py index 7224e78d1..279b5e7f5 100755 --- a/.github/actions/python-directories/python_directories.py +++ b/.github/actions/python-directories/python_directories.py @@ -23,6 +23,7 @@ from __future__ import annotations +import argparse import io import os import posixpath @@ -30,7 +31,7 @@ import sys import tomllib import uuid -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path UNCOVERED_SHOWN = 10 @@ -127,7 +128,10 @@ def uncovered(directories: list[str], tracked: list[str]) -> list[str]: ] -def main() -> int: +def main(argv: Sequence[str] = ()) -> int: + argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None).parse_args( + list(argv) + ) # A tracked path need not be valid UTF-8, and a warning naming one must not crash the step it only warns in. if isinstance(sys.stdout, io.TextIOWrapper): sys.stdout.reconfigure(errors="backslashreplace") @@ -187,4 +191,4 @@ def read_once(path: str) -> str: if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index 3d13e248a..e68c830e2 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -4,15 +4,16 @@ description: >- Governs Python code style for ptr727/ProjectTemplate fleet repos: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, src layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test - conventions, and versioning. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever running or choosing a Python formatting, lint, type-check, - or test command, whenever deciding whether a Python subtree is a shippable project or a lint-only - scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or whenever writing or - reviewing a Python test. Triggers even when the task looks like a small local fix ("just add a - helper function", "silence this lint warning", "add a dependency") or verification step ("run - the tests"), because choosing pytest before reading the profile turns an intentional unittest - suite into a false missing-dependency diagnosis. Applies only to a repo's Python side, a repo - with no Python has no use for this Skill. + conventions, versioning, and the --help and unknown-argument rule for a script. Use this + whenever writing, reviewing, or editing a .py file, a pyproject.toml, or a uv.lock, whenever + running or choosing a Python formatting, lint, type-check, or test command, whenever deciding + whether a Python subtree is a shippable project or a lint-only scripts tree, whenever choosing + pyright versus mypy for a repo's CI gate, or whenever writing or reviewing a Python test. + Triggers even when the task looks like a small local fix ("just add a helper function", "silence + this lint warning", "add a dependency") or verification step ("run the tests"), because choosing + pytest before reading the profile turns an intentional unittest suite into a false + missing-dependency diagnosis. Applies only to a repo's Python side, a repo with no Python has no + use for this Skill. --- # Python Codestyle @@ -247,6 +248,17 @@ Before pushing or opening a PR: - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + + [docs-link]: https://docs.pytest.org/ diff --git a/.github/skills/shell-codestyle/SKILL.md b/.github/skills/shell-codestyle/SKILL.md index 1708f1dfc..99f15b37e 100644 --- a/.github/skills/shell-codestyle/SKILL.md +++ b/.github/skills/shell-codestyle/SKILL.md @@ -4,16 +4,17 @@ description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the - shellcheck-plus-shfmt clean-compile, and the why-not-what comment rule. Use this whenever - writing, reviewing, or editing a shell script (a `.sh` file, or an extensionless bash/sh shebang - script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline - built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure, or - whenever a script or probe writes a wrapper or shim named after a command it also calls. - Triggers even when the task looks like a one-line tweak to an existing script, because a missing - `-e`/`pipefail`, or a reader piped straight from a producer that closes the pipe early, are each - invisible until the exact failure mode they guard against actually happens. Fleet-wide: a shell - script can appear in any repo (a bootstrap that installs the interpreter, a host tool that must - run before a toolchain exists), not only a repo whose primary language is shell. + shellcheck-plus-shfmt clean-compile, the --help and unknown-argument rule, and the why-not-what + comment rule. Use this whenever writing, reviewing, or editing a shell script (a `.sh` file, or + an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash + or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it + silently swallowed a failure, or whenever a script or probe writes a wrapper or shim named after + a command it also calls. Triggers even when the task looks like a one-line tweak to an existing + script, because a missing `-e`/`pipefail`, or a reader piped straight from a producer that + closes the pipe early, are each invisible until the exact failure mode they guard against + actually happens. Fleet-wide: a shell script can appear in any repo (a bootstrap that installs + the interpreter, a host tool that must run before a toolchain exists), not only a repo whose + primary language is shell. --- # Shell Codestyle @@ -75,3 +76,14 @@ depend on Python either. Everything else is Python, with a test under its own sc - **Comments say why, never what.** The code states what it does. A comment restating it goes stale silently, where a comment carrying a reason fails visibly when the reason stops being true. + +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + diff --git a/CODESTYLE.md b/CODESTYLE.md index 719905334..94ca521d9 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -34,6 +34,13 @@ Each language defines a **clean-compile** verification: the combination of build - **Let the real operation be the check, not a prediction of it.** Attempt the read, the write, or the call itself, and turn its failure into a clean refusal. Do not probe first with an existence, type, or permission test. Where the operation could block or succeed on the wrong kind of target, judge what it opened instead. A probe is a second model of the operation. It misjudges each case its author did not foresee, such as a dangling link, a link loop, or a platform's own permission model. The operation cannot disagree with itself. - **A failure message states the error, the path or other target, and the remedy, and claims nothing about the state the failure left.** The error the operation raised is the one fact the code holds at that point. A description of what the failure left behind is a prediction. It reads false once an unforeseen state reaches the message, such as a file written in full that then fails to close. A remedy such as "fix what it names, then re-run" needs no such claim. +### Command-Line Arguments + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + ### Markdown and Spelling These apply repo-wide, in every directory: Markdown lints clean via `markdownlint-cli2` against the shared config, spelling is US English via CSpell against the shared `cspell.json`, the CI spelling gate covers `README.md` and `HISTORY.md` only, `HISTORY.md` mirrors the README's opening, and "Markdown" is a proper noun in prose. A repo excluding a subtree of its own that it does not treat as authored prose, a committed data archive, a vendored theme, or a hand-maintained record, puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content rather than editing the shared root config, whose contents are fleet-fixed. Those `ignores` patterns resolve against the directory holding them rather than against the repo root, so a repo-root-relative entry there matches nothing and reports no error saying so, and excluding through the CI workflow's own negated Markdown glob input instead is a CI-only fix that leaves the same files flagged for anyone running the linter locally. The full rules are in the `comment-and-doc-style` Skill referenced above. @@ -56,7 +63,7 @@ This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codes ## Shell -Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on Python either. Everything else is Python, with a test under its own scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. Read the skill for the full rules. Run the clean-compile check itself per `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo, not by probing `command -v shellcheck`. +Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on Python either. Everything else is Python, with a test under its own scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. Read the skill for the full rules. A script's own arguments follow "Command-Line Arguments" under General, which the Skill carries whole. Run the clean-compile check itself per `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo, not by probing `command -v shellcheck`. diff --git a/catalog/snippets/hub-fetch-run.py b/catalog/snippets/hub-fetch-run.py index f9354901a..cc68b2e8a 100644 --- a/catalog/snippets/hub-fetch-run.py +++ b/catalog/snippets/hub-fetch-run.py @@ -102,12 +102,16 @@ def resolve_unborn_head(argv: list[str]) -> list[str]: return out +USAGE = "usage: hub-fetch-run.py [script-args...]" + + def main(argv: list[str]) -> int: - if not argv: - print( - "hub-fetch-run: usage: hub-fetch-run.py [script-args...]", - file=sys.stderr, - ) + if argv[:1] in (["-h"], ["--help"]): + print(USAGE) + return 0 + if not argv or argv[0].startswith("-"): + problem = f"unknown option {argv[0]!r}, " if argv else "" + print(f"hub-fetch-run: {problem}{USAGE}", file=sys.stderr) return 2 hub_path, script_args = argv[0], resolve_unborn_head(argv[1:]) is_prose_gate = hub_path == PROSE_GATE_PATH diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 709c335aa..0d81b8a0b 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -1462,6 +1462,8 @@ function Resolve-Selection { function main { if ($script:WANT_HELP) { usage; exit 0 } + $unknown = @($script:WANT_TOOLS | Where-Object { $_ -like '-*' }) + if ($unknown.Count -gt 0) { die "Unknown option `"$($unknown[0])`", -Help lists the options" } $script:MODE = Resolve-Mode Test-HostSupported Add-RepositoryToolCatalog diff --git a/host-setup/windows/setup-wsl.ps1 b/host-setup/windows/setup-wsl.ps1 index 1de1958e6..cc5f0282d 100644 --- a/host-setup/windows/setup-wsl.ps1 +++ b/host-setup/windows/setup-wsl.ps1 @@ -338,7 +338,10 @@ function Resolve-Mode { function main { if ($script:WANT_HELP) { usage; exit 0 } + $unknown = @($script:WANT_NAMES | Where-Object { $_ -like '-*' }) + if ($unknown.Count -gt 0) { die "Unknown option `"$($unknown[0])`", -Help lists the options" } $script:MODE = Resolve-Mode + if ($script:MODE -ne 'install' -and $script:WANT_NAMES.Count -gt 0) { die "Only -Install takes a distribution, and `"$($script:WANT_NAMES[0])`" was given" } switch ($script:MODE) { 'list' { Show-List } diff --git a/spec/fidelity_honesty.py b/spec/fidelity_honesty.py index c43a3881b..11ae380f6 100755 --- a/spec/fidelity_honesty.py +++ b/spec/fidelity_honesty.py @@ -21,6 +21,7 @@ Usage: python3 spec/fidelity_honesty.py [--report] """ +import argparse import base64 import sys @@ -379,9 +380,12 @@ def buckets(path): return "\n".join(out).rstrip() + "\n" -def main(): - argv = sys.argv[1:] - report_mode = "--report" in argv +def main(argv=()): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument( + "--report", action="store_true", help=f"write {REPORT_PATH} rather than print" + ) + report_mode = parser.parse_args(list(argv)).report spec = { "registry": audit.load("registry/repos.json"), "files": audit.load("spec/files.json"), @@ -442,4 +446,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/spec/resolve_description.py b/spec/resolve_description.py index c9b756152..5ec0b42c9 100755 --- a/spec/resolve_description.py +++ b/spec/resolve_description.py @@ -15,6 +15,7 @@ from __future__ import annotations +import argparse import json import sys from pathlib import Path @@ -71,11 +72,12 @@ def resolve_description(registry: dict, name: str) -> str | None: return desc -def main() -> int: - if len(sys.argv) != 3: - print("usage: resolve_description.py REGISTRY_PATH NAME", file=sys.stderr) - return 1 - registry_path, name = sys.argv[1], sys.argv[2] +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument("registry_path", metavar="REGISTRY_PATH") + parser.add_argument("name", metavar="NAME") + args = parser.parse_args(argv) + registry_path, name = args.registry_path, args.name try: registry = json.loads(Path(registry_path).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: @@ -92,4 +94,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/spec/validate.py b/spec/validate.py index 94bb9389b..53a6caef5 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -7,6 +7,7 @@ dry-run the CI lint job runs; it needs no third-party packages. """ +import argparse import collections import fnmatch import json @@ -594,7 +595,10 @@ def utf8_console() -> None: reconfigure(encoding="utf-8", errors="replace") -def main(): +def main(argv=()): + argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None).parse_args( + list(argv) + ) utf8_console() errors = [] repos = load("registry/repos.json") @@ -1537,4 +1541,4 @@ def check_selector(where, applies_to): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/spec/workflow_reuse.py b/spec/workflow_reuse.py index ca91b0f4e..66eab4433 100755 --- a/spec/workflow_reuse.py +++ b/spec/workflow_reuse.py @@ -24,6 +24,7 @@ Usage: python3 spec/workflow_reuse.py [--report] [--selftest] """ +import argparse import base64 import difflib import re @@ -398,9 +399,16 @@ def reader(slug, path, ref): return 0 -def main(): - argv = sys.argv[1:] - if "--selftest" in argv: +def main(argv=()): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument( + "--report", action="store_true", help=f"write {REPORT_PATH} rather than print" + ) + parser.add_argument( + "--selftest", action="store_true", help="run the offline self-test and exit" + ) + args = parser.parse_args(list(argv)) + if args.selftest: return _selftest() registry = audit.load("registry/repos.json") hub_entry = next( @@ -409,7 +417,7 @@ def main(): ) hub_slug = audit.repo_slug(hub_entry) rows, unreadable, empty = measure(registry, canonical_texts(), hub_slug) - if "--report" in argv: + if args.report: content = render_report(rows, unreadable, empty, hub_sha()) # LF matches the fleet default, since reports/*.md is LF. # Bytes are written so the local platform does not re-translate them. @@ -435,4 +443,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py new file mode 100755 index 000000000..64569cc0d --- /dev/null +++ b/tests/test_cli_usage.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Hold every tracked command-line entry point to CODESTYLE.md's argument rule by running it. + +Each entry point runs with `--help`, with `-h`, and with an unknown `--bogus-flag`. Stubs ahead of +the real commands on PATH record any call to gh or another network or host-changing command, +no credential is passed, and the proxy is a local listener that records every connection made to +it. Help exits 0 printing its usage. The unknown flag exits non-zero with a refusal that names it or +prints the usage, rather than a crash. No run reaches a stub or the listener, or changes the working +tree. + +The set is discovered from the tree rather than listed, so a new script is held to the rule the +moment it is tracked. A hook its own host runs is not a command-line entry point and is listed in +NOT_COMMAND_LINE. A Windows script builds paths from APPDATA and LOCALAPPDATA as it loads, so the +sandbox sets both to keep a run off Windows from failing before the script's own argument handling. +A parser missing a required argument refuses on that before it reaches the unknown one, so a +refusal printing the usage passes as well as one naming the flag. The case is skipped on Windows, +where a stub without an executable extension shadows nothing and the real commands would run. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import unittest +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from host_capability import bash_or_skip + +ROOT = Path(__file__).resolve().parents[1] + +NOT_COMMAND_LINE = { + "catalog/snippets/devcontainer/dotnet/post-create.sh": "a devcontainer lifecycle hook", + "catalog/snippets/devcontainer/python/post-create.sh": "a devcontainer lifecycle hook", + "host-setup/agent-safety/claude/gh-write-guard.py": "a Claude Code hook", + "host-setup/agent-safety/claude/stray-process-sweep.py": "a Claude Code hook", + "host-setup/agent-safety/claude/tool-containment.py": "a Claude Code hook", + "host-setup/agent-safety/claude/test_install.py": "a test module", +} + +RECORDED = ("gh", "curl", "wget", "ssh", "sudo", "apt-get", "winget", "docker", "uv", "uvx") + + +def entry_points() -> list[str]: + """Every tracked shell, PowerShell, or Python script run by name, tests and hooks aside.""" + listed = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "-s", "-z", "--", "*.sh", "*.ps1", "*.py"], + capture_output=True, + check=True, + ).stdout.decode("utf-8") + found = [] + for record in filter(None, listed.split("\0")): + mode, path = record.split(" ", 1)[0], record.split("\t", 1)[1] + if path.startswith(("tests/", ".husky/")) or path in NOT_COMMAND_LINE: + continue + if path.endswith(".py") and mode != "100755": + text = (ROOT / path).read_text(encoding="utf-8") + if 'if __name__ == "__main__":' not in text: + continue + found.append(path) + return sorted(found) + + +def tree_state() -> str: + return subprocess.run( + ["git", "-C", str(ROOT), "status", "--porcelain=v1", "-uall"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ).stdout + + +@contextmanager +def recording_proxy() -> Iterator[tuple[str, list[int]]]: + """A proxy URL whose listener counts each connection and answers none, so every fetch fails.""" + connections: list[int] = [] + stop = threading.Event() + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.bind(("127.0.0.1", 0)) + server.listen() + server.settimeout(0.1) + + def accept() -> None: + while not stop.is_set(): + try: + peer, _ = server.accept() + except TimeoutError: + continue + connections.append(1) + peer.close() + + worker = threading.Thread(target=accept, daemon=True) + worker.start() + try: + yield f"http://127.0.0.1:{server.getsockname()[1]}", connections + finally: + stop.set() + worker.join() + + +@unittest.skipIf(os.name == "nt", "a stub with no executable extension shadows nothing on Windows") +class EntryPointCase(unittest.TestCase): + """Help answers with usage and exit 0, and `--bogus-flag` is refused, each touching nothing.""" + + def setUp(self) -> None: + self.before = tree_state() + self.addCleanup(self.assert_tree_unchanged) + + def assert_tree_unchanged(self) -> None: + self.assertEqual(tree_state(), self.before, "a help or refused run changed the tree") + + def command(self, path: str) -> list[str]: + if path.endswith(".py"): + return [sys.executable, str(ROOT / path)] + if path.endswith(".sh"): + return [bash_or_skip(), str(ROOT / path)] + pwsh = shutil.which("pwsh") + if pwsh is None: + raise unittest.SkipTest("no pwsh on PATH to run a PowerShell entry point") + return [pwsh, "-NoProfile", "-NonInteractive", "-File", str(ROOT / path)] + + def run_entry(self, path: str, flag: str) -> tuple[subprocess.CompletedProcess[str], str]: + command = self.command(path) + with tempfile.TemporaryDirectory() as tmp: + scratch = Path(tmp) + stubs = scratch / "bin" + stubs.mkdir() + calls = scratch / "calls" + for name in RECORDED: + stub = stubs / name + stub.write_text( + f'#!/bin/sh\necho "{name} $*" >>"{calls}"\nexit 1\n', encoding="utf-8" + ) + stub.chmod(0o755) + home = scratch / "home" + home.mkdir() + appdata = {name: str(scratch / name) for name in ("APPDATA", "LOCALAPPDATA")} + env = { + key: value + for key, value in os.environ.items() + if key + not in ( + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_OUTPUT", + "GITHUB_ENV", + "GITHUB_PATH", + "GITHUB_STEP_SUMMARY", + ) + } + with recording_proxy() as (proxy, connections): + env.update( + PATH=f"{stubs}{os.pathsep}{os.environ.get('PATH', os.defpath)}", + HOME=str(home), + **appdata, + PYTHONDONTWRITEBYTECODE="1", + HTTP_PROXY=proxy, + HTTPS_PROXY=proxy, + http_proxy=proxy, + https_proxy=proxy, + NO_PROXY="", + no_proxy="", + ) + result = subprocess.run( + [*command, flag], + capture_output=True, + stdin=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + check=False, + env=env, + cwd=ROOT, + ) + recorded = calls.read_text(encoding="utf-8") if calls.exists() else "" + recorded += "proxy connection\n" * len(connections) + return result, recorded + + def test_help_prints_usage_and_exits_zero(self) -> None: + for path in entry_points(): + for flag in ("--help", "-h"): + with self.subTest(path=path, flag=flag): + result, calls = self.run_entry(path, flag) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("usage", result.stdout.lower(), result.stdout) + self.assertEqual(calls, "") + + def test_an_unknown_flag_is_refused(self) -> None: + for path in entry_points(): + with self.subTest(path=path): + result, calls = self.run_entry(path, "--bogus-flag") + output = result.stdout + result.stderr + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertTrue("bogus-flag" in output or "usage" in output.lower(), output) + self.assertNotIn("Traceback", result.stderr) + self.assertEqual(calls, "") + + def test_discovery_finds_each_kind(self) -> None: + """The control: a discovery that matched nothing would pass both cases above vacuously.""" + found = entry_points() + for expected in ( + "repo-config/configure.sh", + "scripts/handoff.py", + "scripts/prose_lint.py", + "catalog/snippets/hub-fetch-run.py", + "host-setup/bootstrap.ps1", + ): + with self.subTest(expected=expected): + self.assertIn(expected, found) + for excluded in NOT_COMMAND_LINE: + with self.subTest(excluded=excluded): + self.assertTrue( + (ROOT / excluded).is_file(), "an exclusion names a file the tree no longer has" + ) + + +if __name__ == "__main__": + unittest.main()