From 8d4fe60c9e72a6605737296996102554b5956749 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:48:11 +1000 Subject: [PATCH 1/2] feat: finish dashboard and cleanup workflows --- README.md | 4 +- bin/_common.py | 25 +++ bin/agent | 71 ++++--- bin/agent.man | 11 +- bin/cleaner | 475 +++++++++++++++++++++++++++++++++++++++++++- bin/cleaner.man | 81 ++++++-- dev/gui.py | 115 ++++++++--- dev/gui.py.man | 9 +- docs/agent-tools.md | 7 +- sys/linux/clean | 4 +- sys/linux/clean.man | 4 +- sys/macos/clean | 58 ++++-- sys/macos/clean.man | 6 +- 13 files changed, 763 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 986ad89..7605a70 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ From a local checkout, use the shorter equivalents: ``` Cleanup scripts always preview first and require explicit confirmation before -applying changes. The [agent research](docs/agent-tools.md) records supported +applying changes. `cleaner dev --root ~/Coding` inventories manifest-backed +build output, dependency trees, and language caches before offering removal. +The [agent research](docs/agent-tools.md) records supported clients and cleanup limits; the [security research](docs/security-research.md) explains scanner, CVE, KEV, and host-check coverage; the [Slurm example](examples/slurm.toml) shows every resource and command option. diff --git a/bin/_common.py b/bin/_common.py index ceb56f0..1435fea 100644 --- a/bin/_common.py +++ b/bin/_common.py @@ -6,6 +6,7 @@ import os import shlex import shutil +import stat import subprocess import sys from collections.abc import Sequence @@ -167,6 +168,30 @@ def safe_targets(root: Path, patterns: Sequence[str]) -> list[Path]: return sorted(targets, key=lambda path: (len(path.parts), str(path)), reverse=True) +def target_usage(targets: Sequence[Path]) -> tuple[int, int]: + """Count entries and logical bytes without following symlinks.""" + 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() + 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 + except OSError: + continue + return items, total_bytes + + def remove_targets(targets: Sequence[Path]) -> tuple[int, int]: files = directories = 0 for target in targets: diff --git a/bin/agent b/bin/agent index ef11003..a049e8c 100755 --- a/bin/agent +++ b/bin/agent @@ -28,9 +28,12 @@ from _common import ( run, safe_targets, show_steps, + target_usage, warning, ) +SKILLS_PACKAGE = "skills@1.5.22" + @dataclass(frozen=True) class AgentSpec: @@ -498,29 +501,6 @@ def _native_clean_steps(spec: AgentSpec, kind: str) -> list[Step]: 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] = [] @@ -555,7 +535,7 @@ 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) + 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) " @@ -717,12 +697,12 @@ def market(args: argparse.Namespace) -> int: if npx is None: raise ToolError("npx is required; install a current Node.js release") if args.install: - if args.agent == "all" and os.environ.get("SCRIPTS_TUI"): - raise ToolError("choose one target agent before installing a skill") - command = [npx, "--yes", "skills", "add", args.install] + command = [npx, "--yes", SKILLS_PACKAGE, "add", args.install] if args.skill: command.extend(("--skill", args.skill)) - if args.agent != "all": + if args.agent == "all": + command.extend(("--agent", "*")) + else: spec = choose_agents(args.agent)[0] if spec.skill_agent is None: raise ToolError(f"{spec.label} does not natively load Agent Skills") @@ -749,7 +729,7 @@ def market(args: argparse.Namespace) -> int: if not args.query and os.environ.get("SCRIPTS_TUI"): raise ToolError("enter a market query in the wizard") - command = [npx, "--yes", "skills", "find"] + command = [npx, "--yes", SKILLS_PACKAGE, "find"] if args.query: command.append(args.query) heading("Indexed Agent Skills catalog") @@ -790,6 +770,37 @@ def self_test() -> int: assert all( spec.skill_agent is not None for key, spec in agents.items() if key != "aider" ) + add = ["npx", "--yes", SKILLS_PACKAGE, "add", "owner/repository", "--agent"] + market_cases = ( + ( + ["market", "--install", "owner/repository"], + [*add, "*", "--list"], + ), + ( + ["market", "--install", "owner/repository", "--agent", "codex"], + [*add, "codex", "--list"], + ), + ( + ["market", "--install", "owner/repository", "--apply", "--yes"], + [*add, "*", "--yes"], + ), + ( + ["market", "--query", "python"], + ["npx", "--yes", SKILLS_PACKAGE, "find", "python"], + ), + ) + with ( + patch(f"{__name__}.find_program", return_value="npx"), + patch( + f"{__name__}.run", + return_value=SimpleNamespace(returncode=0), + ) as market_run, + ): + for arguments, _expected in market_cases: + assert market(parse_args(arguments)) == 0 + assert [call.args[0] for call in market_run.call_args_list] == [ + expected for _arguments, expected in market_cases + ] assert _read_json_lines('{"id":"one"}\n{"id":"two"}') == [ {"id": "one"}, {"id": "two"}, @@ -888,7 +899,7 @@ def self_test() -> int: (usage / "one").write_bytes(b"123") nested_file = nested / "two" nested_file.write_bytes(b"45") - assert _target_usage([usage, nested_file]) == (4, 5) + assert target_usage([usage, nested_file]) == (4, 5) partial = AgentSpec( "fixture", "Fixture", diff --git a/bin/agent.man b/bin/agent.man index 7827fb6..4d36436 100644 --- a/bin/agent.man +++ b/bin/agent.man @@ -113,8 +113,8 @@ Droid, OpenHands, Pi, and Rovo Dev. Market discovery delegates to the live private skill. > **Trust boundary:** Every `market` search or install runs -> `npx --yes skills ...`. It requires Node.js, `npx`, and network access and -> may download and execute the current npm package. Review that package and +> `npx --yes skills@1.5.22 ...`. It requires Node.js, `npx`, and network access and +> may download and execute the pinned npm package. Review that package and > each skill source before continuing. ## Actions @@ -174,9 +174,10 @@ retain separate copies. Gemini session listing is current-project scoped. ### `market` Enter a `--query` to search interactively. To preview an install, set -`--install owner/repository`, optionally `--skill name`, choose an `--agent`, -and select `--global` for user scope. A preview lists what the live package -would install; it does not establish that the source is trustworthy. +`--install owner/repository`, optionally `--skill name`, and select `--global` +for user scope. The default `all` target uses the installer's supported `*` +agent target; choose one `--agent` to narrow it. A preview lists what the live +package would install; it does not establish that the source is trustworthy. ## Direct CLI diff --git a/bin/cleaner b/bin/cleaner index dca592a..830b7d3 100755 --- a/bin/cleaner +++ b/bin/cleaner @@ -7,8 +7,11 @@ from __future__ import annotations import argparse +import os import re import sys +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from tempfile import TemporaryDirectory @@ -18,13 +21,374 @@ from _common import ( ToolError, apply_steps, color, + confirmed, + find_program, heading, + remove_targets, require_program, run, + target_usage, warning, ) EXPIRY = re.compile(r"^(?:\d+\.(?:days?|weeks?|months?|years?)\.ago|now)$") +CACHE_TAG = b"Signature: 8a477f597d28d172789f06886806bc55" +VCS_DIRS = {".git", ".hg", ".svn"} +HOME_STATE_DIRS = {"AppData", "Library"} +PYTHON_CACHES = { + "__pycache__", + ".hypothesis", + ".mypy_cache", + ".nox", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".tox", +} +PROJECT_OUTPUTS = { + "node_modules": {"package.json"}, + "vendor": {"composer.json"}, + "Pods": {"Podfile"}, + ".next": {"package.json"}, + ".nuxt": {"package.json"}, + ".parcel-cache": {"package.json"}, + ".svelte-kit": {"package.json"}, + ".turbo": {"package.json"}, + "target": {"Cargo.toml", "pom.xml"}, + ".gradle": { + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + }, + "build": { + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "CMakeLists.txt", + "pubspec.yaml", + }, + ".build": {"Package.swift"}, + "bin": {"*.csproj", "*.fsproj", "*.vbproj"}, + "obj": {"*.csproj", "*.fsproj", "*.vbproj"}, + ".dart_tool": {"pubspec.yaml"}, + "_build": {"mix.exs"}, + "deps": {"mix.exs"}, + ".stack-work": {"*.cabal", "cabal.project"}, + "dist-newstyle": {"*.cabal", "cabal.project"}, + ".zig-cache": {"build.zig"}, + "zig-out": {"build.zig"}, + ".terraform": {"*.tf", ".terraform.lock.hcl"}, +} + + +@dataclass(frozen=True) +class Artifact: + label: str + path: Path + items: int + bytes: int + + +@dataclass(frozen=True) +class CachePlan: + label: str + paths: tuple[Path, ...] + command: tuple[str, ...] | None = None + + +def _generated_label(name: str, files: set[str], path: Path) -> str | None: + if name in PYTHON_CACHES: + return "Python tool cache" + if ( + name in {".venv", "venv"} + and (path / "pyvenv.cfg").is_file() + and ( + {"pyproject.toml", "requirements.txt", "Pipfile", "poetry.lock", "uv.lock"} + & files + ) + ): + return "Python virtual environment" + markers = PROJECT_OUTPUTS.get(name, ()) + if any( + marker in files + or (marker.startswith("*") and any(file.endswith(marker[1:]) for file in files)) + for marker in markers + ): + return "Generated project data" + return None + + +def _cache_tagged(path: Path, entries: list[os.DirEntry[str]]) -> bool: + tag = next((entry for entry in entries if entry.name == "CACHEDIR.TAG"), None) + if tag is None or not tag.is_file(follow_symlinks=False): + return False + try: + with (path / tag.name).open("rb") as file: + return file.read(len(CACHE_TAG)) == CACHE_TAG + except OSError: + return False + + +def _artifact(label: str, path: Path) -> Artifact: + items, total_bytes = target_usage([path]) + return Artifact(label, path, items, total_bytes) + + +def discover_artifacts(root: Path) -> tuple[list[Artifact], int]: + """Find explicitly regenerable project directories below root.""" + if root.is_symlink(): + raise ToolError(f"refusing symbolic-link scan root: {root}") + try: + root = root.resolve(strict=True) + device = root.stat().st_dev + except OSError as error: + raise ToolError(f"cannot inspect scan root {root}: {error}") from error + if not root.is_dir() or root == Path(root.anchor): + raise ToolError("scan root must be a directory below the filesystem root") + + findings: list[Artifact] = [] + skipped = 0 + pending = [root] + home = Path.home().resolve() + while pending: + current = pending.pop() + try: + with os.scandir(current) as scan: + entries = list(scan) + except OSError: + skipped += 1 + continue + if current != root and _cache_tagged(current, entries): + findings.append(_artifact("Tagged cache", current)) + continue + + files: set[str] = set() + directories: list[os.DirEntry[str]] = [] + for entry in entries: + try: + if entry.is_dir(follow_symlinks=False): + directories.append(entry) + elif entry.is_file(follow_symlinks=False): + files.add(entry.name) + except OSError: + skipped += 1 + for entry in directories: + try: + path = Path(entry.path) + if entry.stat(follow_symlinks=False).st_dev != device: + continue + except OSError: + skipped += 1 + continue + if label := _generated_label(entry.name, files, path): + findings.append(_artifact(label, path)) + continue + if entry.name.startswith("cmake-build-") and "CMakeLists.txt" in files: + findings.append(_artifact("Generated project data", path)) + continue + if entry.name in VCS_DIRS or ( + current == root == home + and (entry.name.startswith(".") or entry.name in HOME_STATE_DIRS) + ): + continue + pending.append(path) + return sorted(findings, key=lambda item: (-item.bytes, str(item.path))), skipped + + +def _safe_cache_path(value: str, home: Path) -> Path | None: + path = Path(value.strip()).expanduser() + if not path.is_absolute(): + return None + try: + relative = path.relative_to(home) + current = home + for part in relative.parts: + current /= part + if current.is_symlink(): + return None + path = path.resolve(strict=True) + path.relative_to(home) + except (OSError, ValueError): + return None + return path if path != home and path.is_dir() else None + + +def _reported_cache( + label: str, + names: tuple[str, ...], + query: tuple[str, ...], + clean: tuple[str, ...], + home: Path, +) -> CachePlan | None: + program = find_program(*names) + if program is None: + return None + try: + result = run( + (program, *query), + capture=True, + check=False, + announce=False, + timeout=5, + ) + except (ToolError, OSError): + return None + lines = (result.stdout or "").strip().splitlines() + path = ( + _safe_cache_path(lines[-1], home) if result.returncode == 0 and lines else None + ) + return CachePlan(label, (path,), (program, *clean)) if path else None + + +def discover_caches() -> list[CachePlan]: + """Locate user caches through native tools, then known tool-owned paths.""" + home = Path.home().resolve() + definitions = ( + ( + "Go build cache", + ("go",), + ("env", "GOCACHE"), + ("clean", "-cache", "-testcache"), + ), + ( + "Go module cache", + ("go",), + ("env", "GOMODCACHE"), + ("clean", "-modcache"), + ), + ( + "npm cache", + ("npm",), + ("config", "get", "cache"), + ("cache", "clean", "--force"), + ), + ("pnpm store", ("pnpm",), ("store", "path"), ("store", "prune")), + ("pip cache", ("pip3", "pip"), ("cache", "dir"), ("cache", "purge")), + ("uv cache", ("uv",), ("cache", "dir"), ("cache", "prune")), + ) + with ThreadPoolExecutor(max_workers=len(definitions)) as pool: + plans = [ + plan + for plan in pool.map(lambda item: _reported_cache(*item, home), definitions) + if plan is not None + ] + + cargo = Path(os.environ.get("CARGO_HOME", home / ".cargo")).expanduser() + gradle = Path(os.environ.get("GRADLE_USER_HOME", home / ".gradle")).expanduser() + static = ( + ("Cargo registry and Git caches", (cargo / "registry", cargo / "git")), + ( + "Gradle caches and wrapper distributions", + (gradle / "caches", gradle / "wrapper/dists"), + ), + ("Maven local repository", (home / ".m2/repository",)), + ( + "NuGet global packages", + (Path(os.environ.get("NUGET_PACKAGES", home / ".nuget/packages")),), + ), + ) + for label, candidates in static: + paths = tuple( + path + for candidate in candidates + if (path := _safe_cache_path(str(candidate), home)) is not None + ) + if paths: + plans.append(CachePlan(label, paths)) + + seen: list[Path] = [] + unique: list[CachePlan] = [] + for plan in plans: + paths = tuple( + path + for path in plan.paths + if not any(root == path or root in path.parents for root in seen) + ) + seen.extend(paths) + if paths: + unique.append(CachePlan(plan.label, paths, plan.command)) + return unique + + +def _human_bytes(value: int) -> str: + amount = float(value) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if amount < 1024 or unit == "TiB": + return f"{amount:.0f} {unit}" if unit == "B" else f"{amount:.1f} {unit}" + amount /= 1024 + raise AssertionError("unreachable") + + +def dev(args: argparse.Namespace) -> int: + root = Path(args.root or ".").expanduser() + artifacts, skipped = discover_artifacts(root) + cache_plans = [] if args.project_only else discover_caches() + cache_roots = [path for plan in cache_plans for path in plan.paths] + artifacts = [ + item + for item in artifacts + if not any( + root == item.path or root in item.path.parents for root in cache_roots + ) + ] + cache_artifacts = [ + _artifact(plan.label, path) for plan in cache_plans for path in plan.paths + ] + findings = [*artifacts, *cache_artifacts] + total_items = sum(item.items for item in findings) + total_bytes = sum(item.bytes for item in findings) + + heading("Developer clutter") + print(f"Scan root: {root.resolve()}") + for item in findings: + print(f" {_human_bytes(item.bytes):>10} {item.label:<34} {item.path}") + if skipped: + warning(f"inventory skipped {skipped} unreadable directories") + if not findings: + print("No supported developer clutter was found.") + return 1 if skipped else 0 + print( + f"Plan: {len(findings)} target(s), {total_items} item(s), " + f"{_human_bytes(total_bytes)} logical size." + ) + warning("Close package managers, compilers, and editors before applying.") + if not confirmed( + apply=args.apply, + yes=args.yes, + prompt="remove the listed developer clutter", + ): + return 1 if skipped else 0 + + failed = 0 + for plan in cache_plans: + if plan.command is None: + continue + try: + result = run(plan.command, check=False) + except (ToolError, OSError) as error: + warning(f"{plan.label} cleanup failed: {error}") + failed += 1 + else: + if result.returncode: + warning(f"{plan.label} cleanup exited {result.returncode}") + failed += 1 + native_paths = { + path for plan in cache_plans if plan.command is not None for path in plan.paths + } + direct = [item.path for item in findings if item.path not in native_paths] + for target in direct: + try: + remove_targets([target]) + except OSError as error: + warning(f"could not remove {target}: {error}") + failed += 1 + remaining = sum(target_usage([item.path])[1] for item in findings) + reclaimed = max(0, total_bytes - remaining) + result = f"Reclaimed {_human_bytes(reclaimed)}; {failed} cleanup(s) failed." + print(color(result, "1;31" if failed else "1;32")) + return 1 if failed or skipped else 0 def checked_expiry(args: argparse.Namespace) -> str: @@ -188,9 +552,13 @@ def rewrite(args: argparse.Namespace, repo: Path) -> int: def self_test() -> int: + from contextlib import redirect_stderr + from io import StringIO + assert EXPIRY.fullmatch("30.days.ago") assert EXPIRY.fullmatch("2.weeks.ago") assert not EXPIRY.fullmatch("30 days ago") + assert _human_bytes(1024) == "1.0 KiB" try: checked_expiry(argparse.Namespace(expire="now", force=False)) except ToolError: @@ -204,8 +572,31 @@ def self_test() -> int: else: raise AssertionError("zero-duration expiry accepted without --force") assert git_command(Path.cwd() / "repo", "status")[-1] == "status" + with redirect_stderr(StringIO()): + try: + parse_args(["dev", "--repo", "."]) + except SystemExit as error: + assert error.code == 2 + else: + raise AssertionError("developer cleanup accepted a Git-only option") with TemporaryDirectory() as directory: - repo = Path(directory) + home = (Path(directory) / "home").resolve() + cache = home / "cache" + outside = (Path(directory) / "outside-cache").resolve() + cache.mkdir(parents=True) + (outside / "nested").mkdir(parents=True) + assert _safe_cache_path(str(cache), home) == cache + assert _safe_cache_path(str(outside), home) is None + linked = home / "linked" + try: + linked.symlink_to(outside, target_is_directory=True) + except OSError: + pass + else: + assert _safe_cache_path(str(linked / "nested"), home) is None + + repo = Path(directory) / "repo" + repo.mkdir() run(git_command(repo, "init", "--quiet")) run(git_command(repo, "config", "status.showUntrackedFiles", "no")) (repo / "untracked").write_text("not in the bundle\n", encoding="utf-8") @@ -218,16 +609,63 @@ def self_test() -> int: assert "dirty" in str(error) else: raise AssertionError("forced rewrite accepted an untracked file") - print("ok: Git audit, expiry, garbage-collection, and rewrite contracts") + + clutter = Path(directory) / "clutter" + node = clutter / "node" + rust = clutter / "rust" + python = clutter / "python" + tagged = clutter / "tagged" + ignored = clutter / "unmarked" + for path in (node, rust, python, tagged, ignored): + path.mkdir(parents=True) + clutter = clutter.resolve() + node = node.resolve() + rust = rust.resolve() + python = python.resolve() + tagged = tagged.resolve() + ignored = ignored.resolve() + (node / "package.json").write_text("{}\n", encoding="utf-8") + (node / "node_modules/pkg").mkdir(parents=True) + (node / "node_modules/pkg/index.js").write_text("x\n", encoding="utf-8") + (rust / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + (rust / "target/debug").mkdir(parents=True) + (rust / "target/debug/app").write_bytes(b"rust") + (python / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (python / ".venv").mkdir() + (python / ".venv/pyvenv.cfg").write_text("home = test\n", encoding="utf-8") + (tagged / "CACHEDIR.TAG").write_bytes(CACHE_TAG) + (tagged / "cache").write_bytes(b"tagged") + (ignored / "target").mkdir() + (ignored / "target/keep").write_text("keep\n", encoding="utf-8") + try: + (clutter / "linked").symlink_to(node, target_is_directory=True) + except OSError: + pass + + findings, skipped = discover_artifacts(clutter) + assert not skipped + found = {item.path.relative_to(clutter).as_posix() for item in findings} + assert found == {"node/node_modules", "python/.venv", "rust/target", "tagged"} + assert sum(item.bytes for item in findings) == 67 + dev_args = argparse.Namespace( + root=str(clutter), + project_only=True, + apply=True, + yes=True, + ) + assert dev(dev_args) == 0 + assert not any((clutter / path).exists() for path in found) + assert (ignored / "target/keep").is_file() + print("ok: developer clutter and Git maintenance contracts") return 0 def parse_args(arguments: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="cleaner", - description="Audit, compact, and safely rewrite repositories.", + description="Find developer clutter or safely maintain Git repositories.", ) - parser.add_argument("tool", nargs="?", choices=("git",), default="git") + parser.add_argument("tool", nargs="?", choices=("git", "dev"), default="git") parser.add_argument( "operation_pos", nargs="?", @@ -239,6 +677,14 @@ def parse_args(arguments: list[str]) -> argparse.Namespace: default="audit", ) parser.add_argument("--repo", help="repository path (default: current directory)") + parser.add_argument( + "--root", help="developer-clutter scan root (default: current directory)" + ) + parser.add_argument( + "--project-only", + action="store_true", + help="exclude user-level package and language caches", + ) parser.add_argument( "--expire", default="30.days.ago", @@ -271,6 +717,23 @@ def parse_args(arguments: list[str]) -> argparse.Namespace: parser.error("--expire must look like 30.days.ago, 2.weeks.ago, or now") if args.operation_pos: args.operation = args.operation_pos + git_flags = { + "--operation", + "--repo", + "--expire", + "--path", + "--keep-path", + "--replace-text", + "--backup", + "--force", + } + if args.tool == "dev" and ( + args.operation_pos + or any(argument.partition("=")[0] in git_flags for argument in arguments) + ): + parser.error("developer cleanup does not take Git-only options") + if args.tool == "git" and (args.root or args.project_only): + parser.error("--root and --project-only require cleaner dev") return args @@ -279,6 +742,8 @@ def main(arguments: list[str] | None = None) -> int: if args.self_test: return self_test() try: + if args.tool == "dev": + return dev(args) repo = repository(args.repo) print(color(f"Repository: {repo}", "1;36")) return { @@ -288,7 +753,7 @@ def main(arguments: list[str] | None = None) -> int: "rewrite": lambda: rewrite(args, repo), }[args.operation]() except (ToolError, OSError) as error: - print(f"cleaner git: {error}", file=sys.stderr) + print(f"cleaner {args.tool}: {error}", file=sys.stderr) return 2 diff --git a/bin/cleaner.man b/bin/cleaner.man index c50e988..97d5869 100644 --- a/bin/cleaner.man +++ b/bin/cleaner.man @@ -1,11 +1,11 @@ --- { - "summary": "Audit, garbage-collect, clean dangling Git objects, and rewrite history safely.", + "summary": "Find and remove developer clutter, or audit and maintain Git repositories safely.", "platform": "any", "privilege": "user", "applyFlag": "--apply", "yesFlag": "--yes", - "applyWhen": ["gc", "dangling", "rewrite"], + "applyWhen": ["dev", "gc", "dangling", "rewrite"], "parameters": [ { "name": "tool", @@ -13,7 +13,8 @@ "required": true, "default": "git", "choices": [ - {"value": "git", "label": "Git repository"} + {"value": "git", "label": "Git repository"}, + {"value": "dev", "label": "Developer caches and generated project data"} ] }, { @@ -21,6 +22,7 @@ "label": "Operation", "required": true, "default": "audit", + "when": {"tool": ["git"]}, "choices": [ {"value": "audit", "label": "Audit object storage and unreachable objects"}, {"value": "gc", "label": "Garbage collect with a safe retention window"}, @@ -32,54 +34,106 @@ "name": "repo", "label": "Repository", "flag": "--repo", + "when": {"tool": ["git"]}, + "placeholder": "defaults to the working directory" + }, + { + "name": "root", + "label": "Developer-project scan root", + "flag": "--root", + "when": {"tool": ["dev"]}, "placeholder": "defaults to the working directory" }, { "name": "expire", "label": "Unreachable-object retention", "flag": "--expire", - "when": {"operation": ["gc", "dangling"]}, + "when": {"tool": ["git"], "operation": ["gc", "dangling"]}, "placeholder": "30.days.ago" }, { "name": "path", "label": "History path", "flag": "--path", - "when": {"operation": ["rewrite"]}, + "when": {"tool": ["git"], "operation": ["rewrite"]}, "placeholder": "file or directory to remove from every commit" }, { "name": "replace-text", "label": "Replacement expressions file", "flag": "--replace-text", - "when": {"operation": ["rewrite"]}, + "when": {"tool": ["git"], "operation": ["rewrite"]}, "placeholder": "git-filter-repo replace-text file" }, { "name": "backup", "label": "Pre-rewrite bundle", "flag": "--backup", - "when": {"operation": ["rewrite"]}, + "when": {"tool": ["git"], "operation": ["rewrite"]}, "placeholder": "defaults inside .git" } ], "options": [ + { + "flag": "--project-only", + "label": "Exclude user-level language and package caches", + "when": {"tool": ["dev"]} + }, { "flag": "--keep-path", "label": "Keep only the history path instead of removing it", - "when": {"operation": ["rewrite"]} + "when": {"tool": ["git"], "operation": ["rewrite"]} }, { "flag": "--force", "label": "Allow immediate expiry or bypass the fresh-clone guard", - "when": {"operation": ["gc", "dangling", "rewrite"]}, + "when": {"tool": ["git"], "operation": ["gc", "dangling", "rewrite"]}, "warning": "Force allows permanent immediate pruning and bypasses git-filter-repo's fresh-clone guard. The working tree must still be clean." } ] } --- -# Git repository cleaner +# Developer and Git cleaner + +`cleaner dev` inventories regenerable project outputs and user-level language +caches. `cleaner git` keeps the existing guarded repository maintenance and +history-rewrite workflow. + +## Developer clutter + +The default read-only scan starts at the working directory. Set `--root` to a +project parent such as `~/Coding`; choose a home directory only when a full +user scan is worth the extra time. Results include logical size and entry count. + +Project cleanup recognizes outputs only beside matching manifests. Coverage +includes Node.js dependencies and framework caches, Python tool caches and +reconstructable virtual environments, Rust and Maven `target`, Gradle and CMake +`build`, Swift `.build`, .NET `bin`/`obj`, Dart, Elixir, Haskell, Zig, and +Terraform outputs, Composer `vendor`, and CocoaPods `Pods`. A directory with a +valid `CACHEDIR.TAG` signature is also shown as explicitly regenerable cache +data. + +Unless `--project-only` is selected, the inventory also asks installed Go, npm, +pnpm, pip, and uv tools for their active cache locations. Apply uses each +tool's native cleanup command. It additionally recognizes the user-owned Cargo, +Gradle, Maven, and NuGet cache locations without deleting tool configuration, +installed binaries, or source projects. + +> **Review every path:** A cache tag is a strong hint, not authority by itself. +> The scanner never follows symlinks, crosses a filesystem boundary, deletes a +> directory based only on a generic name, or scans hidden application state when +> the selected root is the home directory. Close package managers, compilers, +> editors, and builds before applying. + +```sh +cleaner dev +cleaner dev --root ~/Coding +cleaner dev --root ~/Coding --project-only +cleaner dev --root ~/Coding --apply +``` + +## Git repositories `cleaner git` replaces error-prone `filter-branch` recipes with `git-filter-repo`, keeps normal garbage collection conservative, and exposes @@ -131,10 +185,13 @@ cleaner git rewrite --replace-text replacements.txt ``` Nothing mutates without `--apply`. Direct applies require typing `APPLY`; the -shared TUI confirms with `CLEAN` and then supplies `--apply --yes`. +shared TUI confirms with `CLEAN` and then supplies `--apply --yes`. Developer +apply repeats discovery, so the preview and final target set can differ if files +change between runs. ## Exit status -`0` means the audit or requested maintenance completed. `2` means the +`0` means the inventory or requested maintenance completed. `1` means a +developer scan was incomplete or a cleanup command failed. `2` means the root, repository, arguments, dependency, backup, or Git operation was unsafe or failed. Invalid command-line syntax also exits `2`. diff --git a/dev/gui.py b/dev/gui.py index d5d9fac..5539fc9 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -73,6 +73,12 @@ 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"} +PLATFORM_LABELS = { + "any": "Any", + "linux": "Linux", + "macos": "macOS", + "windows": "Windows", +} METADATA_KEYS = { "summary", "platform", @@ -839,9 +845,14 @@ def action_cancel(self) -> None: self.dismiss(None) @on(Input.Changed, "#directory-filter") - async def filter_tree(self, event: Input.Changed) -> None: + def filter_tree(self, event: Input.Changed) -> None: + self.reload_tree(event.value) + + @work(group="directory-filter", exclusive=True) + async def reload_tree(self, query: str) -> None: + await asyncio.sleep(0.12) tree = self.query_one("#directory-tree", FilteredDirectoryTree) - tree.filter_text = event.value + tree.filter_text = query await tree.reload() @on(DirectoryTree.DirectorySelected, "#directory-tree") @@ -1357,7 +1368,7 @@ class ScriptsApp(App[None]): } #catalog-panel { - width: 42%; + width: 38%; min-width: 32; height: 1fr; border: round $primary; @@ -1385,8 +1396,19 @@ class ScriptsApp(App[None]): padding: 0 1; } - #help { + #help-scroll { height: 1fr; + background: $surface; + scrollbar-gutter: stable; + } + + #help-scroll:focus { + outline: tall $accent; + } + + #help { + height: auto; + padding: 0 1; } #help MarkdownH1 { @@ -1618,7 +1640,7 @@ def compose(self) -> ComposeResult: allow_blank=False, id="theme", ) - yield Button("Run", variant="primary", id="run") + yield Button("▶ Run", variant="primary", id="run") yield Button("Files", id="files-button") with Horizontal(id="workspace"): with Vertical(id="catalog-panel"): @@ -1629,7 +1651,10 @@ def compose(self) -> ComposeResult: id="scripts", ) with TabbedContent(id="details-tabs"): - with TabPane("Help", id="help-pane"): + with ( + TabPane("Help", id="help-pane"), + VerticalScroll(id="help-scroll"), + ): yield Markdown(id="help") with TabPane("Output", id="output-pane"): yield Label("Ready", id="activity-heading") @@ -1689,8 +1714,6 @@ def on_mount(self) -> None: table = self.query_one("#scripts", DataTable) table.add_column("State", key="state") table.add_column("Script", key="script") - table.add_column("OS", key="platform") - table.add_column("Summary", key="summary") history = self.query_one("#history", DataTable) history.add_columns("Time", "Script", "Phase", "Result", "Seconds") self.refresh_catalog() @@ -1719,20 +1742,22 @@ def refresh_catalog(self, query: str = "") -> None: for script in visible: ready = script.supported and script.launchable state = Text( - "Ready" if ready else "Docs" if not script.launchable else "Other OS", + "● Ready" + if ready + else "◆ Guide" + if not script.launchable + else f"○ {PLATFORM_LABELS[script.platform]}", style=( "green" if ready else "bright_blue" if not script.launchable - else "bright_black" + else "bright_yellow" ), ) table.add_row( state, script.title, - script.platform, - script.summary, key=script.script_id, ) if self.sort_column is not None: @@ -1756,6 +1781,7 @@ def refresh_catalog(self, query: str = "") -> None: self.query_one("#help", Markdown).update( "# No matches\n\nTry a shorter filter." ) + self.query_one("#help-scroll", VerticalScroll).scroll_home(animate=False) self.update_controls() def show_script(self, script: ScriptSpec) -> None: @@ -1765,12 +1791,13 @@ def show_script(self, script: ScriptSpec) -> None: if not script.launchable else "Available on this computer" if script.supported - else f"Available on {script.platform}" + else f"Available on {PLATFORM_LABELS[script.platform]}" ) self.query_one("#help", Markdown).update( f"> **{badge}** · {PRIVILEGE_LABELS[script.privilege]} · " f"`{script.script_id}`\n\n{script.markdown}" ) + self.query_one("#help-scroll", VerticalScroll).scroll_home(animate=False) self.update_controls() def update_controls(self) -> None: @@ -1788,9 +1815,9 @@ def update_controls(self) -> None: script.supported and script.launchable for script in self.scripts ) state = "running" if self.started_at is not None else "ready" + platform = PLATFORM_LABELS.get(host_platform(), host_platform()) self.query_one("#status", Static).update( - f"[b]{supported} of {len(self.scripts)}[/b] available · " - f"{host_platform()} · {state}" + f"[b]{supported}/{len(self.scripts)} available[/b] · {platform} · {state}" ) @on(Input.Changed, "#script-filter") @@ -1878,6 +1905,7 @@ def action_focus_filter(self) -> None: def action_help_tab(self) -> None: self.query_one("#details-tabs", TabbedContent).active = "help-pane" + self.query_one("#help-scroll", VerticalScroll).focus() def action_files(self) -> None: self.query_one("#details-tabs", TabbedContent).active = "files-pane" @@ -2159,7 +2187,9 @@ def _is_command(path: Path) -> bool: async def self_test(root: Path, scripts: tuple[ScriptSpec, ...]) -> None: - from unittest.mock import patch + from unittest.mock import AsyncMock, patch + + from textual.events import MouseScrollDown def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: snapshot = {} @@ -2168,7 +2198,7 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: "#toolbar", "#catalog-panel", "#details-tabs", - "#help", + "#help-scroll", ): region = app.query_one(selector).region snapshot[selector] = (region.x, region.y, region.width, region.height) @@ -2294,7 +2324,7 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: "#toolbar": (1, 1, 48, 7), "#catalog-panel": (1, 8, 48, 4), "#details-tabs": (1, 13, 48, 4), - "#help": (2, 15, 46, 2), + "#help-scroll": (2, 15, 46, 2), } fallback_tree = compact.query_one("#repo-tree", FilteredDirectoryTree) assert ( @@ -2305,7 +2335,8 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: table = compact.query_one("#scripts", DataTable) assert table.row_count == len(scripts) details = compact.query_one("#details-tabs", TabbedContent).region - help_region = compact.query_one("#help", Markdown).region + help_scroll = compact.query_one("#help-scroll", VerticalScroll) + help_region = help_scroll.region assert details.height > 0 and details.y < compact.size.height assert help_region.height > 0 and help_region.y < compact.size.height for script in scripts: @@ -2322,14 +2353,29 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: 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" + assert table.get_row(runnable.script_id)[0].plain == "● Ready" + assert table.get_row(dashboard.script_id)[0].plain == "◆ Guide" + assert table.get_row(other_os.script_id)[0].plain == ( + f"○ {PLATFORM_LABELS[other_os.platform]}" + ) compact.show_script(runnable) + await pilot.pause() + assert help_scroll.max_scroll_y > 0 await pilot.press("/") assert compact.focused is compact.query_one("#script-filter", Input) await pilot.press("f1") assert compact.query_one("#details-tabs", TabbedContent).active == "help-pane" + assert compact.focused is help_scroll + await pilot.press("end") + await pilot.pause() + assert help_scroll.scroll_y > 0 + help_scroll.scroll_home(animate=False) + await pilot.pause() + help_scroll.post_message( + MouseScrollDown(help_scroll, 1, 1, 0, 1, 0, False, False, False) + ) + await pilot.pause() + assert help_scroll.scroll_y > 0 await pilot.press("f3") assert compact.query_one("#details-tabs", TabbedContent).active == "files-pane" assert compact.focused is compact.query_one("#file-filter", Input) @@ -2379,6 +2425,25 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: await pilot.press("enter") await pilot.pause() assert confirmed == [True] + compact.push_screen(DirectoryPicker(root, False)) + await pilot.pause() + directory_filter = compact.screen.query_one("#directory-filter", Input) + directory_tree = compact.screen.query_one( + "#directory-tree", FilteredDirectoryTree + ) + with patch.object( + directory_tree, + "reload", + AsyncMock(wraps=directory_tree.reload), + ) as reload_tree: + directory_filter.value = "b" + directory_filter.value = "bi" + directory_filter.value = "bin" + await pilot.pause(0.3) + assert reload_tree.await_count == 1 + assert directory_tree.filter_text == "bin" + await pilot.press("escape") + await pilot.pause() wide = ScriptsApp(root, scripts, nerd_fonts=True) async with wide.run_test(size=(140, 40)) as pilot: @@ -2387,9 +2452,9 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: 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), + "#catalog-panel": (1, 10, 52, 29), + "#details-tabs": (54, 10, 85, 29), + "#help-scroll": (55, 12, 83, 27), } nerd_tree = wide.query_one("#repo-tree", FilteredDirectoryTree) assert ( diff --git a/dev/gui.py.man b/dev/gui.py.man index 3819c29..54947e4 100644 --- a/dev/gui.py.man +++ b/dev/gui.py.man @@ -69,7 +69,7 @@ cancels a safe running preview. | Key | Action | | --- | --- | | `/` | focus the script filter | -| `F1` or `?` | show the selected script's help | +| `F1` or `?` | show and focus 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 | @@ -80,10 +80,13 @@ To run entirely from the keyboard, filter and select a script, press `F2`, use activate Run or Preview with `Enter`. Destructive apply still requires typing `CLEAN` and pressing `Enter` after a successful preview. +While help is focused, use arrows, `Page Up`/`Page Down`, `Home`/`End`, or the +mouse wheel to scroll its full manual. + 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 +catalog also labels states as `● Ready`, `◆ Guide`, or `○ Linux`/`macOS`/`Windows`, +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. diff --git a/docs/agent-tools.md b/docs/agent-tools.md index 46a538b..7865414 100644 --- a/docs/agent-tools.md +++ b/docs/agent-tools.md @@ -4,11 +4,12 @@ The `agent` command keeps a deliberately small local-state registry and uses the live [Agent Skills CLI](https://github.com/vercel-labs/skills) for market compatibility. No index can guarantee every public, private, or unpublished 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. +`agent market --install OWNER/REPOSITORY` previews every supported detected +agent by default; `--agent NAME` narrows the market tool to one installer. CLI +behavior is pinned to `skills@1.5.22`. Contracts and links were reviewed against official vendor documentation on -31 July 2026 (Australia/Brisbane). +12 August 2026 (Australia/Brisbane). ## Safety contract diff --git a/sys/linux/clean b/sys/linux/clean index 8160388..cb9a6e6 100755 --- a/sys/linux/clean +++ b/sys/linux/clean @@ -36,7 +36,7 @@ Clean regenerable Linux caches. Nothing is deleted without --apply. --logs Keep only 7 days of archived systemd journal logs --store-gc Garbage-collect unreachable Nix and Guix store objects --yes Skip confirmation; requires --apply - --verbose List every filesystem target + --verbose List completed filesystem targets after apply --self-test Test deletion and path guards in a temporary directory -h, --help Show help @@ -178,7 +178,7 @@ clean_dir() { TARGETS=$((TARGETS + 1)) if [ "$APPLY" -eq 0 ]; then - [ "$VERBOSE" -eq 0 ] || printf 'would clean %s: %s\n' "$kind" "$dir" + printf 'would clean %s: %s\n' "$kind" "$dir" return 0 fi diff --git a/sys/linux/clean.man b/sys/linux/clean.man index 338fdff..dac9b5f 100644 --- a/sys/linux/clean.man +++ b/sys/linux/clean.man @@ -23,7 +23,7 @@ }, { "flag": "--verbose", - "label": "Show every filesystem target" + "label": "Show completed filesystem targets after apply" } ] } @@ -63,7 +63,7 @@ password prompts are intentionally disabled inside it. | `--all-temp` | include recent current-user temp files | | `--logs` | keep only seven days of archived systemd journals | | `--store-gc` | collect unreachable Nix and Guix store objects | -| `--verbose` | print every filesystem target | +| `--verbose` | print completed filesystem targets after apply | | `--apply` | repeat discovery with deletion enabled | | `--yes` | accept the script's prompt after TUI confirmation | diff --git a/sys/macos/clean b/sys/macos/clean index 741c05c..17d1d8d 100755 --- a/sys/macos/clean +++ b/sys/macos/clean @@ -22,7 +22,7 @@ Delete regenerable macOS caches. Nothing is deleted without --apply. --all-temp Delete all current-user temp files instead of files over 3 days old --logs Also delete user application and diagnostic logs --yes Skip confirmation; requires --apply - --verbose List every target + --verbose List completed filesystem targets after apply --self-test Test deletion and path guards in a temporary directory -h, --help Show help @@ -140,7 +140,7 @@ clean_dir() { safe_dir "$kind" "$dir" || die "refusing $kind path: $dir" if (( ! APPLY )); then - (( VERBOSE )) && print -r -- "would clean $kind: $dir" + print -r -- "would clean $kind: $dir" return 0 fi @@ -158,6 +158,8 @@ clean_dir() { } prepare_simulators() { + SIMULATORS=0 + [[ -z $TEST_ROOT ]] || return 0 if ! /usr/bin/xcrun --find simctl >/dev/null 2>&1; then warn "simctl is unavailable; skipping Simulator cleanup" return 0 @@ -206,8 +208,20 @@ reset_quicklook() { fi } +clean_all() { + local dir='' + + prepare_simulators + discover + print -r -- "targets: ${#CACHE_DIRS} cache, ${#TEMP_DIRS} temp" + clean_homebrew + for dir in "${CACHE_DIRS[@]}"; do clean_dir cache "$dir"; done + for dir in "${TEMP_DIRS[@]}"; do clean_dir temp "$dir"; done + reset_quicklook +} + self_test() { - local root='' old_file='' new_file='' + local root='' old_file='' new_file='' preview='' root=$(/usr/bin/mktemp -d /private/tmp/mac-clean-test.XXXXXX) TEST_ROOT=${root:A} @@ -229,6 +243,18 @@ self_test() { [[ ! -e $old_file && -e $new_file ]] || die "self-test failed: stale-temp selection is wrong" + /bin/mkdir -p "$root/preview" + /usr/bin/touch "$root/preview/file" + APPLY=0 + preview=$(clean_dir cache "$root/preview") + [[ $preview == "would clean cache: $root/preview" && -e $root/preview/file ]] || + die "self-test failed: preview mutated or omitted its target" + APPLY=1 + SIMULATORS=1 + prepare_simulators + (( ! SIMULATORS )) || + die "self-test failed: unavailable Simulator state was retained" + if safe_dir cache / || safe_dir cache /private/tmp || safe_dir cache "$HOME_REAL/Library/Containers/a/extra/Data/Library/Caches"; then @@ -239,7 +265,7 @@ self_test() { } main() { - local arg='' dir='' + local arg='' requested_apply=0 for arg in "$@"; do case $arg in @@ -270,21 +296,19 @@ main() { DARWIN_TEMP=${DARWIN_TEMP%/} [[ -n $DARWIN_CACHE ]] && DARWIN_CACHE=${DARWIN_CACHE:A} [[ -n $DARWIN_TEMP ]] && DARWIN_TEMP=${DARWIN_TEMP:A} - confirm_apply - prepare_simulators - discover - - print -r -- "mode: $([[ $APPLY == 1 ]] && print apply || print dry-run)" - print -r -- "targets: ${#CACHE_DIRS} cache, ${#TEMP_DIRS} temp" - - clean_homebrew - for dir in "${CACHE_DIRS[@]}"; do clean_dir cache "$dir"; done - for dir in "${TEMP_DIRS[@]}"; do clean_dir temp "$dir"; done - reset_quicklook - - if (( APPLY )); then + requested_apply=$APPLY + if (( requested_apply )); then + APPLY=0 + print -r -- "mode: apply preview" + clean_all + APPLY=1 + confirm_apply + print -r -- "mode: apply" + clean_all print -r -- "cleanup complete; warnings: $FAILURES" else + print -r -- "mode: dry-run" + clean_all print -r -- "dry run only; rerun with --apply to delete" fi (( FAILURES == 0 )) diff --git a/sys/macos/clean.man b/sys/macos/clean.man index 0d43ed3..621b575 100644 --- a/sys/macos/clean.man +++ b/sys/macos/clean.man @@ -18,7 +18,7 @@ }, { "flag": "--verbose", - "label": "Show every filesystem target" + "label": "Show completed filesystem targets after apply" } ] } @@ -42,6 +42,8 @@ than three days; `--all-temp` includes all owned entries. - unavailable Simulator devices - Homebrew downloads and old package versions - Quick Look caches and stale current-user temporary files +- owned entries older than three days in `/private/tmp` (`/tmp`) and + `/private/var/tmp`; `--all-temp` includes recent owned entries ## Safety @@ -57,7 +59,7 @@ Close applications, Xcode, and Simulator before applying the plan. | --- | --- | | `--all-temp` | include recent current-user temp files | | `--logs` | include user application and diagnostic logs | -| `--verbose` | print every filesystem target | +| `--verbose` | print completed filesystem targets after apply | | `--apply` | repeat discovery with deletion enabled | | `--yes` | accept the script's prompt after TUI confirmation | From a3a2254ee82e6fdddc4b1df29a686e288bf8e35d Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:26:57 +1000 Subject: [PATCH 2/2] fix: preserve blank dashboard choices --- dev/gui.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/dev/gui.py b/dev/gui.py index 5539fc9..039c4ce 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -977,7 +977,7 @@ def compose(self) -> ComposeResult: for value, label in parameter.choices ), allow_blank=not parameter.required, - value=parameter.default or Select.BLANK, + value=parameter.default or Select.NULL, id=f"parameter-{index}", ) else: @@ -1051,7 +1051,7 @@ def controller_values(self) -> dict[str, str]: if not parameter.choices: continue value = self.query_one(f"#parameter-{index}", Select).value - selected[parameter.name] = "" if value is Select.BLANK else str(value) + selected[parameter.name] = "" if value is Select.NULL else str(value) return selected def refresh_conditions(self) -> None: @@ -1082,7 +1082,7 @@ def selected_arguments(self) -> tuple[str, ...] | None: widget.value.strip() if isinstance(widget, Input) else "" - if widget.value is Select.BLANK + if widget.value is Select.NULL else str(widget.value) ) if parameter.required and not value: @@ -2276,12 +2276,20 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: "placeholder": "optional text", "when": {"action": ["fix"]}, }, + { + "name": "format", + "label": "Format", + "choices": [ + {"value": "text", "label": "Text"}, + {"value": "json", "label": "JSON"}, + ], + }, ], } help_path = script_path.with_name("probe.py.man") probe_markdown = ( - "# Probe\n\n`action`, `--query`, `--apply`, and `--yes` exercise " - "typed parameters.\n" + "# Probe\n\n`action`, `--query`, `format`, `--apply`, and `--yes` " + "exercise typed parameters.\n" ) help_path.write_text( f"---\n{json.dumps(metadata)}\n---\n{probe_markdown}", @@ -2401,6 +2409,7 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: await pilot.pause() conditional_wizard = compact.screen assert isinstance(conditional_wizard, RunWizard) + assert conditional_wizard.query_one("#parameter-2", Select).value is Select.NULL hidden_query = conditional_wizard.query_one("#parameter-1", Input) hidden_query.value = "ignored" assert not conditional_wizard.query_one("#parameter-row-1").display