diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 91fe003..7078852 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report reproducible AI Optimizer behavior +description: Report reproducible AI Environment Optimizer behavior title: "bug: " labels: - bug @@ -10,7 +10,7 @@ body: - type: input id: version attributes: - label: AI Optimizer version + label: AI Environment Optimizer version placeholder: 0.1.0 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index acee8d6..4af43ef 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: Setup and usage questions - url: https://github.com/nyldn/ai-optimizer/discussions + url: https://github.com/nyldn/ai-env-optimizer/discussions about: Ask the community before filing a bug. - name: Security vulnerability - url: https://github.com/nyldn/ai-optimizer/security/advisories/new + url: https://github.com/nyldn/ai-env-optimizer/security/advisories/new about: Report security issues privately. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dab48f2..1c3a98a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,12 +35,12 @@ jobs: run: | first="$RUNNER_TEMP/release-a" second="$RUNNER_TEMP/release-b" - AI_OPTIMIZER_DIST_DIR="$first" scripts/build-release.sh - AI_OPTIMIZER_DIST_DIR="$second" scripts/build-release.sh - cmp "$first/ai-optimizer-$(tr -d '[:space:]' < VERSION).tar.gz" "$second/ai-optimizer-$(tr -d '[:space:]' < VERSION).tar.gz" + AI_ENV_OPTIMIZER_DIST_DIR="$first" scripts/build-release.sh + AI_ENV_OPTIMIZER_DIST_DIR="$second" scripts/build-release.sh + cmp "$first/ai-env-optimizer-$(tr -d '[:space:]' < VERSION).tar.gz" "$second/ai-env-optimizer-$(tr -d '[:space:]' < VERSION).tar.gz" - name: Verify install and uninstall round trip run: | - AI_OPTIMIZER_DIST_DIR="$RUNNER_TEMP/release-a" test/install_test.sh + AI_ENV_OPTIMIZER_DIST_DIR="$RUNNER_TEMP/release-a" test/install_test.sh - name: Scan Git history for secrets run: | brew install gitleaks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 153d3d3..71749d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,14 +31,14 @@ jobs: - name: Attest release archive uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-path: dist/ai-optimizer-*.tar.gz + subject-path: dist/ai-env-optimizer-*.tar.gz - name: Retain release assets in the workflow uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ai-optimizer-release + name: ai-env-optimizer-release path: | - dist/ai-optimizer-*.tar.gz - dist/ai-optimizer-*.tar.gz.sha256 + dist/ai-env-optimizer-*.tar.gz + dist/ai-env-optimizer-*.tar.gz.sha256 dist/install.sh dist/install.sh.sha256 if-no-files-found: error @@ -46,4 +46,4 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" dist/ai-optimizer-*.tar.gz dist/ai-optimizer-*.tar.gz.sha256 dist/install.sh dist/install.sh.sha256 --verify-tag --generate-notes --title "AI Optimizer $GITHUB_REF_NAME" + gh release create "$GITHUB_REF_NAME" dist/ai-env-optimizer-*.tar.gz dist/ai-env-optimizer-*.tar.gz.sha256 dist/install.sh dist/install.sh.sha256 --verify-tag --generate-notes --title "AI Environment Optimizer $GITHUB_REF_NAME" diff --git a/AGENTS.md b/AGENTS.md index d940e05..b120fe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,32 +1,45 @@ -# AI Optimizer agent instructions +# AI Environment Optimizer agent instructions -AI Optimizer is a public, macOS-only diagnostic and narrowly owned maintenance -CLI for Claude Code, Codex, MCP, skills, and coding workspaces. Codex reads this -file automatically. Claude Code receives the same contract through `CLAUDE.md`. +AI Environment Optimizer is a public, macOS-only diagnostic and narrowly owned +maintenance CLI for Claude Code, Codex, MCP, skills, and coding workspaces. +Codex reads this file automatically. Claude Code receives the same contract +through `CLAUDE.md`. ## Choose the workflow - Use the **Operator workflow** when the user asks to inspect, configure, optimize, maintain, or repair their AI coding environment. -- Use the **Development workflow** when the user asks to change AI Optimizer - itself. +- Use the **Development workflow** when the user asks to change AI Environment + Optimizer itself. - If both apply, gather operator evidence first, then develop against a failing test without mixing host-specific data into the repository. ## Operator workflow -1. From this repository root, run the read-only handshake: +1. Choose the first read-only command from the user's request. + + For a storage request, make the first command: + + ```sh + ./bin/ai-env-optimizer storage --json + ``` + + Use it only when the user asks about storage or an evening receipt already + reports a storage warning. The inventory is read-only and path-free. For + other environment work, run the general handshake: ```sh - ./bin/ai-optimizer agent-context --json + ./bin/ai-env-optimizer agent-context --json ``` Add `--workspace-root PATH` only when the user identifies a different workspace container. Do not run `setup`, scheduling, package-manager, or repair commands merely to gather context. -2. Read `overall_status`, `prioritized_actions`, and both embedded reports. - Treat priorities as: +2. Read the selected command's evidence. For `agent-context`, read + `overall_status`, `prioritized_actions`, and both embedded reports. For + `storage`, read `summary` and each classified source; do not ask for or infer + hidden paths. Treat diagnostic priorities as: - `P0`: required product/platform failure or unknown state; - `P1`: non-required failure or unknown state; @@ -40,17 +53,22 @@ file automatically. Claude Code receives the same contract through `CLAUDE.md`. - Ask before deleting data, creating credentials, authenticating remote MCP services, enabling schedules, upgrading unrelated software, or changing another repository's behavior. + - For storage cleanup, first show the aggregate `--dry-run` result. + Never infer permission to apply cleanup from a preview token. Apply only + the exact token and filters the user explicitly approved. + - Sessions, transcripts, memories, worktrees, and active plugin state are + protected and must never be reclassified as cleanup candidates ad hoc. 4. Never print or commit credentials, environment values, MCP endpoints, command arguments from user sessions, workspace names, or workspace file - contents. AI Optimizer reports are deliberately redacted; do not weaken that + contents. AI Environment Optimizer reports are deliberately redacted; do not weaken that boundary while investigating. 5. Verify repairs with the affected tool's native doctor or focused test, then re-run: ```sh - ./bin/ai-optimizer agent-context --json + ./bin/ai-env-optimizer agent-context --json ``` Report exact evidence, remaining findings, changed paths, and rollback @@ -62,10 +80,14 @@ file automatically. Claude Code receives the same contract through `CLAUDE.md`. 1. Read `README.md`, `docs/architecture.md`, and the relevant tests. 2. Preserve these invariants: - - `doctor`, `scan`, and `agent-context` are read-only. - - Mutating commands may write only AI Optimizer-owned configuration, + - `doctor`, `scan`, `agent-context`, `storage`, and cleanup preview are read-only. + - Mutating commands may write only AI Environment Optimizer-owned configuration, reports, release installation paths, and launchd label `io.github.nyldn.ai-optimizer.daily`. + This legacy internal label is intentionally stable across the v0.2 rename. + - Cleanup apply may move only catalog entries marked eligible in code, only + after token revalidation, and only to the current user's Trash. Scheduled + maintenance must never invoke cleanup. - Never silently edit or upgrade Claude Code, Codex, MCP servers, skills, companion tools, or repositories. - Keep runtime code compatible with `/usr/bin/ruby` 2.6 and the standard diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5ff7e..8d74754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,27 @@ # Changelog -All notable changes are documented here. AI Optimizer follows semantic +All notable changes are documented here. AI Environment Optimizer follows semantic versioning. +## 0.2.0 - 2026-08-23 + +- Rename the public project, repository, formula, release archive, and primary + command to AI Environment Optimizer and `ai-env-optimizer`. +- Preserve `ai-optimizer` as a working command alias and accept existing v0.1 + state, install markers, environment variables, and direct-install roots. +- Keep both existing launchd service labels stable to prevent duplicate jobs, + while new installs use the canonical state directory and environment prefix. +- Add a Homebrew same-tap formula migration so installed users upgrade without + uninstalling or losing their opted-in service. +- Add path-free storage inventory for Claude, Codex, Claude-Mem, and product + state with historical sessions, memories, worktrees, and active state + protected by code-level classifications. +- Add token-verified, provider-idle, same-filesystem cleanup of allowlisted old + caches through a recoverable private Trash folder; no scheduled deletion or + arbitrary path mode exists. +- Add report-only storage health to evening maintenance with a configurable + 10 GiB warning threshold. + ## 0.1.8 - 2026-08-23 - Add a read-only `agent-context` handshake with embedded doctor and workspace diff --git a/CLAUDE.md b/CLAUDE.md index 30cbc6e..3df8228 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# AI Optimizer +# AI Environment Optimizer @AGENTS.md @@ -6,9 +6,13 @@ Follow `AGENTS.md` as the shared Codex/Claude contract. For any environment assessment, begin with the read-only repository handshake: ```sh -./bin/ai-optimizer agent-context --json +./bin/ai-env-optimizer agent-context --json ``` Do not infer repair authority from a diagnostic finding. If the user asked for changes, make the smallest reversible fix, verify it with the owning tool, and re-run the handshake before reporting completion. + +For storage requests, follow the protected preview/apply workflow in +`AGENTS.md`. Make `./bin/ai-env-optimizer storage --json` the first command; +never treat a preview token as authorization to apply cleanup. diff --git a/README.md b/README.md index 0dc56e7..d265536 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -# AI Optimizer +# AI Environment Optimizer -AI Optimizer is a read-only health check and narrowly owned maintenance layer +AI Environment Optimizer (`ai-env-optimizer`) is a read-only health check and +narrowly owned maintenance layer for macOS AI coding environments. It makes Claude Code, Codex, MCP servers, skills, and Git workspaces understandable without uploading your configuration or silently changing third-party tools. @@ -12,9 +13,9 @@ Requires macOS 13 or later. Apple Silicon and Intel are both tested. Homebrew is the recommended installation path: ```sh -brew install nyldn/tap/ai-optimizer -ai-optimizer setup -ai-optimizer doctor +brew install nyldn/tap/ai-env-optimizer +ai-env-optimizer setup +ai-env-optimizer doctor ``` `setup` saves local defaults. It does not change Claude Code, Codex, MCP, @@ -23,9 +24,11 @@ skills, repositories, or launchd unless you explicitly add `--schedule`. ## What you get - A credential-free `doctor` for macOS, PATH, Claude Code, Codex, optional - companion tools, MCP configuration health, skills, and AI Optimizer state. + companion tools, MCP configuration health, skills, and product-owned state. - A `scan` that summarizes Git workspace coverage without reporting repository names or file contents. +- A privacy-safe storage inventory with protected-history classifications and + explicit, token-verified cache cleanup through macOS Trash. - The same stable findings in readable text or one clean JSON document. - A shared `agent-context` handshake that tells Codex or Claude Code what to inspect, how to prioritize findings, where mutation authority stops, and how @@ -36,7 +39,7 @@ skills, repositories, or launchd unless you explicitly add `--schedule`. Example: ```text -AI Optimizer 0.1.8 +AI Environment Optimizer 0.2.0 [PASS] system.macos - macOS is supported [PASS] tools.claude.present - Claude Code is available @@ -53,20 +56,37 @@ automation. ## Commands ```text -ai-optimizer setup [--workspace-root PATH] [--schedule] -ai-optimizer doctor [--json] [--strict] -ai-optimizer scan [--json] [--strict] [--workspace-root PATH] -ai-optimizer agent-context [--json] [--strict] [--workspace-root PATH] -ai-optimizer report [--json] -ai-optimizer schedule [--hour H] [--minute M] -ai-optimizer schedule status -ai-optimizer unschedule -ai-optimizer version +ai-env-optimizer setup [--workspace-root PATH] [--schedule] +ai-env-optimizer doctor [--json] [--strict] +ai-env-optimizer scan [--json] [--strict] [--workspace-root PATH] +ai-env-optimizer agent-context [--json] [--strict] [--workspace-root PATH] +ai-env-optimizer storage [--json] [--strict] +ai-env-optimizer storage cleanup --dry-run [--older-than DAYS] [--min-size MB] [--json] +ai-env-optimizer storage cleanup --apply TOKEN [--older-than DAYS] [--min-size MB] [--json] +ai-env-optimizer report [--json] +ai-env-optimizer schedule [--hour H] [--minute M] +ai-env-optimizer schedule status +ai-env-optimizer unschedule +ai-env-optimizer version ``` The default workspace root is `~/git` when it exists, otherwise the current directory. +## Upgrading from `ai-optimizer` + +Version 0.2 renamed the project and canonical command to `ai-env-optimizer`. +Homebrew migrates the old formula name automatically. The legacy +`ai-optimizer` command remains an exact compatibility alias, and the direct +installer recognizes existing v0.1 install roots, state manifests, environment +variables, and schedules. New environment variables use the +`AI_ENV_OPTIMIZER_*` prefix; existing `AI_OPTIMIZER_*` variables remain +supported. + +The direct launchd label `io.github.nyldn.ai-optimizer.daily` and Homebrew +service label `homebrew.mxcl.ai-optimizer` intentionally remain stable so an +upgrade cannot create a duplicate background job. + ## Use with Codex or Claude Code Clone or open this repository, then start either agent from its root. Codex @@ -74,7 +94,7 @@ automatically reads `AGENTS.md`; Claude Code loads `CLAUDE.md`, which imports the same operating contract. Both are directed to begin with: ```sh -./bin/ai-optimizer agent-context --json +./bin/ai-env-optimizer agent-context --json ``` The handshake combines current doctor and workspace evidence with deduplicated @@ -82,12 +102,44 @@ priorities, remediation, safety rules, and completion checks. It is read-only. See [Working with Codex and Claude Code](docs/agent-workflow.md) for the schema, recommended prompt, and repair loop. +For a storage request, both agents are instructed to start with the read-only +`./bin/ai-env-optimizer storage --json` inventory. They do not infer cleanup +permission from a warning or preview token. + +## Storage health and recoverable cleanup + +Start with a path-free inventory: + +```sh +ai-env-optimizer storage --json +``` + +It reports aggregate allocated, protected, and potentially reclaimable bytes +for known Claude, Codex, Claude-Mem, and product-owned locations. +Sessions, transcripts, memories, worktrees, and active plugin state are protected +and are never cleanup candidates. Inventory and preview do not write files. + +If reclaimable cache data is material, use this exact two-step loop: + +```sh +ai-env-optimizer storage cleanup --dry-run --older-than 30 --min-size 100 +ai-env-optimizer storage cleanup --apply TOKEN --older-than 30 --min-size 100 +``` + +Preview reports only aggregate source IDs, counts, allocated bytes, and a +candidate-set token. Apply recomputes the candidate set and requires the same +filters and token. It refuses metadata drift, symlinks, running provider apps, +cross-filesystem moves, and destinations it did not create. Eligible files are +moved—not copied or deleted—into one private dated folder in `~/.Trash`, so +they remain recoverable until the user empties Trash. The tool never empties +Trash and has no unattended cleanup mode. + ## Evening maintenance Scheduling is opt-in: ```sh -brew services start nyldn/tap/ai-optimizer +brew services start nyldn/tap/ai-env-optimizer ``` For Homebrew installs, this is the recommended path. Homebrew creates a @@ -96,20 +148,22 @@ loaded, and resolves the stable `opt` path across package upgrades. Check or remove it with: ```sh -brew services info nyldn/tap/ai-optimizer -brew services stop nyldn/tap/ai-optimizer +brew services info nyldn/tap/ai-env-optimizer +brew services stop nyldn/tap/ai-env-optimizer ``` Do not enable both schedulers. For a checksum-verified direct install, or when -you need a custom time, use AI Optimizer's own scheduler: +you need a custom time, use AI Environment Optimizer's own scheduler: ```sh -ai-optimizer schedule +ai-env-optimizer schedule ``` -The default is 21:00 local time. AI Optimizer accepts 19:00 through 02:00 and -checks the time again when launchd actually starts the process. A Mac waking -later in the morning records `skipped_outside_window` and performs no scan. +The default is 21:00 local time. AI Environment Optimizer accepts 19:00 through +02:00 and checks the time again when launchd actually starts the process. A Mac +waking later in the morning records `skipped_outside_window` and performs no scan. +Evening maintenance never applies cleanup; it only records aggregate storage +health and a warning when configured storage exceeds the default 10 GiB threshold. Configuration, receipts, and scheduler logs are stored with owner-only permissions. The direct-install launch agent uses an owner-only, product-owned maintenance launcher under @@ -118,7 +172,7 @@ executable path, and the Homebrew or direct-install path does not appear in the launchd plist. Package upgrades therefore leave an already opted-in schedule registered. -AI Optimizer owns only: +AI Environment Optimizer owns only: ```text ~/Library/LaunchAgents/io.github.nyldn.ai-optimizer.daily.plist @@ -128,23 +182,27 @@ io.github.nyldn.ai-optimizer.daily Remove it with: ```sh -ai-optimizer unschedule +ai-env-optimizer unschedule ``` ## Privacy and mutation boundary -`doctor`, `scan`, and `agent-context` are read-only. Reports contain status identifiers, -counts, tool versions, and remediation. They do not contain environment values, -command arguments, MCP endpoints, tokens, workspace names, or workspace file +`doctor`, `scan`, `agent-context`, `storage`, and storage cleanup preview are +read-only. Reports contain status identifiers, counts, tool versions, and +remediation. They do not contain environment values, command arguments, MCP +endpoints, tokens, workspace names, original storage paths, filenames, or file contents. The only mutating commands are: -- `setup`, which writes AI Optimizer configuration; +- `setup`, which writes AI Environment Optimizer configuration; - `schedule`, which writes and bootstraps the exact launch agent above; -- `unschedule`, which removes that exact launch agent. +- `unschedule`, which removes that exact launch agent; +- `storage cleanup --apply TOKEN`, which moves only verified allowlisted cache + candidates to a private folder in the current user's Trash and writes an + aggregate owner-only receipt. -Graphify, Claude-Mem, and ATK are useful optional companions. AI Optimizer +Graphify, Claude-Mem, and ATK are useful optional companions. AI Environment Optimizer detects them but does not install, configure, update, or remove them. See [Privacy](docs/privacy.md) and [Architecture](docs/architecture.md). @@ -157,14 +215,14 @@ The direct path verifies the installer before it runs, then the installer verifies the release archive before changing live paths: ```sh -VERSION=0.1.8 -curl -fLO "https://github.com/nyldn/ai-optimizer/releases/download/v$VERSION/install.sh" -curl -fLO "https://github.com/nyldn/ai-optimizer/releases/download/v$VERSION/install.sh.sha256" +VERSION=0.2.0 +curl -fLO "https://github.com/nyldn/ai-env-optimizer/releases/download/v$VERSION/install.sh" +curl -fLO "https://github.com/nyldn/ai-env-optimizer/releases/download/v$VERSION/install.sh.sha256" shasum -a 256 -c install.sh.sha256 bash install.sh --version "$VERSION" ``` -Direct installs use `~/.local/share/ai-optimizer` and link the command into +Direct installs use `~/.local/share/ai-env-optimizer` and link the command into `~/.local/bin`. If that directory is not on PATH, the installer prints the exact zsh line to add. @@ -173,22 +231,22 @@ exact zsh line to add. Homebrew: ```sh -brew upgrade ai-optimizer -brew services stop nyldn/tap/ai-optimizer -brew uninstall ai-optimizer +brew upgrade ai-env-optimizer +brew services stop nyldn/tap/ai-env-optimizer +brew uninstall ai-env-optimizer ``` -If you used `ai-optimizer schedule` instead, run `ai-optimizer unschedule` +If you used `ai-env-optimizer schedule` instead, run `ai-env-optimizer unschedule` before uninstalling. Direct install: ```sh -~/.local/share/ai-optimizer/scripts/uninstall.sh +~/.local/share/ai-env-optimizer/scripts/uninstall.sh ``` -Add `--keep-state` to retain AI Optimizer configuration and reports. -Uninstall removes only paths with AI Optimizer provenance. +Add `--keep-state` to retain AI Environment Optimizer configuration and reports. +Uninstall removes only paths with product provenance. ## Development diff --git a/SECURITY.md b/SECURITY.md index eb1f398..8623fe8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ SHA-256 checksums and GitHub build provenance. The direct-install runtime supports macOS's system Ruby 2.6 so it can start without bootstrapping another language runtime. Ruby 2.6 is upstream end-of-life; -AI Optimizer therefore uses only the operating-system Ruby standard library, +AI Environment Optimizer therefore uses only the operating-system Ruby standard library, does not open a listening service, does not parse untrusted network responses in normal doctor runs, and also tests under Homebrew's maintained Ruby. This tradeoff will be revisited if macOS removes the system runtime. diff --git a/SUPPORT.md b/SUPPORT.md index 74f085c..17542b2 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,14 +1,14 @@ # Support -Use [GitHub Discussions](https://github.com/nyldn/ai-optimizer/discussions) for +Use [GitHub Discussions](https://github.com/nyldn/ai-env-optimizer/discussions) for setup questions and ideas. Use -[GitHub Issues](https://github.com/nyldn/ai-optimizer/issues) for reproducible +[GitHub Issues](https://github.com/nyldn/ai-env-optimizer/issues) for reproducible bugs. Before reporting a bug, run: ```sh -ai-optimizer doctor --json +ai-env-optimizer doctor --json ``` Share only the finding IDs, statuses, and product version needed to reproduce diff --git a/VERSION b/VERSION index 699c6c6..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.8 +0.2.0 diff --git a/bin/ai-optimizer b/bin/ai-env-optimizer similarity index 100% rename from bin/ai-optimizer rename to bin/ai-env-optimizer diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md index 18d1c40..deccbcb 100644 --- a/docs/agent-workflow.md +++ b/docs/agent-workflow.md @@ -1,17 +1,17 @@ # Working with Codex and Claude Code -AI Optimizer gives coding agents a deterministic, read-only starting point. +AI Environment Optimizer gives coding agents a deterministic, read-only starting point. The repository's `AGENTS.md` and `CLAUDE.md` route both Codex and Claude Code to the same command: ```sh -./bin/ai-optimizer agent-context --json +./bin/ai-env-optimizer agent-context --json ``` Use the installed command instead when you are not working from a checkout: ```sh -ai-optimizer agent-context --json +ai-env-optimizer agent-context --json ``` ## What the handshake returns @@ -74,3 +74,19 @@ A useful first request is: Codex automatically discovers `AGENTS.md`; Claude Code loads `CLAUDE.md`, which imports the same contract. + +## Storage requests + +Make storage inventory the first command only when the user asks about storage +or the latest evening receipt reports a warning: + +```sh +./bin/ai-env-optimizer storage --json +``` + +The output is read-only and excludes paths and filenames. Historical sessions, +transcripts, Claude-Mem knowledge, worktrees, and active plugin state are +protected. If the user wants cache reclamation, produce a dry-run first and +show its aggregate counts and token. Do not apply that token until the user has +explicitly approved the same `--older-than` and `--min-size` filters. Scheduled +maintenance never supplies that approval and never runs cleanup. diff --git a/docs/architecture.md b/docs/architecture.md index 44266e6..64f7653 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -AI Optimizer is deliberately smaller than an agent framework or package +AI Environment Optimizer is deliberately smaller than an agent framework or package manager. It observes existing tools and mutates only its own state. ```mermaid @@ -12,6 +12,9 @@ flowchart LR E --> F[Human report] E --> G[JSON report] G --> L[Agent context handshake] + M[Known AI storage catalog] --> N[Read-only allocation scanner] + N --> O[Path-free inventory and preview] + O -->|Explicit token apply| P[Same-volume move to Trash] H[Explicit setup or schedule] --> I[Owned config and direct-install launchd state] J[Explicit brew services start] --> K[Homebrew-managed launchd state] ``` @@ -40,10 +43,32 @@ actionable findings, assigns deterministic P0/P1/P2 priorities, and includes a static authorization and verification contract for Codex and Claude Code. It does not infer repair commands or introduce another mutation path. +## Storage subsystem + +The immutable catalog classifies known Claude, Codex, Claude-Mem, and +product-owned locations as `regenerable`, `bounded_logs`, `historical`, or +`active`. Only entries explicitly marked cleanup-eligible in source code can +reach the planner. Historical sessions and memories plus active worktrees, +plugin state, and VM bundles cannot be made eligible through CLI arguments or +configuration. + +The scanner uses `lstat`, refuses symlinked source ancestors, does not follow +symlinks, de-duplicates allocation by device and inode, and emits only source +IDs and aggregates. Cleanup preview selects old eligible files and hashes its +options plus filesystem identity metadata into a token. Apply recomputes that +set, checks provider processes and filesystem devices, then uses atomic rename +operations into a new mode-0700 folder under the current user's Trash. There +is no copy, recursive-delete, arbitrary-path, force, or scheduled apply path. +The owner-only receipt contains only counts, source IDs, and the Trash folder +basename. + ## Ownership -Configuration and reports live under -`~/Library/Application Support/io.github.nyldn.ai-optimizer`. The direct-install +For a new installation, configuration and reports live under +`~/Library/Application Support/io.github.nyldn.ai-env-optimizer`. An existing +v0.1 installation keeps using its owned +`~/Library/Application Support/io.github.nyldn.ai-optimizer` directory so a +read-only command never moves state as a side effect. The direct-install scheduler uses the exact label `io.github.nyldn.ai-optimizer.daily`. Writes reject symlink targets, stage and validate before rename, and record product provenance. @@ -53,3 +78,13 @@ label is `homebrew.mxcl.ai-optimizer`. Its command resolves through Homebrew's stable `opt` path and its 19:30 schedule is generated by Homebrew's public service DSL. The direct scheduler uses `launchctl bootstrap`, `bootout`, and `print` in the current user's GUI domain. Neither path writes a user crontab. + +The old labels are stable internal compatibility identifiers, not the public +product name. Keeping them prevents duplicate launchd jobs during the v0.2 +rename. The canonical CLI is `ai-env-optimizer`; `ai-optimizer` is shipped as a +compatibility alias. Canonical `AI_ENV_OPTIMIZER_*` environment variables take +precedence over supported legacy `AI_OPTIMIZER_*` variables. + +Both scheduling paths run diagnostics only within 19:00–02:00 local time. +Storage inventory may add an aggregate warning to the maintenance receipt, but +maintenance has no dependency on the cleanup planner or executor. diff --git a/docs/privacy.md b/docs/privacy.md index c2f77ef..76c1aca 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,6 @@ # Privacy -AI Optimizer 0.1.8 has no telemetry and sends no diagnostic data anywhere. +AI Environment Optimizer 0.2.0 has no telemetry and sends no diagnostic data anywhere. ## Read @@ -11,7 +11,9 @@ The doctor may inspect: - whether Claude Code and Codex MCP configuration can be parsed; - counts and basic frontmatter health for known skill directories; - counts and Git state for direct child workspaces; -- AI Optimizer-owned configuration and launchd state. +- AI Environment Optimizer-owned configuration and launchd state. +- aggregate allocated bytes and age buckets for a fixed catalog of AI-related + storage locations. ## Never report @@ -20,6 +22,8 @@ The doctor may inspect: - tokens, passwords, API keys, or authorization headers; - MCP server names, commands, arguments, URLs, or query strings; - repository names, paths, remotes, file names, or file contents. +- storage source paths, original paths or filenames, session content, or + transcript content. Paths shown during explicit setup are shortened to `~` for the current home. Unexpected check failures discard command output and become an `unknown` @@ -27,13 +31,31 @@ finding. ## Write -`doctor`, `scan`, and `agent-context` do not write. `setup`, `schedule`, and `unschedule` -write or remove only AI Optimizer-owned files and the documented launchd label. +`doctor`, `scan`, and `agent-context` do not write. Storage inventory and +cleanup preview do not write either. +`setup`, `schedule`, and `unschedule` write or remove only AI Environment +Optimizer-owned files and the documented launchd label. Scheduled maintenance writes one local run receipt after the execution-time evening guard. Configuration, receipts, and scheduler logs use owner-only permissions. Its fixed maintenance launcher is also owner-only and uses only the shell-quoted absolute executable path written by the explicit `schedule` command. That path does not appear in the launchd plist. -AI Optimizer refuses to claim a nonempty Application Support directory unless +AI Environment Optimizer refuses to claim a nonempty Application Support directory unless its state manifest already proves product ownership. + +Cleanup apply is the only mutation of third-party storage. It accepts no path, +protects sessions, transcripts, memories, worktrees, plugin state, and VM +bundles, and moves only catalog-allowlisted regenerable caches or bounded +product logs. It requires a fresh preview token, stopped provider processes, +and a same-filesystem move into a new private Trash folder. Its receipt omits +original paths or filenames. AI Environment Optimizer never empties Trash and +scheduled maintenance never invokes cleanup. + +Apply revalidates identity metadata immediately before each rename. A +same-user process with concurrent write access could still change a directory +between that check and the rename because Ruby 2.6 has no portable +directory-descriptor-relative rename API. Provider-process refusal, allowlisted +roots, fresh private destinations, and recoverability through Trash bound this +residual race; it is not a protection against a hostile process already +running as the same macOS user. diff --git a/docs/research-notes.md b/docs/research-notes.md index d4f2c10..76b17f9 100644 --- a/docs/research-notes.md +++ b/docs/research-notes.md @@ -4,7 +4,7 @@ Observed on 2026-08-22. Recommendations used sources freshly served or changed since 2026-05-22. These are independently implemented design patterns; no project code was copied. -| Pattern | Recent evidence | AI Optimizer decision | +| Pattern | Recent evidence | AI Environment Optimizer decision | |---|---|---| | Structured findings and JSON | [Homebrew Finding objects, 2026-07-24](https://github.com/Homebrew/brew/commit/a05f49aa85a885a9c524616085a5ee1abdd87803) | One stable finding model renders human and JSON output. | | Text/JSON parity | [mise doctor fix and test, 2026-08-22](https://github.com/jdx/mise/commit/6f52dcdf99e282ef7a7db68c81301fa4618d0f79) | Regression tests ensure warnings appear in both formats. | @@ -18,6 +18,6 @@ project code was copied. | Native service lifecycle | [Homebrew service DSL public API, 2026-07-26](https://github.com/Homebrew/brew/commit/29cb5e338a49290d813aa063312b7b79c5d10732) and [Formula Cookbook](https://docs.brew.sh/Formula-Cookbook#service-files) | Homebrew installs use `brew services` with a stable `opt` command and an explicit 19:30 cron expression; no user crontab is created. | | Precise linked-skill failures | [fx linked-skill diagnostic, 2026-08-22](https://github.com/vercel-labs/fx/commit/c6d210b1fe47808e1a3553b17a022bfb82831a89) | Broken linked skill directories warn separately from invalid frontmatter, without leaking paths. | -The resulting product boundary is intentional: AI Optimizer is a macOS +The resulting product boundary is intentional: AI Environment Optimizer is a macOS observer and owner of its own maintenance state. It is not another package manager, provider proxy, memory system, or autonomous workspace repair agent. diff --git a/docs/superpowers/plans/2026-08-23-storage-health-implementation.md b/docs/superpowers/plans/2026-08-23-storage-health-implementation.md new file mode 100644 index 0000000..6ba1fd4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-storage-health-implementation.md @@ -0,0 +1,841 @@ +# Storage Health and Safe Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship AI Environment Optimizer v0.2.0 with privacy-preserving storage inventory, protected historical sessions, explicit reversible cache cleanup, a safe product rename, Homebrew migration, and a verified local upgrade. + +**Architecture:** Add focused storage source, scanner, report, planner, and executor units under `lib/ai_optimizer`. Inventory and cleanup preview remain read-only; apply recomputes an exact candidate token and moves only allowlisted regenerable files to a new owner-only Trash folder. Existing launchd labels and all v0.1 identities remain compatibility surfaces, while the Homebrew formula and public CLI use `ai-env-optimizer`. + +**Tech Stack:** macOS system Ruby 2.6 standard library, Minitest, Bash, launchd, Homebrew formula DSL, GitHub Actions, SHA-256 release checksums and attestations. + +--- + +## File structure + +New runtime files have one responsibility each: + +- `lib/ai_optimizer/storage_source.rb`: immutable source definition and safe path resolution. +- `lib/ai_optimizer/storage_catalog.rb`: non-overlapping supported Claude, Codex, Claude-Mem, and product-owned categories. +- `lib/ai_optimizer/storage_scanner.rb`: read-only allocated-byte and age-bucket accounting. +- `lib/ai_optimizer/storage_report.rb`: deterministic human and JSON rendering. +- `lib/ai_optimizer/cleanup_planner.rb`: allowlist filtering and candidate-token generation. +- `lib/ai_optimizer/cleanup_executor.rb`: token verification, process guard, Trash moves, and receipt. + +Tests mirror each runtime unit. Existing CLI, config, maintenance, installer, +documentation, workflow, and Homebrew formula files change only where they own +integration behavior. + +### Task 1: Close the v0.2 rename release gates + +**Files:** +- Modify: `test/install_test.sh` +- Modify: `install.sh` +- Modify: `lib/ai_optimizer/report.rb` +- Modify: `lib/ai_optimizer/maintenance.rb` +- Modify: `test/report_test.rb` +- Create: `test/maintenance_test.rb` + +- [ ] **Step 1: Add a second-run legacy upgrade regression** + +After the existing legacy-root upgrade assertions in `test/install_test.sh`, run +the same v0.2 installer again without a prefix and assert it continues using the +legacy root: + +```bash +env HOME="$LEGACY_HOME" \ + AI_ENV_OPTIMIZER_RELEASE_BASE="file://$DIST_DIR" \ + PATH="/usr/bin:/bin" \ + /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION" >/dev/null + +[ ! -e "$LEGACY_HOME/.local/share/ai-env-optimizer" ] +[ -f "$LEGACY_ROOT/.ai-env-optimizer-install" ] +[ "$("$LEGACY_BIN_DIR/ai-env-optimizer" version)" = "ai-env-optimizer $VERSION" ] +``` + +- [ ] **Step 2: Run the round trip and observe the failure** + +Run: + +```bash +rtk test/install_test.sh +``` + +Expected: FAIL when the second invocation selects the canonical root and +refuses the legacy-root command links. + +- [ ] **Step 3: Make legacy-root discovery idempotent** + +Replace the legacy selection condition in `install.sh` with: + +```bash +elif [ -f "$LEGACY_INSTALL_ROOT/.ai-env-optimizer-install" ] || + [ -f "$LEGACY_INSTALL_ROOT/.ai-optimizer-install" ]; then + INSTALL_ROOT="$LEGACY_INSTALL_ROOT" +``` + +- [ ] **Step 4: Add compatibility metadata to every JSON document** + +In `AIOptimizer::Report#to_h` and `Maintenance#base_receipt`, add: + +```ruby +compatibility: { legacy_names: ["ai-optimizer"] } +``` + +Use the string key form in `Maintenance`: + +```ruby +"compatibility" => { "legacy_names" => ["ai-optimizer"] } +``` + +Add assertions that doctor/scan reports and scheduled receipts carry this +signal. Create `test/maintenance_test.rb` with a fixed clock, temporary data +directory, and a passing empty `Report`. + +- [ ] **Step 5: Verify and commit** + +Run: + +```bash +rtk /usr/bin/ruby -w test/test_all.rb +rtk scripts/check-syntax.sh +rtk test/install_test.sh +``` + +Expected: all tests and both installer passes succeed. + +Commit: + +```bash +rtk git add install.sh test/install_test.sh lib/ai_optimizer/report.rb lib/ai_optimizer/maintenance.rb test/report_test.rb test/maintenance_test.rb +rtk git commit -m "fix: make rename migration idempotent" +``` + +### Task 2: Define storage sources and protected classifications + +**Files:** +- Create: `lib/ai_optimizer/storage_source.rb` +- Create: `lib/ai_optimizer/storage_catalog.rb` +- Create: `test/storage_source_test.rb` +- Modify: `lib/ai_optimizer.rb` + +- [ ] **Step 1: Write source-resolution tests** + +Cover canonical resolution, home containment, supported classifications, unique +IDs, non-overlapping catalog paths, and the initial cleanup allowlist: + +```ruby +def test_historical_sources_are_never_cleanup_eligible + catalog = AIOptimizer::StorageCatalog.new(home: @home, data_dir: @data_dir) + protected_sources = catalog.sources.select do |source| + %w[historical active].include?(source.classification) + end + + refute_empty protected_sources + assert protected_sources.none?(&:cleanup_eligible?) +end + +def test_source_refuses_parent_traversal + assert_raises(AIOptimizer::OwnershipError) do + AIOptimizer::StorageSource.new( + id: "bad.source", provider: "test", base: :home, + components: ["..", "outside"], classification: "regenerable", + cleanup_eligible: true + ).resolve(home: @home, data_dir: @data_dir) + end +end +``` + +- [ ] **Step 2: Run the test and observe missing classes** + +Run: + +```bash +rtk /usr/bin/ruby -w test/storage_source_test.rb +``` + +Expected: FAIL with an uninitialized `StorageSource` constant. + +- [ ] **Step 3: Implement `StorageSource`** + +Create an immutable object with this public contract: + +```ruby +module AIOptimizer + class StorageSource + CLASSIFICATIONS = %w[regenerable bounded_logs historical active].freeze + + attr_reader :id, :provider, :base, :components, :classification, + :process_names + + def initialize(id:, provider:, base:, components:, classification:, + cleanup_eligible:, process_names: []) + raise ArgumentError, "invalid source id" unless id.match?(/\A[a-z0-9_.]+\z/) + raise ArgumentError, "invalid classification" unless CLASSIFICATIONS.include?(classification) + raise ArgumentError, "invalid base" unless %i[home application_support data_dir].include?(base) + raise OwnershipError, "unsafe storage component" if components.any? { |item| item.empty? || item == ".." || item.include?(File::SEPARATOR) } + raise ArgumentError, "protected storage cannot be cleanup eligible" if cleanup_eligible && %w[historical active].include?(classification) + + @id = id.freeze + @provider = provider.freeze + @base = base + @components = components.map(&:dup).map(&:freeze).freeze + @classification = classification.freeze + @cleanup_eligible = cleanup_eligible + @process_names = process_names.map(&:dup).map(&:freeze).freeze + freeze + end + + def cleanup_eligible? + @cleanup_eligible + end + + def resolve(home:, data_dir:) + root = case base + when :home then File.expand_path(home) + when :application_support then File.join(File.expand_path(home), "Library", "Application Support") + when :data_dir then File.expand_path(data_dir) + end + path = File.expand_path(File.join(root, *components)) + prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR + raise OwnershipError, "storage source escapes its base" unless path.start_with?(prefix) + + path + end + end +end +``` + +- [ ] **Step 4: Implement the non-overlapping catalog** + +`StorageCatalog#sources` returns definitions for these source IDs: + +```ruby +DEFINITIONS = [ + ["claude.projects", "claude", :home, [".claude", "projects"], "historical", false, []], + ["claude.plugin_cache", "claude", :home, [".claude", "plugins", "cache"], "active", false, []], + ["claude.marketplaces", "claude", :home, [".claude", "plugins", "marketplaces"], "active", false, []], + ["claude.app_cache", "claude", :application_support, ["Claude", "Cache"], "regenerable", true, ["Claude"]], + ["claude.code_cache", "claude", :application_support, ["Claude", "Code Cache"], "regenerable", true, ["Claude"]], + ["claude.gpu_cache", "claude", :application_support, ["Claude", "GPUCache"], "regenerable", true, ["Claude"]], + ["claude.local_sessions", "claude", :application_support, ["Claude", "local-agent-mode-sessions"], "historical", false, []], + ["claude.vm_bundles", "claude", :application_support, ["Claude", "vm_bundles"], "active", false, []], + ["codex.sessions", "codex", :home, [".codex", "sessions"], "historical", false, []], + ["codex.archived_sessions", "codex", :home, [".codex", "archived_sessions"], "historical", false, []], + ["codex.plugins", "codex", :home, [".codex", "plugins"], "active", false, []], + ["codex.worktrees", "codex", :home, [".codex", "worktrees"], "active", false, []], + ["codex.cache", "codex", :home, [".codex", "cache"], "regenerable", true, ["Codex"]], + ["claude_mem.index", "claude-mem", :home, [".claude-mem", "chroma"], "historical", false, []], + ["claude_mem.database", "claude-mem", :home, [".claude-mem", "claude-mem.db"], "historical", false, []], + ["claude_mem.logs", "claude-mem", :home, [".claude-mem", "logs"], "bounded_logs", false, []], + ["product.logs", "ai-env-optimizer", :data_dir, ["logs"], "bounded_logs", true, []] +].freeze +``` + +Build each `StorageSource`, reject duplicate IDs, and reject one resolved source +being an ancestor of another. + +- [ ] **Step 5: Wire requires, verify, and commit** + +Require source and catalog before scanner/report integration in +`lib/ai_optimizer.rb`. Run the focused and full tests, then commit: + +```bash +rtk /usr/bin/ruby -w test/storage_source_test.rb +rtk /usr/bin/ruby -w test/test_all.rb +rtk git add lib/ai_optimizer.rb lib/ai_optimizer/storage_source.rb lib/ai_optimizer/storage_catalog.rb test/storage_source_test.rb +rtk git commit -m "feat: define protected storage catalog" +``` + +### Task 3: Add read-only allocation scanning + +**Files:** +- Create: `lib/ai_optimizer/storage_scanner.rb` +- Create: `test/storage_scanner_test.rb` +- Modify: `lib/ai_optimizer.rb` + +- [ ] **Step 1: Write failing scanner tests** + +Create synthetic files with fixed mtimes. Assert allocated bytes are positive, +age buckets partition the total, hard links count once, missing roots return +`absent`, and a symlinked source returns `unknown` without following it: + +```ruby +measurement = AIOptimizer::StorageScanner.new( + sources: [source], home: home, data_dir: data_dir, + clock: -> { Time.utc(2026, 8, 23) } +).scan.first + +assert_equal "complete", measurement.fetch("status") +assert_equal measurement.fetch("allocated_bytes"), measurement.fetch("age_buckets").values.sum +refute measurement.key?("path") +``` + +- [ ] **Step 2: Run the focused test and observe the missing scanner** + +```bash +rtk /usr/bin/ruby -w test/storage_scanner_test.rb +``` + +Expected: FAIL with an uninitialized `StorageScanner` constant. + +- [ ] **Step 3: Implement traversal and de-duplication** + +Use `Find.find`, `File.lstat`, and a `Set` of `[stat.dev, stat.ino]`. Never call +`File.realpath` during traversal and never descend into symlinks: + +```ruby +AGE_BUCKETS = { + "0_7_days" => 0..7, + "8_30_days" => 8..30, + "31_90_days" => 31..90, + "over_90_days" => 91..Float::INFINITY +}.freeze + +def allocated_bytes(stat) + blocks = stat.respond_to?(:blocks) ? stat.blocks : nil + blocks ? blocks * 512 : stat.size +end + +def bucket_for(stat) + days = [((@clock.call - stat.mtime) / 86_400).floor, 0].max + AGE_BUCKETS.find { |_name, range| range.cover?(days) }.first +end +``` + +Return frozen hashes containing only `id`, `provider`, `classification`, +`cleanup_eligible`, `status`, `allocated_bytes`, `file_count`, `directory_count`, +and the four age buckets. Rescue filesystem errors per source and return +`status: "unknown"` with zero totals. + +- [ ] **Step 4: Verify privacy and performance fixtures** + +Assert serialized measurements do not include the temporary home, child names, +or symlink target. Add a fixture with 10,000 empty files and assert completion +without changing any mtime or content. + +- [ ] **Step 5: Verify and commit** + +```bash +rtk /usr/bin/ruby -w test/storage_scanner_test.rb +rtk /usr/bin/ruby -w test/test_all.rb +rtk git add lib/ai_optimizer.rb lib/ai_optimizer/storage_scanner.rb test/storage_scanner_test.rb +rtk git commit -m "feat: scan AI storage without following links" +``` + +### Task 4: Expose deterministic storage reports in the CLI + +**Files:** +- Create: `lib/ai_optimizer/storage_report.rb` +- Create: `test/storage_report_test.rb` +- Modify: `lib/ai_optimizer.rb` +- Modify: `lib/ai_optimizer/cli.rb` +- Modify: `test/cli_test.rb` + +- [ ] **Step 1: Write failing report and CLI tests** + +Assert canonical identity, compatibility metadata, deterministic source order, +aggregate totals, protected labels, no paths, one JSON document, and `--strict` +exit behavior: + +```ruby +payload = JSON.parse(stdout) +assert_equal "ai-env-optimizer", payload.fetch("product") +assert_includes payload.fetch("compatibility").fetch("legacy_names"), "ai-optimizer" +assert payload.fetch("sources").all? { |item| !item.key?("path") } +assert_equal payload.fetch("summary").fetch("allocated_bytes"), + payload.fetch("sources").sum { |item| item.fetch("allocated_bytes") } +``` + +- [ ] **Step 2: Run tests and observe missing command/report failures** + +```bash +rtk /usr/bin/ruby -w test/storage_report_test.rb +rtk /usr/bin/ruby -w test/cli_test.rb +``` + +Expected: FAIL because `storage` is unknown and `StorageReport` is undefined. + +- [ ] **Step 3: Implement `StorageReport`** + +Use schema version 1 and this top-level shape: + +```ruby +{ + schema_version: 1, + product: "ai-env-optimizer", + compatibility: { legacy_names: ["ai-optimizer"] }, + version: VERSION, + generated_at: generated_at.iso8601, + summary: { + allocated_bytes: complete.sum { |item| item.fetch("allocated_bytes") }, + reclaimable_bytes: eligible.sum { |item| item.fetch("allocated_bytes") }, + protected_bytes: protected.sum { |item| item.fetch("allocated_bytes") }, + unknown_sources: measurements.count { |item| item.fetch("status") == "unknown" } + }, + sources: measurements.sort_by { |item| item.fetch("id") } +} +``` + +Human output formats bytes with binary units, labels each source as +`reclaimable`, `protected`, `absent`, or `unknown`, and never renders paths. +`exit_code(strict:)` returns 1 for unknown sources, and under strict mode also +returns 1 when allocated bytes exceed the configured warning threshold. + +- [ ] **Step 4: Add `storage` CLI routing** + +Extend help and dispatch with: + +```ruby +when "storage" + run_storage(args) +``` + +`run_storage` parses `--json` and `--strict`, constructs `StorageCatalog` and +`StorageScanner` using `Dir.home` and `data_dir`, renders one report, and returns +its exit code. Reject cleanup subcommands until Tasks 5 and 6 add them. + +- [ ] **Step 5: Verify and commit** + +```bash +rtk /usr/bin/ruby -w test/storage_report_test.rb +rtk /usr/bin/ruby -w test/cli_test.rb +rtk /usr/bin/ruby -w test/test_all.rb +rtk git add lib/ai_optimizer.rb lib/ai_optimizer/storage_report.rb lib/ai_optimizer/cli.rb test/storage_report_test.rb test/cli_test.rb +rtk git commit -m "feat: add privacy-safe storage inventory" +``` + +### Task 5: Build a read-only cleanup planner + +**Files:** +- Create: `lib/ai_optimizer/cleanup_planner.rb` +- Create: `test/cleanup_planner_test.rb` +- Modify: `lib/ai_optimizer.rb` +- Modify: `lib/ai_optimizer/cli.rb` +- Modify: `test/cli_test.rb` + +- [ ] **Step 1: Write failing eligibility and token tests** + +Cover the 30-day and 100-MB defaults, option bounds, source-level minimum size, +protected-category exclusion, symlink refusal, deterministic tokens, token +change after metadata drift, and path-free serialization: + +```ruby +plan = planner.preview(older_than_days: 30, min_size_mb: 100) +assert plan.fetch("sources").all? { |item| item.fetch("cleanup_eligible") } +refute_includes JSON.generate(plan), home +assert_match(/\A[0-9a-f]{64}\z/, plan.fetch("token")) +``` + +- [ ] **Step 2: Run the test and observe the missing planner** + +```bash +rtk /usr/bin/ruby -w test/cleanup_planner_test.rb +``` + +Expected: FAIL with an uninitialized `CleanupPlanner` constant. + +- [ ] **Step 3: Implement candidate enumeration** + +Only sources with `cleanup_eligible?` may be traversed. Use `lstat`; include +regular files older than the cutoff; never include symlinks, sockets, devices, +historical sources, or active sources. Keep full candidate paths private in an +internal `Plan` object while `to_h` returns only source IDs, counts, bytes, +options, and token. + +Build the token from a sorted JSON array containing source ID, relative path, +device, inode, mode, size, blocks, and integer mtime: + +```ruby +token = Digest::SHA256.hexdigest(JSON.generate(token_rows.sort)) +``` + +Validate `older_than_days` in `1..3650` and `min_size_mb` as a positive integer. +Drop an entire source when its aggregate candidate bytes are below the minimum. + +- [ ] **Step 4: Add CLI preview** + +Route `storage cleanup` with no apply option, or with `--dry-run`, to the same +read-only preview. Parse `--older-than DAYS`, `--min-size MB`, and `--json`. +Human output prints aggregate counts/bytes and the exact follow-up command with +the token and repeated options. + +- [ ] **Step 5: Prove preview writes nothing** + +Snapshot all temporary-home paths, modes, mtimes, and contents before and after +both human and JSON previews. Assert the snapshots are identical and no product +data directory was created. + +- [ ] **Step 6: Verify and commit** + +```bash +rtk /usr/bin/ruby -w test/cleanup_planner_test.rb +rtk /usr/bin/ruby -w test/cli_test.rb +rtk /usr/bin/ruby -w test/test_all.rb +rtk git add lib/ai_optimizer.rb lib/ai_optimizer/cleanup_planner.rb lib/ai_optimizer/cli.rb test/cleanup_planner_test.rb test/cli_test.rb +rtk git commit -m "feat: preview allowlisted storage cleanup" +``` + +### Task 6: Apply verified cleanup through macOS Trash + +**Files:** +- Create: `lib/ai_optimizer/cleanup_executor.rb` +- Create: `test/cleanup_executor_test.rb` +- Modify: `lib/ai_optimizer.rb` +- Modify: `lib/ai_optimizer/cli.rb` +- Modify: `test/cli_test.rb` + +- [ ] **Step 1: Write failing executor tests** + +Use a temporary Trash root and injected process guard. Cover successful moves, +token mismatch, metadata drift, provider-running refusal, symlinked root, +symlinked candidate, existing destination, cross-device refusal, partial move, +owner-only receipt, and protected sources never moving. + +```ruby +result = executor.apply(plan.token) +assert_equal "moved_to_trash", result.fetch("status") +assert_equal 0o700, File.stat(result.fetch("trash_path_for_test")).mode & 0o777 +assert File.file?(File.join(data_dir, "reports", "latest-cleanup.json")) +refute File.exist?(original_file) +``` + +The test-only result may expose `trash_path_for_test`; public renderers must +strip it. + +- [ ] **Step 2: Run the test and observe the missing executor** + +```bash +rtk /usr/bin/ruby -w test/cleanup_executor_test.rb +``` + +Expected: FAIL with an uninitialized `CleanupExecutor` constant. + +- [ ] **Step 3: Implement preflight and same-volume checks** + +`CleanupExecutor` receives a planner, config, Trash root, clock, and process +guard. Before any move it: + +```ruby +fresh = planner.preview( + older_than_days: plan.older_than_days, + min_size_mb: plan.min_size_mb +) +raise UsageError, "cleanup preview expired; run --dry-run again" unless secure_equal(fresh.token, supplied_token) +raise OwnershipError, "Trash root must not be a symlink" if File.symlink?(trash_root) +raise InternalError, "close the affected AI application before cleanup" if running_provider? +raise OwnershipError, "cleanup cannot cross filesystems" unless File.stat(source_root).dev == File.stat(trash_root).dev +``` + +Implement constant-time token comparison without ActiveSupport. + +- [ ] **Step 4: Implement recoverable moves and receipt** + +Create `ai-env-optimizer-YYYYMMDDTHHMMSSZ-` under Trash with mode +0700 using `Dir.mkdir`, refusing any pre-existing entry. Preserve relative +structure below source-ID directories and move with `File.rename`; do not copy +or recursively delete. Stop on the first error and leave completed moves in +Trash. + +Write `reports/latest-cleanup.json` through `Config#atomic_write` with mode 0600: + +```ruby +{ + "schema_version" => 1, + "product" => "ai-env-optimizer", + "compatibility" => { "legacy_names" => ["ai-optimizer"] }, + "status" => status, + "generated_at" => clock.call.utc.iso8601, + "token" => token, + "trash_folder" => File.basename(destination), + "moved_files" => moved_count, + "moved_bytes" => moved_bytes, + "sources" => moved_source_ids.sort +} +``` + +- [ ] **Step 5: Add explicit CLI apply** + +Parse `--apply TOKEN` only under `storage cleanup`. Require exactly 64 lowercase +hex characters. Reuse preview filters, initialize or validate product-owned +state before moving, invoke the executor, and render only aggregate receipt +fields. No `--force`, arbitrary path, or scheduled apply mode exists. + +- [ ] **Step 6: Verify and commit** + +```bash +rtk /usr/bin/ruby -w test/cleanup_executor_test.rb +rtk /usr/bin/ruby -w test/cli_test.rb +rtk /usr/bin/ruby -w test/test_all.rb +rtk git add lib/ai_optimizer.rb lib/ai_optimizer/cleanup_executor.rb lib/ai_optimizer/cli.rb test/cleanup_executor_test.rb test/cli_test.rb +rtk git commit -m "feat: move verified cache cleanup to Trash" +``` + +### Task 7: Add non-destructive evening storage health + +**Files:** +- Modify: `lib/ai_optimizer/config.rb` +- Modify: `lib/ai_optimizer/maintenance.rb` +- Modify: `lib/ai_optimizer/cli.rb` +- Modify: `test/config_test.rb` +- Modify: `test/maintenance_test.rb` + +- [ ] **Step 1: Write failing configuration and maintenance tests** + +Assert defaults contain a 10-GiB report-only threshold and scheduled maintenance +records aggregate storage health without calling planner or executor: + +```ruby +assert_equal 10 * 1024 * 1024 * 1024, + config.defaults.fetch("storage").fetch("warning_bytes") + +receipt = maintenance.run +assert_equal 12_000_000_000, receipt.fetch("storage").fetch("allocated_bytes") +assert_equal "warning", receipt.fetch("storage").fetch("status") +assert_equal 0, cleanup_calls +``` + +- [ ] **Step 2: Run tests and observe missing storage receipt behavior** + +```bash +rtk /usr/bin/ruby -w test/config_test.rb +rtk /usr/bin/ruby -w test/maintenance_test.rb +``` + +Expected: FAIL because the default and receipt field are absent. + +- [ ] **Step 3: Add the report-only threshold** + +Extend `Config#defaults` with: + +```ruby +"storage" => { "warning_bytes" => 10 * 1024 * 1024 * 1024 } +``` + +Validate that a configured threshold is an integer greater than zero before use; +fall back to the default in read-only commands when existing configuration is +invalid. + +- [ ] **Step 4: Inject storage inventory into maintenance** + +Add an optional `storage:` callable to `Maintenance#initialize`. During an +inside-window run, call it after the doctor and merge only: + +```ruby +"storage" => { + "status" => allocated_bytes >= warning_bytes ? "warning" : "healthy", + "allocated_bytes" => allocated_bytes, + "protected_bytes" => protected_bytes, + "reclaimable_bytes" => reclaimable_bytes, + "unknown_sources" => unknown_sources +} +``` + +Outside the evening window, do not scan storage. Never instantiate +`CleanupPlanner` or `CleanupExecutor` from maintenance. + +- [ ] **Step 5: Wire the CLI, verify, and commit** + +Pass a lambda that builds the catalog, scanner, and report into `Maintenance` +from `CLI#run_maintenance`. Run: + +```bash +rtk /usr/bin/ruby -w test/config_test.rb +rtk /usr/bin/ruby -w test/maintenance_test.rb +rtk /usr/bin/ruby -w test/test_all.rb +rtk git add lib/ai_optimizer/config.rb lib/ai_optimizer/maintenance.rb lib/ai_optimizer/cli.rb test/config_test.rb test/maintenance_test.rb +rtk git commit -m "feat: report storage health in evening maintenance" +``` + +### Task 8: Document and independently review the complete public experience + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` +- Modify: `docs/agent-workflow.md` +- Modify: `docs/architecture.md` +- Modify: `docs/privacy.md` +- Modify: `docs/troubleshooting.md` +- Modify: `CHANGELOG.md` +- Modify: `test/script_test.rb` + +- [ ] **Step 1: Add failing documentation contract tests** + +Assert the docs include the canonical inventory, preview, and apply commands; +state that sessions are protected; state that evening maintenance never cleans; +and name the Trash rollback location without using a machine-specific path. + +- [ ] **Step 2: Run the documentation test and observe failure** + +```bash +rtk /usr/bin/ruby -w test/script_test.rb +``` + +Expected: FAIL because storage commands are not yet documented. + +- [ ] **Step 3: Update public documentation** + +Document this exact safe loop: + +```bash +ai-env-optimizer storage --json +ai-env-optimizer storage cleanup --dry-run --older-than 30 --min-size 100 +ai-env-optimizer storage cleanup --apply TOKEN --older-than 30 --min-size 100 +``` + +Explain classifications, privacy, process-close refusal, token drift, Trash +recovery, protected sessions, and the absence of unattended deletion. Add +`storage --json` to the agent workflow only when a user asks about storage or +the evening receipt reports a warning. + +- [ ] **Step 4: Run real Codex and Claude onboarding checks** + +From the repository root, run fresh non-persistent read-only sessions and ask +each agent what command it would run first for a storage request. Expected: both +select `./bin/ai-env-optimizer storage --json`, describe it as read-only, and do +not propose deletion without preview and apply authorization. + +- [ ] **Step 5: Run Fable 5 release review** + +Ask Claude Fable 5 to review the exact commit for privacy leakage, hard-link +accounting, symlink/race handling, session protection, preview/apply parity, +installer rerun compatibility, and scheduled non-deletion. Require PASS before +release. + +- [ ] **Step 6: Verify and commit** + +```bash +rtk /usr/bin/ruby -w test/test_all.rb +rtk scripts/check-syntax.sh +rtk git diff --check +rtk git add README.md AGENTS.md CLAUDE.md CHANGELOG.md docs test/script_test.rb +rtk git commit -m "docs: explain protected storage optimization" +``` + +### Task 9: Complete GitHub, Homebrew, release, and local migration + +**Files:** +- Modify in a sibling Homebrew tap checkout: `Formula/ai-optimizer.rb` (rename to `Formula/ai-env-optimizer.rb`) +- Create in the sibling Homebrew tap checkout: `formula_renames.json` +- Modify in the sibling Homebrew tap checkout: `.github/workflows/ci.yml` +- Modify in the sibling Homebrew tap checkout: `README.md` +- Update external repository name: `nyldn/ai-optimizer` to `nyldn/ai-env-optimizer` + +- [ ] **Step 1: Run the complete local public-repository gate** + +```bash +rtk /usr/bin/ruby -w test/test_all.rb +rtk scripts/check-syntax.sh +rtk zsh -lc 'set -euo pipefail +task_tmp="$(mktemp -d "${TMPDIR:-/tmp}/ai-env-final.XXXXXX")" +trap '\''rm -rf "$task_tmp"'\'' EXIT +AI_ENV_OPTIMIZER_DIST_DIR="$task_tmp/a" scripts/build-release.sh +AI_ENV_OPTIMIZER_DIST_DIR="$task_tmp/b" scripts/build-release.sh +cmp "$task_tmp/a/ai-env-optimizer-0.2.0.tar.gz" "$task_tmp/b/ai-env-optimizer-0.2.0.tar.gz" +AI_ENV_OPTIMIZER_DIST_DIR="$task_tmp/a" test/install_test.sh' +rtk gitleaks git --redact --exit-code 1 . +``` + +Expected: full suite, syntax, deterministic archive, fresh install, two-pass +legacy upgrade, uninstall, and secret scan all pass. + +- [ ] **Step 2: Rename the public GitHub repository and update the local remote** + +Verify there are no open conflicting pull requests, rename through the GitHub +API, then set `origin` to `https://github.com/nyldn/ai-env-optimizer.git`. +Confirm the old URL redirects and the new default branch is `main`. + +- [ ] **Step 3: Merge through a reviewed pull request** + +Push the feature branch, open a PR summarizing mutation and privacy boundaries, +wait for both architecture jobs and review threads, address findings, merge, +and verify exact-main CI on the resulting main SHA. + +- [ ] **Step 4: Publish and verify v0.2.0** + +Tag the exact verified main SHA, wait for the release workflow, verify the +GitHub attestation, independently download all four assets, check SHA-256, and +run the downloaded archive's full tests plus install round trip. + +- [ ] **Step 5: Implement the Homebrew rename with a stable service label** + +Compute the release digest with `shasum -a 256 +ai-env-optimizer-0.2.0.tar.gz`, validate it as exactly 64 lowercase hex +characters, and write that exact value into the formula's mandatory `sha256` +field. Create `Formula/ai-env-optimizer.rb` with this remaining content: + +```ruby +class AiEnvOptimizer < Formula + desc "macOS health, storage, and maintenance CLI for AI coding environments" + homepage "https://github.com/nyldn/ai-env-optimizer" + url "https://github.com/nyldn/ai-env-optimizer/releases/download/v0.2.0/ai-env-optimizer-0.2.0.tar.gz" + license "MIT" + + depends_on macos: :ventura + depends_on "ruby" + + def install + inreplace "bin/ai-env-optimizer", "#!/usr/bin/env ruby", "#!#{formula_opt_bin("ruby")}/ruby" + bin.install "bin/ai-env-optimizer" + bin.install_symlink "ai-env-optimizer" => "ai-optimizer" + lib.install "lib/ai_optimizer.rb", "lib/ai_optimizer" + prefix.install "VERSION" + end + + service do + name macos: "homebrew.mxcl.ai-optimizer" + run [opt_bin/"ai-env-optimizer", "run-maintenance"] + run_type :cron + cron "30 19 * * *" + run_at_load false + process_type :background + end +end +``` + +Create `formula_renames.json`: + +```json +{ + "ai-optimizer": "ai-env-optimizer" +} +``` + +Replace tap CI commands with the new formula name and assert the generated plist +label is exactly `homebrew.mxcl.ai-optimizer`. + +- [ ] **Step 6: Verify Homebrew migration on Intel and Apple Silicon** + +Open a tap PR, require `brew audit --strict --online`, install, formula test, and +service-info checks on both runners, merge, and verify exact-main CI. + +- [ ] **Step 7: Upgrade this Mac without duplicating jobs** + +Record the existing Homebrew formula, version, service label, schedule, and +latest owner-only maintenance receipt. Run `brew update` and upgrade the renamed +formula. Verify: + +```bash +rtk ai-env-optimizer version +rtk ai-optimizer version +rtk brew list --versions ai-env-optimizer +rtk brew services info nyldn/tap/ai-env-optimizer --json +``` + +Expected: both commands report `ai-env-optimizer 0.2.0`; one formula is +installed; exactly one loaded service retains `homebrew.mxcl.ai-optimizer` and +the 19:30 schedule; no direct-install scheduler is enabled. + +- [ ] **Step 8: Run final host health and storage inventory** + +Run canonical doctor, scan, agent-context, storage JSON, native Claude doctor, +Codex doctor, MCP inventory, skills inventory, and launchd/cron audit. Do not run +storage cleanup until the user reviews its dry-run token and aggregate candidate +summary. Close the named browser session and report rollback instructions. + +## Plan completion gate + +The work is complete only when public exact-main CI, v0.2.0 release assets and +attestation, Homebrew dual-architecture CI, the installed local version, both +CLI names, one stable service label, the 19:30 schedule, read-only storage +inventory, and a final Fable 5 PASS all refer to the same released source. diff --git a/docs/superpowers/specs/2026-08-23-storage-health-design.md b/docs/superpowers/specs/2026-08-23-storage-health-design.md new file mode 100644 index 0000000..b46b39d --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-storage-health-design.md @@ -0,0 +1,240 @@ +# Storage health and safe cleanup design + +## Status + +Approved direction for AI Environment Optimizer v0.2. Sessions, transcripts, +memories, worktrees, databases, plugins, and runtime bundles are protected by +default. Scheduled maintenance remains diagnostic only. + +## Problem + +AI coding tools can accumulate many gigabytes of session history, plugin +copies, application caches, logs, runtime bundles, temporary worktrees, and +generated media. These categories have different value and risk. Treating all +old data as disposable would destroy useful historical evidence; treating all +data as permanent leaves users unable to understand or recover storage. + +AI Environment Optimizer needs to answer three questions without exposing +private work: + +1. How much allocated storage does each AI-tool category use? +2. Which data is regenerable, historical, active, or merely bounded logs? +3. What can be reclaimed safely, reversibly, and only with explicit approval? + +## Considered approaches + +### Advisory only + +Report storage without providing cleanup. This has the smallest mutation +surface, but leaves users to translate aggregate findings into risky manual +filesystem commands. + +### Protected-history hybrid (selected) + +Inventory all supported categories, protect historical and active data, and +offer a two-command cleanup only for a narrow allowlist of regenerable data. +Move eligible filesystem entries to the macOS Trash and prefer official native +cleanup commands when a provider exposes them. This balances useful recovery +with auditability and rollback. + +### Automatic retention + +Delete old caches, logs, or sessions during evening maintenance. This could +recover space without interaction, but age does not prove that data is +unimportant. It also makes a diagnostic schedule destructive. This approach is +rejected. + +## Commands and user experience + +### Inventory + +```text +ai-env-optimizer storage [--json] [--strict] +``` + +`storage` is read-only. Human output shows allocated size, age buckets, +classification, reclaimability, and any native cleanup capability. JSON uses a +stable schema and the canonical `ai-env-optimizer` product identity. + +The report contains source IDs such as `claude.app_cache` and +`codex.sessions`, never absolute paths, filenames, project names, session IDs, +prompts, tool arguments, endpoints, or file contents. `--strict` exits 1 when a +configured storage threshold is exceeded or a supported root cannot be safely +inspected. + +### Cleanup preview + +```text +ai-env-optimizer storage cleanup --dry-run [--older-than DAYS] [--min-size MB] +``` + +Cleanup defaults to preview behavior and remains read-only. It reports only +aggregate candidate counts and allocated bytes. It emits a candidate-set token +derived from source IDs, selection options, and `lstat` metadata. Paths are not +encoded in the displayed token. + +`--older-than` defaults to 30 days and accepts 1 through 3650 days. +`--min-size` defaults to 100 MB and applies to the aggregate eligible bytes for +each source, not to individual files. This prevents a cache made of many small +files from being overlooked. + +### Cleanup apply + +```text +ai-env-optimizer storage cleanup --apply TOKEN [--older-than DAYS] [--min-size MB] +``` + +Apply recomputes the candidate set and refuses to continue unless it exactly +matches the preview token. The options must match the preview. A changed file, +new symlink, provider process, permission error, mount change, or candidate-set +drift invalidates the token and requires a new preview. + +Eligible filesystem entries are moved into one dated +`~/.Trash/ai-env-optimizer-` directory. The command does not empty +Trash. It writes an owner-only receipt containing aggregate counts, source IDs, +the Trash folder basename, and the verification result. It never records the +original paths or filenames. + +The legacy `ai-optimizer` command alias exposes the same behavior. + +## Storage model + +Each supported source definition contains: + +- a stable public source ID and provider; +- a standard root resolved below the current user's home directory; +- a classification; +- whether inspection may recurse; +- whether cleanup is eligible; +- the provider process that must be stopped for cleanup; +- an optional official native dry-run and apply command. + +The initial classifications are: + +| Classification | Examples | Initial cleanup policy | +|---|---|---| +| `regenerable` | application caches, code caches, GPU caches | Explicit preview and Trash move only | +| `bounded_logs` | product logs and third-party diagnostic logs | Report; only product-owned logs are initially eligible | +| `historical` | sessions, transcripts, file history, memories | Protected; report only | +| `active` | plugins, marketplaces, VM bundles, databases, worktrees | Protected; report only | + +Age is evidence for prioritization, not deletion authority. Session history is +valuable for work continuation, provenance, failure analysis, and future +workflow evaluation. No session, transcript, memory, file-history entry, +database, worktree, plugin, marketplace, or runtime bundle is eligible in the +initial cleanup allowlist. + +Claude's native plugin prune preview can be reported as an additional signal. +It is not automatically applied in the initial release. Codex's native archive +and delete commands remain user-directed per-session operations and are never +called by cleanup. + +## Measurement and privacy + +The scanner uses `lstat` and never follows symlinks. It de-duplicates regular +files by device and inode, then reports allocated bytes from filesystem blocks +rather than apparent file length. Directory metadata is included once. + +Age buckets are `0-7`, `8-30`, `31-90`, and `over-90` days. Aggregation occurs +in memory before rendering. A source that changes or becomes unreadable during +inspection returns `unknown`; the report does not silently present a partial +total as complete. + +Standard paths remain internal implementation details. Errors are redacted to +stable source IDs and status messages. The storage report follows the existing +rule that environment values, usernames, workspace names, session identifiers, +and command arguments never enter output. + +## Components + +### `StorageSource` + +Immutable definition and validation for one supported category. It resolves a +standard path, enforces home-directory containment, and owns the classification +and cleanup policy. + +### `StorageScanner` + +Read-only traversal and allocation accounting. It produces per-source +measurements and warnings without exposing filesystem identities. + +### `StorageReport` + +Human and JSON renderers with deterministic ordering, totals, age buckets, +classification, reclaimability, and exit behavior. + +### `CleanupPlanner` + +Filters only allowlisted regenerable/product-owned candidates. It validates +age and size options and creates the deterministic candidate-set token without +writing state. + +### `CleanupExecutor` + +Recomputes and verifies the token, refuses unsafe state, checks that relevant +providers are stopped, moves candidates to Trash without crossing filesystems, +and writes the aggregate receipt. There is no recursive-delete fallback. + +## Safety and failure handling + +- Inventory and preview never write. +- Evening maintenance may run inventory and record aggregate warnings, but may + not invoke cleanup. +- Cleanup operates only on source definitions marked eligible in code; command + arguments cannot add arbitrary paths. +- A symlink at a source root, candidate, ancestor, or Trash destination causes + refusal. +- Cleanup refuses unrelated or unowned existing Trash destinations and receipt + paths. +- No cross-volume copy fallback is allowed. A failed move stops the operation, + reports how many entries were already moved, and preserves them in Trash for + manual recovery. +- Provider-native cleanup commands must support a dry-run or inventory mode and + a noninteractive explicit apply mode before integration. +- Cleanup never invokes shell command strings; native tools receive argument + arrays through the existing timeout runner. +- Reports and receipts are owner-only and use atomic writes. + +## Configuration + +Default warning thresholds are conservative and affect reporting only. Users +may configure aggregate warning sizes and preview filters. Configuration cannot +make a protected classification eligible or enable unattended cleanup. + +Session archival is a future, separate design. Its eventual requirements are +explicit opt-in, pinned-session protection, export verification, encryption or +owner-only cold storage, and confirmation before removing originals. + +## Testing and verification + +Tests use isolated temporary homes and synthetic allocated files. Required +coverage includes: + +- deterministic human/JSON parity and ordering; +- allocated-size accounting, inode de-duplication, and all age buckets; +- no path, filename, project, session, or environment-value leakage; +- symlink, containment, permission, race, and candidate-drift refusal; +- historical and active categories never entering a cleanup plan; +- preview producing zero filesystem writes; +- option mismatch and invalid token refusal; +- successful same-volume Trash move and owner-only receipt; +- partial-move failure retaining already moved entries in Trash; +- scheduled maintenance remaining non-destructive; +- canonical and legacy command/environment compatibility; +- macOS system Ruby 2.6 compatibility on Apple Silicon and Intel CI. + +Release verification includes the existing full suite, deterministic archive, +direct-install upgrade round trip, secret scan, Homebrew formula test, and an +independent review of the exact committed artifact. + +## Rollout + +1. Ship read-only inventory and JSON reporting. +2. Validate classifications against current official Claude Code and Codex + behavior. +3. Ship preview and explicit Trash-based cleanup for the narrow initial + allowlist. +4. Observe receipts and false-positive reports before considering more native + cleanup integrations. +5. Design session archival separately; never broaden cleanup eligibility as an + incidental change. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 52e6961..8e6d615 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,7 +12,7 @@ exec zsh ## JSON consumers fail -Use `ai-optimizer doctor --json` or `scan --json`. These write exactly one +Use `ai-env-optimizer doctor --json` or `scan --json`. These write exactly one JSON document to stdout. Do not merge stderr into stdout. ## A missing tool is a warning @@ -26,26 +26,47 @@ fail CI. For a Homebrew install: ```sh -brew services info nyldn/tap/ai-optimizer -brew services restart nyldn/tap/ai-optimizer +brew services info nyldn/tap/ai-env-optimizer +brew services restart nyldn/tap/ai-env-optimizer ``` For a direct install or custom schedule: ```sh -ai-optimizer schedule status -ai-optimizer unschedule -ai-optimizer schedule +ai-env-optimizer schedule status +ai-env-optimizer unschedule +ai-env-optimizer schedule ``` The scheduler is idempotent and reconciles file and launchd state. It owns only `io.github.nyldn.ai-optimizer.daily`. Do not run both scheduling modes. Stop the Homebrew service before enabling a -custom AI Optimizer schedule. +custom AI Environment Optimizer schedule. ## A morning run says skipped launchd coalesces calendar events missed while a Mac sleeps and may start the -job when the computer wakes. AI Optimizer rechecks local time and records +job when the computer wakes. AI Environment Optimizer rechecks local time and records `skipped_outside_window` instead of scanning outside 19:00-02:00. + +## Cleanup says the preview expired + +Something in the candidate set or its filters changed after preview. Run the +same dry-run again, review the new aggregate counts, and use only its new token. +Do not reuse the old token or add a force flag. + +## Cleanup asks you to close an AI application + +Quit the named provider application, rerun the dry-run because cache metadata +may have changed, then apply the new token. The optimizer will not move a +provider's live cache. + +## Recover files moved by cleanup + +Successful and partial cleanup runs preserve files under +`~/.Trash/ai-env-optimizer--`, grouped by public source +ID. Move the required entries back before launching the affected provider. +AI Environment Optimizer never empties Trash. The owner-only +`latest-cleanup.json` receipt records aggregate moved counts and the exact +Trash folder basename without recording original filenames. diff --git a/install.sh b/install.sh index afc1b02..bb4eaec 100755 --- a/install.sh +++ b/install.sh @@ -31,11 +31,24 @@ case "$VERSION" in ;; esac -RELEASE_BASE="${AI_OPTIMIZER_RELEASE_BASE:-https://github.com/nyldn/ai-optimizer/releases/download/v${VERSION}}" -INSTALL_ROOT="${AI_OPTIMIZER_PREFIX:-$HOME/.local/share/ai-optimizer}" -BIN_DIR="${AI_OPTIMIZER_BIN_DIR:-$HOME/.local/bin}" -ARCHIVE="ai-optimizer-${VERSION}.tar.gz" -TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ai-optimizer-install.XXXXXX")" +RELEASE_BASE="${AI_ENV_OPTIMIZER_RELEASE_BASE:-${AI_OPTIMIZER_RELEASE_BASE:-https://github.com/nyldn/ai-env-optimizer/releases/download/v${VERSION}}}" +CANONICAL_INSTALL_ROOT="$HOME/.local/share/ai-env-optimizer" +LEGACY_INSTALL_ROOT="$HOME/.local/share/ai-optimizer" +if [ -n "${AI_ENV_OPTIMIZER_PREFIX:-}" ]; then + INSTALL_ROOT="$AI_ENV_OPTIMIZER_PREFIX" +elif [ -n "${AI_OPTIMIZER_PREFIX:-}" ]; then + INSTALL_ROOT="$AI_OPTIMIZER_PREFIX" +elif [ -e "$CANONICAL_INSTALL_ROOT" ]; then + INSTALL_ROOT="$CANONICAL_INSTALL_ROOT" +elif [ -f "$LEGACY_INSTALL_ROOT/.ai-env-optimizer-install" ] || + [ -f "$LEGACY_INSTALL_ROOT/.ai-optimizer-install" ]; then + INSTALL_ROOT="$LEGACY_INSTALL_ROOT" +else + INSTALL_ROOT="$CANONICAL_INSTALL_ROOT" +fi +BIN_DIR="${AI_ENV_OPTIMIZER_BIN_DIR:-${AI_OPTIMIZER_BIN_DIR:-$HOME/.local/bin}}" +ARCHIVE="ai-env-optimizer-${VERSION}.tar.gz" +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ai-env-optimizer-install.XXXXXX")" STAGED_ROOT="${INSTALL_ROOT}.staged.$$" BACKUP_ROOT="${INSTALL_ROOT}.backup.$$" @@ -62,7 +75,7 @@ curl -fsSL --retry 2 --connect-timeout 10 "$RELEASE_BASE/$ARCHIVE.sha256" -o "$T while IFS= read -r entry; do case "$entry" in - "ai-optimizer-${VERSION}"|"ai-optimizer-${VERSION}/"|"ai-optimizer-${VERSION}/"*) ;; + "ai-env-optimizer-${VERSION}"|"ai-env-optimizer-${VERSION}/"|"ai-env-optimizer-${VERSION}/"*) ;; *) echo "Release archive contains an unexpected path." >&2 exit 1 @@ -77,18 +90,18 @@ while IFS= read -r entry; do done < <(tar -tzf "$TEMP_DIR/$ARCHIVE") tar -xzf "$TEMP_DIR/$ARCHIVE" -C "$TEMP_DIR" -SOURCE_ROOT="$TEMP_DIR/ai-optimizer-${VERSION}" +SOURCE_ROOT="$TEMP_DIR/ai-env-optimizer-${VERSION}" [ ! -L "$SOURCE_ROOT" ] || { echo "Release root must not be a symlink." >&2 exit 1 } -[ -x "$SOURCE_ROOT/bin/ai-optimizer" ] || { - echo "Release archive is missing bin/ai-optimizer." >&2 +[ -x "$SOURCE_ROOT/bin/ai-env-optimizer" ] || { + echo "Release archive is missing bin/ai-env-optimizer." >&2 exit 1 } -ACTUAL_VERSION="$(/usr/bin/ruby "$SOURCE_ROOT/bin/ai-optimizer" version)" -[ "$ACTUAL_VERSION" = "ai-optimizer $VERSION" ] || { +ACTUAL_VERSION="$(/usr/bin/ruby "$SOURCE_ROOT/bin/ai-env-optimizer" version)" +[ "$ACTUAL_VERSION" = "ai-env-optimizer $VERSION" ] || { echo "Release version validation failed." >&2 exit 1 } @@ -98,15 +111,42 @@ if [ -e "$INSTALL_ROOT" ]; then echo "Refusing to replace a symlinked install root." >&2 exit 1 } - [ -f "$INSTALL_ROOT/.ai-optimizer-install" ] || { - echo "Refusing to replace a directory not owned by AI Optimizer." >&2 + if ! { [ -f "$INSTALL_ROOT/.ai-env-optimizer-install" ] && + grep -q '^owner=ai-env-optimizer$' "$INSTALL_ROOT/.ai-env-optimizer-install"; } && + ! { [ -f "$INSTALL_ROOT/.ai-optimizer-install" ] && + grep -q '^owner=ai-optimizer$' "$INSTALL_ROOT/.ai-optimizer-install"; }; then + echo "Refusing to replace a directory not owned by AI Environment Optimizer." >&2 exit 1 - } + fi fi mkdir -p "$(dirname "$INSTALL_ROOT")" "$BIN_DIR" + +CANONICAL_LINK="$BIN_DIR/ai-env-optimizer" +LEGACY_LINK="$BIN_DIR/ai-optimizer" +validate_existing_link() { + link_path="$1" + shift + if [ -e "$link_path" ] || [ -L "$link_path" ]; then + [ -L "$link_path" ] || { + echo "Refusing to replace unrelated $link_path." >&2 + exit 1 + } + link_target="$(readlink "$link_path")" + for expected_target in "$@"; do + [ "$link_target" != "$expected_target" ] || return 0 + done + echo "Refusing to replace unrelated $link_path." >&2 + exit 1 + fi +} +validate_existing_link "$CANONICAL_LINK" "$INSTALL_ROOT/bin/ai-env-optimizer" +validate_existing_link "$LEGACY_LINK" \ + "$INSTALL_ROOT/bin/ai-env-optimizer" \ + "$INSTALL_ROOT/bin/ai-optimizer" + mv "$SOURCE_ROOT" "$STAGED_ROOT" -printf 'owner=ai-optimizer\nversion=%s\n' "$VERSION" > "$STAGED_ROOT/.ai-optimizer-install" +printf 'owner=ai-env-optimizer\nversion=%s\n' "$VERSION" > "$STAGED_ROOT/.ai-env-optimizer-install" if [ -e "$INSTALL_ROOT" ]; then mv "$INSTALL_ROOT" "$BACKUP_ROOT" @@ -118,41 +158,21 @@ if ! mv "$STAGED_ROOT" "$INSTALL_ROOT"; then exit 1 fi -LINK_PATH="$BIN_DIR/ai-optimizer" -if [ -e "$LINK_PATH" ] || [ -L "$LINK_PATH" ]; then - if [ -L "$LINK_PATH" ]; then - LINK_TARGET="$(readlink "$LINK_PATH")" - case "$LINK_TARGET" in - "$INSTALL_ROOT"/bin/ai-optimizer) ;; - *) - rm -rf "$INSTALL_ROOT" - [ ! -e "$BACKUP_ROOT" ] || mv "$BACKUP_ROOT" "$INSTALL_ROOT" - echo "Refusing to replace unrelated $LINK_PATH." >&2 - exit 1 - ;; - esac - else - rm -rf "$INSTALL_ROOT" - [ ! -e "$BACKUP_ROOT" ] || mv "$BACKUP_ROOT" "$INSTALL_ROOT" - echo "Refusing to replace unrelated $LINK_PATH." >&2 - exit 1 - fi -fi - -if ! ln -sfn "$INSTALL_ROOT/bin/ai-optimizer" "$LINK_PATH"; then +if ! ln -sfn "$INSTALL_ROOT/bin/ai-env-optimizer" "$CANONICAL_LINK" || + ! ln -sfn "$INSTALL_ROOT/bin/ai-env-optimizer" "$LEGACY_LINK"; then rm -rf "$INSTALL_ROOT" [ ! -e "$BACKUP_ROOT" ] || mv "$BACKUP_ROOT" "$INSTALL_ROOT" - echo "Command link failed; the previous version was restored." >&2 + echo "Command links failed; the previous version was restored." >&2 exit 1 fi rm -rf "$BACKUP_ROOT" -echo "Installed AI Optimizer $VERSION." -echo "Run: $LINK_PATH setup" +echo "Installed AI Environment Optimizer $VERSION." +echo "Run: $CANONICAL_LINK setup" case ":$PATH:" in *":$BIN_DIR:"*) ;; *) - echo "Add AI Optimizer to zsh PATH:" + echo "Add AI Environment Optimizer to zsh PATH:" echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc" ;; esac diff --git a/lib/ai_optimizer.rb b/lib/ai_optimizer.rb index 6a129f2..8a52703 100644 --- a/lib/ai_optimizer.rb +++ b/lib/ai_optimizer.rb @@ -7,6 +7,12 @@ require_relative "ai_optimizer/report" require_relative "ai_optimizer/runner" require_relative "ai_optimizer/config" +require_relative "ai_optimizer/storage_source" +require_relative "ai_optimizer/storage_catalog" +require_relative "ai_optimizer/storage_scanner" +require_relative "ai_optimizer/storage_report" +require_relative "ai_optimizer/cleanup_planner" +require_relative "ai_optimizer/cleanup_executor" require_relative "ai_optimizer/checks" require_relative "ai_optimizer/doctor" require_relative "ai_optimizer/agent_context" diff --git a/lib/ai_optimizer/agent_context.rb b/lib/ai_optimizer/agent_context.rb index d0ae8f4..963e948 100644 --- a/lib/ai_optimizer/agent_context.rb +++ b/lib/ai_optimizer/agent_context.rb @@ -29,7 +29,8 @@ def initialize(doctor_report:, scan_report:, generated_at: Time.now.utc, version def to_h { schema_version: 1, - product: "ai-optimizer", + product: "ai-env-optimizer", + compatibility: { legacy_names: ["ai-optimizer"] }, version: version, generated_at: generated_at.iso8601, mode: "read_only_advisor", @@ -59,7 +60,7 @@ def to_json(*_args) def to_text lines = [ - "AI Optimizer agent context #{version}", + "AI Environment Optimizer agent context #{version}", "Mode: read-only advisor", "Overall: #{overall_status}", "", diff --git a/lib/ai_optimizer/checks.rb b/lib/ai_optimizer/checks.rb index 4008c7c..ddf40bc 100644 --- a/lib/ai_optimizer/checks.rb +++ b/lib/ai_optimizer/checks.rb @@ -56,8 +56,8 @@ def call id: "system.macos", category: "system", status: status, message: message, detail: "macOS #{@context.macos_version}, #{@context.architecture}", - remediation: status == "fail" ? "Run AI Optimizer on macOS 13 or later." : nil, - required: true, affects: ["ai-optimizer"] + remediation: status == "fail" ? "Run AI Environment Optimizer on macOS 13 or later." : nil, + required: true, affects: ["ai-env-optimizer"] )] end @@ -308,7 +308,7 @@ def call id: "skills.linked_candidates", category: "skills", status: "warn", message: "Some linked skill directories are unavailable", detail: "#{unavailable_links} linked skill #{noun} could not be used; paths were omitted.", - remediation: "Repair or remove broken skill links, then rerun ai-optimizer doctor.", + remediation: "Repair or remove broken skill links, then rerun ai-env-optimizer doctor.", required: false, affects: ["claude", "codex"] ) end @@ -354,23 +354,23 @@ def call config.load [Finding.new( id: "product.config", category: "product", status: "pass", - message: "AI Optimizer configuration is valid", required: true, - affects: ["ai-optimizer"] + message: "AI Environment Optimizer configuration is valid", required: true, + affects: ["ai-env-optimizer"] )] else [Finding.new( id: "product.config", category: "product", status: "info", - message: "AI Optimizer has not been set up yet", - remediation: "Run ai-optimizer setup when you want to save defaults.", - required: false, affects: ["ai-optimizer"] + message: "AI Environment Optimizer has not been set up yet", + remediation: "Run ai-env-optimizer setup when you want to save defaults.", + required: false, affects: ["ai-env-optimizer"] )] end rescue ConfigError, OwnershipError [Finding.new( id: "product.config", category: "product", status: "fail", - message: "AI Optimizer configuration is invalid", - remediation: "Move the invalid file aside, then run ai-optimizer setup.", - required: true, affects: ["ai-optimizer"] + message: "AI Environment Optimizer configuration is invalid", + remediation: "Move the invalid file aside, then run ai-env-optimizer setup.", + required: true, affects: ["ai-env-optimizer"] )] end diff --git a/lib/ai_optimizer/cleanup_executor.rb b/lib/ai_optimizer/cleanup_executor.rb new file mode 100644 index 0000000..a5eeafa --- /dev/null +++ b/lib/ai_optimizer/cleanup_executor.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "time" + +module AIOptimizer + class CleanupExecutor + attr_reader :planner, :config, :trash_root + + def initialize(planner:, config:, trash_root:, clock: -> { Time.now }, + process_guard: nil, device_reader: nil, mover: nil) + @planner = planner + @config = config + @trash_root = File.expand_path(trash_root) + @clock = clock + @process_guard = process_guard || method(:provider_running?) + @device_reader = device_reader || ->(path) { File.stat(path).dev } + @mover = mover || ->(source, destination) { File.rename(source, destination) } + end + + def apply(token:, older_than_days:, min_size_mb:) + plan = planner.preview( + older_than_days: older_than_days, + min_size_mb: min_size_mb + ) + unless secure_equal?(plan.token, token) + raise UsageError, "cleanup preview expired; run --dry-run again" + end + raise UsageError, "cleanup preview has no eligible files" if plan.candidates.empty? + + validate_trash_root + process_names = plan.candidates.flat_map { |candidate| candidate.source.process_names }.uniq.sort + if @process_guard.call(process_names) + raise InternalError, "close the affected AI application before cleanup" + end + validate_devices(plan) + + destination = File.join(trash_root, destination_basename(token)) + if File.exist?(destination) || File.symlink?(destination) + raise OwnershipError, "cleanup Trash destination already exists" + end + + claim_product_state + Dir.mkdir(destination, 0o700) + File.chmod(0o700, destination) + move_plan(plan, token, destination) + end + + private + + def secure_equal?(expected, supplied) + left = expected.to_s.b + right = supplied.to_s.b + return false unless left.bytesize == right.bytesize + + difference = 0 + left.bytes.zip(right.bytes) { |a, b| difference |= a ^ b } + difference.zero? + end + + def validate_trash_root + raise OwnershipError, "Trash root must not be a symlink" if File.symlink?(trash_root) + unless File.directory?(trash_root) + raise OwnershipError, "Trash root must be an existing directory" + end + end + + def validate_devices(plan) + trash_device = @device_reader.call(trash_root) + plan.candidates.each do |candidate| + candidate_device = @device_reader.call(candidate.path) + unless candidate_device == trash_device + raise OwnershipError, "cleanup cannot cross filesystems" + end + end + rescue SystemCallError + raise OwnershipError, "cleanup filesystem could not be verified" + end + + def destination_basename(token) + "ai-env-optimizer-#{@clock.call.utc.strftime("%Y%m%dT%H%M%SZ")}-#{token[0, 12]}" + end + + def claim_product_state + config.save(config.load) + end + + def move_plan(plan, token, destination) + moved_count = 0 + moved_bytes = 0 + moved_sources = [] + + begin + plan.candidates.each do |candidate| + validate_candidate(candidate) + target = candidate_destination(candidate, destination) + parent = File.dirname(target) + FileUtils.mkdir_p(parent, mode: 0o700) + raise OwnershipError, "cleanup destination must not be a symlink" if File.symlink?(target) + + @mover.call(candidate.path, target) + moved_count += 1 + moved_bytes += candidate.allocated_bytes + moved_sources << candidate.source.id + end + rescue StandardError + status = moved_count.positive? ? "partial" : "failed" + write_receipt( + receipt_for(plan, token, destination, status, moved_count, moved_bytes, moved_sources) + ) + raise InternalError, "cleanup move failed after #{moved_count} files" + end + + receipt = receipt_for( + plan, token, destination, "moved_to_trash", moved_count, moved_bytes, moved_sources + ) + write_receipt(receipt) + receipt.merge("trash_path_for_test" => destination) + end + + def validate_candidate(candidate) + stat = File.lstat(candidate.path) + raise OwnershipError, "cleanup candidate became a symlink" if stat.symlink? + raise UsageError, "cleanup preview expired; run --dry-run again" unless stat.file? + + current = [ + candidate.source.id, + candidate.relative_path, + stat.dev, + stat.ino, + stat.mode, + stat.size, + stat.respond_to?(:blocks) ? stat.blocks : nil, + stat.nlink, + (stat.mtime.to_f * 1_000_000).to_i + ] + unless current == candidate.identity_row + raise UsageError, "cleanup preview expired; run --dry-run again" + end + rescue SystemCallError + raise UsageError, "cleanup preview expired; run --dry-run again" + end + + def candidate_destination(candidate, destination) + target = File.expand_path( + File.join(destination, candidate.source.id, candidate.relative_path) + ) + prefix = destination.end_with?(File::SEPARATOR) ? destination : destination + File::SEPARATOR + raise OwnershipError, "cleanup destination escapes Trash folder" unless target.start_with?(prefix) + + target + end + + def receipt_for(plan, token, destination, status, moved_count, moved_bytes, moved_sources) + { + "schema_version" => 1, + "product" => "ai-env-optimizer", + "compatibility" => { "legacy_names" => ["ai-optimizer"] }, + "status" => status, + "generated_at" => @clock.call.utc.iso8601, + "token" => token, + "trash_folder" => File.basename(destination), + "moved_files" => moved_count, + "moved_bytes" => moved_bytes, + "sources" => moved_sources.uniq.sort, + "requested" => { + "older_than_days" => plan.older_than_days, + "min_size_mb" => plan.min_size_mb + } + } + end + + def write_receipt(receipt) + reports_dir = File.join(config.data_dir, "reports") + raise OwnershipError, "reports directory must not be a symlink" if File.symlink?(reports_dir) + if File.exist?(reports_dir) && !File.directory?(reports_dir) + raise OwnershipError, "reports path is not a directory" + end + + Dir.mkdir(reports_dir, 0o700) unless File.directory?(reports_dir) + File.chmod(0o700, reports_dir) + target = File.join(reports_dir, "latest-cleanup.json") + config.atomic_write(target, JSON.pretty_generate(receipt) + "\n", 0o600) + end + + def provider_running?(names) + runner = CommandRunner.new + names.any? do |name| + runner.run(["/usr/bin/pgrep", "-x", name], timeout: 3).success? + end + end + end +end diff --git a/lib/ai_optimizer/cleanup_planner.rb b/lib/ai_optimizer/cleanup_planner.rb new file mode 100644 index 0000000..5e383a4 --- /dev/null +++ b/lib/ai_optimizer/cleanup_planner.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "digest" +require "find" +require "json" +require "set" + +module AIOptimizer + StorageCandidate = Struct.new( + :source, :path, :relative_path, :allocated_bytes, :identity_row, + keyword_init: true + ) + + class CleanupPlan + attr_reader :candidates, :token, :older_than_days, :min_size_mb + + def initialize(candidates:, token:, older_than_days:, min_size_mb:) + @candidates = candidates.freeze + @token = token.freeze + @older_than_days = older_than_days + @min_size_mb = min_size_mb + freeze + end + + def to_h + grouped = candidates.group_by { |candidate| candidate.source.id } + sources = grouped.keys.sort.map do |id| + matches = grouped.fetch(id) + { + "id" => id, + "cleanup_eligible" => true, + "candidate_files" => matches.length, + "allocated_bytes" => matches.sum(&:allocated_bytes) + } + end + { + "schema_version" => 1, + "product" => "ai-env-optimizer", + "compatibility" => { "legacy_names" => ["ai-optimizer"] }, + "mode" => "dry_run", + "older_than_days" => older_than_days, + "min_size_mb" => min_size_mb, + "summary" => { + "candidate_files" => candidates.length, + "allocated_bytes" => candidates.sum(&:allocated_bytes) + }, + "sources" => sources, + "token" => token + } + end + end + + class CleanupPlanner + DEFAULT_OLDER_THAN_DAYS = 30 + DEFAULT_MIN_SIZE_MB = 100 + + attr_reader :sources, :home, :data_dir + + def initialize(sources:, home:, data_dir:, clock: -> { Time.now }) + @sources = Array(sources).freeze + @home = File.expand_path(home) + @data_dir = File.expand_path(data_dir) + @clock = clock + end + + def preview(older_than_days: DEFAULT_OLDER_THAN_DAYS, + min_size_mb: DEFAULT_MIN_SIZE_MB) + days = validated_integer(older_than_days, 1..3650, "older-than days") + megabytes = validated_integer(min_size_mb, 1..Float::INFINITY, "minimum size") + cutoff = @clock.call - (days * 86_400) + candidates = sources.select(&:cleanup_eligible?).flat_map do |source| + source_candidates(source, cutoff, megabytes * 1024 * 1024) + end.sort_by { |candidate| [candidate.source.id, candidate.relative_path] } + token_rows = [["options", days, megabytes]] + candidates.map(&:identity_row) + token = Digest::SHA256.hexdigest(JSON.generate(token_rows)) + CleanupPlan.new( + candidates: candidates, + token: token, + older_than_days: days, + min_size_mb: megabytes + ) + end + + private + + def validated_integer(value, range, label) + parsed = Integer(value) + raise UsageError, "#{label} is out of range" unless range.cover?(parsed) + + parsed + rescue ArgumentError, TypeError + raise UsageError, "#{label} must be an integer" + end + + def source_candidates(source, cutoff, minimum_bytes) + if source.symlinked_component?(home: home, data_dir: data_dir) + raise OwnershipError, "cleanup source has a symlinked ancestor" + end + + root = source.resolve(home: home, data_dir: data_dir) + return [] unless File.exist?(root) + + matches = [] + seen = Set.new + Find.find(root) do |entry| + stat = File.lstat(entry) + raise OwnershipError, "cleanup source contains a symlink" if stat.symlink? + next unless stat.file? && stat.mtime < cutoff + next unless stat.nlink == 1 + + identity = [stat.dev, stat.ino] + next if seen.include?(identity) + + seen << identity + relative = entry == root ? File.basename(entry) : entry[(root.length + 1)..-1] + allocated = allocated_bytes(stat) + matches << StorageCandidate.new( + source: source, + path: entry, + relative_path: relative, + allocated_bytes: allocated, + identity_row: [ + source.id, relative, stat.dev, stat.ino, stat.mode, stat.size, + stat.respond_to?(:blocks) ? stat.blocks : nil, stat.nlink, + (stat.mtime.to_f * 1_000_000).to_i + ] + ).freeze + end + matches.sum(&:allocated_bytes) >= minimum_bytes ? matches : [] + rescue SystemCallError + raise OwnershipError, "cleanup source could not be inspected safely" + end + + def allocated_bytes(stat) + blocks = stat.respond_to?(:blocks) ? stat.blocks : nil + blocks ? blocks * 512 : stat.size + end + end +end diff --git a/lib/ai_optimizer/cli.rb b/lib/ai_optimizer/cli.rb index c9a3c4e..aabe4df 100644 --- a/lib/ai_optimizer/cli.rb +++ b/lib/ai_optimizer/cli.rb @@ -7,19 +7,22 @@ module AIOptimizer class CLI HELP = <<~HELP - AI Optimizer keeps a macOS Claude Code and Codex environment understandable. + AI Environment Optimizer keeps a macOS Claude Code and Codex environment understandable. Usage: - ai-optimizer setup [--workspace-root PATH] [--schedule] - ai-optimizer doctor [--json] [--strict] - ai-optimizer scan [--json] [--strict] [--workspace-root PATH] - ai-optimizer agent-context [--json] [--strict] [--workspace-root PATH] - ai-optimizer report [--json] - ai-optimizer schedule [--hour H] [--minute M] [--force-outside-window] - ai-optimizer schedule status - ai-optimizer unschedule - ai-optimizer version - ai-optimizer help + ai-env-optimizer setup [--workspace-root PATH] [--schedule] + ai-env-optimizer doctor [--json] [--strict] + ai-env-optimizer scan [--json] [--strict] [--workspace-root PATH] + ai-env-optimizer agent-context [--json] [--strict] [--workspace-root PATH] + ai-env-optimizer storage [--json] [--strict] + ai-env-optimizer storage cleanup --dry-run [--older-than DAYS] [--min-size MB] [--json] + ai-env-optimizer storage cleanup --apply TOKEN [--older-than DAYS] [--min-size MB] [--json] + ai-env-optimizer report [--json] + ai-env-optimizer schedule [--hour H] [--minute M] [--force-outside-window] + ai-env-optimizer schedule status + ai-env-optimizer unschedule + ai-env-optimizer version + ai-env-optimizer help doctor, scan, and agent-context are read-only. Scheduling is opt-in and owns only: #{Scheduler::LABEL} @@ -32,7 +35,10 @@ def self.start(argv, stdout: $stdout, stderr: $stderr, env: ENV) stderr.puts(HELP) 2 rescue StandardError => error - stderr.puts("AI Optimizer could not complete: #{Redactor.scrub(error.message)}") + stderr.puts( + "AI Environment Optimizer could not complete: " \ + "#{Redactor.scrub(error.message, home: env["HOME"] || Dir.home)}" + ) 3 end @@ -50,7 +56,7 @@ def run(argv) @stdout.write(HELP) 0 when "version", "--version" - @stdout.puts("ai-optimizer #{VERSION}") + @stdout.puts("ai-env-optimizer #{VERSION}") 0 when "doctor" run_diagnostic(args, Doctor) @@ -58,6 +64,8 @@ def run(argv) run_diagnostic(args, Scanner) when "agent-context" run_agent_context(args) + when "storage" + run_storage(args) when "setup" run_setup(args) when "schedule" @@ -107,7 +115,7 @@ def run_setup(args) raise UsageError, "workspace root must be an existing directory" unless Dir.exist?(workspace_root) values = config.defaults.merge("workspace_root" => workspace_root) - @stdout.puts("AI Optimizer will create:") + @stdout.puts("AI Environment Optimizer will create:") @stdout.puts(" #{display_path(config.path)}") @stdout.puts("Workspace root: #{display_path(workspace_root)}") config.save(values) @@ -117,7 +125,97 @@ def run_setup(args) config.save(values) @stdout.puts("Evening doctor scheduled for 21:00 local time.") end - @stdout.puts("Ready. Run: ai-optimizer doctor") + @stdout.puts("Ready. Run: ai-env-optimizer doctor") + 0 + end + + def run_storage(args) + if args.first == "cleanup" + args.shift + return run_storage_cleanup(args) + end + + options = { json: false, strict: false } + parser = OptionParser.new do |opts| + opts.on("--json") { options[:json] = true } + opts.on("--strict") { options[:strict] = true } + end + parser.parse!(args) + require_no_args(args) + + report = build_storage_report + @stdout.write(options[:json] ? report.to_json + "\n" : report.to_text) + report.exit_code(strict: options[:strict]) + end + + def run_storage_cleanup(args) + options = { + json: false, + dry_run: false, + apply_token: nil, + older_than_days: CleanupPlanner::DEFAULT_OLDER_THAN_DAYS, + min_size_mb: CleanupPlanner::DEFAULT_MIN_SIZE_MB + } + parser = OptionParser.new do |opts| + opts.on("--dry-run") { options[:dry_run] = true } + opts.on("--apply TOKEN") { |value| options[:apply_token] = value } + opts.on("--older-than DAYS", Integer) { |value| options[:older_than_days] = value } + opts.on("--min-size MB", Integer) { |value| options[:min_size_mb] = value } + opts.on("--json") { options[:json] = true } + end + parser.parse!(args) + require_no_args(args) + if options[:dry_run] && options[:apply_token] + raise UsageError, "choose either --dry-run or --apply" + end + if options[:apply_token] && !options[:apply_token].match?(/\A[0-9a-f]{64}\z/) + raise UsageError, "cleanup token must be 64 lowercase hexadecimal characters" + end + + catalog = build_storage_catalog + planner = CleanupPlanner.new( + sources: catalog.sources, + home: home_dir, + data_dir: data_dir + ) + if options[:apply_token] + result = CleanupExecutor.new( + planner: planner, + config: config, + trash_root: File.join(home_dir, ".Trash") + ).apply( + token: options[:apply_token], + older_than_days: options[:older_than_days], + min_size_mb: options[:min_size_mb] + ) + public_result = result.reject { |key, _value| key == "trash_path_for_test" } + if options[:json] + @stdout.puts(JSON.generate(public_result)) + else + @stdout.puts( + "Cleanup #{public_result.fetch("status")}: " \ + "#{public_result.fetch("moved_files")} files moved to " \ + "Trash/#{public_result.fetch("trash_folder")}" + ) + end + return 0 + end + + plan = planner.preview( + older_than_days: options[:older_than_days], + min_size_mb: options[:min_size_mb] + ) + if options[:json] + @stdout.puts(JSON.generate(plan.to_h)) + else + summary = plan.to_h.fetch("summary") + @stdout.puts("Cleanup preview: #{summary.fetch("candidate_files")} files, #{summary.fetch("allocated_bytes")} allocated bytes") + @stdout.puts("Token: #{plan.token}") + @stdout.puts( + "Apply: ai-env-optimizer storage cleanup --apply #{plan.token} " \ + "--older-than #{plan.older_than_days} --min-size #{plan.min_size_mb}" + ) + end 0 end @@ -208,7 +306,9 @@ def run_report(args) def run_maintenance maintenance = Maintenance.new( data_dir: data_dir, - doctor: -> { Scanner.new(build_context(nil)).run } + doctor: -> { Scanner.new(build_context(nil)).run }, + storage: -> { build_storage_report }, + warning_bytes: config.storage_warning_bytes ) receipt = maintenance.run @stdout.puts(JSON.generate(receipt)) @@ -216,7 +316,7 @@ def run_maintenance end def build_context(workspace_override) - runner = CommandRunner.new + runner = CommandRunner.new(home: home_dir) values = begin config.load rescue ConfigError, OwnershipError @@ -237,20 +337,39 @@ def build_context(workspace_override) ) end + def build_storage_report + catalog = build_storage_catalog + measurements = StorageScanner.new( + sources: catalog.sources, + home: home_dir, + data_dir: data_dir + ).scan + StorageReport.new(measurements, warning_bytes: config.storage_warning_bytes) + end + + def build_storage_catalog + StorageCatalog.new(home: home_dir, data_dir: data_dir) + end + + def home_dir + @home_dir ||= File.expand_path(@env["HOME"] || Dir.home) + end + def config @config ||= Config.new(data_dir: data_dir) end def data_dir @data_dir ||= File.expand_path( - @env["AI_OPTIMIZER_DATA_DIR"] || Config.default_data_dir + env_value("AI_ENV_OPTIMIZER_DATA_DIR", "AI_OPTIMIZER_DATA_DIR") || Config.default_data_dir ) end def scheduler @scheduler ||= Scheduler.new( launch_agents_dir: File.expand_path( - @env["AI_OPTIMIZER_LAUNCH_AGENTS_DIR"] || File.join(Dir.home, "Library", "LaunchAgents") + env_value("AI_ENV_OPTIMIZER_LAUNCH_AGENTS_DIR", "AI_OPTIMIZER_LAUNCH_AGENTS_DIR") || + File.join(Dir.home, "Library", "LaunchAgents") ), data_dir: data_dir, executable: executable_path, @@ -273,6 +392,10 @@ def require_no_args(args) raise UsageError, "unexpected arguments: #{args.length}" unless args.empty? end + def env_value(canonical, legacy) + @env[canonical] || @env[legacy] + end + def display_path(path) Redactor.scrub(path, home: Dir.home) end diff --git a/lib/ai_optimizer/config.rb b/lib/ai_optimizer/config.rb index 6f00118..f63788d 100644 --- a/lib/ai_optimizer/config.rb +++ b/lib/ai_optimizer/config.rb @@ -6,12 +6,22 @@ module AIOptimizer class Config - APPLICATION_SUPPORT_NAME = "io.github.nyldn.ai-optimizer" + PRODUCT_ID = "ai-env-optimizer" + LEGACY_PRODUCT_IDS = ["ai-optimizer"].freeze + APPLICATION_SUPPORT_NAME = "io.github.nyldn.ai-env-optimizer" + LEGACY_APPLICATION_SUPPORT_NAMES = ["io.github.nyldn.ai-optimizer"].freeze + DEFAULT_STORAGE_WARNING_BYTES = 10 * 1024 * 1024 * 1024 attr_reader :data_dir, :path, :manifest_path, :default_workspace_root def self.default_data_dir(home = Dir.home) - File.join(home, "Library", "Application Support", APPLICATION_SUPPORT_NAME) + support = File.join(home, "Library", "Application Support") + canonical = File.join(support, APPLICATION_SUPPORT_NAME) + return canonical if File.exist?(canonical) + + legacy = LEGACY_APPLICATION_SUPPORT_NAMES.map { |name| File.join(support, name) } + .find { |path| File.exist?(path) } + legacy || canonical end def initialize(data_dir: nil, default_workspace_root: nil) @@ -26,10 +36,20 @@ def defaults "schema_version" => 1, "workspace_root" => default_workspace_root, "schedule" => { "enabled" => false, "hour" => 21, "minute" => 0 }, + "storage" => { "warning_bytes" => DEFAULT_STORAGE_WARNING_BYTES }, "telemetry" => false } end + def storage_warning_bytes(values = nil) + loaded = values || load + storage = loaded["storage"] + candidate = storage.is_a?(Hash) ? storage["warning_bytes"] : nil + candidate.is_a?(Integer) && candidate.positive? ? candidate : DEFAULT_STORAGE_WARNING_BYTES + rescue ConfigError, OwnershipError + DEFAULT_STORAGE_WARNING_BYTES + end + def load verify_existing_ownership return defaults unless File.file?(path) @@ -88,7 +108,7 @@ def claim_directory raise OwnershipError, "data directory must not be a symlink" if File.symlink?(data_dir) if File.exist?(data_dir) && !File.directory?(data_dir) - raise OwnershipError, "AI Optimizer data path is not a directory" + raise OwnershipError, "AI Environment Optimizer data path is not a directory" end FileUtils.mkdir_p(data_dir, mode: 0o700) File.chmod(0o700, data_dir) @@ -102,7 +122,7 @@ def claim_directory def assert_contained_target(target) expanded = File.expand_path(target) prefix = data_dir.end_with?(File::SEPARATOR) ? data_dir : data_dir + File::SEPARATOR - raise OwnershipError, "write target is outside AI Optimizer data" unless expanded.start_with?(prefix) + raise OwnershipError, "write target is outside AI Environment Optimizer data" unless expanded.start_with?(prefix) raise OwnershipError, "refusing to replace a symlink" if File.symlink?(expanded) end @@ -111,13 +131,14 @@ def verify_existing_ownership return if Dir.children(data_dir).empty? return if owned_manifest? - raise OwnershipError, "AI Optimizer data directory is not product-owned" + raise OwnershipError, "AI Environment Optimizer data directory is not product-owned" end def owned_manifest? return false unless File.file?(manifest_path) && !File.symlink?(manifest_path) - JSON.parse(File.binread(manifest_path)).fetch("owner", nil) == "ai-optimizer" + owner = JSON.parse(File.binread(manifest_path)).fetch("owner", nil) + ([PRODUCT_ID] + LEGACY_PRODUCT_IDS).include?(owner) rescue JSON::ParserError false end @@ -125,7 +146,7 @@ def owned_manifest? def write_manifest manifest = { "schema_version" => 1, - "owner" => "ai-optimizer", + "owner" => PRODUCT_ID, "version" => VERSION, "files" => [File.basename(path), File.basename(manifest_path)] } diff --git a/lib/ai_optimizer/doctor.rb b/lib/ai_optimizer/doctor.rb index b6ce4bb..ab02e0b 100644 --- a/lib/ai_optimizer/doctor.rb +++ b/lib/ai_optimizer/doctor.rb @@ -19,9 +19,9 @@ def run status: "unknown", message: "A diagnostic check could not complete", detail: "The failure was contained; sensitive command output was discarded.", - remediation: "Run the native tool doctor, then retry AI Optimizer.", + remediation: "Run the native tool doctor, then retry AI Environment Optimizer.", required: check.required?, - affects: ["ai-optimizer"] + affects: ["ai-env-optimizer"] ) end end diff --git a/lib/ai_optimizer/maintenance.rb b/lib/ai_optimizer/maintenance.rb index 1d78838..bc49cbc 100644 --- a/lib/ai_optimizer/maintenance.rb +++ b/lib/ai_optimizer/maintenance.rb @@ -8,10 +8,17 @@ module AIOptimizer class Maintenance attr_reader :data_dir - def initialize(data_dir:, clock: -> { Time.now }, doctor: nil) + def initialize(data_dir:, clock: -> { Time.now }, doctor: nil, storage: nil, + warning_bytes: Config::DEFAULT_STORAGE_WARNING_BYTES) @data_dir = File.expand_path(data_dir) @clock = clock @doctor = doctor + @storage = storage + @warning_bytes = if warning_bytes.is_a?(Integer) && warning_bytes.positive? + warning_bytes + else + Config::DEFAULT_STORAGE_WARNING_BYTES + end end def run @@ -43,21 +50,42 @@ def run def run_doctor(time) report = @doctor.call code = report.exit_code - base_receipt(time).merge( + receipt = base_receipt(time).merge( "status" => code.zero? ? "passed" : "failed", "exit_code" => code, "summary" => report.summary ) + @storage ? receipt.merge("storage" => storage_summary) : receipt end def base_receipt(time) { "schema_version" => 1, - "product" => "ai-optimizer", + "product" => "ai-env-optimizer", + "compatibility" => { "legacy_names" => ["ai-optimizer"] }, "generated_at" => time.utc.iso8601 } end + def storage_summary + summary = @storage.call.summary + allocated = nonnegative_integer(summary, "allocated_bytes") + { + "status" => allocated >= @warning_bytes ? "warning" : "healthy", + "allocated_bytes" => allocated, + "protected_bytes" => nonnegative_integer(summary, "protected_bytes"), + "reclaimable_bytes" => nonnegative_integer(summary, "reclaimable_bytes"), + "unknown_sources" => nonnegative_integer(summary, "unknown_sources") + } + end + + def nonnegative_integer(summary, key) + value = summary.fetch(key) + raise InternalError, "invalid storage summary" unless value.is_a?(Integer) && value >= 0 + + value + end + def write_receipt(receipt) reports_dir = File.join(data_dir, "reports") raise OwnershipError, "reports directory must not be a symlink" if File.symlink?(reports_dir) diff --git a/lib/ai_optimizer/report.rb b/lib/ai_optimizer/report.rb index 018e6f1..304aab0 100644 --- a/lib/ai_optimizer/report.rb +++ b/lib/ai_optimizer/report.rb @@ -25,7 +25,8 @@ def summary def to_h { schema_version: 1, - product: "ai-optimizer", + product: "ai-env-optimizer", + compatibility: { legacy_names: ["ai-optimizer"] }, version: version, generated_at: generated_at.iso8601, summary: summary, @@ -38,7 +39,7 @@ def to_json(*_args) end def to_text(color: default_color?) - lines = ["AI Optimizer #{version}", ""] + lines = ["AI Environment Optimizer #{version}", ""] findings.each do |finding| label = finding.status.upcase label = colorize(label, finding.status) if color diff --git a/lib/ai_optimizer/runner.rb b/lib/ai_optimizer/runner.rb index 0941435..ea5b515 100644 --- a/lib/ai_optimizer/runner.rb +++ b/lib/ai_optimizer/runner.rb @@ -20,6 +20,10 @@ def to_h end class CommandRunner + def initialize(home: Dir.home) + @home = File.expand_path(home) + end + def run(argv, timeout: 10, env: {}) raise ArgumentError, "command must be a non-empty argument array" unless argv.is_a?(Array) && !argv.empty? @@ -48,8 +52,8 @@ def run(argv, timeout: 10, env: {}) CommandResult.new( File.basename(argv.first.to_s), status, - Redactor.scrub(stdout_text), - Redactor.scrub(stderr_text), + Redactor.scrub(stdout_text, home: @home), + Redactor.scrub(stderr_text, home: @home), timed_out, (monotonic_time - started).round(3) ) @@ -58,7 +62,7 @@ def run(argv, timeout: 10, env: {}) File.basename(argv.first.to_s), 127, "", - Redactor.scrub(error.message), + Redactor.scrub(error.message, home: @home), false, (monotonic_time - started).round(3) ) diff --git a/lib/ai_optimizer/scheduler.rb b/lib/ai_optimizer/scheduler.rb index ae4d0df..b7c86c1 100644 --- a/lib/ai_optimizer/scheduler.rb +++ b/lib/ai_optimizer/scheduler.rb @@ -106,7 +106,7 @@ def ensure_owned_paths content.include?("/ai-optimizer-maintenance") || content.match?(versioned_wrapper) owned = content.include?("#{LABEL}") && command_owned - raise OwnershipError, "existing launch agent is not provably owned by AI Optimizer" unless owned + raise OwnershipError, "existing launch agent is not provably owned by AI Environment Optimizer" unless owned end def restore_previous_plist(previous_content, temporary) @@ -164,7 +164,7 @@ def write_maintenance_wrapper if File.exist?(maintenance_wrapper_path) existing = File.file?(maintenance_wrapper_path) && File.binread(maintenance_wrapper_path) expected = maintenance_wrapper_content.dup.force_encoding(Encoding::BINARY) - raise OwnershipError, "existing maintenance launcher does not match AI Optimizer" unless existing == expected + raise OwnershipError, "existing maintenance launcher does not match AI Environment Optimizer" unless existing == expected File.chmod(0o700, maintenance_wrapper_path) return @@ -220,7 +220,7 @@ def plist(hour, minute) PATH /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - AI_OPTIMIZER_DATA_DIR + AI_ENV_OPTIMIZER_DATA_DIR #{escape(data_dir)} StandardOutPath diff --git a/lib/ai_optimizer/storage_catalog.rb b/lib/ai_optimizer/storage_catalog.rb new file mode 100644 index 0000000..405c51e --- /dev/null +++ b/lib/ai_optimizer/storage_catalog.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module AIOptimizer + class StorageCatalog + DEFINITIONS = [ + ["claude.projects", "claude", :home, [".claude", "projects"], "historical", false, []], + ["claude.plugin_cache", "claude", :home, [".claude", "plugins", "cache"], "active", false, []], + ["claude.marketplaces", "claude", :home, [".claude", "plugins", "marketplaces"], "active", false, []], + ["claude.app_cache", "claude", :application_support, ["Claude", "Cache"], "regenerable", true, ["Claude"]], + ["claude.code_cache", "claude", :application_support, ["Claude", "Code Cache"], "regenerable", true, ["Claude"]], + ["claude.gpu_cache", "claude", :application_support, ["Claude", "GPUCache"], "regenerable", true, ["Claude"]], + ["claude.local_sessions", "claude", :application_support, ["Claude", "local-agent-mode-sessions"], "historical", false, []], + ["claude.vm_bundles", "claude", :application_support, ["Claude", "vm_bundles"], "active", false, []], + ["codex.sessions", "codex", :home, [".codex", "sessions"], "historical", false, []], + ["codex.archived_sessions", "codex", :home, [".codex", "archived_sessions"], "historical", false, []], + ["codex.plugins", "codex", :home, [".codex", "plugins"], "active", false, []], + ["codex.worktrees", "codex", :home, [".codex", "worktrees"], "active", false, []], + ["codex.cache", "codex", :home, [".codex", "cache"], "regenerable", true, ["Codex"]], + ["claude_mem.index", "claude-mem", :home, [".claude-mem", "chroma"], "historical", false, []], + ["claude_mem.database", "claude-mem", :home, [".claude-mem", "claude-mem.db"], "historical", false, []], + ["claude_mem.logs", "claude-mem", :home, [".claude-mem", "logs"], "bounded_logs", false, []], + ["product.logs", "ai-env-optimizer", :data_dir, ["logs"], "bounded_logs", true, []] + ].freeze + + attr_reader :home, :data_dir, :sources + + def initialize(home:, data_dir:) + @home = File.expand_path(home) + @data_dir = File.expand_path(data_dir) + @sources = DEFINITIONS.map do |id, provider, base, components, classification, eligible, processes| + StorageSource.new( + id: id, + provider: provider, + base: base, + components: components, + classification: classification, + cleanup_eligible: eligible, + process_names: processes + ) + end.freeze + validate_unique_ids + validate_non_overlapping_paths + end + + private + + def validate_unique_ids + ids = sources.map(&:id) + raise InternalError, "duplicate storage source id" unless ids.uniq.length == ids.length + end + + def validate_non_overlapping_paths + paths = sources.map { |source| source.resolve(home: home, data_dir: data_dir) } + paths.combination(2) do |left, right| + left_prefix = left.end_with?(File::SEPARATOR) ? left : left + File::SEPARATOR + right_prefix = right.end_with?(File::SEPARATOR) ? right : right + File::SEPARATOR + if left == right || left.start_with?(right_prefix) || right.start_with?(left_prefix) + raise InternalError, "overlapping storage sources" + end + end + end + end +end diff --git a/lib/ai_optimizer/storage_report.rb b/lib/ai_optimizer/storage_report.rb new file mode 100644 index 0000000..68e4603 --- /dev/null +++ b/lib/ai_optimizer/storage_report.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "json" +require "time" + +module AIOptimizer + class StorageReport + DEFAULT_WARNING_BYTES = 10 * 1024 * 1024 * 1024 + PUBLIC_SOURCE_KEYS = %w[ + id provider classification cleanup_eligible status allocated_bytes + file_count directory_count age_buckets + ].freeze + + attr_reader :measurements, :generated_at, :version, :warning_bytes + + def initialize(measurements, generated_at: Time.now.utc, version: VERSION, + warning_bytes: DEFAULT_WARNING_BYTES) + raise ArgumentError, "warning bytes must be positive" unless warning_bytes.is_a?(Integer) && warning_bytes.positive? + + @measurements = Array(measurements).map { |item| public_measurement(item) } + .sort_by { |item| item.fetch("id") } + .freeze + @generated_at = generated_at.utc + @version = version + @warning_bytes = warning_bytes + end + + def summary + complete = measurements.select { |item| item.fetch("status") == "complete" } + { + "allocated_bytes" => complete.sum { |item| item.fetch("allocated_bytes") }, + "reclaimable_bytes" => complete.select { |item| item.fetch("cleanup_eligible") } + .sum { |item| item.fetch("allocated_bytes") }, + "protected_bytes" => complete.reject { |item| item.fetch("cleanup_eligible") } + .sum { |item| item.fetch("allocated_bytes") }, + "unknown_sources" => measurements.count { |item| item.fetch("status") == "unknown" } + } + end + + def to_h + { + "schema_version" => 1, + "product" => "ai-env-optimizer", + "compatibility" => { "legacy_names" => ["ai-optimizer"] }, + "version" => version, + "generated_at" => generated_at.iso8601, + "summary" => summary, + "sources" => measurements + } + end + + def to_json(*_args) + JSON.generate(to_h) + end + + def to_text + totals = summary + lines = [ + "AI Environment Optimizer storage #{version}", + "", + "Allocated: #{format_bytes(totals.fetch("allocated_bytes"))}", + "Potentially reclaimable: #{format_bytes(totals.fetch("reclaimable_bytes"))}", + "Protected: #{format_bytes(totals.fetch("protected_bytes"))}", + "" + ] + measurements.each do |item| + lines << "[#{label(item)}] #{item.fetch("id")} - #{format_bytes(item.fetch("allocated_bytes"))} (#{item.fetch("classification")})" + end + lines.join("\n") + "\n" + end + + def exit_code(strict: false) + return 1 if summary.fetch("unknown_sources").positive? + return 1 if strict && summary.fetch("allocated_bytes") >= warning_bytes + + 0 + end + + private + + def public_measurement(item) + PUBLIC_SOURCE_KEYS.each_with_object({}) do |key, result| + result[key] = item.fetch(key) + end.freeze + end + + def label(item) + return "ABSENT" if item.fetch("status") == "absent" + return "UNKNOWN" if item.fetch("status") == "unknown" + return "RECLAIMABLE" if item.fetch("cleanup_eligible") + + "PROTECTED" + end + + def format_bytes(bytes) + units = %w[B KiB MiB GiB TiB] + value = bytes.to_f + unit = units.shift + while value >= 1024 && !units.empty? + value /= 1024 + unit = units.shift + end + value < 10 && unit != "B" ? format("%.1f %s", value, unit) : format("%.0f %s", value, unit) + end + end +end diff --git a/lib/ai_optimizer/storage_scanner.rb b/lib/ai_optimizer/storage_scanner.rb new file mode 100644 index 0000000..59cf337 --- /dev/null +++ b/lib/ai_optimizer/storage_scanner.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "find" +require "set" + +module AIOptimizer + class StorageScanner + AGE_BUCKETS = [ + ["0_7_days", 0, 7], + ["8_30_days", 8, 30], + ["31_90_days", 31, 90], + ["over_90_days", 91, Float::INFINITY] + ].freeze + + attr_reader :sources, :home, :data_dir + + def initialize(sources:, home:, data_dir:, clock: -> { Time.now }) + @sources = Array(sources).freeze + @home = File.expand_path(home) + @data_dir = File.expand_path(data_dir) + @clock = clock + end + + def scan + sources.map { |source| measure(source) }.freeze + end + + private + + def measure(source) + return empty_measurement(source, "unknown") if source.symlinked_component?(home: home, data_dir: data_dir) + + path = source.resolve(home: home, data_dir: data_dir) + return empty_measurement(source, "absent") unless File.exist?(path) + + bytes = 0 + file_count = 0 + directory_count = 0 + buckets = empty_buckets + seen = Set.new + + Find.find(path) do |entry| + stat = File.lstat(entry) + next if stat.symlink? + + identity = [stat.dev, stat.ino] + next if seen.include?(identity) + + seen << identity + allocated = allocated_bytes(stat) + bytes += allocated + buckets[bucket_for(stat)] += allocated + file_count += 1 if stat.file? + directory_count += 1 if stat.directory? + end + + base_measurement(source).merge( + "status" => "complete", + "allocated_bytes" => bytes, + "file_count" => file_count, + "directory_count" => directory_count, + "age_buckets" => buckets.freeze + ).freeze + rescue SystemCallError, OwnershipError + empty_measurement(source, "unknown") + end + + def empty_measurement(source, status) + base_measurement(source).merge( + "status" => status, + "allocated_bytes" => 0, + "file_count" => 0, + "directory_count" => 0, + "age_buckets" => empty_buckets.freeze + ).freeze + end + + def base_measurement(source) + { + "id" => source.id, + "provider" => source.provider, + "classification" => source.classification, + "cleanup_eligible" => source.cleanup_eligible? + } + end + + def empty_buckets + AGE_BUCKETS.each_with_object({}) { |(name, _minimum, _maximum), result| result[name] = 0 } + end + + def allocated_bytes(stat) + blocks = stat.respond_to?(:blocks) ? stat.blocks : nil + blocks ? blocks * 512 : stat.size + end + + def bucket_for(stat) + days = [((@clock.call - stat.mtime) / 86_400).floor, 0].max + match = AGE_BUCKETS.find { |_name, minimum, maximum| days >= minimum && days <= maximum } + match.fetch(0) + end + end +end diff --git a/lib/ai_optimizer/storage_source.rb b/lib/ai_optimizer/storage_source.rb new file mode 100644 index 0000000..3f2343a --- /dev/null +++ b/lib/ai_optimizer/storage_source.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module AIOptimizer + class StorageSource + CLASSIFICATIONS = %w[regenerable bounded_logs historical active].freeze + BASES = %i[home application_support data_dir].freeze + + attr_reader :id, :provider, :base, :components, :classification, + :process_names + + def initialize(id:, provider:, base:, components:, classification:, + cleanup_eligible:, process_names: []) + raise ArgumentError, "invalid source id" unless id.to_s.match?(/\A[a-z0-9_.]+\z/) + raise ArgumentError, "invalid provider" unless provider.to_s.match?(/\A[a-z0-9-]+\z/) + raise ArgumentError, "invalid classification" unless CLASSIFICATIONS.include?(classification) + raise ArgumentError, "invalid base" unless BASES.include?(base) + raise ArgumentError, "storage source needs components" if components.empty? + if components.any? { |item| item.to_s.empty? || %w[. ..].include?(item) || item.to_s.include?(File::SEPARATOR) } + raise OwnershipError, "unsafe storage component" + end + if cleanup_eligible && %w[historical active].include?(classification) + raise ArgumentError, "protected storage cannot be cleanup eligible" + end + + @id = id.to_s.dup.freeze + @provider = provider.to_s.dup.freeze + @base = base + @components = components.map { |item| item.to_s.dup.freeze }.freeze + @classification = classification.dup.freeze + @cleanup_eligible = !!cleanup_eligible + @process_names = process_names.map { |item| item.to_s.dup.freeze }.freeze + freeze + end + + def cleanup_eligible? + @cleanup_eligible + end + + def resolve(home:, data_dir:) + root = base_root(home: home, data_dir: data_dir) + path = File.expand_path(File.join(root, *components)) + prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR + raise OwnershipError, "storage source escapes its base" unless path.start_with?(prefix) + + path + end + + def symlinked_component?(home:, data_dir:) + prefixes = base_prefixes(home: home, data_dir: data_dir) + current = prefixes.last + components.each do |component| + current = File.join(current, component) + prefixes << current + end + prefixes.any? { |path| File.symlink?(path) } + end + + private + + def base_root(home:, data_dir:) + case base + when :home + File.expand_path(home) + when :application_support + File.join(File.expand_path(home), "Library", "Application Support") + when :data_dir + File.expand_path(data_dir) + end + end + + def base_prefixes(home:, data_dir:) + expanded_home = File.expand_path(home) + root = base_root(home: home, data_dir: data_dir) + return [expanded_home] if root == expanded_home + + home_prefix = expanded_home.end_with?(File::SEPARATOR) ? expanded_home : expanded_home + File::SEPARATOR + return [root] unless root.start_with?(home_prefix) + + relative_components = root.delete_prefix(home_prefix).split(File::SEPARATOR) + relative_components.each_with_object([expanded_home]) do |component, prefixes| + prefixes << File.join(prefixes.last, component) + end + end + end +end diff --git a/research/sources.md b/research/sources.md index a06c6e4..ab75512 100644 --- a/research/sources.md +++ b/research/sources.md @@ -1,6 +1,6 @@ # Source ledger -Observed on 2026-08-22. Environment-practice recommendations are limited to +Observed on 2026-08-23. Environment-practice recommendations are limited to sources published, released, committed, or freshly served since 2026-05-22. ## Official platform and product sources @@ -9,7 +9,14 @@ sources published, released, committed, or freshly served since 2026-05-22. 2026-08-22: formula metadata, functional tests, service definitions, and `brew audit`. - [Homebrew tap guide](https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap), - observed 2026-08-22: one-command direct tap install and tap naming. + observed 2026-08-23: one-command direct tap install and tap naming. +- [Homebrew same-tap formula rename implementation](https://github.com/Homebrew/brew/blob/master/Library/Homebrew/tap.rb), + observed 2026-08-23; the file was changed on 2026-08-07: taps load + `formula_renames.json` as an old-name to new-name map. +- [Homebrew service name implementation](https://github.com/Homebrew/brew/blob/master/Library/Homebrew/service.rb), + observed 2026-08-23; the file was changed on 2026-07-31: the public service + DSL accepts an explicit macOS plist name, allowing a formula rename to retain + an existing launchd identity. - [GitHub releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases), observed 2026-08-22: tagged releases and immutable uploaded assets. - [GitHub Actions hardening](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions), diff --git a/scripts/build-release.sh b/scripts/build-release.sh index d25b7aa..92620d1 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -3,8 +3,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" VERSION="$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")" -DIST_DIR="${AI_OPTIMIZER_DIST_DIR:-$ROOT_DIR/dist}" -ARCHIVE="ai-optimizer-${VERSION}.tar.gz" +DIST_DIR="${AI_ENV_OPTIMIZER_DIST_DIR:-${AI_OPTIMIZER_DIST_DIR:-$ROOT_DIR/dist}}" +ARCHIVE="ai-env-optimizer-${VERSION}.tar.gz" case "$VERSION" in ''|*[!0-9.]*) @@ -22,7 +22,7 @@ mkdir -p "$DIST_DIR" rm -f "$DIST_DIR/$ARCHIVE" "$DIST_DIR/$ARCHIVE.sha256" rm -f "$DIST_DIR/install.sh" "$DIST_DIR/install.sh.sha256" -git -C "$ROOT_DIR" archive --format=tar --prefix="ai-optimizer-${VERSION}/" HEAD | gzip -n -9 > "$DIST_DIR/$ARCHIVE" +git -C "$ROOT_DIR" archive --format=tar --prefix="ai-env-optimizer-${VERSION}/" HEAD | gzip -n -9 > "$DIST_DIR/$ARCHIVE" cp "$ROOT_DIR/install.sh" "$DIST_DIR/install.sh" ( diff --git a/scripts/check-syntax.sh b/scripts/check-syntax.sh index 80bc730..7829c4f 100755 --- a/scripts/check-syntax.sh +++ b/scripts/check-syntax.sh @@ -7,7 +7,7 @@ while IFS= read -r file; do /usr/bin/ruby -c "$file" >/dev/null done < <(find "$ROOT_DIR/lib" "$ROOT_DIR/test" -type f -name '*.rb' -print | sort) -/usr/bin/ruby -c "$ROOT_DIR/bin/ai-optimizer" >/dev/null +/usr/bin/ruby -c "$ROOT_DIR/bin/ai-env-optimizer" >/dev/null while IFS= read -r file; do /bin/bash -n "$file" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 0740733..4e52b0b 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -18,10 +18,31 @@ while [ "$#" -gt 0 ]; do esac done -INSTALL_ROOT="${AI_OPTIMIZER_PREFIX:-$HOME/.local/share/ai-optimizer}" -BIN_DIR="${AI_OPTIMIZER_BIN_DIR:-$HOME/.local/bin}" -DATA_DIR="${AI_OPTIMIZER_DATA_DIR:-$HOME/Library/Application Support/io.github.nyldn.ai-optimizer}" -LINK_PATH="$BIN_DIR/ai-optimizer" +CANONICAL_INSTALL_ROOT="$HOME/.local/share/ai-env-optimizer" +LEGACY_INSTALL_ROOT="$HOME/.local/share/ai-optimizer" +if [ -n "${AI_ENV_OPTIMIZER_PREFIX:-}" ]; then + INSTALL_ROOT="$AI_ENV_OPTIMIZER_PREFIX" +elif [ -n "${AI_OPTIMIZER_PREFIX:-}" ]; then + INSTALL_ROOT="$AI_OPTIMIZER_PREFIX" +elif [ -f "$CANONICAL_INSTALL_ROOT/.ai-env-optimizer-install" ]; then + INSTALL_ROOT="$CANONICAL_INSTALL_ROOT" +else + INSTALL_ROOT="$LEGACY_INSTALL_ROOT" +fi +BIN_DIR="${AI_ENV_OPTIMIZER_BIN_DIR:-${AI_OPTIMIZER_BIN_DIR:-$HOME/.local/bin}}" +CANONICAL_DATA_DIR="$HOME/Library/Application Support/io.github.nyldn.ai-env-optimizer" +LEGACY_DATA_DIR="$HOME/Library/Application Support/io.github.nyldn.ai-optimizer" +if [ -n "${AI_ENV_OPTIMIZER_DATA_DIR:-}" ]; then + DATA_DIR="$AI_ENV_OPTIMIZER_DATA_DIR" +elif [ -n "${AI_OPTIMIZER_DATA_DIR:-}" ]; then + DATA_DIR="$AI_OPTIMIZER_DATA_DIR" +elif [ -e "$CANONICAL_DATA_DIR" ]; then + DATA_DIR="$CANONICAL_DATA_DIR" +else + DATA_DIR="$LEGACY_DATA_DIR" +fi +CANONICAL_LINK="$BIN_DIR/ai-env-optimizer" +LEGACY_LINK="$BIN_DIR/ai-optimizer" case "$INSTALL_ROOT" in ''|/|"$HOME") @@ -30,19 +51,26 @@ case "$INSTALL_ROOT" in ;; esac -if [ ! -f "$INSTALL_ROOT/.ai-optimizer-install" ] || - ! grep -q '^owner=ai-optimizer$' "$INSTALL_ROOT/.ai-optimizer-install"; then - echo "Refusing to remove an install root without AI Optimizer provenance." >&2 +if ! { [ -f "$INSTALL_ROOT/.ai-env-optimizer-install" ] && + grep -q '^owner=ai-env-optimizer$' "$INSTALL_ROOT/.ai-env-optimizer-install"; } && + ! { [ -f "$INSTALL_ROOT/.ai-optimizer-install" ] && + grep -q '^owner=ai-optimizer$' "$INSTALL_ROOT/.ai-optimizer-install"; }; then + echo "Refusing to remove an install root without AI Environment Optimizer provenance." >&2 exit 1 fi -echo "AI Optimizer will remove:" +echo "AI Environment Optimizer will remove:" echo " $INSTALL_ROOT" -if [ -L "$LINK_PATH" ] && [ "$(readlink "$LINK_PATH")" = "$INSTALL_ROOT/bin/ai-optimizer" ]; then - echo " $LINK_PATH" +if [ -L "$CANONICAL_LINK" ] && [ "$(readlink "$CANONICAL_LINK")" = "$INSTALL_ROOT/bin/ai-env-optimizer" ]; then + echo " $CANONICAL_LINK" +fi +if [ -L "$LEGACY_LINK" ] && + { [ "$(readlink "$LEGACY_LINK")" = "$INSTALL_ROOT/bin/ai-env-optimizer" ] || + [ "$(readlink "$LEGACY_LINK")" = "$INSTALL_ROOT/bin/ai-optimizer" ]; }; then + echo " $LEGACY_LINK" fi if [ "$KEEP_STATE" -eq 0 ] && [ -f "$DATA_DIR/state-manifest.json" ] && - grep -q '"owner": "ai-optimizer"' "$DATA_DIR/state-manifest.json"; then + grep -Eq '"owner": "(ai-env-optimizer|ai-optimizer)"' "$DATA_DIR/state-manifest.json"; then echo " $DATA_DIR" fi @@ -55,19 +83,26 @@ if [ "$FORCE" -eq 0 ]; then esac fi -if [ -x "$INSTALL_ROOT/bin/ai-optimizer" ]; then +if [ -x "$INSTALL_ROOT/bin/ai-env-optimizer" ]; then + env AI_ENV_OPTIMIZER_DATA_DIR="$DATA_DIR" "$INSTALL_ROOT/bin/ai-env-optimizer" unschedule >/dev/null 2>&1 || true +elif [ -x "$INSTALL_ROOT/bin/ai-optimizer" ]; then env AI_OPTIMIZER_DATA_DIR="$DATA_DIR" "$INSTALL_ROOT/bin/ai-optimizer" unschedule >/dev/null 2>&1 || true fi -if [ -L "$LINK_PATH" ] && [ "$(readlink "$LINK_PATH")" = "$INSTALL_ROOT/bin/ai-optimizer" ]; then - rm -f "$LINK_PATH" +if [ -L "$CANONICAL_LINK" ] && [ "$(readlink "$CANONICAL_LINK")" = "$INSTALL_ROOT/bin/ai-env-optimizer" ]; then + rm -f "$CANONICAL_LINK" +fi +if [ -L "$LEGACY_LINK" ] && + { [ "$(readlink "$LEGACY_LINK")" = "$INSTALL_ROOT/bin/ai-env-optimizer" ] || + [ "$(readlink "$LEGACY_LINK")" = "$INSTALL_ROOT/bin/ai-optimizer" ]; }; then + rm -f "$LEGACY_LINK" fi rm -rf "$INSTALL_ROOT" if [ "$KEEP_STATE" -eq 0 ] && [ -f "$DATA_DIR/state-manifest.json" ] && - grep -q '"owner": "ai-optimizer"' "$DATA_DIR/state-manifest.json"; then + grep -Eq '"owner": "(ai-env-optimizer|ai-optimizer)"' "$DATA_DIR/state-manifest.json"; then rm -rf "$DATA_DIR" fi -echo "AI Optimizer uninstalled." +echo "AI Environment Optimizer uninstalled." diff --git a/test/cleanup_executor_test.rb b/test/cleanup_executor_test.rb new file mode 100644 index 0000000..70915f9 --- /dev/null +++ b/test/cleanup_executor_test.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class CleanupExecutorTest < Minitest::Test + NOW = Time.utc(2026, 8, 23, 23, 15, 0) + + def source(classification: "regenerable", eligible: true, process_names: ["TestAI"]) + AIOptimizer::StorageSource.new( + id: eligible ? "test.cache" : "test.sessions", + provider: "test", + base: :home, + components: [eligible ? "cache" : "sessions"], + classification: classification, + cleanup_eligible: eligible, + process_names: process_names + ) + end + + def write_old_file(path, content: "x" * 1024 * 1024) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, content) + timestamp = NOW - (45 * 86_400) + File.utime(timestamp, timestamp, path) + end + + def build_fixture(home, sources: [source], process_guard: ->(_names) { false }, + device_reader: ->(path) { File.stat(path).dev }, mover: nil) + data_dir = File.join(home, "product-data") + trash_root = File.join(home, ".Trash") + FileUtils.mkdir_p(trash_root) + planner = AIOptimizer::CleanupPlanner.new( + sources: sources, + home: home, + data_dir: data_dir, + clock: -> { NOW } + ) + executor = AIOptimizer::CleanupExecutor.new( + planner: planner, + config: AIOptimizer::Config.new(data_dir: data_dir, default_workspace_root: home), + trash_root: trash_root, + clock: -> { NOW }, + process_guard: process_guard, + device_reader: device_reader, + mover: mover + ) + [planner, executor, data_dir, trash_root] + end + + def apply(executor, token) + executor.apply(token: token, older_than_days: 30, min_size_mb: 1) + end + + def test_moves_verified_candidates_to_private_trash_and_writes_private_receipt + in_tmpdir do |home| + original = File.join(home, "cache", "private-filename") + write_old_file(original) + planner, executor, data_dir = build_fixture(home) + plan = planner.preview(older_than_days: 30, min_size_mb: 1) + + result = apply(executor, plan.token) + + assert_equal "moved_to_trash", result.fetch("status") + assert_equal 1, result.fetch("moved_files") + assert_equal 0o700, File.stat(result.fetch("trash_path_for_test")).mode & 0o777 + refute File.exist?(original) + moved = File.join(result.fetch("trash_path_for_test"), "test.cache", "private-filename") + assert File.file?(moved) + receipt_path = File.join(data_dir, "reports", "latest-cleanup.json") + assert_equal 0o600, File.stat(receipt_path).mode & 0o777 + receipt = JSON.parse(File.read(receipt_path)) + refute receipt.key?("trash_path_for_test") + refute_includes JSON.generate(receipt), "private-filename" + refute_includes JSON.generate(receipt), home + end + end + + def test_refuses_token_mismatch_and_metadata_drift + in_tmpdir do |home| + path = File.join(home, "cache", "candidate") + write_old_file(path) + planner, executor = build_fixture(home) + plan = planner.preview(older_than_days: 30, min_size_mb: 1) + + assert_raises(AIOptimizer::UsageError) { apply(executor, "0" * 64) } + File.open(path, "ab") { |file| file.write("changed") } + File.utime(NOW - (45 * 86_400), NOW - (45 * 86_400), path) + assert_raises(AIOptimizer::UsageError) { apply(executor, plan.token) } + assert File.exist?(path) + end + end + + def test_refuses_running_provider_and_symlinked_candidate + in_tmpdir do |home| + path = File.join(home, "cache", "candidate") + write_old_file(path) + planner, executor = build_fixture(home, process_guard: ->(names) { names.include?("TestAI") }) + plan = planner.preview(older_than_days: 30, min_size_mb: 1) + + assert_raises(AIOptimizer::InternalError) { apply(executor, plan.token) } + assert File.exist?(path) + + planner, executor = build_fixture(home) + plan = planner.preview(older_than_days: 30, min_size_mb: 1) + FileUtils.rm_f(path) + outside = File.join(home, "outside") + File.write(outside, "keep") + File.symlink(outside, path) + assert_raises(AIOptimizer::OwnershipError) { apply(executor, plan.token) } + assert_equal "keep", File.read(outside) + end + end + + def test_refuses_symlinked_trash_existing_destination_and_cross_device_move + in_tmpdir do |home| + path = File.join(home, "cache", "candidate") + write_old_file(path) + planner, executor, _data_dir, trash_root = build_fixture(home) + plan = planner.preview(older_than_days: 30, min_size_mb: 1) + FileUtils.rm_rf(trash_root) + real_trash = File.join(home, "real-trash") + FileUtils.mkdir_p(real_trash) + File.symlink(real_trash, trash_root) + assert_raises(AIOptimizer::OwnershipError) { apply(executor, plan.token) } + + FileUtils.rm_f(trash_root) + FileUtils.mkdir_p(trash_root) + destination = File.join(trash_root, "ai-env-optimizer-20260823T231500Z-#{plan.token[0, 12]}") + FileUtils.mkdir_p(destination) + assert_raises(AIOptimizer::OwnershipError) { apply(executor, plan.token) } + + FileUtils.rm_rf(destination) + planner, executor = build_fixture( + home, + device_reader: ->(candidate) { candidate == trash_root ? 2 : 1 } + ) + assert_raises(AIOptimizer::OwnershipError) { apply(executor, plan.token) } + assert File.exist?(path) + end + end + + def test_partial_failure_stops_and_records_completed_moves + in_tmpdir do |home| + first = File.join(home, "cache", "a") + second = File.join(home, "cache", "b") + write_old_file(first) + write_old_file(second) + calls = 0 + mover = lambda do |from, to| + calls += 1 + raise Errno::EIO, "simulated move failure" if calls == 2 + + File.rename(from, to) + end + planner, executor, data_dir = build_fixture(home, mover: mover) + plan = planner.preview(older_than_days: 30, min_size_mb: 1) + + assert_raises(AIOptimizer::InternalError) { apply(executor, plan.token) } + receipt = JSON.parse(File.read(File.join(data_dir, "reports", "latest-cleanup.json"))) + assert_equal "partial", receipt.fetch("status") + assert_equal 1, receipt.fetch("moved_files") + assert_equal 1, [first, second].count { |path| File.exist?(path) } + end + end + + def test_protected_sources_are_never_moved + in_tmpdir do |home| + protected = source(classification: "historical", eligible: false, process_names: []) + path = File.join(home, "sessions", "history") + write_old_file(path) + planner, executor = build_fixture(home, sources: [protected]) + plan = planner.preview(older_than_days: 30, min_size_mb: 1) + + assert_empty plan.candidates + assert_raises(AIOptimizer::UsageError) { apply(executor, plan.token) } + assert File.exist?(path) + end + end +end diff --git a/test/cleanup_planner_test.rb b/test/cleanup_planner_test.rb new file mode 100644 index 0000000..324df00 --- /dev/null +++ b/test/cleanup_planner_test.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class CleanupPlannerTest < Minitest::Test + NOW = Time.utc(2026, 8, 23, 12, 0, 0) + + def source(id:, components:, classification:, eligible:) + AIOptimizer::StorageSource.new( + id: id, provider: "test", base: :home, components: components, + classification: classification, cleanup_eligible: eligible + ) + end + + def planner(home, sources) + AIOptimizer::CleanupPlanner.new( + sources: sources, + home: home, + data_dir: File.join(home, "product-data"), + clock: -> { NOW } + ) + end + + def write_old_file(path, bytes: 1024 * 1024) + FileUtils.mkdir_p(File.dirname(path)) + File.open(path, "wb") { |file| file.write("x" * bytes) } + timestamp = NOW - (45 * 86_400) + File.utime(timestamp, timestamp, path) + end + + def test_selects_only_allowlisted_sources_and_never_history + in_tmpdir do |home| + cache_source = source( + id: "test.cache", components: ["cache"], + classification: "regenerable", eligible: true + ) + history_source = source( + id: "test.sessions", components: ["sessions"], + classification: "historical", eligible: false + ) + write_old_file(File.join(home, "cache", "cache-private-name")) + write_old_file(File.join(home, "sessions", "session-private-name")) + + plan = planner(home, [cache_source, history_source]).preview( + older_than_days: 30, + min_size_mb: 1 + ) + payload = plan.to_h + + assert_equal ["test.cache"], payload.fetch("sources").map { |item| item.fetch("id") } + assert payload.fetch("sources").all? { |item| item.fetch("cleanup_eligible") } + assert_equal 1, payload.fetch("summary").fetch("candidate_files") + assert_match(/\A[0-9a-f]{64}\z/, payload.fetch("token")) + serialized = JSON.generate(payload) + refute_includes serialized, home + refute_includes serialized, "private-name" + assert plan.candidates.all? { |candidate| candidate.source.id == "test.cache" } + end + end + + def test_defaults_and_source_level_minimum_are_enforced + in_tmpdir do |home| + cache_source = source( + id: "test.cache", components: ["cache"], + classification: "regenerable", eligible: true + ) + write_old_file(File.join(home, "cache", "small"), bytes: 4_096) + + plan = planner(home, [cache_source]).preview + + assert_equal 30, plan.older_than_days + assert_equal 100, plan.min_size_mb + assert_empty plan.candidates + assert_empty plan.to_h.fetch("sources") + end + end + + def test_token_is_deterministic_and_changes_after_metadata_drift + in_tmpdir do |home| + cache_source = source( + id: "test.cache", components: ["cache"], + classification: "regenerable", eligible: true + ) + path = File.join(home, "cache", "candidate") + write_old_file(path) + cleanup_planner = planner(home, [cache_source]) + + first = cleanup_planner.preview(older_than_days: 30, min_size_mb: 1) + second = cleanup_planner.preview(older_than_days: 30, min_size_mb: 1) + assert_equal first.token, second.token + + File.open(path, "ab") { |file| file.write("changed") } + File.utime(NOW - (45 * 86_400), NOW - (45 * 86_400), path) + changed = cleanup_planner.preview(older_than_days: 30, min_size_mb: 1) + refute_equal first.token, changed.token + end + end + + def test_skips_hard_linked_files_instead_of_overstating_reclaimed_bytes + in_tmpdir do |home| + cache_source = source( + id: "test.cache", components: ["cache"], + classification: "regenerable", eligible: true + ) + first = File.join(home, "cache", "first") + second = File.join(home, "cache", "second") + write_old_file(first) + File.link(first, second) + + plan = planner(home, [cache_source]).preview( + older_than_days: 30, + min_size_mb: 1 + ) + + assert_empty plan.candidates + assert_equal 0, plan.to_h.fetch("summary").fetch("allocated_bytes") + assert File.exist?(first) + assert File.exist?(second) + end + end + + def test_refuses_symlinks_and_invalid_filters + in_tmpdir do |home| + cache_source = source( + id: "test.cache", components: ["cache"], + classification: "regenerable", eligible: true + ) + outside = File.join(home, "outside") + FileUtils.mkdir_p(File.join(home, "cache")) + File.write(outside, "outside") + File.symlink(outside, File.join(home, "cache", "linked")) + cleanup_planner = planner(home, [cache_source]) + + assert_raises(AIOptimizer::OwnershipError) do + cleanup_planner.preview(older_than_days: 30, min_size_mb: 1) + end + assert_raises(AIOptimizer::UsageError) { cleanup_planner.preview(older_than_days: 0) } + assert_raises(AIOptimizer::UsageError) { cleanup_planner.preview(older_than_days: 3651) } + assert_raises(AIOptimizer::UsageError) { cleanup_planner.preview(min_size_mb: 0) } + end + end + + def test_refuses_a_symlinked_source_ancestor + in_tmpdir do |home| + outside = File.join(home, "outside") + FileUtils.mkdir_p(File.join(outside, "cache")) + write_old_file(File.join(outside, "cache", "candidate")) + File.symlink(outside, File.join(home, ".codex")) + cache_source = source( + id: "test.cache", components: [".codex", "cache"], + classification: "regenerable", eligible: true + ) + + assert_raises(AIOptimizer::OwnershipError) do + planner(home, [cache_source]).preview(older_than_days: 30, min_size_mb: 1) + end + end + end +end diff --git a/test/cli_test.rb b/test/cli_test.rb index 5b27b7e..7705d2d 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require_relative "test_helper" +require "stringio" class CLITest < Minitest::Test - BIN = File.expand_path("../bin/ai-optimizer", __dir__) + BIN = File.expand_path("../bin/ai-env-optimizer", __dir__) def run_cli(*args, env: {}) Open3.capture3(env, "/usr/bin/ruby", BIN, *args) @@ -12,7 +13,7 @@ def run_cli(*args, env: {}) def test_version_and_help stdout, stderr, status = run_cli("version") assert status.success?, stderr - assert_equal "ai-optimizer #{AIOptimizer::VERSION}\n", stdout + assert_equal "ai-env-optimizer #{AIOptimizer::VERSION}\n", stdout stdout, stderr, status = run_cli("help") assert status.success?, stderr @@ -21,6 +22,25 @@ def test_version_and_help assert_includes stdout, "doctor, scan, and agent-context are read-only" end + def test_generic_error_redacts_the_current_home + in_tmpdir do |home| + failing = Object.new + failing.define_singleton_method(:run) do |_argv| + raise Errno::ENOENT, File.join(home, "private", "missing") + end + stdout = StringIO.new + stderr = StringIO.new + + status = AIOptimizer::CLI.stub(:new, failing) do + AIOptimizer::CLI.start([], stdout: stdout, stderr: stderr, env: { "HOME" => home }) + end + + assert_equal 3, status + refute_includes stderr.string, home + assert_includes stderr.string, "~/private/missing" + end + end + def test_doctor_json_is_one_document in_tmpdir do |dir| stdout, stderr, status = run_cli("doctor", "--json", env: { "AI_OPTIMIZER_DATA_DIR" => File.join(dir, "data") }) @@ -39,6 +59,8 @@ def test_agent_context_json_is_one_actionable_document ) assert_includes [0, 1], status.exitstatus, stderr payload = JSON.parse(stdout) + assert_equal "ai-env-optimizer", payload.fetch("product") + assert_includes payload.fetch("compatibility").fetch("legacy_names"), "ai-optimizer" assert_equal "read_only_advisor", payload.fetch("mode") assert payload.fetch("agent_contract").fetch("workflow").any? assert payload.fetch("reports").fetch("doctor").fetch("findings").is_a?(Array) @@ -47,6 +69,113 @@ def test_agent_context_json_is_one_actionable_document end end + def test_storage_json_is_read_only_path_free_and_canonical + in_tmpdir do |dir| + private_name = File.join(dir, ".codex", "sessions", "private-project") + FileUtils.mkdir_p(private_name) + File.write(File.join(private_name, "secret-session.jsonl"), "private") + data_dir = File.join(dir, "product-data") + + stdout, stderr, status = run_cli( + "storage", "--json", + env: { "HOME" => dir, "AI_ENV_OPTIMIZER_DATA_DIR" => data_dir } + ) + assert status.success?, stderr + payload = JSON.parse(stdout) + assert_equal "ai-env-optimizer", payload.fetch("product") + assert payload.fetch("sources").all? { |item| !item.key?("path") } + refute_includes stdout, "private-project" + refute_includes stdout, "secret-session.jsonl" + refute File.exist?(data_dir) + assert_empty stderr + end + end + + def test_storage_cleanup_preview_is_read_only_and_path_free + in_tmpdir do |dir| + cache = File.join(dir, "Library", "Application Support", "Claude", "Cache") + FileUtils.mkdir_p(cache) + candidate = File.join(cache, "private-cache-entry") + File.open(candidate, "wb") { |file| file.write("x" * 1024 * 1024) } + old = Time.now - (45 * 86_400) + File.utime(old, old, candidate) + data_dir = File.join(dir, "product-data") + before = File.stat(candidate) + + stdout, stderr, status = run_cli( + "storage", "cleanup", "--dry-run", "--older-than", "30", + "--min-size", "1", "--json", + env: { "HOME" => dir, "AI_ENV_OPTIMIZER_DATA_DIR" => data_dir } + ) + + assert status.success?, stderr + payload = JSON.parse(stdout) + assert_equal "dry_run", payload.fetch("mode") + assert_match(/\A[0-9a-f]{64}\z/, payload.fetch("token")) + assert_equal 1, payload.fetch("summary").fetch("candidate_files") + refute_includes stdout, "private-cache-entry" + refute_includes stdout, dir + refute File.exist?(data_dir) + assert_equal before.mtime, File.stat(candidate).mtime + assert_equal before.size, File.stat(candidate).size + assert_empty stderr + end + end + + def test_storage_cleanup_apply_requires_preview_token_and_returns_only_aggregates + in_tmpdir do |home| + data_dir = File.join(home, "product-data") + env = { "HOME" => home, "AI_ENV_OPTIMIZER_DATA_DIR" => data_dir } + _setup_out, setup_err, setup_status = run_cli( + "setup", "--workspace-root", home, env: env + ) + assert setup_status.success?, setup_err + FileUtils.mkdir_p(File.join(home, ".Trash")) + candidate = File.join(data_dir, "logs", "private-log-name") + FileUtils.mkdir_p(File.dirname(candidate)) + File.open(candidate, "wb") { |file| file.write("x" * 1024 * 1024) } + old = Time.now - (45 * 86_400) + File.utime(old, old, candidate) + + preview_out, preview_err, preview_status = run_cli( + "storage", "cleanup", "--dry-run", "--older-than", "30", + "--min-size", "1", "--json", env: env + ) + assert preview_status.success?, preview_err + token = JSON.parse(preview_out).fetch("token") + + stdout, stderr, status = run_cli( + "storage", "cleanup", "--apply", token, "--older-than", "30", + "--min-size", "1", "--json", env: env + ) + + assert status.success?, stderr + payload = JSON.parse(stdout) + assert_equal "moved_to_trash", payload.fetch("status") + assert_equal 1, payload.fetch("moved_files") + refute payload.key?("trash_path_for_test") + refute_includes stdout, "private-log-name" + refute_includes stdout, home + refute File.exist?(candidate) + assert File.file?(File.join(data_dir, "reports", "latest-cleanup.json")) + assert_empty stderr + end + end + + def test_storage_cleanup_apply_rejects_malformed_token + in_tmpdir do |home| + data_dir = File.join(home, "product-data") + _stdout, stderr, status = run_cli( + "storage", "cleanup", "--apply", "NOT-A-TOKEN", "--json", + env: { "HOME" => home, "AI_ENV_OPTIMIZER_DATA_DIR" => data_dir } + ) + + assert_equal 2, status.exitstatus + assert_includes stderr, "64 lowercase hexadecimal" + refute File.exist?(data_dir) + end + end + def test_usage_error_exits_two _stdout, stderr, status = run_cli("doctor", "--not-a-real-flag") assert_equal 2, status.exitstatus @@ -65,4 +194,22 @@ def test_setup_rejects_missing_workspace_root refute File.exist?(File.join(dir, "data", "config.json")) end end + + def test_canonical_environment_variable_takes_precedence_over_legacy_name + in_tmpdir do |dir| + canonical = File.join(dir, "canonical-data") + legacy = File.join(dir, "legacy-data") + _stdout, stderr, status = run_cli( + "setup", "--workspace-root", dir, + env: { + "AI_ENV_OPTIMIZER_DATA_DIR" => canonical, + "AI_OPTIMIZER_DATA_DIR" => legacy + } + ) + + assert status.success?, stderr + assert File.file?(File.join(canonical, "config.json")) + refute File.exist?(legacy) + end + end end diff --git a/test/config_test.rb b/test/config_test.rb index 28f165d..1f5cef8 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -3,6 +3,43 @@ require_relative "test_helper" class ConfigTest < Minitest::Test + def test_defaults_include_report_only_storage_warning_threshold + in_tmpdir do |dir| + config = AIOptimizer::Config.new(data_dir: File.join(dir, "data"), default_workspace_root: dir) + + assert_equal 10 * 1024 * 1024 * 1024, + config.defaults.fetch("storage").fetch("warning_bytes") + end + end + + def test_invalid_storage_warning_threshold_falls_back_to_default + in_tmpdir do |dir| + config = AIOptimizer::Config.new(data_dir: File.join(dir, "data"), default_workspace_root: dir) + + assert_equal 10 * 1024 * 1024 * 1024, + config.storage_warning_bytes("storage" => { "warning_bytes" => -1 }) + assert_equal 10 * 1024 * 1024 * 1024, + config.storage_warning_bytes("storage" => { "warning_bytes" => "large" }) + end + end + + def test_default_data_dir_prefers_canonical_path_for_new_users + in_tmpdir do |home| + expected = File.join(home, "Library", "Application Support", "io.github.nyldn.ai-env-optimizer") + + assert_equal expected, AIOptimizer::Config.default_data_dir(home) + end + end + + def test_default_data_dir_preserves_an_existing_legacy_install + in_tmpdir do |home| + legacy = File.join(home, "Library", "Application Support", "io.github.nyldn.ai-optimizer") + FileUtils.mkdir_p(legacy) + + assert_equal legacy, AIOptimizer::Config.default_data_dir(home) + end + end + def test_round_trip_uses_private_permissions_and_preserves_unknown_keys in_tmpdir do |dir| config = AIOptimizer::Config.new(data_dir: File.join(dir, "data"), default_workspace_root: dir) @@ -10,7 +47,23 @@ def test_round_trip_uses_private_permissions_and_preserves_unknown_keys assert_equal true, config.load.fetch("future_key").fetch("enabled") assert_equal 0o600, File.stat(config.path).mode & 0o777 - assert_equal "ai-optimizer", JSON.parse(File.read(config.manifest_path)).fetch("owner") + assert_equal "ai-env-optimizer", JSON.parse(File.read(config.manifest_path)).fetch("owner") + end + end + + def test_existing_legacy_manifest_is_accepted_and_upgraded_on_save + in_tmpdir do |dir| + data_dir = File.join(dir, "data") + FileUtils.mkdir_p(data_dir) + File.write( + File.join(data_dir, "state-manifest.json"), + JSON.generate("schema_version" => 1, "owner" => "ai-optimizer", "version" => "0.1.8") + ) + config = AIOptimizer::Config.new(data_dir: data_dir, default_workspace_root: dir) + + config.save(config.defaults) + + assert_equal "ai-env-optimizer", JSON.parse(File.read(config.manifest_path)).fetch("owner") end end diff --git a/test/install_test.sh b/test/install_test.sh index a1416d5..644a909 100755 --- a/test/install_test.sh +++ b/test/install_test.sh @@ -3,34 +3,37 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" VERSION="$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")" -DIST_DIR="${AI_OPTIMIZER_DIST_DIR:-$ROOT_DIR/dist}" -ARCHIVE="ai-optimizer-${VERSION}.tar.gz" +DIST_DIR="${AI_ENV_OPTIMIZER_DIST_DIR:-${AI_OPTIMIZER_DIST_DIR:-$ROOT_DIR/dist}}" +ARCHIVE="ai-env-optimizer-${VERSION}.tar.gz" [ -f "$DIST_DIR/$ARCHIVE" ] || "$ROOT_DIR/scripts/build-release.sh" >/dev/null -TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ai-optimizer-roundtrip.XXXXXX")" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ai-env-optimizer-roundtrip.XXXXXX")" trap 'rm -rf "$TEST_ROOT"' EXIT INT TERM -INSTALL_ROOT="$TEST_ROOT/share/ai-optimizer" +INSTALL_ROOT="$TEST_ROOT/share/ai-env-optimizer" BIN_DIR="$TEST_ROOT/bin" DATA_DIR="$TEST_ROOT/data" AGENTS_DIR="$TEST_ROOT/LaunchAgents" -OUTPUT="$(env AI_OPTIMIZER_RELEASE_BASE="file://$DIST_DIR" AI_OPTIMIZER_PREFIX="$INSTALL_ROOT" AI_OPTIMIZER_BIN_DIR="$BIN_DIR" PATH="/usr/bin:/bin" /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION")" +OUTPUT="$(env AI_ENV_OPTIMIZER_RELEASE_BASE="file://$DIST_DIR" AI_ENV_OPTIMIZER_PREFIX="$INSTALL_ROOT" AI_ENV_OPTIMIZER_BIN_DIR="$BIN_DIR" PATH="/usr/bin:/bin" /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION")" -echo "$OUTPUT" | grep -q "Installed AI Optimizer $VERSION" -echo "$OUTPUT" | grep -q "Add AI Optimizer to zsh PATH" +echo "$OUTPUT" | grep -q "Installed AI Environment Optimizer $VERSION" +echo "$OUTPUT" | grep -q "Add AI Environment Optimizer to zsh PATH" +[ -L "$BIN_DIR/ai-env-optimizer" ] [ -L "$BIN_DIR/ai-optimizer" ] -[ "$("$BIN_DIR/ai-optimizer" version)" = "ai-optimizer $VERSION" ] +[ "$("$BIN_DIR/ai-env-optimizer" version)" = "ai-env-optimizer $VERSION" ] +[ "$("$BIN_DIR/ai-optimizer" version)" = "ai-env-optimizer $VERSION" ] mkdir -p "$TEST_ROOT/workspaces" -env AI_OPTIMIZER_DATA_DIR="$DATA_DIR" AI_OPTIMIZER_LAUNCH_AGENTS_DIR="$AGENTS_DIR" "$BIN_DIR/ai-optimizer" setup --workspace-root "$TEST_ROOT/workspaces" >/dev/null +env AI_ENV_OPTIMIZER_DATA_DIR="$DATA_DIR" AI_ENV_OPTIMIZER_LAUNCH_AGENTS_DIR="$AGENTS_DIR" "$BIN_DIR/ai-env-optimizer" setup --workspace-root "$TEST_ROOT/workspaces" >/dev/null [ -f "$DATA_DIR/state-manifest.json" ] -env AI_OPTIMIZER_PREFIX="$INSTALL_ROOT" AI_OPTIMIZER_BIN_DIR="$BIN_DIR" AI_OPTIMIZER_DATA_DIR="$DATA_DIR" AI_OPTIMIZER_LAUNCH_AGENTS_DIR="$AGENTS_DIR" /bin/bash "$INSTALL_ROOT/scripts/uninstall.sh" --force >/dev/null +env AI_ENV_OPTIMIZER_PREFIX="$INSTALL_ROOT" AI_ENV_OPTIMIZER_BIN_DIR="$BIN_DIR" AI_ENV_OPTIMIZER_DATA_DIR="$DATA_DIR" AI_ENV_OPTIMIZER_LAUNCH_AGENTS_DIR="$AGENTS_DIR" /bin/bash "$INSTALL_ROOT/scripts/uninstall.sh" --force >/dev/null [ ! -e "$INSTALL_ROOT" ] +[ ! -e "$BIN_DIR/ai-env-optimizer" ] [ ! -e "$BIN_DIR/ai-optimizer" ] [ ! -e "$DATA_DIR" ] @@ -39,10 +42,50 @@ mkdir -p "$TAMPERED_DIR" cp "$DIST_DIR/$ARCHIVE" "$DIST_DIR/$ARCHIVE.sha256" "$TAMPERED_DIR/" printf 'tampered' >> "$TAMPERED_DIR/$ARCHIVE" -if env AI_OPTIMIZER_RELEASE_BASE="file://$TAMPERED_DIR" AI_OPTIMIZER_PREFIX="$TEST_ROOT/tampered-install" AI_OPTIMIZER_BIN_DIR="$TEST_ROOT/tampered-bin" /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION" >/dev/null 2>&1; then +if env AI_ENV_OPTIMIZER_RELEASE_BASE="file://$TAMPERED_DIR" AI_ENV_OPTIMIZER_PREFIX="$TEST_ROOT/tampered-install" AI_ENV_OPTIMIZER_BIN_DIR="$TEST_ROOT/tampered-bin" /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION" >/dev/null 2>&1; then echo "tampered archive was accepted" >&2 exit 1 fi [ ! -e "$TEST_ROOT/tampered-install" ] + +UNOWNED_ROOT="$TEST_ROOT/unowned-install" +mkdir -p "$UNOWNED_ROOT" +printf 'owner=someone-else\n' > "$UNOWNED_ROOT/.ai-env-optimizer-install" +printf 'preserve\n' > "$UNOWNED_ROOT/sentinel" +if env AI_ENV_OPTIMIZER_RELEASE_BASE="file://$DIST_DIR" AI_ENV_OPTIMIZER_PREFIX="$UNOWNED_ROOT" AI_ENV_OPTIMIZER_BIN_DIR="$TEST_ROOT/unowned-bin" /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION" >/dev/null 2>&1; then + echo "unowned install root was accepted" >&2 + exit 1 +fi +[ "$(cat "$UNOWNED_ROOT/sentinel")" = "preserve" ] + +LEGACY_HOME="$TEST_ROOT/legacy-home" +LEGACY_ROOT="$LEGACY_HOME/.local/share/ai-optimizer" +LEGACY_BIN_DIR="$LEGACY_HOME/.local/bin" +mkdir -p "$LEGACY_ROOT/bin" "$LEGACY_BIN_DIR" +printf 'owner=ai-optimizer\nversion=0.1.8\n' > "$LEGACY_ROOT/.ai-optimizer-install" +printf '#!/bin/sh\nexit 0\n' > "$LEGACY_ROOT/bin/ai-optimizer" +chmod +x "$LEGACY_ROOT/bin/ai-optimizer" +ln -s "$LEGACY_ROOT/bin/ai-optimizer" "$LEGACY_BIN_DIR/ai-optimizer" + +env HOME="$LEGACY_HOME" AI_ENV_OPTIMIZER_RELEASE_BASE="file://$DIST_DIR" PATH="/usr/bin:/bin" /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION" >/dev/null + +[ ! -e "$LEGACY_HOME/.local/share/ai-env-optimizer" ] +[ -f "$LEGACY_ROOT/.ai-env-optimizer-install" ] +[ -L "$LEGACY_BIN_DIR/ai-env-optimizer" ] +[ "$(readlink "$LEGACY_BIN_DIR/ai-optimizer")" = "$LEGACY_ROOT/bin/ai-env-optimizer" ] +[ "$("$LEGACY_BIN_DIR/ai-env-optimizer" version)" = "ai-env-optimizer $VERSION" ] +[ "$("$LEGACY_BIN_DIR/ai-optimizer" version)" = "ai-env-optimizer $VERSION" ] + +env HOME="$LEGACY_HOME" AI_ENV_OPTIMIZER_RELEASE_BASE="file://$DIST_DIR" PATH="/usr/bin:/bin" /bin/bash "$ROOT_DIR/install.sh" --version "$VERSION" >/dev/null + +[ ! -e "$LEGACY_HOME/.local/share/ai-env-optimizer" ] +[ -f "$LEGACY_ROOT/.ai-env-optimizer-install" ] +[ "$("$LEGACY_BIN_DIR/ai-env-optimizer" version)" = "ai-env-optimizer $VERSION" ] + +env HOME="$LEGACY_HOME" AI_ENV_OPTIMIZER_DATA_DIR="$TEST_ROOT/legacy-data" AI_ENV_OPTIMIZER_LAUNCH_AGENTS_DIR="$TEST_ROOT/legacy-agents" /bin/bash "$LEGACY_ROOT/scripts/uninstall.sh" --force >/dev/null + +[ ! -e "$LEGACY_ROOT" ] +[ ! -e "$LEGACY_BIN_DIR/ai-env-optimizer" ] +[ ! -e "$LEGACY_BIN_DIR/ai-optimizer" ] echo "install round trip passed" diff --git a/test/maintenance_test.rb b/test/maintenance_test.rb new file mode 100644 index 0000000..12fdde4 --- /dev/null +++ b/test/maintenance_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class MaintenanceTest < Minitest::Test + def storage_report(allocated:, protected:, reclaimable:, unknown:) + Struct.new(:summary).new( + { + "allocated_bytes" => allocated, + "protected_bytes" => protected, + "reclaimable_bytes" => reclaimable, + "unknown_sources" => unknown + } + ) + end + + def test_receipt_carries_the_canonical_and_legacy_product_names + in_tmpdir do |dir| + receipt = AIOptimizer::Maintenance.new( + data_dir: dir, + clock: -> { Time.local(2026, 8, 23, 21, 0, 0) }, + doctor: -> { AIOptimizer::Report.new([], generated_at: Time.utc(2026, 8, 24, 1, 0, 0)) } + ).run + + assert_equal "ai-env-optimizer", receipt.fetch("product") + assert_includes receipt.fetch("compatibility").fetch("legacy_names"), "ai-optimizer" + assert_equal receipt, JSON.parse(File.read(File.join(dir, "reports", "latest-run.json"))) + end + end + + def test_inside_window_records_report_only_storage_warning + in_tmpdir do |dir| + cleanup_calls = 0 + receipt = AIOptimizer::Maintenance.new( + data_dir: dir, + clock: -> { Time.local(2026, 8, 23, 21, 0, 0) }, + doctor: -> { AIOptimizer::Report.new([], generated_at: Time.utc(2026, 8, 24, 1, 0, 0)) }, + storage: lambda { + storage_report( + allocated: 12_000_000_000, + protected: 9_000_000_000, + reclaimable: 3_000_000_000, + unknown: 2 + ) + }, + warning_bytes: 10 * 1024 * 1024 * 1024 + ).run + + assert_equal 12_000_000_000, receipt.fetch("storage").fetch("allocated_bytes") + assert_equal "warning", receipt.fetch("storage").fetch("status") + assert_equal 9_000_000_000, receipt.fetch("storage").fetch("protected_bytes") + assert_equal 3_000_000_000, receipt.fetch("storage").fetch("reclaimable_bytes") + assert_equal 2, receipt.fetch("storage").fetch("unknown_sources") + assert_equal 0, cleanup_calls + end + end + + def test_outside_window_does_not_scan_storage + in_tmpdir do |dir| + storage_calls = 0 + receipt = AIOptimizer::Maintenance.new( + data_dir: dir, + clock: -> { Time.local(2026, 8, 23, 12, 0, 0) }, + doctor: -> { raise "doctor must not run" }, + storage: lambda { + storage_calls += 1 + raise "storage must not run" + } + ).run + + assert_equal "skipped_outside_window", receipt.fetch("status") + assert_equal 0, storage_calls + refute receipt.key?("storage") + end + end +end diff --git a/test/report_test.rb b/test/report_test.rb index 0b5209e..3c600e0 100644 --- a/test/report_test.rb +++ b/test/report_test.rb @@ -24,6 +24,8 @@ def test_json_and_text_share_order_and_summary ) payload = JSON.parse(report.to_json) + assert_equal "ai-env-optimizer", payload.fetch("product") + assert_includes payload.fetch("compatibility").fetch("legacy_names"), "ai-optimizer" assert_equal %w[system.macos tools.optional], payload.fetch("findings").map { |item| item.fetch("id") } assert_equal({ "pass" => 1, "warn" => 1, "fail" => 0, "info" => 0, "skip" => 0, "unknown" => 0 }, payload.fetch("summary")) assert_includes report.to_text(color: false), "system.macos" diff --git a/test/runner_test.rb b/test/runner_test.rb index 59b6df0..2c73772 100644 --- a/test/runner_test.rb +++ b/test/runner_test.rb @@ -11,6 +11,17 @@ def test_records_only_executable_basename refute_includes result.to_h.values.join(" "), "secret-argument" end + def test_redacts_home_paths_from_captured_output + in_tmpdir do |home| + result = AIOptimizer::CommandRunner.new(home: home).run( + ["/usr/bin/printf", "%s", File.join(home, "private", "file")] + ) + + refute_includes result.stdout, home + assert_equal "~/private/file", result.stdout + end + end + def test_times_out_hung_process result = AIOptimizer::CommandRunner.new.run(["/bin/sleep", "2"], timeout: 0.05) diff --git a/test/scheduler_test.rb b/test/scheduler_test.rb index e199c3e..0449bd6 100644 --- a/test/scheduler_test.rb +++ b/test/scheduler_test.rb @@ -31,6 +31,7 @@ def test_default_evening_schedule_round_trip_is_exact_and_idempotent expected_program = %r{ProgramArguments\s*\s*#{Regexp.escape(wrapper_path)}\s*} assert_match expected_program, plist refute_includes plist, "AI_OPTIMIZER_EXECUTABLE" + assert_includes plist, "AI_ENV_OPTIMIZER_DATA_DIR" refute_includes plist, "/usr/local/bin/ai-optimizer" assert_equal "#!/bin/sh\nexec '/usr/local/bin/ai-optimizer' run-maintenance\n", File.read(wrapper_path) assert_equal 0o700, File.stat(File.dirname(wrapper_path)).mode & 0o777 diff --git a/test/script_test.rb b/test/script_test.rb index cbf7b8b..2087d9b 100644 --- a/test/script_test.rb +++ b/test/script_test.rb @@ -13,16 +13,55 @@ def test_scripts_and_workflows_have_no_patch_continuation_artifacts end end + def test_public_text_files_do_not_contain_the_maintainer_home_path + paths = Dir[File.join(ROOT, "**", "*.{md,rb,sh,yml,yaml,json}")] + maintainer_home = File::SEPARATOR + ["Users", "chris", ""].join(File::SEPARATOR) + paths.each do |path| + refute_includes File.read(path), maintainer_home, "host path in #{path}" + end + end + def test_codex_and_claude_entrypoints_share_the_agent_context_handshake agents = File.read(File.join(ROOT, "AGENTS.md")) claude = File.read(File.join(ROOT, "CLAUDE.md")) - assert_includes agents, "./bin/ai-optimizer agent-context --json" + assert_includes agents, "./bin/ai-env-optimizer agent-context --json" assert_includes agents, "Operator workflow" assert_includes agents, "Development workflow" assert_includes claude, "AGENTS.md" - assert_includes claude, "./bin/ai-optimizer agent-context --json" + assert_includes claude, "./bin/ai-env-optimizer agent-context --json" assert_includes File.read(File.join(ROOT, "docs", "architecture.md")), "Agent context" assert_includes File.read(File.join(ROOT, "docs", "privacy.md")), "`doctor`, `scan`, and `agent-context` do not write" end + + + def test_release_and_install_surfaces_use_the_canonical_name + build = File.read(File.join(ROOT, "scripts", "build-release.sh")) + install = File.read(File.join(ROOT, "install.sh")) + release = File.read(File.join(ROOT, ".github", "workflows", "release.yml")) + + assert_includes build, 'ARCHIVE="ai-env-optimizer-${VERSION}.tar.gz"' + assert_includes install, "github.com/nyldn/ai-env-optimizer" + assert_includes release, "dist/ai-env-optimizer-*.tar.gz" + assert File.executable?(File.join(ROOT, "bin", "ai-env-optimizer")) + refute File.exist?(File.join(ROOT, "bin", "ai-optimizer")) + end + + def test_storage_workflow_is_documented_as_previewed_recoverable_and_session_safe + readme = File.read(File.join(ROOT, "README.md")) + agents = File.read(File.join(ROOT, "AGENTS.md")) + privacy = File.read(File.join(ROOT, "docs", "privacy.md")) + troubleshooting = File.read(File.join(ROOT, "docs", "troubleshooting.md")) + + assert_includes readme, "ai-env-optimizer storage --json" + assert_includes readme, "ai-env-optimizer storage cleanup --dry-run --older-than 30 --min-size 100" + assert_includes readme, "ai-env-optimizer storage cleanup --apply TOKEN --older-than 30 --min-size 100" + assert_includes readme, "Sessions, transcripts, memories, worktrees, and active plugin state are protected" + assert_includes readme, "Evening maintenance never applies cleanup" + assert_includes troubleshooting, "~/.Trash/ai-env-optimizer--" + assert_includes privacy, "original paths or filenames" + assert_includes agents, "only when the user asks about storage" + assert_includes agents, "For a storage request, make the first command" + assert_includes agents, "Never infer permission to apply cleanup from a preview token" + end end diff --git a/test/storage_report_test.rb b/test/storage_report_test.rb new file mode 100644 index 0000000..a6ca05b --- /dev/null +++ b/test/storage_report_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class StorageReportTest < Minitest::Test + def measurements + [ + { + "id" => "codex.sessions", "provider" => "codex", + "classification" => "historical", "cleanup_eligible" => false, + "status" => "complete", "allocated_bytes" => 2_048, + "file_count" => 2, "directory_count" => 1, + "age_buckets" => { "0_7_days" => 1_024, "8_30_days" => 1_024, + "31_90_days" => 0, "over_90_days" => 0 }, + "path" => "/private/session/path", "filename" => "secret.jsonl" + }, + { + "id" => "claude.app_cache", "provider" => "claude", + "classification" => "regenerable", "cleanup_eligible" => true, + "status" => "complete", "allocated_bytes" => 4_096, + "file_count" => 4, "directory_count" => 1, + "age_buckets" => { "0_7_days" => 0, "8_30_days" => 0, + "31_90_days" => 4_096, "over_90_days" => 0 } + } + ] + end + + def test_json_is_deterministic_canonical_and_path_free + report = AIOptimizer::StorageReport.new( + measurements.reverse, + generated_at: Time.utc(2026, 8, 23, 12, 0, 0), + version: "0.2.0", + warning_bytes: 10_000 + ) + payload = JSON.parse(report.to_json) + + assert_equal "ai-env-optimizer", payload.fetch("product") + assert_includes payload.fetch("compatibility").fetch("legacy_names"), "ai-optimizer" + assert_equal %w[claude.app_cache codex.sessions], payload.fetch("sources").map { |item| item.fetch("id") } + assert_equal 6_144, payload.fetch("summary").fetch("allocated_bytes") + assert_equal 4_096, payload.fetch("summary").fetch("reclaimable_bytes") + assert_equal 2_048, payload.fetch("summary").fetch("protected_bytes") + refute_includes report.to_json, "/private/session/path" + refute_includes report.to_json, "secret.jsonl" + end + + def test_human_output_explains_protection_without_paths + report = AIOptimizer::StorageReport.new(measurements, warning_bytes: 10_000) + text = report.to_text + + assert_includes text, "AI Environment Optimizer storage" + assert_includes text, "[PROTECTED] codex.sessions" + assert_includes text, "[RECLAIMABLE] claude.app_cache" + refute_includes text, "/private/session/path" + end + + def test_unknown_sources_fail_and_threshold_only_fails_strict + report = AIOptimizer::StorageReport.new(measurements, warning_bytes: 1) + assert_equal 0, report.exit_code + assert_equal 1, report.exit_code(strict: true) + + unknown = measurements.first.merge("status" => "unknown", "allocated_bytes" => 0) + assert_equal 1, AIOptimizer::StorageReport.new([unknown], warning_bytes: 10_000).exit_code + end +end diff --git a/test/storage_scanner_test.rb b/test/storage_scanner_test.rb new file mode 100644 index 0000000..ddb0b19 --- /dev/null +++ b/test/storage_scanner_test.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class StorageScannerTest < Minitest::Test + NOW = Time.utc(2026, 8, 23, 12, 0, 0) + + def source(components: ["cache"], classification: "regenerable", eligible: true) + AIOptimizer::StorageSource.new( + id: "test.storage", provider: "test", base: :home, + components: components, classification: classification, + cleanup_eligible: eligible + ) + end + + def scan(home, sources = [source]) + AIOptimizer::StorageScanner.new( + sources: sources, + home: home, + data_dir: File.join(home, "data"), + clock: -> { NOW } + ).scan + end + + def test_measures_allocated_bytes_age_buckets_and_hard_links_once + in_tmpdir do |home| + root = File.join(home, "cache") + FileUtils.mkdir_p(root) + fresh = File.join(root, "fresh-private-name") + old = File.join(root, "old-private-name") + linked = File.join(root, "hard-link-private-name") + File.write(fresh, "f" * 8_192) + File.write(old, "o" * 8_192) + File.link(old, linked) + File.utime(NOW - (3 * 86_400), NOW - (3 * 86_400), fresh) + File.utime(NOW - (45 * 86_400), NOW - (45 * 86_400), old) + + measurement = scan(home).first + + assert_equal "complete", measurement.fetch("status") + assert_operator measurement.fetch("allocated_bytes"), :>, 0 + assert_equal 2, measurement.fetch("file_count") + assert_equal 1, measurement.fetch("directory_count") + assert_equal measurement.fetch("allocated_bytes"), measurement.fetch("age_buckets").values.sum + assert_operator measurement.fetch("age_buckets").fetch("0_7_days"), :>, 0 + assert_operator measurement.fetch("age_buckets").fetch("31_90_days"), :>, 0 + serialized = JSON.generate(measurement) + refute_includes serialized, home + refute_includes serialized, "private-name" + end + end + + def test_age_bucket_boundaries_are_complete + in_tmpdir do |home| + root = File.join(home, "cache") + FileUtils.mkdir_p(root) + { 7 => "0_7_days", 8 => "8_30_days", 30 => "8_30_days", + 31 => "31_90_days", 90 => "31_90_days", 91 => "over_90_days" }.each do |days, _bucket| + path = File.join(root, "age-#{days}") + File.write(path, "x" * 4_096) + timestamp = NOW - (days * 86_400) + File.utime(timestamp, timestamp, path) + end + + buckets = scan(home).first.fetch("age_buckets") + + %w[0_7_days 8_30_days 31_90_days over_90_days].each do |name| + assert_operator buckets.fetch(name), :>, 0 + end + end + end + + def test_missing_root_is_absent_and_symlinked_root_is_unknown + in_tmpdir do |home| + absent = scan(home).first + assert_equal "absent", absent.fetch("status") + assert_equal 0, absent.fetch("allocated_bytes") + + outside = File.join(home, "outside") + FileUtils.mkdir_p(outside) + File.write(File.join(outside, "secret"), "do not inspect") + File.symlink(outside, File.join(home, "cache")) + + linked = scan(home).first + assert_equal "unknown", linked.fetch("status") + assert_equal 0, linked.fetch("allocated_bytes") + refute_includes JSON.generate(linked), "outside" + end + end + + def test_symlinked_source_ancestor_is_unknown_and_not_followed + in_tmpdir do |home| + outside = File.join(home, "outside") + FileUtils.mkdir_p(File.join(outside, "cache")) + File.write(File.join(outside, "cache", "secret"), "do not inspect") + File.symlink(outside, File.join(home, ".codex")) + codex_source = source(components: [".codex", "cache"]) + + measurement = scan(home, [codex_source]).first + + assert_equal "unknown", measurement.fetch("status") + assert_equal 0, measurement.fetch("allocated_bytes") + refute_includes JSON.generate(measurement), "outside" + end + end + + def test_large_directory_scan_does_not_modify_entries + in_tmpdir do |home| + root = File.join(home, "cache") + FileUtils.mkdir_p(root) + 10_000.times { |index| File.write(File.join(root, "entry-#{index}"), "") } + before = File.stat(root).mtime + + measurement = scan(home).first + + assert_equal "complete", measurement.fetch("status") + assert_equal 10_000, measurement.fetch("file_count") + assert_equal before, File.stat(root).mtime + end + end +end diff --git a/test/storage_source_test.rb b/test/storage_source_test.rb new file mode 100644 index 0000000..c96450d --- /dev/null +++ b/test/storage_source_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class StorageSourceTest < Minitest::Test + def test_resolves_only_below_the_declared_base + in_tmpdir do |home| + data_dir = File.join(home, "data") + source = AIOptimizer::StorageSource.new( + id: "claude.projects", provider: "claude", base: :home, + components: [".claude", "projects"], classification: "historical", + cleanup_eligible: false + ) + + assert_equal File.join(home, ".claude", "projects"), + source.resolve(home: home, data_dir: data_dir) + assert source.frozen? + assert source.components.frozen? + end + end + + def test_refuses_parent_traversal_and_invalid_identifiers + assert_raises(AIOptimizer::OwnershipError) do + AIOptimizer::StorageSource.new( + id: "bad.source", provider: "test", base: :home, + components: ["..", "outside"], classification: "regenerable", + cleanup_eligible: true + ) + end + assert_raises(ArgumentError) do + AIOptimizer::StorageSource.new( + id: "BAD PATH", provider: "test", base: :home, + components: ["cache"], classification: "regenerable", + cleanup_eligible: true + ) + end + end + + def test_protected_sources_cannot_be_cleanup_eligible + %w[historical active].each do |classification| + assert_raises(ArgumentError) do + AIOptimizer::StorageSource.new( + id: "test.#{classification}", provider: "test", base: :home, + components: [classification], classification: classification, + cleanup_eligible: true + ) + end + end + end + + def test_catalog_has_unique_non_overlapping_paths_and_protects_history + in_tmpdir do |home| + catalog = AIOptimizer::StorageCatalog.new( + home: home, + data_dir: File.join(home, "Library", "Application Support", "io.github.nyldn.ai-env-optimizer") + ) + sources = catalog.sources + ids = sources.map(&:id) + resolved = sources.map { |source| source.resolve(home: home, data_dir: catalog.data_dir) } + + assert_equal ids.uniq.sort, ids.sort + assert_equal resolved.uniq.sort, resolved.sort + resolved.combination(2) do |left, right| + refute left.start_with?(right + File::SEPARATOR), "#{left} nested below #{right}" + refute right.start_with?(left + File::SEPARATOR), "#{right} nested below #{left}" + end + protected_sources = sources.select do |source| + %w[historical active].include?(source.classification) + end + refute_empty protected_sources + assert protected_sources.none?(&:cleanup_eligible?) + assert sources.any? { |source| source.id == "product.logs" && source.cleanup_eligible? } + end + end +end