diff --git a/README.md b/README.md index 7605a70..60f457d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ From a local checkout, use the shorter equivalents: ./run.sh matrix ./run.sh man disk ./run.sh clean +./run.sh fetch --plain +./run.sh games list --json ``` Cleanup scripts always preview first and require explicit confirmation before @@ -69,10 +71,15 @@ bin/ # Ready-to-run command-line tools dev/ # Helpers for development, repositories, and local workflows lib/ # Shared code used by more than one script sys/ # OS maintenance, cleanup, and fixes +packs/ # Typed manuals for optional external executables; no wrapper logic ``` Scripts are grouped by purpose rather than programming language. Platform- -specific scripts can be placed below `sys/` when needed. +specific scripts can be placed below `sys/` when needed. A `packs/NAME.man` +manual may declare `"executable": "NAME"` to expose one installed external +tool through the same launcher and dashboard without duplicating it in Bash or +Python. Missing executables fail with an install hint; packs never download +software implicitly. ## Contributing diff --git a/dev/gui.py b/dev/gui.py index 039c4ce..2800f2e 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -69,9 +69,10 @@ OUTPUT_LINE_LIMIT = 8 * 1024 HISTORY_LIMIT = 200 MAN_SUFFIX = ".man" -SEARCH_DIRS = ("bin", "dev", "sys") +SEARCH_DIRS = ("bin", "dev", "sys", "packs") FLAG_PATTERN = re.compile(r"^--?[A-Za-z][A-Za-z0-9-]*$") NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]*$") +COMMAND_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") PLATFORMS = {"any", "linux", "macos", "windows"} PLATFORM_LABELS = { "any": "Any", @@ -89,6 +90,7 @@ "directWhen", "findingExitCodes", "launchable", + "executable", "parameters", "options", } @@ -166,6 +168,8 @@ class ScriptSpec: apply_when: tuple[str, ...] = () direct_when: tuple[str, ...] = () launchable: bool = True + external: bool = False + installed: bool = True @property def supported(self) -> bool: @@ -480,9 +484,6 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: ) if apply_when and apply_flag is None: raise GuiError(f"{help_path}: applyWhen requires applyFlag") - if direct_when and apply_flag is None: - raise GuiError(f"{help_path}: directWhen requires applyFlag") - launchable = metadata.get("launchable", True) if not isinstance(launchable, bool): raise GuiError(f"{help_path}: launchable must be true or false") @@ -498,11 +499,17 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: f"{help_path}: findingExitCodes must contain unique integers from 1 to 255" ) - script_path = Path(str(help_path)[: -len(MAN_SUFFIX)]).resolve() - if not script_path.is_file(): + executable = metadata.get("executable") + if executable is not None and ( + not isinstance(executable, str) or not COMMAND_PATTERN.fullmatch(executable) + ): + raise GuiError(f"{help_path}: executable must be one command name") + adjacent_path = Path(str(help_path)[: -len(MAN_SUFFIX)]).resolve() + script_path = adjacent_path if executable is None else Path(executable) + if executable is None and not script_path.is_file(): raise GuiError(f"{help_path}: adjacent script does not exist") try: - script_id = script_path.relative_to(root).as_posix() + script_id = adjacent_path.relative_to(root).as_posix() except ValueError as error: raise GuiError(f"{help_path}: script escapes the repository") from error @@ -534,6 +541,8 @@ def parse_help(help_path: Path, root: Path) -> ScriptSpec: apply_when=apply_when, direct_when=direct_when, launchable=launchable, + external=executable is not None, + installed=executable is None or shutil.which(executable) is not None, ) @@ -657,6 +666,8 @@ def _resolve_program(program: str) -> str: def command_for(script: ScriptSpec, flags: Iterable[str]) -> list[str]: + if script.external: + return [_resolve_program(str(script.path)), *flags] if script.path.suffix.lower() == ".ps1": powershell = shutil.which("pwsh") or shutil.which("powershell") if powershell is None: @@ -699,7 +710,8 @@ def command_for(script: ScriptSpec, flags: Iterable[str]) -> list[str]: def display_command(command: Iterable[str]) -> str: - return shlex.join(command) + values = list(command) + return subprocess.list2cmdline(values) if os.name == "nt" else shlex.join(values) def markdown_code_block(text: str) -> str: @@ -1128,7 +1140,13 @@ def update_step(self) -> None: self.query_one("#wizard-back", Button).disabled = self.step == 0 arguments = self.selected_arguments() if self.step == 2 else () self.query_one("#wizard-next", Button).label = ( - ("Preview" if self.script.needs_apply(arguments or ()) else "Run") + ( + "Preview" + if self.script.needs_apply(arguments or ()) + else "Show command" + if self.script.needs_direct_terminal(arguments or ()) + else "Run" + ) if self.step == 2 else "Next" ) @@ -1171,6 +1189,9 @@ def advance(self) -> None: "This runs a read-only preview first, then shows the apply " "command for a direct interactive terminal." if needs_apply and self.script.needs_direct_terminal(arguments) + else "This interactive tool needs the terminal. Confirm to show the " + "exact command to run." + if self.script.needs_direct_terminal(arguments) else "This runs a read-only preview first. Apply repeats " "discovery with the same settings, so targets may change " "between runs. A successful preview must be followed by `CLEAN`." @@ -1732,7 +1753,7 @@ def refresh_catalog(self, query: str = "") -> None: ] visible.sort( key=lambda script: ( - not (script.supported and script.launchable), + not (script.supported and script.launchable and script.installed), not script.supported, script.script_id, ) @@ -1740,12 +1761,14 @@ def refresh_catalog(self, query: str = "") -> None: table = self.query_one("#scripts", DataTable) table.clear() for script in visible: - ready = script.supported and script.launchable + ready = script.supported and script.launchable and script.installed state = Text( "● Ready" if ready else "◆ Guide" if not script.launchable + else "○ Install" + if script.supported and not script.installed else f"○ {PLATFORM_LABELS[script.platform]}", style=( "green" @@ -1789,6 +1812,8 @@ def show_script(self, script: ScriptSpec) -> None: badge = ( "Documentation only" if not script.launchable + else f"Install `{script.path}` to run" + if script.supported and not script.installed else "Available on this computer" if script.supported else f"Available on {PLATFORM_LABELS[script.platform]}" @@ -1805,6 +1830,7 @@ def update_controls(self) -> None: self.selected is not None and self.selected.supported and self.selected.launchable + and self.selected.installed and self.started_at is None ) self.query_one("#run", Button).disabled = not available @@ -1812,7 +1838,8 @@ def update_controls(self) -> None: self.process is None or not cancel_allowed(self.active_phase) ) supported = sum( - script.supported and script.launchable for script in self.scripts + script.supported and script.launchable and script.installed + for script in self.scripts ) state = "running" if self.started_at is not None else "ready" platform = PLATFORM_LABELS.get(host_platform(), host_platform()) @@ -1919,6 +1946,7 @@ def action_run(self) -> None: self.selected is None or not self.selected.supported or not self.selected.launchable + or not self.selected.installed or self.started_at is not None ): return @@ -2113,6 +2141,21 @@ async def run_once( async def run_selection(self, selection: RunSelection) -> None: try: needs_apply = selection.script.needs_apply(selection.flags) + if ( + selection.script.needs_direct_terminal(selection.flags) + and not needs_apply + ): + command = display_command( + command_for(selection.script, selection.flags) + ) + self.query_one("#details-tabs", TabbedContent).active = "output-pane" + self.query_one("#activity-heading", Label).update( + "Direct interactive terminal required" + ) + log = self.query_one("#run-log", RichLog) + log.clear() + log.write(f"Run directly:\n$ {command}") + return result = await self.run_once( selection, "Preview" if needs_apply else "Run", @@ -2215,7 +2258,7 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: if legacy: names = ", ".join(path.as_posix() for path in legacy) raise GuiError(f"legacy .help manuals remain: {names}") - documented = {script.path for script in scripts} + documented = {script.path for script in scripts if not script.external} commands = {path.resolve() for path in root.iterdir() if _is_command(path)} commands |= { path.resolve() @@ -2242,6 +2285,17 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: ) if dashboard is None or dashboard.launchable: raise GuiError("dashboard manual must be present and non-launchable") + external = next((script for script in scripts if script.external), None) + if external is not None: + with patch("shutil.which", return_value="/installed/tool"): + assert command_for(external, ("--plain",)) == [ + "/installed/tool", + "--plain", + ] + with patch.object(os, "name", "nt"): + assert display_command((r"C:\Program Files\fetch.exe", "--plain")) == ( + '"C:\\Program Files\\fetch.exe" --plain' + ) with tempfile.TemporaryDirectory(prefix="scripts-gui-contract-") as directory: contract_root = Path(directory).resolve() @@ -2308,6 +2362,18 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: probe.parameters[1].when, {"action": "show"}, ) + metadata["executable"] = "../escape" + 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 "executable must be one command name" in str(error) + else: + raise AssertionError("unsafe external executable was accepted") + metadata.pop("executable") metadata["privlege"] = metadata.pop("privilege") help_path.write_text( f"---\n{json.dumps(metadata)}\n---\n{probe_markdown}", @@ -2366,6 +2432,12 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: assert table.get_row(other_os.script_id)[0].plain == ( f"○ {PLATFORM_LABELS[other_os.platform]}" ) + missing_external = next( + (script for script in scripts if script.external and not script.installed), + None, + ) + if missing_external is not None: + assert table.get_row(missing_external.script_id)[0].plain == "○ Install" compact.show_script(runnable) await pilot.pause() assert help_scroll.max_scroll_y > 0 diff --git a/dev/gui.py.man b/dev/gui.py.man index 54947e4..b8d21f8 100644 --- a/dev/gui.py.man +++ b/dev/gui.py.man @@ -32,9 +32,10 @@ # Responsive scripts dashboard -Discovers adjacent `.man` manuals under `bin`, `dev`, and `sys`, then renders -their Markdown, typed parameters, warnings, progress, files, and command output -in one keyboard- and mouse-friendly interface. +Discovers adjacent `.man` manuals under `bin`, `dev`, `sys`, and `packs`, then +renders their Markdown, typed parameters, warnings, progress, files, and +command output in one keyboard- and mouse-friendly interface. External packs +resolve one installed executable and contain no shell or Python wrapper. > **Apply safety:** Destructive commands always preview first and require > `CLEAN`. Cancel and Quit are disabled during apply because interrupting @@ -46,8 +47,8 @@ non-interactive use; a normal terminal credential cache usually does not carry into the isolated child process. Run the displayed command directly when a password is required. Launch the Windows dashboard from an Administrator terminal for privileged actions. Commands that need their own interactive -terminal are previewed in the dashboard, which then shows the direct apply -command. +terminal show the exact direct command instead of corrupting either full-screen +interface. Without `--root`, the dashboard uses a local checkout when one can be found; otherwise it downloads a temporary copy of the repository and removes it when diff --git a/dev/run.py b/dev/run.py index 8516ad0..08f9362 100644 --- a/dev/run.py +++ b/dev/run.py @@ -12,9 +12,10 @@ from _manual import PRIVILEGE_LABELS -SEARCH_DIRS = ("bin", "dev", "sys") +SEARCH_DIRS = ("bin", "dev", "sys", "packs") MAN_SUFFIX = ".man" SAFE_NAME = re.compile(r"[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*") +COMMAND_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") SCRIPT_SUFFIXES = {".bat", ".cmd", ".ps1", ".py", ".sh"} @@ -63,6 +64,7 @@ def catalog(root: Path) -> list[dict[str, object]]: privilege = value.get("privilege") apply_flag = value.get("applyFlag") launchable = value.get("launchable", True) + executable = value.get("executable") 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(): @@ -75,16 +77,26 @@ def catalog(root: Path) -> list[dict[str, object]]: 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)]) - if script.is_symlink() or not script.is_file(): - raise LauncherError(f"unsafe or missing script for {help_path}") - script_id = script.relative_to(root).as_posix() + if executable is not None and ( + not isinstance(executable, str) or not COMMAND_NAME.fullmatch(executable) + ): + raise LauncherError(f"invalid executable in {help_path}") + adjacent = Path(str(help_path)[: -len(MAN_SUFFIX)]) + if executable is None: + if adjacent.is_symlink() or not adjacent.is_file(): + raise LauncherError(f"unsafe or missing script for {help_path}") + script = adjacent + script_id = script.relative_to(root).as_posix() + else: + script = Path(executable) + script_id = adjacent.relative_to(root).as_posix() alias = script.stem if script.suffix.lower() in SCRIPT_SUFFIXES else script.name entries.append( { "id": script_id, "alias": alias, "path": script, + "external": executable is not None, "manual": help_path, "platform": platform, "privilege": privilege, @@ -147,14 +159,20 @@ def show(entries: list[dict[str, object]], platform: str) -> None: else str(entry["id"]), str(entry["id"]), str(entry["summary"]), + entry, ) for entry in usable ] width = max((len(row[0]) for row in rows), default=4) print(f"Usable scripts on {platform}:\n") - for name, script_id, summary in rows: + for name, script_id, summary, entry in rows: location = "" if name == script_id else f" [{script_id}]" - print(f" {name:<{width}}{location}\n {summary}") + missing = ( + f" [install {entry['path']}]" + if entry["external"] and shutil.which(str(entry["path"])) is None + else "" + ) + print(f" {name:<{width}}{location}{missing}\n {summary}") def support_matrix(entries: list[dict[str, object]]) -> str: @@ -180,6 +198,14 @@ def support_matrix(entries: list[dict[str, object]]) -> str: def launch(entry: dict[str, object], arguments: list[str]) -> None: script = Path(str(entry["path"])) + if entry["external"]: + executable = shutil.which(str(script)) + if not executable: + raise LauncherError( + f"{script} is not installed; see './run.sh man {entry['alias']}'" + ) + os.execvp(executable, [executable, *arguments]) + return suffix = script.suffix.lower() if suffix == ".ps1": powershell = shutil.which("pwsh") or shutil.which("powershell") @@ -226,6 +252,8 @@ def launch_dashboard(entries: list[dict[str, object]]) -> None: def self_test(root: Path, entries: list[dict[str, object]]) -> None: + from unittest.mock import patch + for platform in ("linux", "macos", "windows"): assert compatible(entries, platform) assert resolve(entries, platform, "security-audit")["platform"] == "any" @@ -235,6 +263,24 @@ 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" + external = next((entry for entry in entries if entry["external"]), None) + if external: + assert not Path(str(external["path"])).is_absolute() + with ( + patch.object(shutil, "which", return_value="/installed/tool"), + patch.object(os, "execvp") as execute, + ): + launch(external, ["--plain"]) + execute.assert_called_once_with( + "/installed/tool", ["/installed/tool", "--plain"] + ) + with patch.object(shutil, "which", return_value=None): + try: + launch(external, []) + except LauncherError as error: + assert "is not installed" in str(error) + else: + raise AssertionError("missing external executable was launched") matrix = support_matrix(entries) assert all(f"| `{entry['id']}` |" in matrix for entry in entries) assert "User; elevation for some actions" in matrix diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0122ca1..d3afbf4 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -62,6 +62,9 @@ Only clean, understandable, and tested scripts are accepted. - Put commands for users in `bin/`, development helpers in `dev/`, reusable code in `lib/`, and system maintenance in `sys/`. +- Put an optional installed tool in `packs/NAME.man` with an `executable` + command name. Do not add a Bash or Python wrapper that only forwards + arguments; the shared launchers resolve the executable directly. - Include an appropriate shebang and make executable scripts executable. - Validate input and fail with a useful message. - Add comments where they explain intent, risk, or an unusual decision. Do not diff --git a/packs/fetch.man b/packs/fetch.man new file mode 100644 index 0000000..d33bfba --- /dev/null +++ b/packs/fetch.man @@ -0,0 +1,34 @@ +--- +{ + "summary": "Show a fast native system and CPU overview.", + "platform": "any", + "privilege": "user", + "executable": "fetch", + "parameters": [ + { + "name": "format", + "label": "Output format", + "required": true, + "default": "--plain", + "choices": [ + {"value": "--plain", "label": "Plain text"}, + {"value": "--json", "label": "JSON"} + ] + } + ] +} +--- +# Fetch + +`fetch` is the native system-and-CPU command from +[`keys-i/tools`](https://github.com/keys-i/tools). It reports host, operating +system, kernel, architecture, CPU, topology, caches, core counts, memory, +shell, and terminal without starting Python or a shell subprocess. + +The dashboard exposes the stable `format` choices `--plain` and `--json`. +Running `fetch` directly in a terminal uses the styled view. `NO_COLOR=1` +disables decoration. + +Build [`keys-i/tools`](https://github.com/keys-i/tools) and place `fetch` on +`PATH`. The prepared `keys-tools` PyPI package has not been published. This +script pack does not download or install binaries. diff --git a/packs/games.man b/packs/games.man new file mode 100644 index 0000000..7481c2b --- /dev/null +++ b/packs/games.man @@ -0,0 +1,76 @@ +--- +{ + "summary": "Play eleven original native terminal games from one command.", + "platform": "any", + "privilege": "user", + "executable": "games", + "directWhen": ["run"], + "parameters": [ + { + "name": "action", + "label": "Action", + "required": true, + "default": "list", + "choices": [ + {"value": "list", "label": "List games"}, + {"value": "info", "label": "Show game details"}, + {"value": "run", "label": "Play a game"} + ] + }, + { + "name": "game", + "label": "Game", + "required": true, + "default": "merge", + "choices": [ + {"value": "cards", "label": "Hand Threshold"}, + {"value": "delve", "label": "Delve"}, + {"value": "glyphs", "label": "Five Glyphs"}, + {"value": "keybed", "label": "Keybed"}, + {"value": "merge", "label": "Number Merge"}, + {"value": "orbit", "label": "Orbit Court"}, + {"value": "scout", "label": "Field Scout"}, + {"value": "seedling", "label": "Seedling"}, + {"value": "serpent", "label": "Serpent Run"}, + {"value": "sprint", "label": "Type Sprint"}, + {"value": "vector", "label": "Exit Vector"} + ], + "when": {"action": ["info", "run"]} + }, + { + "name": "format", + "label": "List format", + "required": true, + "default": "--plain", + "choices": [ + {"value": "--plain", "label": "Plain text"}, + {"value": "--json", "label": "JSON"} + ], + "when": {"action": ["list"]} + }, + { + "name": "seed", + "label": "Reproducible seed", + "flag": "--seed", + "placeholder": "unsigned integer", + "when": {"action": ["run"]} + } + ] +} +--- +# Games + +`games` is the native clean-room terminal arcade from +[`keys-i/tools`](https://github.com/keys-i/tools). The `action` may be `list`, +`info`, or `run`. Choose a `game` for details or play; choose a `format` of +`--plain` or `--json` when listing. An optional unsigned `--seed` reproduces +the randomized setup; elapsed time and input timing remain live. + +Interactive games cannot run inside the dashboard output pane. Selecting +`run` shows the exact direct-terminal command. All eleven modes are built into +the Rust binary; no external game, shell, Python process, network service, or +download is involved. + +This scripts pack contains only the shared manual and typed UI contract; it +does not duplicate gameplay or argument logic. The prepared `keys-tools` PyPI +package has not been published. diff --git a/run.sh.man b/run.sh.man index cfb011a..5ba24c3 100644 --- a/run.sh.man +++ b/run.sh.man @@ -9,9 +9,10 @@ # OS-aware script launcher -`run.sh` reads the adjacent `.man` contracts under `bin`, `dev`, and `sys`; it -does not maintain a second script list. Run it from a checkout or stream it -without installing this repository. +`run.sh` reads the adjacent `.man` contracts under `bin`, `dev`, `sys`, and +`packs`; it does not maintain a second script list. A pack may name one +installed `executable` instead of adding a wrapper. Run the launcher from a +checkout or stream it without installing this repository. > **Behavior:** The launcher detects Linux, macOS, or Windows internally, > hides incompatible and non-launchable commands, passes arguments unchanged, @@ -31,6 +32,8 @@ can supply Python. `curl` and `tar` are also needed for streamed runs. ./run.sh security-audit audit --scope system ./run.sh disk health ./run.sh clean +./run.sh fetch --plain +./run.sh games list --json ./run.sh --self-test ``` @@ -38,9 +41,10 @@ With no arguments, the responsive dashboard opens when `uv` and a terminal are available; otherwise the compatible script list is printed. Use a listed short name or its repository-relative path. Remaining arguments -are passed unchanged to that command. Ambiguous names, traversal, malformed -manual metadata, symlinks, unsupported platforms, and incompatible scripts are -rejected. +are passed unchanged to that command. External packs resolve one command name +from `PATH` and never invoke a shell or installer. 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;