From 01ce025418dd58ae9d8b90e1861bf86c898729e6 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:59:22 +1000 Subject: [PATCH 1/6] feat: publish support and privilege matrix --- README.md | 5 ++- bin/agent.man | 6 ++++ bin/cleaner.man | 1 + bin/disk.man | 1 + bin/hardware.man | 1 + bin/security-audit.man | 1 + bin/slurm | 13 ++++++- bin/slurm.man | 1 + dev/_manual.py | 8 +++++ dev/gui.py | 74 ++++++++++++++++++++++++++++++++++++--- dev/gui.py.man | 1 + dev/run.py | 46 ++++++++++++++++++++++-- run.sh.man | 17 +++++++-- sys/linux/clean.man | 1 + sys/macos/clean.man | 1 + sys/windows/clean.ps1.man | 1 + 16 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 dev/_manual.py diff --git a/README.md b/README.md index 64a2c3b..986ad89 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ endpoint above downloads one temporary source archive, opens the responsive dashboard when `uv` and a terminal are available, and removes the archive when the selected command exits. Streamed use needs `curl`, `tar`, and either Python 3.11+ or [`uv`](https://docs.astral.sh/uv/getting-started/installation/). On -Windows, run it from Git Bash or WSL. +Windows, use Git Bash with native Windows Python or `uv`; WSL runs the Linux +catalog instead. The same entry point lists scripts, renders any adjacent `.man` page, or runs a command directly: @@ -27,6 +28,7 @@ command directly: ```sh RUN=https://github.com/keys-i/scripts/raw/refs/heads/main/run.sh curl -fsSL "$RUN" | bash -s -- list +curl -fsSL "$RUN" | bash -s -- matrix curl -fsSL "$RUN" | bash -s -- man disk curl -fsSL "$RUN" | bash -s -- disk health curl -fsSL "$RUN" | bash -s -- security-audit audit --scope system @@ -40,6 +42,7 @@ From a local checkout, use the shorter equivalents: ```sh ./run.sh ./run.sh list +./run.sh matrix ./run.sh man disk ./run.sh clean ``` diff --git a/bin/agent.man b/bin/agent.man index fe4d6e3..de6cc2a 100644 --- a/bin/agent.man +++ b/bin/agent.man @@ -2,6 +2,7 @@ { "summary": "Clean local agent state, diagnose installations, and search indexed compatible skills.", "platform": "any", + "privilege": "user", "applyFlag": "--apply", "yesFlag": "--yes", "applyWhen": ["clean", "--fix", "--install"], @@ -83,6 +84,11 @@ } ], "options": [ + { + "flag": "--list-agents", + "label": "List supported agent identifiers", + "when": {"action": ["doctor"]} + }, { "flag": "--fix", "label": "Repair safe doctor findings", diff --git a/bin/cleaner.man b/bin/cleaner.man index 77360dd..c50e988 100644 --- a/bin/cleaner.man +++ b/bin/cleaner.man @@ -2,6 +2,7 @@ { "summary": "Audit, garbage-collect, clean dangling Git objects, and rewrite history safely.", "platform": "any", + "privilege": "user", "applyFlag": "--apply", "yesFlag": "--yes", "applyWhen": ["gc", "dangling", "rewrite"], diff --git a/bin/disk.man b/bin/disk.man index 881fd05..5511768 100644 --- a/bin/disk.man +++ b/bin/disk.man @@ -2,6 +2,7 @@ { "summary": "Inspect, health-check, recover, and repair disks on all three desktop operating systems.", "platform": "any", + "privilege": "mixed", "applyFlag": "--apply", "yesFlag": "--yes", "applyWhen": ["recover", "fix"], diff --git a/bin/hardware.man b/bin/hardware.man index 0abc6ce..4236cd6 100644 --- a/bin/hardware.man +++ b/bin/hardware.man @@ -2,6 +2,7 @@ { "summary": "Diagnose hardware and apply conservative native repairs without hiding platform tools.", "platform": "any", + "privilege": "mixed", "applyFlag": "--apply", "yesFlag": "--yes", "applyWhen": ["fix"], diff --git a/bin/security-audit.man b/bin/security-audit.man index 42dadbc..0750204 100644 --- a/bin/security-audit.man +++ b/bin/security-audit.man @@ -2,6 +2,7 @@ { "summary": "Audit host and project security, correlate findings with CVEs and CISA KEV, and diagnose scanner readiness.", "platform": "any", + "privilege": "user", "findingExitCodes": [1], "parameters": [ { diff --git a/bin/slurm b/bin/slurm index a8b43b4..59fff15 100755 --- a/bin/slurm +++ b/bin/slurm @@ -661,7 +661,18 @@ def self_test() -> int: else: raise AssertionError("existing Slurm output was overwritten") assert parse_args(["plan", "jobs.toml"]).config_path == Path("jobs.toml") - print("ok: practical TOML, matrix expansion, branch guard, and safe rendering") + example = Path(__file__).resolve().parents[1] / "examples/slurm.toml" + example_runs = read_runs(example, 10) + assert [run.name for run in example_runs] == [ + "t0", + "cpp-simulation-slurm-threads-1", + "cpp-simulation-slurm-threads-4", + "shell-pipeline", + ] + example_script = render_script(example_runs[0]) + assert "#SBATCH --output=t0.out" in example_script + assert "#SBATCH --error=t0.err" in example_script + print("ok: stable names, complete example, branch guard, and safe rendering") return 0 diff --git a/bin/slurm.man b/bin/slurm.man index d08d71d..11be3a7 100644 --- a/bin/slurm.man +++ b/bin/slurm.man @@ -2,6 +2,7 @@ { "summary": "Plan, render, and submit Slurm jobs from a small validated TOML file.", "platform": "any", + "privilege": "scheduler", "applyFlag": "--apply", "yesFlag": "--yes", "applyWhen": ["render", "submit"], diff --git a/dev/_manual.py b/dev/_manual.py new file mode 100644 index 0000000..5db62f3 --- /dev/null +++ b/dev/_manual.py @@ -0,0 +1,8 @@ +"""Shared values for adjacent ``.man`` contracts.""" + +PRIVILEGE_LABELS = { + "user": "User", + "mixed": "User; elevation for some actions", + "administrator": "Administrator", + "scheduler": "User; Slurm account to submit", +} diff --git a/dev/gui.py b/dev/gui.py index 88ab9fa..f55a8d0 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -35,6 +35,7 @@ from pathlib import Path, PurePosixPath from typing import ClassVar +from _manual import PRIVILEGE_LABELS from rich.text import Text from textual import on, work from textual.app import App, ComposeResult @@ -72,6 +73,32 @@ FLAG_PATTERN = re.compile(r"^--?[A-Za-z][A-Za-z0-9-]*$") NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]*$") PLATFORMS = {"any", "linux", "macos", "windows"} +METADATA_KEYS = { + "summary", + "platform", + "privilege", + "applyFlag", + "yesFlag", + "applyWhen", + "directWhen", + "findingExitCodes", + "launchable", + "parameters", + "options", +} +OPTION_KEYS = {"flag", "label", "warning", "when"} +PARAMETER_KEYS = { + "name", + "label", + "choices", + "default", + "flag", + "placeholder", + "required", + "warning", + "when", +} +CHOICE_KEYS = {"value", "label"} SCRIPT_DECK_THEME = Theme( name="script-deck", primary="#82aaff", @@ -122,6 +149,7 @@ class ScriptSpec: title: str summary: str platform: str + privilege: str path: Path markdown: str parameters: tuple[ParameterSpec, ...] @@ -189,6 +217,13 @@ def _text(mapping: dict[str, object], key: str, source: Path) -> str: return value.strip() +def _check_keys( + mapping: dict[str, object], allowed: set[str], label: str, source: Path +) -> None: + if unknown := sorted(mapping.keys() - allowed): + raise GuiError(f"{source}: unknown {label} key(s): {', '.join(unknown)}") + + def _conditions( raw: object, controllers: dict[str, set[str]], @@ -256,6 +291,7 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: raise GuiError(f"{help_path}: invalid JSON: {error}") from error if not isinstance(metadata, dict): raise GuiError(f"{help_path}: front matter must be a JSON object") + _check_keys(metadata, METADATA_KEYS, "metadata", help_path) markdown = text[marker + 5 :].strip() heading = re.search(r"^# +(.+?)\s*$", markdown, re.MULTILINE) @@ -267,6 +303,12 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: raise GuiError( f"{help_path}: platform must be one of {', '.join(sorted(PLATFORMS))}" ) + privilege = _text(metadata, "privilege", help_path).lower() + if privilege not in PRIVILEGE_LABELS: + raise GuiError( + f"{help_path}: privilege must be one of " + f"{', '.join(sorted(PRIVILEGE_LABELS))}" + ) raw_options = metadata.get("options", []) if not isinstance(raw_options, list): @@ -277,6 +319,7 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: for raw_option in raw_options: if not isinstance(raw_option, dict): raise GuiError(f"{help_path}: every option must be an object") + _check_keys(raw_option, OPTION_KEYS, "option", help_path) flag = _text(raw_option, "flag", help_path) if not FLAG_PATTERN.fullmatch(flag): raise GuiError(f"{help_path}: invalid option flag {flag!r}") @@ -305,6 +348,7 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: for raw_parameter in raw_parameters: if not isinstance(raw_parameter, dict): raise GuiError(f"{help_path}: every parameter must be an object") + _check_keys(raw_parameter, PARAMETER_KEYS, "parameter", help_path) name = _text(raw_parameter, "name", help_path) if not NAME_PATTERN.fullmatch(name): raise GuiError(f"{help_path}: invalid parameter name {name!r}") @@ -331,6 +375,7 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: for raw_choice in raw_choices: if not isinstance(raw_choice, dict): raise GuiError(f"{help_path}: {name} choices must be objects") + _check_keys(raw_choice, CHOICE_KEYS, "choice", help_path) value = _text(raw_choice, "value", help_path) if "\0" in value or value in seen_values: raise GuiError(f"{help_path}: invalid duplicate choice {value!r}") @@ -472,6 +517,7 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: title=heading.group(1).strip(), summary=_text(metadata, "summary", help_path), platform=platform, + privilege=privilege, path=script_path, markdown=markdown, parameters=tuple(parameters), @@ -1539,7 +1585,8 @@ def __init__( self.catalog_query: str | None = None self.search_text = { script.script_id: ( - f"{script.script_id} {script.title} {script.summary} {script.platform}" + f"{script.script_id} {script.title} {script.summary} " + f"{script.platform} {script.privilege}" ).casefold() for script in scripts } @@ -1721,7 +1768,8 @@ def show_script(self, script: ScriptSpec) -> None: else f"Available on {script.platform}" ) self.query_one("#help", Markdown).update( - f"> **{badge}** · `{script.script_id}`\n\n{script.markdown}" + f"> **{badge}** · {PRIVILEGE_LABELS[script.privilege]} · " + f"`{script.script_id}`\n\n{script.markdown}" ) self.update_controls() @@ -2158,6 +2206,7 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: metadata = { "summary": "Exercise typed parameters.", "platform": "any", + "privilege": "user", "applyFlag": "--apply", "yesFlag": "--yes", "applyWhen": ["fix"], @@ -2185,14 +2234,17 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: ], } help_path = script_path.with_name("probe.py.man") - help_path.write_text( - f"---\n{json.dumps(metadata)}\n---\n" + probe_markdown = ( "# Probe\n\n`action`, `--query`, `--apply`, and `--yes` exercise " - "typed parameters.\n", + "typed parameters.\n" + ) + help_path.write_text( + f"---\n{json.dumps(metadata)}\n---\n{probe_markdown}", encoding="utf-8", ) probe = parse_help(help_path, contract_root) assert probe.parameters[0].default == "show" + assert probe.privilege == "user" assert probe.parameters[1].flag == "--query" assert not probe.launchable assert not probe.needs_apply(("show",)) @@ -2203,6 +2255,17 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: probe.parameters[1].when, {"action": "show"}, ) + metadata["privlege"] = metadata.pop("privilege") + help_path.write_text( + f"---\n{json.dumps(metadata)}\n---\n{probe_markdown}", + encoding="utf-8", + ) + try: + parse_help(help_path, contract_root) + except GuiError as error: + assert "unknown metadata key(s): privlege" in str(error) + else: + raise AssertionError("unknown manual metadata was accepted") run_root = root.parent compact = ScriptsApp(root, scripts, working_directory=run_root) @@ -2281,6 +2344,7 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: "Long output", "Exercise bounded output streaming.", "any", + "user", probe, "# Long output", (), diff --git a/dev/gui.py.man b/dev/gui.py.man index 1f7690d..cc5a515 100644 --- a/dev/gui.py.man +++ b/dev/gui.py.man @@ -2,6 +2,7 @@ { "summary": "Browse, configure, and run every documented script in a responsive terminal dashboard.", "platform": "any", + "privilege": "user", "launchable": false, "parameters": [ { diff --git a/dev/run.py b/dev/run.py index 1d52a10..8516ad0 100644 --- a/dev/run.py +++ b/dev/run.py @@ -10,6 +10,8 @@ import sys from pathlib import Path +from _manual import PRIVILEGE_LABELS + SEARCH_DIRS = ("bin", "dev", "sys") MAN_SUFFIX = ".man" SAFE_NAME = re.compile(r"[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*") @@ -58,11 +60,19 @@ def catalog(root: Path) -> list[dict[str, object]]: value = metadata(help_path) platform = value.get("platform") summary = value.get("summary") + privilege = value.get("privilege") + apply_flag = value.get("applyFlag") launchable = value.get("launchable", True) if platform not in {"any", "linux", "macos", "windows"}: raise LauncherError(f"invalid platform in {help_path}") if not isinstance(summary, str) or not summary.strip(): raise LauncherError(f"missing summary in {help_path}") + if privilege not in PRIVILEGE_LABELS: + raise LauncherError(f"invalid privilege in {help_path}") + if apply_flag is not None and ( + not isinstance(apply_flag, str) or not apply_flag.startswith("-") + ): + raise LauncherError(f"invalid apply flag in {help_path}") if not isinstance(launchable, bool): raise LauncherError(f"invalid launchable value in {help_path}") script = Path(str(help_path)[: -len(MAN_SUFFIX)]) @@ -77,6 +87,8 @@ def catalog(root: Path) -> list[dict[str, object]]: "path": script, "manual": help_path, "platform": platform, + "privilege": privilege, + "apply_flag": apply_flag, "summary": summary.strip(), "launchable": launchable, } @@ -145,6 +157,27 @@ def show(entries: list[dict[str, object]], platform: str) -> None: print(f" {name:<{width}}{location}\n {summary}") +def support_matrix(entries: list[dict[str, object]]) -> str: + lines = [ + "| Command | Linux | macOS | Windows | Privilege | Apply gate | Launcher |", + "| --- | :---: | :---: | :---: | --- | --- | :---: |", + ] + for entry in entries: + platform = str(entry["platform"]) + support = [ + "yes" if platform in {"any", candidate} else "—" + for candidate in ("linux", "macos", "windows") + ] + privilege = PRIVILEGE_LABELS[str(entry["privilege"])] + apply_gate = str(entry["apply_flag"] or "—") + launcher = "run" if entry["launchable"] else "docs" + lines.append( + f"| `{entry['id']}` | {' | '.join(support)} | " + f"{privilege} | `{apply_gate}` | {launcher} |" + ) + return "\n".join(lines) + + def launch(entry: dict[str, object], arguments: list[str]) -> None: script = Path(str(entry["path"])) suffix = script.suffix.lower() @@ -202,6 +235,10 @@ def self_test(root: Path, entries: list[dict[str, object]]) -> None: ) assert all(entry["id"] != "dev/gui.py" for entry in compatible(entries, host_os())) assert resolve(entries, host_os(), "run.sh", runnable=False)["id"] == "run.sh" + matrix = support_matrix(entries) + assert all(f"| `{entry['id']}` |" in matrix for entry in entries) + assert "User; elevation for some actions" in matrix + assert "User; Slurm account to submit" in matrix print(f"ok: {len(entries)} documented scripts and three OS catalogs") @@ -216,7 +253,7 @@ def main(arguments: list[str]) -> int: show(entries, platform) return 0 if arguments in (["--help"], ["-h"]): - print("Usage: ./run.sh [arguments...]") + print("Usage: ./run.sh [arguments...]") return 0 if arguments == ["--self-test"]: self_test(root, entries) @@ -224,12 +261,15 @@ def main(arguments: list[str]) -> int: if arguments == ["list"]: show(entries, platform) return 0 + if arguments == ["matrix"]: + print(support_matrix(entries)) + return 0 if arguments[0] == "man": if len(arguments) != 2: raise LauncherError("usage: ./run.sh man SCRIPT") return show_manual(resolve(entries, platform, arguments[1], runnable=False)) - if arguments[0] == "list": - raise LauncherError("list does not accept arguments") + if arguments[0] in {"list", "matrix"}: + raise LauncherError(f"{arguments[0]} does not accept arguments") launch(resolve(entries, platform, arguments[0]), arguments[1:]) return 0 except (LauncherError, OSError, UnicodeError) as error: diff --git a/run.sh.man b/run.sh.man index a095bf7..cfb011a 100644 --- a/run.sh.man +++ b/run.sh.man @@ -2,6 +2,7 @@ { "summary": "List and launch only the repository scripts compatible with the current operating system.", "platform": "any", + "privilege": "user", "launchable": false } --- @@ -25,6 +26,7 @@ can supply Python. `curl` and `tar` are also needed for streamed runs. ```sh ./run.sh --help ./run.sh list +./run.sh matrix ./run.sh man disk ./run.sh security-audit audit --scope system ./run.sh disk health @@ -40,6 +42,16 @@ are passed unchanged to that command. Ambiguous names, traversal, malformed manual metadata, symlinks, unsupported platforms, and incompatible scripts are rejected. +`./run.sh matrix` publishes the support and privilege contract directly from +the same manual metadata. “Supported” means the command implements that OS; +the command's manual still lists required native tools. Windows streaming uses +Git Bash with native Windows Python or `uv`; WSL is treated as Linux. “Mixed” +privilege means normal-user operation with elevation only for named repair or +system actions. Slurm submission uses the configured scheduler account rather +than operating-system administrator access. The apply-gate column describes +the shared destructive-action gate; a dash does not promise read-only behavior, +so consult the manual for direct report, cache, or catalog writes. + `man SCRIPT` renders the adjacent manual with Glow when installed, or prints plain Markdown. Manuals for other operating systems remain viewable. @@ -56,6 +68,7 @@ RUN='https://github.com/keys-i/scripts/raw/refs/heads/main/run.sh' curl -fsSL "$RUN" | bash curl -fsSL "$RUN" | bash -s -- list +curl -fsSL "$RUN" | bash -s -- matrix curl -fsSL "$RUN" | bash -s -- man disk curl -fsSL "$RUN" | bash -s -- disk health ``` @@ -70,6 +83,6 @@ curl -fsSL \ ## Exit status -`list`, help, and the self-test return `0`; launcher validation errors return -`2`; and a missing Python interpreter returns `127`. A launched command +`list`, `matrix`, help, and the self-test return `0`; launcher validation errors +return `2`; and a missing Python interpreter returns `127`. A launched command replaces the launcher, so its exit status passes through unchanged. diff --git a/sys/linux/clean.man b/sys/linux/clean.man index 450b09d..338fdff 100644 --- a/sys/linux/clean.man +++ b/sys/linux/clean.man @@ -2,6 +2,7 @@ { "summary": "Clean package, desktop, browser, and temporary caches.", "platform": "linux", + "privilege": "mixed", "applyFlag": "--apply", "yesFlag": "--yes", "options": [ diff --git a/sys/macos/clean.man b/sys/macos/clean.man index 6cc6b2d..0d43ed3 100644 --- a/sys/macos/clean.man +++ b/sys/macos/clean.man @@ -2,6 +2,7 @@ { "summary": "Clean app, Xcode, Simulator, Homebrew, and temporary caches.", "platform": "macos", + "privilege": "user", "applyFlag": "--apply", "yesFlag": "--yes", "options": [ diff --git a/sys/windows/clean.ps1.man b/sys/windows/clean.ps1.man index 697e2dd..cb208be 100644 --- a/sys/windows/clean.ps1.man +++ b/sys/windows/clean.ps1.man @@ -2,6 +2,7 @@ { "summary": "Clean user, package, browser, developer, and Windows caches.", "platform": "windows", + "privilege": "mixed", "applyFlag": "-Apply", "yesFlag": "-Yes", "options": [ From 8d016c01c43fe31e475e5d8168a165a37ecc6951 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:28:34 +1000 Subject: [PATCH 2/6] fix: preserve native planner failures --- bin/_common.py | 4 +- bin/disk | 87 ++++++++++++++++++++++++++++++++++++++---- bin/disk.man | 6 ++- bin/security-audit | 18 +++++++-- bin/security-audit.man | 4 +- 5 files changed, 105 insertions(+), 14 deletions(-) diff --git a/bin/_common.py b/bin/_common.py index 9d80780..ceb56f0 100644 --- a/bin/_common.py +++ b/bin/_common.py @@ -69,10 +69,12 @@ def run( cwd: Path | None = None, capture: bool = False, check: bool = True, + announce: bool = True, ok_codes: Sequence[int] = (0,), timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: - print(color(f"$ {command_text(command)}", "2"), flush=True) + if announce: + print(color(f"$ {command_text(command)}", "2"), flush=True) try: result = subprocess.run( list(command), diff --git a/bin/disk b/bin/disk index 636ad41..c544da4 100755 --- a/bin/disk +++ b/bin/disk @@ -58,7 +58,8 @@ def _windows_target(target: str | None) -> tuple[str, str] | None: def show(args: argparse.Namespace, platform: str) -> int: target = args.target or args.target_pos - heading("Disk inventory") + if not args.json: + heading("Disk inventory") if platform == "linux": command = [ require_program("lsblk"), @@ -70,7 +71,7 @@ def show(args: argparse.Namespace, platform: str) -> int: command.append("--json") if target: command.append(target) - return run(command, check=False).returncode + return run(command, check=False, announce=not args.json).returncode if platform == "macos": diskutil = require_program("diskutil") command = [diskutil, "info" if target else "list"] @@ -78,9 +79,16 @@ def show(args: argparse.Namespace, platform: str) -> int: command.append("-plist") if target: command.append(target) - result = run(command, capture=args.json, check=False) + result = run( + command, + capture=args.json, + check=False, + announce=not args.json, + ) if args.json and result.returncode == 0 and result.stdout: print_json(plistlib.loads(result.stdout.encode())) + elif args.json and (detail := result.stderr or result.stdout): + print(detail.rstrip(), file=sys.stderr) return result.returncode if platform == "windows": selected = _windows_target(target) @@ -113,7 +121,7 @@ def show(args: argparse.Namespace, platform: str) -> int: else "$d|Format-Table -AutoSize;$v|Format-Table -AutoSize" ) ) - return run(ps(script), check=False).returncode + return run(ps(script), check=False, announce=not args.json).returncode raise ToolError(f"unsupported operating system: {platform}") @@ -121,7 +129,7 @@ def health(args: argparse.Namespace, platform: str) -> int: target = args.target or args.target_pos heading("Disk health") if platform == "linux": - run( + inventory_status = run( ( require_program("lsblk"), "-d", @@ -129,7 +137,9 @@ def health(args: argparse.Namespace, platform: str) -> int: "NAME,PATH,SIZE,MODEL,SERIAL,TRAN,ROTA,STATE", ), check=False, - ) + ).returncode + if inventory_status: + return inventory_status smartctl = find_program("smartctl") if smartctl and target: return run(elevated((smartctl, "-H", "-A", target)), check=False).returncode @@ -138,7 +148,7 @@ def health(args: argparse.Namespace, platform: str) -> int: return run(elevated((smartctl, "--scan-open")), check=False).returncode warning("Install smartmontools for SMART/NVMe health details.") if find_program("nvme"): - run(("nvme", "list"), check=False) + return run(("nvme", "list"), check=False).returncode return 0 if platform == "macos": diskutil = require_program("diskutil") @@ -397,6 +407,10 @@ def recover(args: argparse.Namespace, platform: str) -> int: def self_test() -> int: + import json + from contextlib import redirect_stderr, redirect_stdout + from io import StringIO + from types import SimpleNamespace from unittest.mock import patch if find_program("diskutil"): @@ -416,6 +430,65 @@ def self_test() -> int: assert recovery_tool("macos", None) == "mount" assert recovery_tool("windows", None) == "testdisk" assert parse_args(["recover"]).tool is None + inventory_args = argparse.Namespace(target=None, target_pos=None, json=True) + + def native_json(_command: object, **options: object) -> SimpleNamespace: + assert options["announce"] is False + if options.get("capture"): + return SimpleNamespace( + returncode=0, + stdout=plistlib.dumps({"Disks": []}).decode(), + stderr="", + ) + print('{"Disks": []}') + return SimpleNamespace(returncode=0, stdout=None, stderr=None) + + for platform in ("linux", "macos", "windows"): + output = StringIO() + with ( + patch(f"{__name__}.require_program", return_value="native-tool"), + patch(f"{__name__}.run", side_effect=native_json), + redirect_stdout(output), + ): + assert show(inventory_args, platform) == 0 + assert json.loads(output.getvalue()) == {"Disks": []} + diagnostic = StringIO() + with ( + patch(f"{__name__}.require_program", return_value="diskutil"), + patch( + f"{__name__}.run", + return_value=SimpleNamespace( + returncode=7, + stdout="", + stderr="native disk failure\n", + ), + ), + redirect_stderr(diagnostic), + ): + assert show(inventory_args, "macos") == 7 + assert diagnostic.getvalue() == "native disk failure\n" + health_args = argparse.Namespace(target=None, target_pos=None) + with ( + patch(f"{__name__}.require_program", return_value="lsblk"), + patch( + f"{__name__}.run", + return_value=SimpleNamespace(returncode=9), + ), + ): + assert health(health_args, "linux") == 9 + with ( + patch(f"{__name__}.require_program", return_value="lsblk"), + patch(f"{__name__}.find_program", side_effect=(None, "nvme")), + patch( + f"{__name__}.run", + side_effect=( + SimpleNamespace(returncode=0), + SimpleNamespace(returncode=7), + ), + ), + redirect_stderr(StringIO()), + ): + assert health(health_args, "linux") == 7 with ( patch.object(Path, "is_block_device", return_value=True), patch(f"{__name__}._linux_filesystem", return_value="ext4"), diff --git a/bin/disk.man b/bin/disk.man index 5511768..0203a77 100644 --- a/bin/disk.man +++ b/bin/disk.man @@ -72,13 +72,15 @@ Windows. It does not implement a filesystem, SMART parser, or recovery engine. Lists physical disks, partitions, filesystems, labels, identifiers, mount points, capacity, and read-only/removable state. Select `--json` for native -structured output. +structured output. In JSON mode, standard output contains only JSON; native +failure details stay on standard error. ### `health` Shows OS reliability status and SMART data when `smartctl` is installed. Pass a specific `--target` for full device attributes. SMART warnings are evidence to -back up and replace a disk, not something software can repair. +back up and replace a disk, not something software can repair. A failed native +inventory or NVMe probe is returned instead of being reported as success. ### `recover` diff --git a/bin/security-audit b/bin/security-audit index 9c6cc1e..5092a47 100755 --- a/bin/security-audit +++ b/bin/security-audit @@ -354,6 +354,16 @@ def _contains_state( return None +def _selinux_status(result: ProcessResult) -> tuple[bool | None, str]: + state = result.output.strip().casefold() + details = f"SELinux: {_clean_text(result.output)}" if result.output.strip() else "" + if result.returncode != 0: + return None, details + return {"enforcing": True, "permissive": False, "disabled": False}.get( + state + ), details + + def linux_posture(timeout: int, max_bytes: int) -> list[Finding]: findings: list[Finding] = [] @@ -462,9 +472,7 @@ def linux_posture(timeout: int, max_bytes: int) -> list[Finding]: mandatory_details = "" if getenforce := find_program("getenforce"): result = _probe((getenforce,), timeout, max_bytes) - state = result.output.strip().casefold() - mandatory_access = True if state == "enforcing" else False if state else None - mandatory_details = f"SELinux: {_clean_text(state)}" if state else "" + mandatory_access, mandatory_details = _selinux_status(result) elif apparmor := find_program("aa-enabled", "aa-status"): command = ( (apparmor,) @@ -1811,6 +1819,10 @@ def audit(args: argparse.Namespace) -> tuple[Path | None, list[Finding]]: def self_test() -> int: + assert _selinux_status(ProcessResult(0, "Enforcing\n"))[0] is True + assert _selinux_status(ProcessResult(0, "Permissive\n"))[0] is False + failed_selinux = _selinux_status(ProcessResult(1, "permission denied\n")) + assert failed_selinux == (None, "SELinux: permission denied") sarif = { "version": "2.1.0", "runs": [ diff --git a/bin/security-audit.man b/bin/security-audit.man index 0750204..9de1702 100644 --- a/bin/security-audit.man +++ b/bin/security-audit.man @@ -126,7 +126,9 @@ Known Exploited Vulnerabilities catalog when a finding contains a CVE ID. With `--scope system`, inspect native update, malware-protection, disk encryption, firewall, application-trust, secure-boot, and mandatory-access -control status where the host exposes them. +control status where the host exposes them. Failed or unrecognized native +probes remain unknown and retain their bounded diagnostics instead of becoming +false findings. With `--scope project`, `--scanner auto` runs each installed supported scanner: OSV-Scanner v2 for dependency vulnerabilities, Trivy for filesystem From cfd9918553629f86bccae8f34766b33b86c32631 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:31:41 +1000 Subject: [PATCH 3/6] docs: define export and rollback contracts --- bin/slurm | 11 ++++++++++- bin/slurm.man | 8 ++++++-- docs/CONTRIBUTING.md | 27 +++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/bin/slurm b/bin/slurm index 59fff15..4ff5d99 100755 --- a/bin/slurm +++ b/bin/slurm @@ -546,6 +546,9 @@ def mutate(args: argparse.Namespace, runs: list[JobRun]) -> int: def self_test() -> int: + from contextlib import redirect_stdout + from io import StringIO + config: dict[str, Any] = { "cluster": { "account": "a_test", @@ -672,7 +675,13 @@ def self_test() -> int: example_script = render_script(example_runs[0]) assert "#SBATCH --output=t0.out" in example_script assert "#SBATCH --error=t0.err" in example_script - print("ok: stable names, complete example, branch guard, and safe rendering") + exported = StringIO() + with redirect_stdout(exported): + show_plan(example_runs, as_json=True) + summary = json.loads(exported.getvalue()) + assert [job["name"] for job in summary] == [run.name for run in example_runs] + assert set(summary[0]) == {"name", "workdir", "branch", "command", "values"} + print("ok: stable names, export contract, branch guard, and safe rendering") return 0 diff --git a/bin/slurm.man b/bin/slurm.man index 11be3a7..c7a59a5 100644 --- a/bin/slurm.man +++ b/bin/slurm.man @@ -47,7 +47,7 @@ "options": [ { "flag": "--json", - "label": "Emit the plan as JSON", + "label": "Export the plan as JSON to standard output", "when": {"action": ["plan"]} } ] @@ -130,13 +130,17 @@ are never overwritten. ```sh slurm plan examples/slurm.toml slurm plan --config slurm.toml --json +slurm plan examples/slurm.toml > slurm-plan.txt +slurm plan examples/slurm.toml --json > slurm-plan.json slurm render slurm.toml slurm render slurm.toml --output slurm-build --apply slurm submit slurm.toml --apply ``` `CONFIG` is optional and defaults to `slurm.toml`; `--config` remains available -for scripts. `plan` prints every expanded command. `render` and `submit` print +for scripts. `plan` prints every expanded command. Redirect it to export a +compact text summary; `--json` exports stable `name`, `workdir`, `branch`, +`command`, and `values` fields for each expanded job. `render` and `submit` print the same plan and exact output paths before doing anything. Direct applies require typing `APPLY`; the shared TUI confirms with `CLEAN` and then supplies `--apply --yes`. Submission reports progress and stops on the first failed diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 01d4c86..0122ca1 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -102,3 +102,30 @@ and include: A pull request is ready only when its scripts run successfully, its comments and help text are current, and the diff contains no secrets or unrelated files. All contributions must follow the [Code of Conduct](CODE_OF_CONDUCT.md). + +## Rollback and Recovery + +`main` is the release. If a merged pull request breaks it: + +1. Use the pull request's **Revert** action to open a rollback PR. If GitHub + cannot revert cleanly, branch from `origin/main` and run + `git revert -m 1 `. +2. Let the Checks and Quality workflows pass, merge the rollback, then verify + the streamed launcher: + + ```sh + curl -fsSL https://github.com/keys-i/scripts/raw/refs/heads/main/run.sh | + bash -s -- list + ``` + +3. Fix forward in a separate PR. Never force-push or rewrite `main`. + +Until the rollback lands, pin both the launcher and archive to one known-good +commit: + +```sh +SHA=<40-character-known-good-commit> +curl -fsSL "https://raw.githubusercontent.com/keys-i/scripts/$SHA/run.sh" | + SCRIPTS_ARCHIVE_URL="https://github.com/keys-i/scripts/archive/$SHA.tar.gz" \ + bash -s -- list +``` From 6ae8c8d4aa66ed19fcf20af1ec8984ed260edac2 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:09:24 +1000 Subject: [PATCH 4/6] update: refresh agent state contracts --- bin/agent | 300 +++++++++++++++++++++++++++++++++++++------- bin/agent.man | 53 +++++--- docs/agent-tools.md | 97 ++++++++------ 3 files changed, 357 insertions(+), 93 deletions(-) diff --git a/bin/agent b/bin/agent index 20abf21..ef11003 100755 --- a/bin/agent +++ b/bin/agent @@ -60,19 +60,23 @@ def registry() -> dict[str, AgentSpec]: home = Path.home() codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) claude = Path(os.environ.get("CLAUDE_CONFIG_DIR", home / ".claude")) - gemini = home / ".gemini" + gemini = (Path(os.environ.get("GEMINI_CLI_HOME") or home) / ".gemini").expanduser() copilot = Path(os.environ.get("COPILOT_HOME", home / ".copilot")) - qwen = home / ".qwen" + qwen = Path(os.environ.get("QWEN_HOME") or home / ".qwen").expanduser() opencode = _xdg("XDG_DATA_HOME", home / ".local/share") / "opencode" - kilo = home / ".kilocode" + if os.name == "nt": + kilo = Path(os.environ.get("LOCALAPPDATA", home / "AppData/Local")) / "kilo" + elif sys.platform == "darwin": + kilo = home / "Library/Application Support/kilo" + else: + kilo = _xdg("XDG_DATA_HOME", home / ".local/share") / "kilo" cline = Path(os.environ.get("CLINE_DATA_DIR", home / ".cline/data")) if goose_override := os.environ.get("GOOSE_PATH_ROOT"): - goose = Path(goose_override) - elif sys.platform == "darwin": - goose = home / "Library/Application Support/Block/goose" + goose = Path(goose_override).expanduser() elif os.name == "nt": goose = ( - Path(os.environ.get("APPDATA", home / "AppData/Roaming")) / "Block/goose" + Path(os.environ.get("APPDATA", home / "AppData/Roaming")) + / "Block/goose/data" ) else: goose = _xdg("XDG_DATA_HOME", home / ".local/share") / "goose" @@ -80,6 +84,12 @@ def registry() -> dict[str, AgentSpec]: amp = _xdg("XDG_CONFIG_HOME", home / ".config") / "amp" kiro = Path(os.environ.get("KIRO_HOME", home / ".kiro")) pi_agent = Path(os.environ.get("PI_CODING_AGENT_DIR", home / ".pi/agent")) + if pi_session_dir := os.environ.get("PI_CODING_AGENT_SESSION_DIR"): + pi_root = Path(pi_session_dir).expanduser() + pi_sessions = ("**/*.jsonl",) + else: + pi_root = pi_agent + pi_sessions = ("sessions/**/*.jsonl",) agents = [ AgentSpec( "codex", @@ -153,7 +163,11 @@ def registry() -> dict[str, AgentSpec]: ("cache",), ("settings.json",), "restart Qwen Code", - "Automatic conversation and memory cleanup is not verified.", + ( + "Use `qwen sessions list --json` and interactive `/delete`, " + "`/forget`, or `/memory`; QWEN_RUNTIME_DIR stays outside " + "automatic cleanup." + ), ), AgentSpec( "opencode", @@ -174,12 +188,13 @@ def registry() -> dict[str, AgentSpec]: ("kilo",), kilo, "kilo", - home / ".kilocode/skills", + home / ".kilo/skills", (), (), (), (), "run /reload or start a new session", + "Cloud Agent sessions are outside local cleanup.", ), AgentSpec( "cline", @@ -222,7 +237,7 @@ def registry() -> dict[str, AgentSpec]: _xdg("XDG_CONFIG_HOME", home / ".config") / "goose/skills", (), (), - ("cache", "data/logs"), + ("logs",) if os.name == "nt" else (), ("config/config.yaml",), "restart goose", "Use `goose session list/remove`; its managed database is never removed.", @@ -287,15 +302,26 @@ def registry() -> dict[str, AgentSpec]: home / ".openhands", "openhands", home / ".openhands/skills", + ("conversations/*",), + (), + (), + (), + "restart OpenHands", + "OpenHands Cloud conversations are outside local cleanup.", ), AgentSpec( "pi", "Pi coding agent", ("pi",), - pi_agent, + pi_root, "pi", pi_agent / "skills", + pi_sessions, + (), (), + (), + "start a new Pi session", + "Cleanup removes local JSONL sessions without following symlinks.", ), AgentSpec( "rovodev", @@ -342,6 +368,28 @@ def _read_json_lines(text: str) -> list[dict[str, object]]: return [value] if isinstance(value, dict) else [] +def _session_delete_steps( + label: str, + binary: str, + text: str, + delete_prefix: tuple[str, ...], +) -> list[Step]: + session_ids = { + session_id + for record in _read_json_lines(text) + if isinstance((session_id := record.get("id") or record.get("sessionID")), str) + and 0 < len(session_id) <= 256 + and all(character.isprintable() for character in session_id) + } + return [ + Step( + f"Delete {label} session {session_id}", + (binary, *delete_prefix, session_id), + ) + for session_id in sorted(session_ids) + ] + + def _codex_session_ids(root: Path) -> list[str]: session_ids: set[str] = set() for path in safe_targets( @@ -362,7 +410,7 @@ def _native_cleanup_unavailable(spec: AgentSpec, kind: str) -> bool: return bool(_codex_session_ids(spec.root)) if spec.key == "claude-code": return kind == "all" and spec.root.exists() - return spec.key in {"gemini-cli", "opencode"} and spec.root.exists() + return spec.key in {"gemini-cli", "opencode", "kilo"} and spec.root.exists() def _native_clean_steps(spec: AgentSpec, kind: str) -> list[Step]: @@ -415,33 +463,64 @@ def _native_clean_steps(spec: AgentSpec, kind: str) -> list[Step]: "use `gemini --delete-session INDEX_OR_ID` for selected sessions." ) return [] - if spec.key == "opencode": + if spec.key in {"opencode", "kilo"}: + if spec.key == "opencode": + list_command = (binary, "session", "list", "--format", "json") + label = "OpenCode" + else: + list_command = ( + binary, + "session", + "list", + "--all", + "--format", + "json", + ) + label = "Kilo" result = run( - (binary, "session", "list", "--format", "json"), + list_command, capture=True, check=False, timeout=20, ) if result.returncode: warning( - f"OpenCode session listing exited {result.returncode}; " - "no OpenCode deletion was planned." + f"{label} session listing exited {result.returncode}; " + f"no {label} deletion was planned." ) return [] - steps: list[Step] = [] - for record in _read_json_lines(result.stdout or ""): - session_id = record.get("id") or record.get("sessionID") - if isinstance(session_id, str): - steps.append( - Step( - f"Delete OpenCode session {session_id}", - (binary, "session", "delete", session_id), - ) - ) - return steps + return _session_delete_steps( + label, + binary, + result.stdout or "", + ("session", "delete"), + ) return [] +def _target_usage(targets: list[Path]) -> tuple[int, int]: + roots: list[Path] = [] + for target in sorted(set(targets), key=lambda path: (len(path.parts), str(path))): + if not any(root == target or root in target.parents for root in roots): + roots.append(target) + + items = total_bytes = 0 + pending = roots[:] + while pending: + path = pending.pop() + try: + metadata = path.lstat() + except FileNotFoundError: + continue + items += 1 + if stat.S_ISDIR(metadata.st_mode): + with os.scandir(path) as entries: + pending.extend(Path(entry.path) for entry in entries) + else: + total_bytes += metadata.st_size + return items, total_bytes + + def clean(args: argparse.Namespace) -> int: heading(f"Agent clean · {args.kind}") targets: list[Path] = [] @@ -476,6 +555,12 @@ def clean(args: argparse.Namespace) -> int: if not targets and not steps: print("No safely automatable local state was found.") return 1 if blocked else 0 + items, total_bytes = _target_usage(targets) + print( + f"Plan: {items} local item(s) ({total_bytes:,} bytes) across " + f"{len(targets)} target(s); {len(steps)} native operation(s) " + "(size unavailable)." + ) warning("Close every selected agent before applying this plan.") if not confirmed( apply=args.apply, @@ -505,11 +590,23 @@ def _config_error(path: Path) -> str | None: return None +def _existing_state_paths(spec: AgentSpec) -> tuple[Path, ...]: + return tuple( + dict.fromkeys( + path + for path in (spec.root, spec.skill_dir) + if path is not None and path.exists() + ) + ) + + def doctor(args: argparse.Namespace) -> int: if args.list_agents: + print(f"{'AGENT':<16} {'STATUS':<18} {'MARKET':<12} NAME") for spec in registry().values(): status = color("installed", "32") if spec.binary else color("missing", "2") - print(f"{spec.key:<16} {status:<18} {spec.label}") + market = "skills" if spec.skill_agent else "unsupported" + print(f"{spec.key:<16} {status:<18} {market:<12} {spec.label}") return 0 fixes: list[tuple[str, Path]] = [] @@ -518,7 +615,13 @@ def doctor(args: argparse.Namespace) -> int: heading(spec.label) binary = spec.binary if binary is None: - print(color("○ not installed", "2")) + remaining = _existing_state_paths(spec) + if remaining: + print(color("! executable missing; local state remains", "33")) + for path in remaining: + print(f" {path}") + else: + print(color("○ not installed", "2")) continue print(color(f"✓ executable: {binary}", "32")) version = run((binary, "--version"), capture=True, check=False, timeout=10) @@ -529,9 +632,13 @@ def doctor(args: argparse.Namespace) -> int: elif output: print(f" {output[0][:200]}") - if spec.key == "codex": + native_command = { + "codex": (binary, "doctor", "--json"), + "kilo": (binary, "config", "check"), + }.get(spec.key) + if native_command: native_doctor = run( - (binary, "doctor", "--json"), + native_command, capture=True, check=False, timeout=30, @@ -558,7 +665,7 @@ def doctor(args: argparse.Namespace) -> int: print(color(f"! permissions are {mode:03o}; recommend 700", "33")) fixes.append(("Restrict state-directory permissions", spec.root)) else: - print(color(f"○ no state root yet: {spec.root}", "2")) + print(color(f"○ installed but not initialized: {spec.root}", "2")) for relative in spec.configs: path = spec.root / relative @@ -583,10 +690,7 @@ def doctor(args: argparse.Namespace) -> int: if spec.skill_dir is None: print(" no native Agent Skills target") elif not spec.skill_dir.exists(): - print( - f" skill directory will be created on first install: {spec.skill_dir}" - ) - fixes.append(("Create skill directory", spec.skill_dir)) + print(f" no skills installed yet: {spec.skill_dir}") if spec.note: print(f" note: {spec.note}") @@ -599,10 +703,8 @@ def doctor(args: argparse.Namespace) -> int: yes=args.yes, prompt=f"apply {len(fixes)} agent repairs", ): - for label, path in fixes: - if label.startswith("Create"): - path.mkdir(parents=True, exist_ok=True) - elif os.name != "nt": + for _, path in fixes: + if os.name != "nt": path.chmod(0o700) print(color("Repairs applied.", "1;32")) elif fixes: @@ -656,6 +758,11 @@ def market(args: argparse.Namespace) -> int: def self_test() -> int: + from contextlib import redirect_stdout + from io import StringIO + from types import SimpleNamespace + from unittest.mock import patch + agents = registry() assert {"codex", "claude-code", "gemini-cli", "github-copilot"} <= agents.keys() assert agents["codex"].sessions == ("history.jsonl",) @@ -670,8 +777,6 @@ def self_test() -> int: "cursor", "kiro-cli", "droid", - "openhands", - "pi", "rovodev", ): assert not agents[key].sessions @@ -681,10 +786,24 @@ def self_test() -> int: for spec in agents.values() for pattern in (*spec.sessions, *spec.memories) ) + assert agents["aider"].skill_agent is None + assert all( + spec.skill_agent is not None for key, spec in agents.items() if key != "aider" + ) assert _read_json_lines('{"id":"one"}\n{"id":"two"}') == [ {"id": "one"}, {"id": "two"}, ] + deletion_steps = _session_delete_steps( + "Fixture", + "fixture", + '[{"id":"two"},{"sessionID":"one"},{"id":3},{"id":"two"}]', + ("session", "delete"), + ) + assert [step.command for step in deletion_steps] == [ + ("fixture", "session", "delete", "one"), + ("fixture", "session", "delete", "two"), + ] try: run( (sys.executable, "-c", "import time; time.sleep(1)"), @@ -700,6 +819,20 @@ def self_test() -> int: outside = Path(temporary) / "outside" root.mkdir() outside.mkdir() + with patch.dict( + os.environ, + { + "GEMINI_CLI_HOME": str(Path(temporary) / "gemini-home"), + "QWEN_HOME": str(Path(temporary) / "qwen-home"), + "PI_CODING_AGENT_SESSION_DIR": str(Path(temporary) / "pi-sessions"), + }, + ): + overridden = registry() + assert overridden["gemini-cli"].root == ( + Path(temporary) / "gemini-home/.gemini" + ) + assert overridden["qwen-code"].root == Path(temporary) / "qwen-home" + assert overridden["pi"].root == Path(temporary) / "pi-sessions" session_id = "12345678-1234-5678-9234-567812345678" session_dir = root / "sessions/2026/07/27" session_dir.mkdir(parents=True) @@ -721,6 +854,89 @@ def self_test() -> int: None, ) assert _native_cleanup_unavailable(missing_codex, "sessions") + kilo = AgentSpec("kilo", "Kilo", (sys.executable,), root, None, None) + with patch( + f"{__name__}.run", + return_value=SimpleNamespace( + returncode=0, + stdout='[{"id":"kilo-session"}]', + ), + ) as listed: + kilo_steps = _native_clean_steps(kilo, "sessions") + listed.assert_called_once_with( + ( + sys.executable, + "session", + "list", + "--all", + "--format", + "json", + ), + capture=True, + check=False, + timeout=20, + ) + assert kilo_steps[0].command == ( + sys.executable, + "session", + "delete", + "kilo-session", + ) + usage = root / "cache" + nested = usage / "nested" + nested.mkdir(parents=True) + (usage / "one").write_bytes(b"123") + nested_file = nested / "two" + nested_file.write_bytes(b"45") + assert _target_usage([usage, nested_file]) == (4, 5) + partial = AgentSpec( + "fixture", + "Fixture", + ("scripts-test-missing-agent-cli",), + root, + None, + None, + ) + assert _existing_state_paths(partial) == (root,) + doctor_args = argparse.Namespace( + list_agents=False, + agent="fixture", + fix=False, + apply=False, + yes=False, + ) + output = StringIO() + with ( + patch(f"{__name__}.choose_agents", return_value=[partial]), + patch(f"{__name__}.find_program", return_value=None), + redirect_stdout(output), + ): + assert doctor(doctor_args) == 0 + assert "executable missing; local state remains" in output.getvalue() + fresh = AgentSpec( + "fixture", + "Fixture", + ("fixture",), + Path(temporary) / "fresh", + None, + None, + ) + output = StringIO() + with ( + patch(f"{__name__}.choose_agents", return_value=[fresh]), + patch(f"{__name__}.find_program", return_value="fixture"), + patch( + f"{__name__}.run", + return_value=SimpleNamespace( + returncode=0, + stdout="fixture 1.0\n", + stderr="", + ), + ), + redirect_stdout(output), + ): + assert doctor(doctor_args) == 0 + assert "installed but not initialized" in output.getvalue() (outside / "session").touch() try: (root / "linked").symlink_to(outside, target_is_directory=True) diff --git a/bin/agent.man b/bin/agent.man index fe4d6e3..ad72f29 100644 --- a/bin/agent.man +++ b/bin/agent.man @@ -116,10 +116,13 @@ private skill. ### `doctor` Finds installed executables, runs version checks, validates JSON/TOML settings, -checks state-directory permissions, and locates the agent's skill directory. +checks state-directory permissions, locates the agent's skill directory, and +distinguishes a missing executable with leftover state from an installed client +that has not initialized its state root. YAML, YAML-derived, and JSONC files are reported as **not parsed**, not as -validated. Select `--fix` to preview owner-only permission repairs and missing -skill directories; applying does not install agents or rewrite their settings. +validated. Select `--fix` to preview owner-only permission repairs. Missing +skill directories are normal and remain the responsibility of the selected +market installer; applying does not install agents or rewrite their settings. ### `clean` @@ -128,23 +131,39 @@ Only documented local state is targeted. Credentials, API keys, configuration, skills, plugins, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and equivalent project-authored instructions are excluded. -Codex and OpenCode conversations are enumerated and deleted with native +Every preview reports the number of local entries and their logical byte size +without following symlinks. Native vendor operations are counted separately +because their reclaimable size is unavailable before deletion. + +Codex, OpenCode, and Kilo conversations are enumerated and deleted with native non-interactive commands. Codex's documented global `history.jsonl` is also -removed for session cleanup. Gemini's native listing is shown, then deletion -is handed off to `gemini --delete-session INDEX_OR_ID` because its -human-readable listing is not a stable machine interface. Claude's native -purge combines conversations, auto-memory, and related project state, so it is -available only through `clean all`; that explicit operation previews every -project recorded by Claude before applying. Aider's documented history files -remain current-project scoped. +removed for session cleanup. OpenHands conversation directories and Pi JSONL +sessions use their documented local deletion contracts. Gemini's native +listing is shown, then deletion is handed off to +`gemini --delete-session INDEX_OR_ID` because its human-readable listing is not +a stable machine interface. Claude's native purge combines conversations, +auto-memory, and related project state, so it is available only through +`clean all`; that explicit operation previews every project recorded by Claude +before applying. Aider's documented history files remain current-project +scoped. Managed Copilot, goose, and Cline session databases are never deleted. Use -Copilot's `/session` UI, `goose session list/remove`, or `cline history` -instead. Other clients without a verified, separable conversation store have -no automatic session target. +Copilot's `/session delete`, `/session delete-all`, or `/session prune` UI, +`goose session` commands, or `cline history` instead. Qwen's native session +listing and interactive memory commands remain manual because it has no +documented noninteractive delete-all contract. Other clients without a +verified, separable conversation store have no automatic session target. + +Close affected agents before applying. + +### Authority boundary -Close affected agents before applying. Copilot and Amp can sync state to a -service; local cleanup does not delete remote copies. +Direct path removal is local only. Native vendor commands run without remote +flags and keep their documented local CLI scope. The command does not access or +delete provider-account, cloud, synchronized, Hub, IDE, or retention records; +Copilot, Amp, Kilo Cloud, Cline Hub, OpenHands Cloud, and Factory Cloud Sync may +retain separate copies. Gemini session listing is current-project scoped. +`market` is the only workflow that intentionally uses the network. ### `market` @@ -161,6 +180,8 @@ agent doctor --agent codex --fix agent clean sessions --agent gemini-cli agent clean all --agent claude-code agent clean all --agent codex --apply +agent clean sessions --agent kilo +agent clean sessions --agent pi agent market "Python security" agent market --install vercel-labs/agent-skills --skill find-skills --agent codex ``` diff --git a/docs/agent-tools.md b/docs/agent-tools.md index b07b252..46a538b 100644 --- a/docs/agent-tools.md +++ b/docs/agent-tools.md @@ -7,6 +7,9 @@ skill. `agent market QUERY` searches the current indexed catalog, and `agent market --install OWNER/REPOSITORY --agent NAME` asks the market tool to apply its current client-specific installer. +Contracts and links were reviewed against official vendor documentation on +31 July 2026 (Australia/Brisbane). + ## Safety contract `agent clean` prefers documented vendor deletion commands. It directly removes @@ -14,7 +17,8 @@ only narrow, documented memory, history, or cache paths below an agent's state root. It does not remove authentication, settings, MCP configuration, plugins, installed skills, or project instructions such as `AGENTS.md`, `CLAUDE.md`, and `GEMINI.md`. The preview is the source of truth: close the selected agents, -inspect every target, then apply. +inspect every target, then apply. It reports local entry counts and logical +bytes without following symlinks; native operations have no preflight size. Directory symlinks are rejected so a declared glob cannot escape its state root. Project-scoped Aider history is cleaned only from the current working @@ -22,28 +26,33 @@ tree. Cloud-backed records are outside this command's authority: in particular, local cleanup does not delete Copilot sessions already synced to GitHub or Amp threads stored by its service. +Direct path removal is local only. Native commands are invoked without remote +flags and retain their vendor-defined local scope. The script never signs into +a provider to erase account, cloud, synchronized, Hub, IDE, or retention +records. `market` is the only intentionally networked workflow. + ## Researched clients -| Registry name | Local cleanup | Doctor and market notes | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `codex` | Discovers saved UUIDs, then uses `codex delete UUID --force`; session cleanup also removes the documented global `history.jsonl`, while generated memories and caches remain separately selectable | Runs the redacted `codex doctor --json`; skills use `CODEX_HOME/skills` | -| `claude-code` | `clean all` previews and runs `claude project purge --all`; the native command couples transcripts, auto-memory, and related project state | Honors `CLAUDE_CONFIG_DIR`; skills use its `skills` directory | -| `gemini-cli` | Shows `gemini --list-sessions`; use `gemini --delete-session INDEX_OR_ID` because the list has no stable machine format | `GEMINI.md`, settings, extensions, and skills remain untouched | -| `github-copilot` | Use the native interactive `/session` manager; managed command history and `session-store.db` are never removed | Synced session data may require deletion on GitHub | -| `qwen-code` | Conversation and memory cleanup is unsupported until a current native deletion contract is verified; cache remains selectable | Doctor and indexed skills only | -| `opencode` | Uses `opencode session list` and `session delete`; local logs are cache | Skills live in the XDG OpenCode configuration directory | -| `kilo` | No speculative database deletion | Doctor and skill installation use `~/.kilocode`; use Kilo itself to delete conversations | -| `cline` | Use `cline history`; the managed SQLite session directory is never removed | Honors `CLINE_DATA_DIR`; logs remain separately selectable | -| `aider` | Current-project chat/input/LLM history and tags cache | Aider does not natively load the shared Agent Skills format, so market install is rejected | -| `goose` | Use `goose session list/remove`; `sessions.db` is never removed | Honors `GOOSE_PATH_ROOT`; cache and logs remain separately selectable | -| `vibe` | Automatic conversation and log cleanup is unsupported | Market client identifier is `mistral-vibe` | -| `amp` | Local logs only | Threads may be cloud-backed; shared skills use the agents skill directory | -| `cursor` | No speculative editor database deletion | Doctor and Agent Skills support only | -| `kiro-cli` | Automatic conversation cleanup is unsupported | Skills use `KIRO_HOME/skills` | -| `droid` | No speculative service/database deletion | Doctor and Agent Skills support only | -| `openhands` | No speculative service/database deletion | Doctor and Agent Skills support only | -| `pi` | Automatic conversation cleanup is unsupported | Skills honor `PI_CODING_AGENT_DIR` | -| `rovodev` | No speculative service/database deletion | Doctor and Agent Skills support only | +| Registry name | Local cleanup | Doctor and market notes | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| `codex` | Discovers saved UUIDs, then uses local `codex delete UUID --force`; session cleanup also removes `history.jsonl`; memories and caches remain separately selectable | Honors `CODEX_HOME`; runs redacted `codex doctor --json`; skills use its `skills` directory | +| `claude-code` | `clean all` previews and runs local `claude project purge --all`; the command couples transcripts, auto-memory, and related project state | Honors `CLAUDE_CONFIG_DIR`; web, desktop, and IDE histories are separate | +| `gemini-cli` | Shows current-project `gemini --list-sessions`; use `gemini --delete-session INDEX_OR_ID`; default retention is 30 days | Honors `GEMINI_CLI_HOME` as the parent of `.gemini`; skills and settings remain untouched | +| `github-copilot` | Use `/session delete ID --yes`, `/session delete-all --yes`, or preview `/session prune --older-than DAYS --dry-run`; the managed database is never removed | Bulk/prune deletion is local; synced copies require GitHub or a prompted single deletion | +| `qwen-code` | Use `qwen sessions list --json` and interactive `/delete`, `/forget`, or `/memory`; there is no documented noninteractive delete-all contract | Honors `QWEN_HOME`; separate `QWEN_RUNTIME_DIR` output remains outside automatic cleanup | +| `opencode` | Uses `opencode session list` and `session delete`; local logs are cache | Skills live in the XDG OpenCode configuration directory | +| `kilo` | Uses `kilo session list --all --format json` and `kilo session delete ID`; Cloud Agent sessions remain separate | Runs `kilo config check`; data uses the platform data directory; skills use `~/.kilo/skills` | +| `cline` | Use `cline history`; the managed SQLite session directory is never removed | Honors `CLINE_DATA_DIR`; Cline Hub and remote backends are outside local cleanup | +| `aider` | Current-project chat/input/LLM history and tags cache | Aider does not natively load the shared Agent Skills format, so market install is rejected | +| `goose` | Use native `goose session` management; `sessions.db` is never removed | Unix sessions use XDG data, Windows uses `%APPDATA%`; Unix logs remain outside cleanup | +| `vibe` | Automatic conversation and log cleanup is unsupported | Market client identifier is `mistral-vibe` | +| `amp` | Local logs only | Threads may be cloud-backed; shared skills use the agents skill directory | +| `cursor` | No speculative editor database deletion | Official Agent Skills support includes shared `.agents/skills` locations | +| `kiro-cli` | Automatic conversation cleanup is unsupported | Skills use `KIRO_HOME/skills` | +| `droid` | No speculative service/database deletion | Factory Cloud Sync may retain a web copy; native skills/plugins stay vendor-managed | +| `openhands` | Removes documented local `~/.openhands/conversations/*` directories | OpenHands Cloud is separate; native Windows requires WSL; skills stay untouched | +| `pi` | Removes documented local session JSONL files without following symlinks | Honors `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`; skills stay untouched | +| `rovodev` | Use the interactive `/sessions` menu (`d`, then Enter) for deletion | Sessions are workspace-scoped under `~/.rovodev/sessions`; skills stay vendor-managed | Clients without a documented, separable local conversation store intentionally have no clean target. Deleting an entire application database just to remove @@ -56,15 +65,18 @@ generic operation. JSON/TOML configuration syntax, reports state roots, and checks owner-only permissions on Unix. Codex also runs its redacted native JSON doctor. YAML, YAML-derived, and JSONC settings are reported as not parsed rather than -silently declared valid. `--fix` can create a missing skill directory or set a -known state root to mode `0700`; it does not reinstall a CLI, rewrite settings, -or invent vendor-specific repairs. +silently declared valid. Kilo also runs `kilo config check`. A missing binary +with remaining state is distinguished from an installed but uninitialized +client. `--fix` can set a known state root to mode `0700`; it does not create +installer-owned skill directories, reinstall a CLI, rewrite settings, or +invent vendor-specific repairs. Environment overrides are evaluated every run: -- `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `COPILOT_HOME`, `CLINE_DATA_DIR` +- `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `GEMINI_CLI_HOME`, `COPILOT_HOME` +- `QWEN_HOME`, `QWEN_RUNTIME_DIR`, `CLINE_DATA_DIR` - `GOOSE_PATH_ROOT`, `VIBE_HOME`, `KIRO_HOME` -- `PI_CODING_AGENT_DIR` +- `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR` - `XDG_CONFIG_HOME` and `XDG_DATA_HOME` where the client follows XDG ## Market @@ -77,6 +89,12 @@ scope. Skills may contain executable code, so review the repository and pinned revision before installing. Set `DISABLE_TELEMETRY=1` if the Skills CLI's documented anonymous telemetry is not wanted. +Compatibility was refreshed from the live Skills CLI support table on the +review date. All registry clients except Aider currently have a market client +identifier. `agent doctor --list-agents` reports `skills` or `unsupported` +directly from that registry rather than maintaining a second compatibility +database. + ## Primary references - OpenAI Codex: [CLI reference](https://developers.openai.com/codex/cli/reference), @@ -88,19 +106,23 @@ documented anonymous telemetry is not wanted. [configuration directories](https://code.claude.com/docs/en/claude-directory), and [plugins](https://code.claude.com/docs/en/discover-plugins) - Gemini CLI: [session management](https://geminicli.com/docs/cli/session-management/), + [enterprise home override](https://geminicli.com/docs/cli/enterprise/), [memory](https://geminicli.com/docs/tools/memory/), [Agent Skills](https://geminicli.com/docs/cli/using-agent-skills/), and [extensions](https://geminicli.com/docs/extensions/) - GitHub Copilot CLI: [command reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference), [configuration directories](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference), and [Chronicle](https://docs.github.com/en/enterprise-cloud@latest/copilot/concepts/agents/copilot-cli/chronicle) -- Qwen Code: [commands and sessions](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/) +- Qwen Code: [commands and sessions](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/), + [memory](https://qwenlm.github.io/qwen-code-docs/en/users/features/memory/), + [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/), and [skills](https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/) - OpenCode: [CLI sessions](https://dev.opencode.ai/docs/cli/), [local data and troubleshooting](https://opencode.ai/docs/troubleshooting/), and [skills](https://opencode.ai/docs/skills/) -- Kilo Code: [skills](https://kilo.ai/docs/customize/skills) and - [CLI](https://kilo.ai/cli) +- Kilo Code: [CLI reference](https://kilo.ai/docs/code-with-ai/platforms/cli-reference), + [data locations](https://kilo.ai/docs/getting-started/settings/sandboxing), + and [skills](https://kilo.ai/docs/customize/skills) - Cline: [CLI reference](https://docs.cline.bot/cli/cli-reference), [data architecture](https://docs.cline.bot/sdk/architecture/hub-spoke), and [skills](https://docs.cline.bot/customization/skills) @@ -111,13 +133,18 @@ documented anonymous telemetry is not wanted. [CLI workflow](https://docs.mistral.ai/vibe/code/cli/work-with-cli), and [skills](https://docs.mistral.ai/vibe/code/cli/skills) - Amp: [manual](https://ampcode.com/manual) -- Cursor: [agent CLI](https://docs.cursor.com/en/cli/using) +- Cursor: [agent CLI](https://docs.cursor.com/en/cli/using) and + [skills](https://cursor.com/docs/skills) - Kiro: [CLI skills](https://kiro.dev/docs/cli/skills/) -- Factory Droid: [skills](https://docs.factory.ai/cli/configuration/skills) -- OpenHands: [skills](https://docs.openhands.dev/overview/skills) -- Pi: [documentation](https://pi.dev/docs/latest) -- Atlassian Rovo Dev: [CLI commands](https://developer.atlassian.com/cloud/acli/reference/commands/rovodev/) - and [skills](https://developer.atlassian.com/cloud/twg-cli/agents/skills/) +- Factory Droid: [settings and Cloud Sync](https://docs.factory.ai/cli/configuration/settings) + and [skills](https://docs.factory.ai/cli/configuration/skills) +- OpenHands: [local conversations](https://docs.openhands.dev/openhands/usage/cli/resume) + and [skills](https://docs.openhands.dev/overview/skills/adding) +- Pi: [sessions](https://pi.dev/docs/latest/sessions), + [session format](https://pi.dev/docs/latest/session-format), and + [skills](https://pi.dev/docs/latest/skills) +- Atlassian Rovo Dev: [session management](https://support.atlassian.com/rovo/docs/manage-sessions-in-rovo-dev-cli/) + and [skills](https://support.atlassian.com/rovo/docs/extend-rovo-dev-cli-with-agent-skills/) Storage layouts and commands can change between client releases. The table describes the contract implemented here; a preview that differs from current From c7d0bc8e9187d5cfb217f2c10ee0bb5882362e25 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:25:21 +1000 Subject: [PATCH 5/6] feat: improve accessible TUI fallbacks --- dev/gui.py | 82 ++++++++++++++++++++++++++++++++++++++++++++------ dev/gui.py.man | 33 ++++++++++++++++++-- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/dev/gui.py b/dev/gui.py index 88ab9fa..db27e7a 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -1672,7 +1672,7 @@ def refresh_catalog(self, query: str = "") -> None: for script in visible: ready = script.supported and script.launchable state = Text( - "●" if ready else "◆" if not script.launchable else "○", + "Ready" if ready else "Docs" if not script.launchable else "Other OS", style=( "green" if ready @@ -2111,6 +2111,21 @@ def _is_command(path: Path) -> bool: async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: + from unittest.mock import patch + + def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: + snapshot = {} + for selector in ( + "#hero", + "#toolbar", + "#catalog-panel", + "#details-tabs", + "#help", + ): + region = app.query_one(selector).region + snapshot[selector] = (region.x, region.y, region.width, region.height) + return snapshot + expected = {"linux", "macos", "windows"} assert cancel_allowed("Preview") and not cancel_allowed("Apply") assert quit_allowed("Apply", False) and not quit_allowed("Apply", True) @@ -2205,10 +2220,25 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: ) run_root = root.parent - compact = ScriptsApp(root, scripts, working_directory=run_root) + with patch.dict(os.environ, {"NO_COLOR": "1"}): + compact = ScriptsApp(root, scripts, working_directory=run_root) + assert compact.no_color async with compact.run_test(size=(50, 18)) as pilot: await pilot.pause() - assert compact.screen.has_class("-compact") + assert compact.screen.classes == {"-compact", "-short"} + assert layout_snapshot(compact) == { + "#hero": (0, 0, 0, 0), + "#toolbar": (1, 1, 48, 7), + "#catalog-panel": (1, 8, 48, 4), + "#details-tabs": (1, 13, 48, 4), + "#help": (2, 15, 46, 2), + } + fallback_tree = compact.query_one("#repo-tree", FilteredDirectoryTree) + assert ( + fallback_tree.ICON_FILE, + fallback_tree.ICON_NODE, + fallback_tree.ICON_NODE_EXPANDED, + ) == ("📄 ", "📁 ", "📂 ") table = compact.query_one("#scripts", DataTable) assert table.row_count == len(scripts) details = compact.query_one("#details-tabs", TabbedContent).region @@ -2226,16 +2256,25 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: runnable = next( script for script in scripts if script.supported and script.launchable ) + other_os = next( + script for script in scripts if not script.supported and script.launchable + ) + assert table.get_row(runnable.script_id)[0].plain == "Ready" + assert table.get_row(dashboard.script_id)[0].plain == "Docs" + assert table.get_row(other_os.script_id)[0].plain == "Other OS" compact.show_script(runnable) await pilot.press("/") assert compact.focused is compact.query_one("#script-filter", Input) - assert await pilot.click("#files-button") + await pilot.press("f1") + assert compact.query_one("#details-tabs", TabbedContent).active == "help-pane" + await pilot.press("f3") assert compact.query_one("#details-tabs", TabbedContent).active == "files-pane" + assert compact.focused is compact.query_one("#file-filter", Input) selected_id = compact.selected.script_id if compact.selected else "" - compact.action_sort() + await pilot.press("f5") assert table.cursor_coordinate.row == table.get_row_index(selected_id) - compact.action_run() + await pilot.press("f2") await pilot.pause() assert isinstance(compact.screen, RunWizard) assert compact.screen.query_one("#cwd", Input).value == str(run_root) @@ -2267,11 +2306,34 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: ) await pilot.press("escape") await pilot.pause() + confirmed: list[bool] = [] + compact.push_screen(ConfirmApply("echo safe"), confirmed.append) + await pilot.pause() + confirmation = compact.screen.query_one("#confirmation", Input) + confirmation.focus() + await pilot.press("C", "L", "E", "A", "N") + assert not compact.screen.query_one("#confirm-apply", Button).disabled + await pilot.press("enter") + await pilot.pause() + assert confirmed == [True] - wide = ScriptsApp(root, scripts) + wide = ScriptsApp(root, scripts, nerd_fonts=True) async with wide.run_test(size=(140, 40)) as pilot: await pilot.pause() - assert wide.screen.has_class("-wide") + assert wide.screen.classes == {"-tall", "-wide"} + assert layout_snapshot(wide) == { + "#hero": (1, 1, 138, 5), + "#toolbar": (1, 6, 138, 4), + "#catalog-panel": (1, 10, 57, 29), + "#details-tabs": (59, 10, 80, 29), + "#help": (60, 12, 78, 27), + } + nerd_tree = wide.query_one("#repo-tree", FilteredDirectoryTree) + assert ( + nerd_tree.ICON_FILE, + nerd_tree.ICON_NODE, + nerd_tree.ICON_NODE_EXPANDED, + ) == ("󰈔 ", "󰉋 ", "󰝰 ") with tempfile.TemporaryDirectory(prefix="scripts-gui-test-") as directory: test_root = Path(directory) probe = test_root / "long-line.py" @@ -2299,7 +2361,7 @@ async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: ) assert result.exit_code == 0 and not result.cancelled - print(f"ok: {len(scripts)} scripts, compact and wide layouts") + print(f"ok: {len(scripts)} scripts, layout snapshots, keyboard, and fallbacks") def parse_args(arguments: list[str]) -> argparse.Namespace: @@ -2327,7 +2389,7 @@ def parse_args(arguments: list[str]) -> argparse.Namespace: parser.add_argument( "--self-test", action="store_true", - help="validate manual contracts and render compact and wide layouts", + help="validate manuals, layout snapshots, keyboard, and fallbacks", ) return parser.parse_args(arguments) diff --git a/dev/gui.py.man b/dev/gui.py.man index 1f7690d..574ea88 100644 --- a/dev/gui.py.man +++ b/dev/gui.py.man @@ -23,7 +23,7 @@ }, { "flag": "--self-test", - "label": "Validate manual contracts and responsive layouts" + "label": "Validate manuals, layout snapshots, keyboard, and fallbacks" } ] } @@ -58,6 +58,34 @@ The dashboard colors headings, safety callouts, tables, links, and code with the selected terminal theme. The `.man` source remains plain Markdown, so it also works through `curl`, Glow, GitHub, and terminals without color. +## Keyboard and accessibility + +The dashboard does not require a mouse. `Tab` and `Shift+Tab` move focus; +arrow keys move through tables, trees, menus, and tabs; `Enter` activates the +focused control; `Space` toggles a checkbox; and `Escape` closes a dialog or +cancels a safe running preview. + +| Key | Action | +| --- | --- | +| `/` | focus the script filter | +| `F1` or `?` | show the selected script's help | +| `F2` | open the three-step run wizard | +| `F3` | open files and focus the file filter | +| `F5` | sort the script catalog | +| `Ctrl+Q` | quit when no non-interruptible apply is running | + +To run entirely from the keyboard, filter and select a script, press `F2`, use +`Tab` and the arrow keys through Options, Working directory, and Review, then +activate Run or Preview with `Enter`. Destructive apply still requires typing +`CLEAN` and pressing `Enter` after a successful preview. + +Set `NO_COLOR=1` for Textual's monochrome rendering. Standard file and folder +icons work without a patched font; `--nerd-fonts` is an explicit opt-in. The +catalog also labels states as `Ready`, `Docs`, or `Other OS`, so meaning never +depends on color. Terminal screen-reader behavior depends on the terminal and +operating system; `./run.sh list`, `./run.sh man SCRIPT`, and direct commands +remain non-TUI alternatives. + ## Options | Option | Meaning | @@ -65,7 +93,7 @@ also works through `curl`, Glow, GitHub, and terminals without color. | `--root PATH` | use one local scripts checkout | | `--inline` | run below the prompt on macOS or Linux | | `--nerd-fonts` | use Nerd Font file and folder icons | -| `--self-test` | validate every manual contract and compact/wide layouts | +| `--self-test` | validate manuals, layout snapshots, keyboard, and fallbacks | ## Direct CLI @@ -73,6 +101,7 @@ also works through `curl`, Glow, GitHub, and terminals without color. uv run dev/gui.py uv run dev/gui.py --root . uv run dev/gui.py --inline --nerd-fonts +NO_COLOR=1 uv run dev/gui.py --root . uv run dev/gui.py --root . --self-test ``` From 2d312f47f1dd292f8dc4e41255aa7b42488d5add Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:34:27 +1000 Subject: [PATCH 6/6] ci: test launcher across supported systems --- .github/workflows/checks.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d0e1e5d..0624cdb 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -136,6 +136,10 @@ jobs: python-version: "3.11" enable-cache: false + - name: Test launcher + shell: bash + run: bash run.sh --self-test + - name: Test dashboard if: hashFiles('dev/gui.py') != '' run: uv run dev/gui.py --self-test