From c7571b6c135ab2b6a3ce794355b4b8de493c489b Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Sun, 30 Aug 2026 22:59:32 -0400 Subject: [PATCH 1/5] feat: add unified agent reorganization --- CHANGELOG.md | 26 ++ CONTRIBUTING.md | 21 +- README.md | 183 ++++++--- SECURITY.md | 9 +- docs/reorg.md | 159 ++++++++ package.json | 12 +- rules/claude.yaml | 22 ++ rules/codex.yaml | 12 +- rules/copilot.yaml | 24 +- rules/cursor.yaml | 24 ++ rules/gemini.yaml | 24 ++ rules/goose.yaml | 24 ++ rules/oh-my-openagent.yaml | 1 + rules/opencode.yaml | 24 ++ rules/reasonix.yaml | 22 ++ scripts/install-core.js | 29 +- scripts/install-ui.mjs | 2 +- scripts/install.js | 84 +++-- scripts/load-config.js | 162 ++++++-- scripts/reorg-core.js | 753 +++++++++++++++++++++++++++++++++++++ scripts/reorg.js | 255 +++++++++++++ scripts/tui/ReorgApp.mjs | 314 ++++++++++++++++ scripts/tui/index.mjs | 36 +- tests/load-config.test.js | 261 ++++++++++++- tests/reorg-core.test.js | 488 ++++++++++++++++++++++++ 25 files changed, 2820 insertions(+), 151 deletions(-) create mode 100644 docs/reorg.md create mode 100644 rules/goose.yaml create mode 100644 rules/reasonix.yaml create mode 100644 scripts/reorg-core.js create mode 100644 scripts/reorg.js create mode 100644 scripts/tui/ReorgApp.mjs create mode 100644 tests/reorg-core.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b77a6ad..af3e593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [0.10.0] — 2026-08-30 + +### Added + +- `saddle reorg` one-shot migration command with interactive review, `--dry-run`, `--json`, `--check`, and headless `--yes` modes +- Configurable canonical roots through `--source`, `SADDLE_SOURCE_ROOT`, or `sourceRoot` in `config.yaml`; new installations no longer assume `~/dev/ai` +- Mutually exclusive `universal-first` and `provider-only` routing strategies +- Version 2 provider-rule schema for rule-based discovery of skills, agents, and commands +- Transaction manifests, verified imports and links, precondition checks, and automatic rollback on apply failure +- Idempotent drift planning: repeat runs are empty when clean and include only newly diverged items +- Reorganization rules for Claude Code, Codex, Copilot, Cursor, Gemini, Goose, OpenCode, and Reasonix (the supported DeepSeek coding harness) + +### Changed + +- Codex skill sync now targets the shared `~/.agents/skills` location +- Copilot detection now uses the standalone `copilot` CLI; skills support both current personal roots and the unsupported `~/.copilot/commands` mapping has been removed +- Bundled reorganization metadata is merged into older user-copied provider rules without overwriting custom sync mappings +- Dry-run, JSON, check, and rejected TUI plans do not initialize Saddle config or state + +### Security + +- Canonical paths are constrained to the selected source root, overlapping managed roots are rejected, and rule locations fail closed on malformed classifications +- Apply validates the complete plan before writing and revalidates each target immediately before mutation + +--- + ## [0.9.3] — 2026-04-08 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98398b2..0ab7a94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thank you for considering a contribution. This document covers how to get set up ## Prerequisites - **Node.js 18 or newer** (the project enforces `engines.node >= 18`) -- **npm** (comes with Node) +- **pnpm 10** - **Git** ## Getting Started @@ -13,10 +13,10 @@ Thank you for considering a contribution. This document covers how to get set up ```bash git clone https://github.com/ndizazzo/saddle.git cd saddle -npm install +pnpm install ``` -Husky will install a pre-commit hook automatically during `npm install`. +Husky installs the Git hooks during `pnpm install`. ## Project Structure @@ -25,6 +25,8 @@ Husky will install a pre-commit hook automatically during `npm install`. | `bin/saddle.js` | CLI entry point | | `scripts/install.js` | Main installer orchestrator | | `scripts/install-core.js` | Core logic (profile discovery, linking, lockfile) | +| `scripts/reorg-core.js` | Reorganization scan, plan, transaction, rollback | +| `scripts/reorg.js` | `saddle reorg` CLI orchestration | | `scripts/install-ui.mjs` | Ink TUI (ESM) | | `scripts/load-config.js` | Config loading + rule normalisation | | `scripts/tui/` | TUI components and utilities | @@ -34,7 +36,7 @@ Husky will install a pre-commit hook automatically during `npm install`. ## Running Tests ```bash -npm test +pnpm test ``` All tests must pass before any PR is merged. The suite uses the Node.js built-in `node:test` runner — no additional test dependencies. @@ -42,7 +44,8 @@ All tests must pass before any PR is merged. The suite uses the Node.js built-in ## Running the Linter ```bash -npm run lint:agents +pnpm lint +pnpm run lint:agents ``` This validates structural invariants (required files, executables, scripts, dependencies). It runs automatically on every commit via the Husky pre-commit hook. @@ -57,6 +60,7 @@ saddle saddle --dry-run --all saddle --list saddle --help +saddle reorg --source /tmp/saddle-fixture --dry-run ``` ## Code Style @@ -81,7 +85,9 @@ To add support for a new AI coding tool: 1. Create `rules/.yaml` following the schema of an existing rule file 2. Add the tool to the support matrix in `README.md` -3. Run `npm test` to confirm nothing regressed +3. Add `schemaVersion: 2` and `reorg.assets` when the harness has reusable global definitions +4. Test both routing strategies and repeat-run idempotency +5. Run `pnpm test` to confirm nothing regressed The installer picks up new rule files automatically via `loadRules()`. @@ -89,8 +95,11 @@ See the **Writing Rules** section in `README.md` for the complete YAML schema, i - `tool`, `label`, `home`, `binary`, `enabled`, `mode` - `mappings` for skills, files, and directories +- `reorg.assets` for canonical collections and universal/provider target locations - `mode: single-select` for mutually exclusive options (vs. `multi-select` default) +Reorganization rules must use documented harness paths. A provider name such as DeepSeek is not itself a harness; map the actual client that owns the on-disk format (for example, Reasonix). Never include credential files or broad configuration homes in a reorganization asset. + ## Reporting Issues Please use the GitHub [issue tracker](https://github.com/ndizazzo/saddle/issues). Bug reports and feature requests are both welcome — use the templates provided. diff --git a/README.md b/README.md index 58e0631..4d7be2a 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@

- Keep your agents, skills, commands, and configurations in sync across
- Claude Code, Codex, Copilot, Cursor, Gemini, and OpenCode — on every machine. + Detect, reorganize, and sync agents, skills, commands, and configurations across
+ Claude Code, Codex, Copilot, Cursor, Gemini, Goose, OpenCode, and Reasonix.

@@ -31,21 +31,39 @@ ## Quick Start ```bash -# Clone and set up -git clone https://github.com/ndizazzo/saddle.git ~/dev/ai -cd ~/dev/ai && pnpm install - -# Launch the interactive installer -npx saddle - -# Or run non-interactively -npx saddle --yes --all # apply everything -npx saddle --dry-run --all # preview first -npx saddle --check # verify sync -npx saddle --uninstall # clean removal +# Install the CLI +npm install -g saddle-cli + +# Import scattered agent configuration into a source root you choose +saddle reorg --source ~/dotfiles/agents + +# Or inspect and apply non-interactively +saddle reorg --source ~/dotfiles/agents --dry-run +saddle reorg --source ~/dotfiles/agents --yes +saddle reorg --check +``` + +The source root is not tied to `~/dev/ai` or to the Saddle checkout. Use any durable local path, dotfiles repository, or synced directory. Saddle stores the confirmed choice in `~/.config/saddle/config.yaml`. + +For an existing canonical tree, run `saddle` (or `saddle sync`) to choose individual mappings. See [One-shot reorganization](docs/reorg.md) for the migration workflow and safety model. + +--- + +## One-shot Reorganization + +`saddle reorg` scans supported coding harnesses, builds a deterministic plan, and shows the proposed imports, links, and duplicate removals before writing anything. + +```bash +# Prefer ~/.agents/* where a harness supports it; use native folders as fallback +saddle reorg --source ~/dotfiles/agents --strategy universal-first + +# Use harness-specific locations only +saddle reorg --source ~/dotfiles/agents --strategy provider-only ``` -The interactive TUI detects which AI tools are installed and walks you through linking skills, agents, commands, and config files for each. +The strategies are mutually exclusive. A compatible harness/asset is never linked to both a universal and a provider-specific location in the same plan. Press Enter to apply in the TUI or Esc to reject without creating config, state, or symlinks. + +Every apply is precondition-checked and transaction-journaled. If an action fails, Saddle restores completed changes from the transaction backup. A clean repeat run produces no actions; if one harness adds a new local definition later, the next plan contains only that drift. --- @@ -55,10 +73,12 @@ The interactive TUI detects which AI tools are installed and walks you through l | ----------- | -------------------- | :----: | :----: | :------: | :---------: | :-------------------------------------: | | Claude Code | `~/.claude` | ✓ | ✓ | ✓ | — | — | | Codex | `~/.codex` | ✓ | ✓ | ✓ | `AGENTS.md` | — | -| Copilot | `~/.copilot` | ✓ | ✓ | ✓ | — | — | +| Copilot | `~/.copilot` | ✓ | ✓ | — | — | — | | Cursor | `~/.cursor` | ✓ | ✓ | ✓ | — | — | | Gemini | `~/.gemini` | ✓ | ✓ | ✓ | `GEMINI.md` | `configurations/gemini/` → `~/.gemini/` | | OpenCode | `~/.config/opencode` | ✓ | ✓ | ✓ | `AGENTS.md` | `opencode/` → `~/.config/opencode/` | +| Goose | `~/.config/goose` | ✓ | — | — | — | — | +| Reasonix | `~/.reasonix` | ✓ | — | — | — | — | --- @@ -66,6 +86,8 @@ The interactive TUI detects which AI tools are installed and walks you through l **Interactive TUI** — A beautiful terminal interface built with Ink. Browse tools, preview diffs, toggle individual actions — all from your terminal. +**One-shot Reorganization** — Inventory scattered definitions, import them into a configurable canonical root, and replace duplicates with verified relative symlinks. + **Headless-ready** — Full non-interactive mode for CI/CD. `--dry-run`, `--yes`, `--all`, `--check` — automate everything. **Smart Symlinks** — Creates relative symlinks, detects existing content, shows diffs before replacing, and backs up what was there. @@ -91,34 +113,33 @@ The interactive TUI detects which AI tools are installed and walks you through l ## Canonical Layout ``` -saddle/ -├── agents/ # Per-tool instruction files and shared agent definitions -│ ├── codex/AGENTS.md -│ ├── gemini/GEMINI.md -│ └── opencode/AGENTS.md -├── commands/ # Slash command files → each tool's commands/ -├── skills/ # Skill subdirectories → each tool's skills/ -├── configurations/ -│ └── gemini/ # Gemini-specific config → ~/.gemini/ -├── opencode/ # OpenCode-specific config → ~/.config/opencode/ -├── rules/ # Per-tool installer rules (YAML) -│ ├── claude.yaml -│ ├── codex.yaml -│ ├── copilot.yaml -│ ├── cursor.yaml -│ ├── gemini.yaml -│ └── opencode.yaml -└── scripts/ # Repo maintenance helpers +/ +├── agents/ # Provider-format agent definitions +│ ├── claude/ +│ ├── copilot/ +│ ├── cursor/ +│ ├── gemini/ +│ └── opencode/ +├── commands/ # Provider-format slash command files +│ ├── claude/ +│ ├── cursor/ +│ ├── gemini/ +│ └── opencode/ +└── skills/ # Portable Agent Skills directories + └── /SKILL.md ``` -Keep the real files in this repo and rebuild tool-specific links on each machine. Do not sync `~/.claude`, `~/.codex`, `~/.cursor`, `~/.gemini`, `~/.copilot`, or `~/.config/opencode` symlinks directly between machines. +Only collections found on the machine are created. Existing `saddle sync` sources may also contain tool configuration collections. Bundled provider rules ship with Saddle; user overrides live in `~/.config/saddle/rules`, outside the canonical source root. + +Keep the real files in the source root and rebuild tool-specific links on each machine. Do not sync `~/.claude`, `~/.codex`, `~/.cursor`, `~/.gemini`, `~/.copilot`, or `~/.config/opencode` symlinks directly between machines. --- ## CLI Reference ``` -saddle [options] +saddle [sync] [options] +saddle reorg [options] ``` | Flag | Description | @@ -133,6 +154,18 @@ saddle [options] | `--verbose` | Show extra detail (source paths, resolved targets) | | `--quiet` | Suppress ok/link/skip/mkdir output; errors and summary only | +### Reorganization Options + +| Flag | Description | +| ------------------ | ---------------------------------------------------------------------- | +| `--source path` | Canonical root to import into; overrides environment and config | +| `--strategy value` | `universal-first` or `provider-only` | +| `--dry-run` | Print the complete plan without creating config, state, or links | +| `--json` | Emit a machine-readable read-only plan | +| `--check` | Exit 1 when changes or conflicts remain; otherwise exit 0 | +| `--yes` | Apply a conflict-free plan without the interactive confirmation screen | +| `--quiet` | Suppress per-action apply output | + ### Interactive Mode The TUI is built with [Ink](https://github.com/vadimdemedes/ink) and [@inkjs/ui](https://github.com/vadimdemedes/ink-ui). It presents selectable profiles grouped by tool, with per-action toggling and diff previews. @@ -158,11 +191,15 @@ npx saddle --profile claude-skills-skills,cursor-directory-agents --yes ## Configuration -| Variable | Default | Description | -| ------------------ | ------------------------------ | ----------------------- | -| `SADDLE_DIR` | `~/.config/saddle` | Base config directory | -| `SADDLE_CONFIG` | `~/.config/saddle/config.yaml` | Path to config file | -| `SADDLE_RULES_DIR` | `~/.config/saddle/rules` | Path to rules directory | +| Variable | Default | Description | +| ---------------------- | ------------------------------ | ------------------------------------------------ | +| `SADDLE_DIR` | `~/.config/saddle` | Base config directory | +| `SADDLE_CONFIG` | `~/.config/saddle/config.yaml` | Path to config file | +| `SADDLE_RULES_DIR` | `~/.config/saddle/rules` | Path to rules directory | +| `SADDLE_SOURCE_ROOT` | unset | Canonical root; overrides `sourceRoot` in config | +| `SADDLE_LINK_STRATEGY` | `universal-first` | Reorganization routing strategy | + +The equivalent config keys are `sourceRoot` and `linkStrategy`. `sourceRoot` has no machine-specific default; interactive commands ask before using one. --- @@ -173,6 +210,7 @@ Rules are YAML files that define how to sync a tool's configurations. Each rule ### Rule Schema ```yaml +schemaVersion: 2 # Required for reorganization rules tool: claude # Unique identifier for this tool label: Claude Code # Display name in the TUI binary: # How to detect if tool is installed (optional) @@ -185,6 +223,17 @@ home: ~/.claude # Tool's config directory (supports ~) enabled: true # Include in sync (default: true) mode: multi-select # Selection mode: multi-select (default) or single-select +reorg: + assets: + - kind: skill # skill | agent | command | instruction | config + canonical: skills # Directory relative to the configured source root + entries: directories # directories | files + locations: + - path: ~/.agents/skills + targetClass: universal + - path: ~/.config/example/skills + targetClass: provider + mappings: # List of what to link - type: skills # Type: skills | file | directory source: skills # Path relative to repo root @@ -202,15 +251,19 @@ mappings: # List of what to link ### Key Fields -| Field | Required | Type | Notes | -| ---------- | -------- | ------- | -------------------------------------------------- | -| `tool` | ✓ | string | Machine-readable identifier (lowercase, no spaces) | -| `label` | ✗ | string | Display name; defaults to capitalized `tool` | -| `binary` | ✗ | object | Detection method; omit to never detect | -| `home` | ✓ | string | Tool's config directory; supports `~` | -| `enabled` | ✗ | boolean | Default: `true`. Set `false` to skip syncing | -| `mode` | ✗ | string | Selection mode (see below) | -| `mappings` | ✓ | array | List of symlink definitions | +| Field | Required | Type | Notes | +| --------------- | -------- | ------- | ------------------------------------------------------------ | +| `schemaVersion` | reorg | integer | Use `2` for the reorganization schema | +| `tool` | ✓ | string | Machine-readable identifier (lowercase, no spaces) | +| `label` | ✗ | string | Display name; defaults to `tool` | +| `binary` | ✗ | object | Binary and platform-path detection | +| `home` | ✓ | string | Tool's config directory; supports `~` | +| `enabled` | ✗ | boolean | Default: `true`. Set `false` to skip | +| `mode` | ✗ | string | Sync selection mode | +| `reorg.assets` | ✗ | array | Canonical collections and universal/provider discovery roots | +| `mappings` | ✓ | array | Existing-source sync definitions | + +Reorganization locations fail closed: unsafe canonical paths, relative harness paths, unknown entry modes, and mistyped target classes are ignored. See [the full reorganization rule contract](docs/reorg.md#provider-rule-schema). ### Selection Mode @@ -326,12 +379,22 @@ Links to official documentation for each supported AI coding tool. - [Slash Commands](https://cursor.com/docs/cli/reference/slash-commands) — in-session commands - [Agent Modes](https://cursor.com/docs/agent/modes) — plan, ask, and agent modes - [Rules](https://cursor.com/docs/context/rules) — .cursor/rules/ configuration -- [Skills](https://cursor.com/docs/context/commands) — multi-step workflow files +- [Skills](https://cursor.com/docs/skills) — portable Agent Skills and discovery roots +- [Subagents](https://cursor.com/docs/subagents) — user and project agent definitions - [MCP in CLI](https://cursor.com/docs/cli/mcp) — MCP server management - [Headless / CI](https://cursor.com/docs/cli/headless) — non-interactive scripting +

+GitHub Copilot CLI + +- [CLI Command Reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference) — commands, settings, skill roots, and customization reference +- [Agent Skills](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills) — personal skills in `~/.copilot/skills` or `~/.agents/skills` +- [Custom Agents](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli) — user agents in `~/.copilot/agents` + +
+
Codex (OpenAI) @@ -356,13 +419,29 @@ Links to official documentation for each supported AI coding tool. - [Configuration Reference](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md) — settings.json schema - [GEMINI.md Context Files](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md) — agent memory and instructions - [Custom Commands](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md) — .toml custom slash commands -- [Skills](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md) — agent skills system +- [Skills](https://geminicli.com/docs/cli/using-agent-skills/) — agent skills system and discovery roots - [MCP Server Integration](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md) — MCP setup - [Plan Mode](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/plan-mode.md) — read-only planning - [Headless / Non-interactive](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/headless.md) — CI/automation usage
+
+Goose + +- [Using Skills](https://goose-docs.ai/docs/guides/context-engineering/using-skills/) — global Agent Skills discovery and Goose-specific skills +- [Goose Documentation](https://goose-docs.ai/) — installation, configuration, and extensions + +
+ +
+Reasonix (DeepSeek) + +- [Reasonix Documentation](https://reasonix.io/docs/) — DeepSeek-oriented coding harness configuration +- [DeepSeek API Documentation](https://api-docs.deepseek.com/) — model-provider reference; on-disk definitions remain owned by the client harness + +
+
OpenCode diff --git a/SECURITY.md b/SECURITY.md index e5a5fee..68f6ca3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,15 +2,15 @@ ## Scope -Saddle creates symlinks from this repository into your AI tool config directories (`~/.claude`, `~/.codex`, `~/.cursor`, `~/.gemini`, `~/.copilot`, `~/.config/opencode`). No network requests are made. No credentials are read or stored. +Saddle creates symlinks between a user-selected canonical source root and supported AI tool config directories. `saddle reorg` reads only the reusable asset locations declared in provider rules; bundled rules do not scan credential files. No network requests are made and no credentials are stored. ## Supported Versions Security fixes are applied to the latest release on `main`. There are no long-term support branches at this time. | Version | Supported | -|---------|-----------| -| 0.1.x | ✓ Yes | +| ------- | --------- | +| 0.10.x | ✓ Yes | ## Reporting a Vulnerability @@ -19,6 +19,7 @@ Security fixes are applied to the latest release on `main`. There are no long-te Report security issues privately by emailing the maintainer directly or using [GitHub's private vulnerability reporting](https://github.com/ndizazzo/saddle/security/advisories/new). Include: + - A description of the vulnerability - Steps to reproduce (or a proof-of-concept) - The potential impact @@ -31,6 +32,8 @@ You should receive an acknowledgement within 72 hours. If you do not, follow up - Path traversal: crafted rule YAML causing links to be created outside the expected target directories - Arbitrary code execution triggered by malformed YAML rule files or config files - Symlink following attacks during install or uninstall that could overwrite unintended files +- Reorganization rules that escape the canonical source root or overlap a managed harness root +- Incomplete rollback that leaves a replaced target without its transaction backup ## What We Do Not Consider a Vulnerability diff --git a/docs/reorg.md b/docs/reorg.md new file mode 100644 index 0000000..322465e --- /dev/null +++ b/docs/reorg.md @@ -0,0 +1,159 @@ +# One-shot reorganization + +`saddle reorg` turns reusable configuration scattered across coding harnesses into one canonical tree. It inventories installed tools, hashes existing items, builds a deterministic plan, and waits for confirmation before it imports or links anything. + +The command manages declared assets such as skills, agents, and commands. It does not move credentials, session data, caches, or undeclared provider configuration. + +## Choose a source root + +The canonical source path belongs to the user. It does not need to be `~/dev/ai`, the Saddle package, or a particular repository layout. + +```bash +saddle reorg --source ~/dotfiles/agents +``` + +Source precedence is: + +1. `--source path` +2. `SADDLE_SOURCE_ROOT` +3. `sourceRoot` in `~/.config/saddle/config.yaml` +4. an interactive prompt + +Saddle stores a CLI- or TUI-confirmed source after a successful apply. Read-only modes and a rejected TUI do not create the config file. + +The bundled rules currently organize imported definitions like this, creating only collections that exist on the machine: + +```text +/ +├── agents// +├── commands// +└── skills//SKILL.md +``` + +Provider rules are Saddle configuration, not canonical content. Bundled rules ship with the CLI and user overrides live in `~/.config/saddle/rules`. + +## Choose one routing strategy + +The two strategies are mutually exclusive for each harness asset. + +### `universal-first` + +Use a universal location such as `~/.agents/skills` when the harness declares support for it. If that asset has no universal location, use its provider-specific directory. + +For example, skills shared by Codex, Copilot, Cursor, OpenCode, Gemini, and Goose can use one `~/.agents/skills` link set, while Claude agents remain under `~/.claude/agents`. + +```bash +saddle reorg --source ~/dotfiles/agents --strategy universal-first +``` + +### `provider-only` + +Use only provider-specific locations. A harness with no declared provider-specific target is reported as unsupported for that asset. Saddle does not also create a universal link. + +```bash +saddle reorg --source ~/dotfiles/agents --strategy provider-only +``` + +Switching strategy produces a plan that creates the selected endpoint and removes matching duplicates from the suppressed endpoint. Different content is a conflict and is never deleted automatically. + +## Review and apply + +In a terminal, Saddle presents the routing decision, canonical root, action counts, and each affected item. Enter confirms the exact plan. Escape or `q` rejects it without writing. + +For automation: + +```bash +# Human-readable preview +saddle reorg --source ~/dotfiles/agents --dry-run + +# Machine-readable preview +saddle reorg --source ~/dotfiles/agents --json + +# Drift check: 0 clean, 1 changes or conflicts remain +saddle reorg --check + +# Apply a conflict-free plan without the TUI +saddle reorg --source ~/dotfiles/agents --yes +``` + +Non-interactive apply requires `--yes`. JSON output is always read-only. + +## Planning rules + +For each canonical collection, Saddle: + +1. inventories immediate files or directories at every declared location; +2. hashes content and file modes using stable ordering; +3. imports an item when it exists in a harness but not in the canonical tree; +4. creates relative symlinks at only the selected endpoint class; +5. removes a suppressed duplicate only when its digest matches the canonical item; and +6. blocks the plan if the same name has different content. + +Actions are ordered as imports, universal links, provider fallbacks, then duplicate removals. The plan records filesystem signatures. Apply rejects stale plans if a target changes after inspection. + +## Transactions and recovery + +Every non-empty apply creates `~/.config/saddle/transactions//manifest.json`. Replaced files and directories are copied into that transaction's backup directory; symlink destinations are recorded directly. + +Imports and links are hashed or resolved after creation. If any action fails, completed actions are rolled back in reverse order. The manifest ends in `rolled-back`, or `rollback-failed` with per-target errors if the operating system prevents complete restoration. + +The latest successful layout is summarized in `~/.config/saddle/reorg-state.json`. Planning does not rely on that file: the filesystem remains the source of truth, so deleted state can be reconstructed on the next scan. + +## Idempotency and drift + +After a successful run, the same command produces an empty plan while the filesystem is unchanged. If a harness later creates one new skill or replaces one local item, the next run plans only the affected name. Existing correct links are listed as unchanged. + +## Bundled harness behavior + +| Harness | Universal skills | Provider-specific assets | +| ------------------- | ------------------ | ------------------------------------------------- | +| Claude Code | — | `~/.claude/skills`, `agents`, `commands` | +| Codex | `~/.agents/skills` | — | +| Copilot | `~/.agents/skills` | `~/.copilot/skills`, `agents` | +| Cursor | `~/.agents/skills` | `~/.cursor/skills`, `agents`, `commands` | +| Gemini | `~/.agents/skills` | `~/.gemini/skills`, `agents`, `commands` | +| Goose | `~/.agents/skills` | `~/.config/goose/skills` | +| OpenCode | `~/.agents/skills` | `~/.config/opencode/skills`, `agents`, `commands` | +| Reasonix (DeepSeek) | — | `~/.reasonix/skills` | + +DeepSeek is a model provider, not an on-disk harness format. Saddle's bundled DeepSeek-oriented rule targets Reasonix, the coding harness that owns `~/.reasonix`. A DeepSeek model used through Codex, Claude Code, or OpenCode follows that harness's rule instead. + +## Provider rule schema + +Reorganization metadata is available in provider rule schema version 2: + +```yaml +schemaVersion: 2 +tool: example +label: Example Agent +binary: example +home: ~/.config/example +enabled: true + +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.agents/skills + targetClass: universal + - path: ~/.config/example/skills + targetClass: provider + - kind: agent + canonical: agents/example + entries: files + locations: + - path: ~/.config/example/agents + targetClass: provider + +mappings: [] +``` + +- `kind` is `skill`, `agent`, `command`, `instruction`, or `config`. +- `canonical` is a relative directory beneath the selected source root. Absolute and parent-traversal paths are rejected. +- `entries` is `directories` for directory-packaged assets such as Agent Skills, or `files` for flat Markdown definitions. +- `locations[].path` is an absolute or tilde-prefixed harness directory. +- `targetClass` is exactly `universal` or `provider`. Unknown values fail closed. + +The same physical location must not claim different canonical collections. Keep credential files and broad config directories out of `reorg.assets`. diff --git a/package.json b/package.json index 41e6ebd..819a95f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "saddle-cli", - "version": "0.9.3", - "description": "CLI that syncs AI coding tool configs (Claude, Codex, Copilot, Cursor, Gemini, OpenCode) across machines via symlinks", + "version": "0.10.0", + "description": "Detect, reorganize, and sync AI coding agent configs through one canonical source and rule-based symlinks", "author": "ndizazzo", "license": "MIT", "repository": { @@ -20,12 +20,16 @@ "gemini", "opencode", "oh-my-openagent", + "goose", + "reasonix", + "deepseek", "symlinks" ], "files": [ "bin/", "scripts/", - "rules/" + "rules/", + "docs/" ], "bin": { "saddle": "bin/saddle.js" @@ -36,7 +40,7 @@ "lint:agents": "bash ./scripts/agent-lint.sh", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "node --test tests/load-config.test.js tests/install-core.test.js tests/format.test.mjs", + "test": "node --test tests/load-config.test.js tests/install-core.test.js tests/reorg-core.test.js tests/format.test.mjs", "prepare": "husky || true" }, "engines": { diff --git a/rules/claude.yaml b/rules/claude.yaml index 609342f..d80b429 100644 --- a/rules/claude.yaml +++ b/rules/claude.yaml @@ -1,3 +1,4 @@ +schemaVersion: 2 tool: claude label: Claude Code binary: claude @@ -5,6 +6,27 @@ home: ~/.claude enabled: true mode: multi-select +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.claude/skills + targetClass: provider + - kind: agent + canonical: agents/claude + entries: files + locations: + - path: ~/.claude/agents + targetClass: provider + - kind: command + canonical: commands/claude + entries: files + locations: + - path: ~/.claude/commands + targetClass: provider + mappings: - type: skills source: skills diff --git a/rules/codex.yaml b/rules/codex.yaml index 7b05d0c..ed2a9a1 100644 --- a/rules/codex.yaml +++ b/rules/codex.yaml @@ -1,3 +1,4 @@ +schemaVersion: 2 tool: codex label: Codex binary: @@ -8,10 +9,19 @@ home: ~/.codex enabled: true mode: multi-select +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.agents/skills + targetClass: universal + mappings: - type: skills source: skills - target: skills + target: ../.agents/skills itemType: skill - type: directory source: agents diff --git a/rules/copilot.yaml b/rules/copilot.yaml index ca035f1..0c63dbf 100644 --- a/rules/copilot.yaml +++ b/rules/copilot.yaml @@ -1,10 +1,28 @@ +schemaVersion: 2 tool: copilot label: Copilot -binary: gh +binary: copilot home: ~/.copilot enabled: true mode: multi-select +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.agents/skills + targetClass: universal + - path: ~/.copilot/skills + targetClass: provider + - kind: agent + canonical: agents/copilot + entries: files + locations: + - path: ~/.copilot/agents + targetClass: provider + mappings: - type: skills source: skills @@ -14,7 +32,3 @@ mappings: source: agents target: agents itemType: agent - - type: directory - source: commands - target: commands - itemType: command diff --git a/rules/cursor.yaml b/rules/cursor.yaml index 7fcbaff..51c7ce2 100644 --- a/rules/cursor.yaml +++ b/rules/cursor.yaml @@ -1,3 +1,4 @@ +schemaVersion: 2 tool: cursor label: Cursor binary: @@ -8,6 +9,29 @@ home: ~/.cursor enabled: true mode: multi-select +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.agents/skills + targetClass: universal + - path: ~/.cursor/skills + targetClass: provider + - kind: agent + canonical: agents/cursor + entries: files + locations: + - path: ~/.cursor/agents + targetClass: provider + - kind: command + canonical: commands/cursor + entries: files + locations: + - path: ~/.cursor/commands + targetClass: provider + mappings: - type: skills source: skills diff --git a/rules/gemini.yaml b/rules/gemini.yaml index de66b15..3c1f18f 100644 --- a/rules/gemini.yaml +++ b/rules/gemini.yaml @@ -1,3 +1,4 @@ +schemaVersion: 2 tool: gemini label: Gemini binary: gemini @@ -5,6 +6,29 @@ home: ~/.gemini enabled: true mode: multi-select +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.agents/skills + targetClass: universal + - path: ~/.gemini/skills + targetClass: provider + - kind: agent + canonical: agents/gemini + entries: files + locations: + - path: ~/.gemini/agents + targetClass: provider + - kind: command + canonical: commands/gemini + entries: files + locations: + - path: ~/.gemini/commands + targetClass: provider + mappings: - type: file source: agents/gemini/GEMINI.md diff --git a/rules/goose.yaml b/rules/goose.yaml new file mode 100644 index 0000000..7f87e10 --- /dev/null +++ b/rules/goose.yaml @@ -0,0 +1,24 @@ +schemaVersion: 2 +tool: goose +label: Goose +binary: goose +home: ~/.config/goose +enabled: true +mode: multi-select + +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.agents/skills + targetClass: universal + - path: ~/.config/goose/skills + targetClass: provider + +mappings: + - type: skills + source: skills + target: skills + itemType: skill diff --git a/rules/oh-my-openagent.yaml b/rules/oh-my-openagent.yaml index ce77e3e..c8e3a12 100644 --- a/rules/oh-my-openagent.yaml +++ b/rules/oh-my-openagent.yaml @@ -1,3 +1,4 @@ +schemaVersion: 2 tool: oh-my-openagent label: Oh My Openagent home: ~/.config/opencode diff --git a/rules/opencode.yaml b/rules/opencode.yaml index ffe9315..329b9a5 100644 --- a/rules/opencode.yaml +++ b/rules/opencode.yaml @@ -1,3 +1,4 @@ +schemaVersion: 2 tool: opencode label: OpenCode binary: opencode @@ -5,6 +6,29 @@ home: ~/.config/opencode enabled: true mode: multi-select +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.agents/skills + targetClass: universal + - path: ~/.config/opencode/skills + targetClass: provider + - kind: agent + canonical: agents/opencode + entries: files + locations: + - path: ~/.config/opencode/agents + targetClass: provider + - kind: command + canonical: commands/opencode + entries: files + locations: + - path: ~/.config/opencode/commands + targetClass: provider + mappings: - type: file source: agents/opencode/AGENTS.md diff --git a/rules/reasonix.yaml b/rules/reasonix.yaml new file mode 100644 index 0000000..c5e44ea --- /dev/null +++ b/rules/reasonix.yaml @@ -0,0 +1,22 @@ +schemaVersion: 2 +tool: reasonix +label: Reasonix (DeepSeek) +binary: reasonix +home: ~/.reasonix +enabled: true +mode: multi-select + +reorg: + assets: + - kind: skill + canonical: skills + entries: directories + locations: + - path: ~/.reasonix/skills + targetClass: provider + +mappings: + - type: skills + source: skills + target: skills + itemType: skill diff --git a/scripts/install-core.js b/scripts/install-core.js index ec6cfed..0973b26 100755 --- a/scripts/install-core.js +++ b/scripts/install-core.js @@ -111,19 +111,18 @@ function commandExists(name) { return result.status === 0; } -function binaryDetected(binary) { +function binaryDetected(binary, expandHome = getConfig().expandHome) { if (!binary) return false; if (binary.which && commandExists(binary.which)) return true; const platformPath = binary.paths && binary.paths[process.platform]; if (platformPath) { - const expanded = getConfig().expandHome(platformPath); + const expanded = expandHome(platformPath); if (expanded && fileExists(expanded)) return true; } return false; } -function detectInstalledTools() { - const config = getConfig(); +function detectInstalledTools(config = getConfig()) { const detection = {}; for (const rule of config.rules) { @@ -133,7 +132,7 @@ function detectInstalledTools() { } const expandedHome = rule.home ? config.expandHome(rule.home) : null; const homeFound = expandedHome ? fileExists(expandedHome) : false; - detection[rule.name] = homeFound || binaryDetected(rule.binary); + detection[rule.name] = homeFound || binaryDetected(rule.binary, config.expandHome); } return detection; @@ -362,11 +361,11 @@ function profileDescriptionForMapping(mapping, targetHome) { return `Links to ${profileTargetPath(mapping, targetHome)}`; } -function discoverProfiles(repoRoot = getDefaultRepoRoot(), detection = null) { +function discoverProfiles(repoRoot = getDefaultRepoRoot(), detection = null, config = getConfig()) { const profiles = []; - for (const rule of getConfig().rules) { - const targetHome = getConfig().expandHome(rule.home); + for (const rule of config.rules) { + const targetHome = config.expandHome(rule.home); if (!targetHome) continue; const isInstalled = detection ? detection[rule.name] !== false : true; @@ -497,11 +496,17 @@ function parseArgs(argv) { } function printUsage(profiles) { + console.log("Usage:"); console.log( - "Usage: saddle [--dry-run] [--uninstall] [--check] [--yes] [--all] [--profile id1,id2] [--list] [--verbose] [--quiet]", + " saddle [--dry-run] [--uninstall] [--check] [--yes] [--all] [--profile id1,id2] [--list] [--verbose] [--quiet]", ); + console.log(" saddle reorg [--source path] [--strategy value] [--dry-run] [--check] [--yes]"); + console.log(""); + console.log("Commands:"); + console.log(" sync Link selected definitions from an existing canonical source (default)"); + console.log(" reorg Import scattered agent definitions, then unify their symlinks"); console.log(""); - console.log("Interactive Ink UI by default when running in a TTY."); + console.log("The interactive Ink UI is used by default in a TTY."); console.log(""); console.log("Flags:"); console.log(" --dry-run Preview changes without writing to disk"); @@ -919,7 +924,7 @@ async function runUninstall(options) { process.stdout.write(`\nUninstall complete: ${removed} removed, ${skipped} skipped, ${missing} missing\n`); } -async function runCheck(options, _config) { +async function runCheck(options, config = getConfig()) { const lockfile = readLockfile(); let linksToCheck; @@ -930,7 +935,7 @@ async function runCheck(options, _config) { linksToCheck = linksToCheck.filter((l) => options.profileIds.includes(l.profileId)); } } else { - const profiles = discoverProfiles(); + const profiles = discoverProfiles(config.sourceRoot, null, config); linksToCheck = []; for (const profile of profiles) { for (const action of profile.actions) { diff --git a/scripts/install-ui.mjs b/scripts/install-ui.mjs index f8f9449..2f90a33 100644 --- a/scripts/install-ui.mjs +++ b/scripts/install-ui.mjs @@ -1 +1 @@ -export { runInkInstaller } from "./tui/index.mjs"; +export { runInkInstaller, runInkReorg } from "./tui/index.mjs"; diff --git a/scripts/install.js b/scripts/install.js index 9bb54bc..cd18313 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -7,7 +7,6 @@ const readline = require("readline/promises"); const { version } = require("../package.json"); const { buildInspectionCache, - getDefaultRepoRoot, detectInstalledTools, discoverProfiles, inspectProfile, @@ -52,13 +51,15 @@ async function handleInvalidConfig(configError) { } async function runPlainInstaller({ profiles, options, initialSelectedIds, sourceRoot }) { - const selectedIds = initialSelectedIds || (() => { - if (!process.stdin.isTTY) { - throw new Error("No profile selection provided. Re-run with --all or --profile in non-interactive mode."); - } + const selectedIds = + initialSelectedIds || + (() => { + if (!process.stdin.isTTY) { + throw new Error("No profile selection provided. Re-run with --all or --profile in non-interactive mode."); + } - return profiles.filter((profile) => profile.recommended && !profile.informational).map((profile) => profile.id); - })(); + return profiles.filter((profile) => profile.recommended && !profile.informational).map((profile) => profile.id); + })(); const unknownProfiles = selectedIds.filter((id) => !profiles.some((profile) => profile.id === id)); if (unknownProfiles.length > 0) { @@ -76,7 +77,9 @@ async function runPlainInstaller({ profiles, options, initialSelectedIds, source console.log(""); console.log("Selected profiles:"); for (const profile of selectedProfiles) { - console.log(`- ${standaloneProfileLabel(profile)} (${profile.actions.length} link${profile.actions.length === 1 ? "" : "s"})`); + console.log( + `- ${standaloneProfileLabel(profile)} (${profile.actions.length} link${profile.actions.length === 1 ? "" : "s"})`, + ); } console.log(""); @@ -179,6 +182,24 @@ async function runPlainInstaller({ profiles, options, initialSelectedIds, source } async function main(argv) { + if (argv[0] === "reorg") { + const config = loadConfig(null, { initialize: false }); + if (config.configError) { + const choice = await handleInvalidConfig(config.configError); + if (choice === "exit") { + process.exit(0); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, ""); + const backupPath = `${CONFIG_PATH}.bak.${timestamp}`; + fs.renameSync(CONFIG_PATH, backupPath); + writeDefaultConfig(); + process.stdout.write(`Backed up to ${backupPath}\nRestored defaults.\n\n`); + } + const { runReorg } = require("./reorg.js"); + await runReorg(argv, loadConfig(null, { initialize: false })); + return; + } + const options = parseArgs(argv); if (options.version) { @@ -186,8 +207,18 @@ async function main(argv) { return; } - const config = loadConfig(getDefaultRepoRoot()); - const { configError, sourceRoot } = config; + if (options.help) { + printUsage([]); + return; + } + + if (options.uninstall) { + await runUninstall(options); + return; + } + + let config = loadConfig(null); + let { configError, sourceRoot } = config; if (sourceRoot && !fs.existsSync(sourceRoot)) { process.stderr.write(`Warning: source root ${sourceRoot} does not exist. Profiles may be empty.\n`); } @@ -201,26 +232,37 @@ async function main(argv) { fs.renameSync(CONFIG_PATH, backupPath); writeDefaultConfig(); process.stdout.write(`Backed up to ${backupPath}\nRestored defaults.\n\n`); + config = loadConfig(null); + sourceRoot = config.sourceRoot; + configError = config.configError; } - const detection = detectInstalledTools(); - const profiles = discoverProfiles(undefined, detection); + if (!sourceRoot) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error(`No source root configured. Set sourceRoot in ${CONFIG_PATH} or use SADDLE_SOURCE_ROOT.`); + } - if (options.help) { - printUsage(profiles); - return; + const suggested = `${process.env.HOME || "~"}/.config/saddle/shared`; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question(`Canonical source root [${suggested}]: `)).trim(); + sourceRoot = answer || suggested; + } finally { + rl.close(); + } + writeSourceRoot(sourceRoot); + config = loadConfig(null); + sourceRoot = config.sourceRoot; } + const detection = detectInstalledTools(config); + const profiles = discoverProfiles(sourceRoot, detection, config); + if (options.listOnly) { printProfiles(profiles); return; } - if (options.uninstall) { - await runUninstall(options); - return; - } - if (options.check) { await runCheck(options, config); return; @@ -241,7 +283,7 @@ async function main(argv) { runInstallation, inspectProfile, buildInspectionCache, - sourceRoot: getDefaultRepoRoot(), + sourceRoot, configPath: CONFIG_PATH, writeSourceRoot, }); diff --git a/scripts/load-config.js b/scripts/load-config.js index 3cd42d2..fa1ee5e 100644 --- a/scripts/load-config.js +++ b/scripts/load-config.js @@ -21,6 +21,20 @@ const { parse, stringify } = require("yaml"); * @property {string} [itemType] - Override the inferred item type label (e.g. "skill", "agent", "command") */ +/** + * @typedef {Object} ReorgLocation + * @property {string} path - Absolute or tilde-prefixed directory scanned and linked by the reorg command + * @property {'universal'|'provider'} targetClass - Whether the location is shared by several tools or owned by one tool + */ + +/** + * @typedef {Object} ReorgAsset + * @property {'skill'|'agent'|'command'|'instruction'|'config'} kind - Canonical asset category + * @property {string} canonical - Source-root-relative canonical directory + * @property {'directories'|'files'} entries - How items are enumerated inside each location + * @property {ReorgLocation[]} locations - Supported discovery and link locations + */ + /** * @typedef {Object} Rule * @property {string} name - Tool identifier (e.g. "claude", "codex") @@ -30,6 +44,8 @@ const { parse, stringify } = require("yaml"); * @property {boolean} enabled - Whether this rule is active * @property {'multi-select'|'single-select'} mode - Selection mode: "multi-select" (default) allows selecting any combination; "single-select" allows only one item at a time * @property {Mapping[]} mappings - Ordered list of source→target mapping definitions + * @property {number} schemaVersion - Provider rule schema version + * @property {ReorgAsset[]} reorgAssets - Provider locations used by `saddle reorg` */ /** @@ -41,6 +57,8 @@ const { parse, stringify } = require("yaml"); /** * @typedef {Object} Config * @property {string} sourceRoot - Absolute path to the canonical definitions repo + * @property {string|null} configuredSourceRoot - Explicit source root from CLI environment or config, null when unset + * @property {'universal-first'|'provider-only'} linkStrategy - Reorganization target selection strategy * @property {IgnoreSpec} ignore - Compiled ignore rules for directory mappings * @property {Rule[]} rules - Loaded and normalised tool rules * @property {function(string|any): string|null} expandHome - Expands a leading `~/` to the OS home directory @@ -54,7 +72,9 @@ const RULES_DIR = process.env.SADDLE_RULES_DIR || path.join(CONFIG_DIR, "rules") const DEFAULT_IGNORE_NAMES = [".gitignore", "package.json", "bun.lock", "yarn.lock", "package-lock.json", ".DS_Store"]; const DEFAULT_IGNORE_GLOBS = ["*.bak.*"]; -const DEFAULT_SOURCE_ROOT = "~/dev/ai"; +const DEFAULT_SOURCE_ROOT = null; +const DEFAULT_LINK_STRATEGY = "universal-first"; +const CURRENT_RULE_SCHEMA_VERSION = 2; const BUNDLED_RULES_DIR = path.join(__dirname, "..", "rules"); @@ -99,8 +119,51 @@ function normalizeBinary(raw) { return null; } +function normalizeLinkStrategy(value) { + return value === "provider-only" ? "provider-only" : DEFAULT_LINK_STRATEGY; +} + +function normalizeReorgAssets(raw) { + const assets = raw && Array.isArray(raw.assets) ? raw.assets : []; + const supportedKinds = new Set(["skill", "agent", "command", "instruction", "config"]); + + return assets + .filter( + (asset) => + asset && + supportedKinds.has(asset.kind) && + typeof asset.canonical === "string" && + asset.canonical.length > 0 && + !path.isAbsolute(asset.canonical) && + path.normalize(asset.canonical) !== ".." && + path.normalize(asset.canonical) !== "." && + !path.normalize(asset.canonical).startsWith(`..${path.sep}`) && + ["directories", "files"].includes(asset.entries) && + Array.isArray(asset.locations), + ) + .map((asset) => ({ + kind: asset.kind, + canonical: asset.canonical, + entries: asset.entries === "files" ? "files" : "directories", + locations: asset.locations + .filter( + (location) => + location && + typeof location.path === "string" && + (path.isAbsolute(location.path) || location.path === "~" || location.path.startsWith("~/")) && + ["universal", "provider"].includes(location.targetClass), + ) + .map((location) => ({ + path: location.path, + targetClass: location.targetClass, + })), + })) + .filter((asset) => asset.locations.length > 0); +} + function normalizeRule(raw) { if (!raw || typeof raw.tool !== "string") return null; + const schemaVersion = Number.isInteger(raw.schemaVersion) ? raw.schemaVersion : 1; return { name: raw.tool, label: raw.label || raw.tool, @@ -108,6 +171,8 @@ function normalizeRule(raw) { home: raw.home || null, enabled: raw.enabled !== false, mode: raw.mode === "single-select" ? "single-select" : "multi-select", + schemaVersion, + reorgAssets: schemaVersion === CURRENT_RULE_SCHEMA_VERSION ? normalizeReorgAssets(raw.reorg) : [], mappings: Array.isArray(raw.mappings) ? raw.mappings .filter((m) => m && m.type && m.source && m.target !== undefined) @@ -135,38 +200,66 @@ function seedDefaultRules() { } } -function loadRules() { - if (!fs.existsSync(RULES_DIR)) { - seedDefaultRules(); - } - - if (!fs.existsSync(RULES_DIR)) { - return []; - } - - const files = fs.readdirSync(RULES_DIR).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); - const rules = []; - +function readRawRules(directoryPath) { + if (!fs.existsSync(directoryPath)) return []; + const files = fs + .readdirSync(directoryPath) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .sort(); + const rawRules = []; for (const file of files) { - const filePath = path.join(RULES_DIR, file); + const filePath = path.join(directoryPath, file); try { const raw = fs.readFileSync(filePath, "utf8"); const parsed = parse(raw); - const rule = normalizeRule(parsed); - if (rule) { - rules.push(rule); - } + if (parsed && typeof parsed.tool === "string") rawRules.push(parsed); } catch { /* skip unparseable rule file — malformed YAML should not crash the whole config load */ } } + return rawRules; +} + +function loadRules({ initialize = true } = {}) { + if (initialize && !fs.existsSync(RULES_DIR)) seedDefaultRules(); + + const customRules = readRawRules(RULES_DIR); + if (process.env.SADDLE_RULES_DIR) { + return customRules.map(normalizeRule).filter(Boolean); + } + + const mergedRules = new Map(); + for (const bundled of readRawRules(BUNDLED_RULES_DIR)) mergedRules.set(bundled.tool, bundled); + for (const custom of customRules) { + const bundled = mergedRules.get(custom.tool); + const inheritsBundledReorg = Boolean(bundled?.reorg) && !Object.hasOwn(custom, "reorg"); + const hasUnversionedCustomReorg = Object.hasOwn(custom, "reorg") && !Object.hasOwn(custom, "schemaVersion"); + const inheritedSchemaVersion = inheritsBundledReorg + ? Math.max( + Number.isInteger(bundled.schemaVersion) ? bundled.schemaVersion : 1, + Number.isInteger(custom.schemaVersion) ? custom.schemaVersion : 1, + ) + : custom.schemaVersion; + mergedRules.set( + custom.tool, + bundled + ? { + ...bundled, + ...custom, + ...(inheritsBundledReorg ? { reorg: bundled.reorg, schemaVersion: inheritedSchemaVersion } : {}), + ...(hasUnversionedCustomReorg ? { schemaVersion: 1 } : {}), + } + : custom, + ); + } - return rules; + return Array.from(mergedRules.values()).map(normalizeRule).filter(Boolean); } function writeDefaultConfig() { const defaultObj = { sourceRoot: DEFAULT_SOURCE_ROOT, + linkStrategy: DEFAULT_LINK_STRATEGY, ignore: [], }; fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); @@ -174,7 +267,7 @@ function writeDefaultConfig() { seedDefaultRules(); } -function loadConfig(fallbackSourceRoot) { +function loadConfig(fallbackSourceRoot, { initialize = true } = {}) { let parsed = {}; let configError = null; @@ -186,16 +279,19 @@ function loadConfig(fallbackSourceRoot) { configError = yamlErr.message || String(yamlErr); } } catch (err) { - if (err.code === "ENOENT") { + if (err.code === "ENOENT" && initialize) { writeDefaultConfig(); } } - const sourceRoot = expandHome(parsed.sourceRoot) || expandHome(DEFAULT_SOURCE_ROOT) || fallbackSourceRoot; + const configuredSourceRoot = + expandHome(process.env.SADDLE_SOURCE_ROOT) || expandHome(parsed.sourceRoot) || expandHome(DEFAULT_SOURCE_ROOT); + const sourceRoot = configuredSourceRoot || fallbackSourceRoot; + const linkStrategy = normalizeLinkStrategy(process.env.SADDLE_LINK_STRATEGY || parsed.linkStrategy); const ignore = buildIgnore(Array.isArray(parsed.ignore) ? parsed.ignore : []); - const rules = loadRules(); + const rules = loadRules({ initialize }); - return { sourceRoot, ignore, rules, expandHome, configError }; + return { sourceRoot, configuredSourceRoot, linkStrategy, ignore, rules, expandHome, configError }; } function writeSourceRoot(newPath) { @@ -211,15 +307,33 @@ function writeSourceRoot(newPath) { fs.writeFileSync(CONFIG_PATH, stringify(parsed, { lineWidth: 120 }), "utf8"); } +function writeReorgSettings({ sourceRoot, linkStrategy }) { + let parsed = {}; + try { + const raw = fs.readFileSync(CONFIG_PATH, "utf8"); + parsed = parse(raw) || {}; + } catch { + /* config file doesn't exist yet - write a fresh one */ + } + + if (sourceRoot !== undefined) parsed.sourceRoot = sourceRoot; + if (linkStrategy !== undefined) parsed.linkStrategy = normalizeLinkStrategy(linkStrategy); + fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); + fs.writeFileSync(CONFIG_PATH, stringify(parsed, { lineWidth: 120 }), "utf8"); +} + module.exports = { loadConfig, loadRules, writeSourceRoot, + writeReorgSettings, writeDefaultConfig, seedDefaultRules, CONFIG_PATH, CONFIG_DIR, RULES_DIR, DEFAULT_SOURCE_ROOT, + DEFAULT_LINK_STRATEGY, + CURRENT_RULE_SCHEMA_VERSION, BUNDLED_RULES_DIR, }; diff --git a/scripts/reorg-core.js b/scripts/reorg-core.js new file mode 100644 index 0000000..881354f --- /dev/null +++ b/scripts/reorg-core.js @@ -0,0 +1,753 @@ +#!/usr/bin/env node + +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { CONFIG_DIR } = require("./load-config.js"); + +const STATE_VERSION = 2; +const PLAN_VERSION = 1; +const ACTION_PRIORITY = { + import: 10, + link: 20, + "remove-duplicate": 40, +}; + +function pathExists(targetPath) { + try { + fs.lstatSync(targetPath); + return true; + } catch { + return false; + } +} + +function ensureCanonicalChild(rootPath, relativePath) { + const resolvedRoot = path.resolve(rootPath); + const resolvedChild = path.resolve(rootPath, relativePath); + if (resolvedChild !== resolvedRoot && !resolvedChild.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error(`Canonical path escapes source root: ${relativePath}`); + } + + const physicalRoot = resolveThroughExistingAncestor(resolvedRoot); + const physicalChild = resolveThroughExistingAncestor(resolvedChild); + if (physicalChild !== physicalRoot && !physicalChild.startsWith(`${physicalRoot}${path.sep}`)) { + throw new Error(`Canonical path escapes source root through a symlink: ${relativePath}`); + } + return resolvedChild; +} + +function resolveThroughExistingAncestor(targetPath) { + const resolved = path.resolve(targetPath); + const suffix = []; + let current = resolved; + + while (!pathExists(current)) { + const parent = path.dirname(current); + if (parent === current) return resolved; + suffix.unshift(path.basename(current)); + current = parent; + } + + try { + return path.join(fs.realpathSync.native(current), ...suffix); + } catch { + return resolved; + } +} + +function pathsOverlap(leftPath, rightPath) { + const left = resolveThroughExistingAncestor(leftPath); + const right = resolveThroughExistingAncestor(rightPath); + return left === right || left.startsWith(`${right}${path.sep}`) || right.startsWith(`${left}${path.sep}`); +} + +function resolveContentPath(entryPath) { + const stat = fs.lstatSync(entryPath); + return stat.isSymbolicLink() ? fs.realpathSync(entryPath) : entryPath; +} + +function hashEntry(entryPath) { + const hash = crypto.createHash("sha256"); + const contentPath = resolveContentPath(entryPath); + + function walk(currentPath, relativePath) { + const stat = fs.lstatSync(currentPath); + const mode = stat.mode & 0o777; + + if (stat.isSymbolicLink()) { + hash.update(`L\0${relativePath}\0${fs.readlinkSync(currentPath)}\0`); + return; + } + + if (stat.isFile()) { + hash.update(`F\0${relativePath}\0${mode}\0`); + hash.update(fs.readFileSync(currentPath)); + hash.update("\0"); + return; + } + + if (!stat.isDirectory()) { + hash.update(`O\0${relativePath}\0${mode}\0`); + return; + } + + hash.update(`D\0${relativePath}\0${mode}\0`); + const entries = fs + .readdirSync(currentPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + walk(path.join(currentPath, entry.name), relativePath ? path.posix.join(relativePath, entry.name) : entry.name); + } + } + + walk(contentPath, ""); + return hash.digest("hex"); +} + +function captureSignature(entryPath) { + if (!pathExists(entryPath)) return { type: "missing" }; + + const stat = fs.lstatSync(entryPath); + if (stat.isSymbolicLink()) { + let resolved = null; + let digest = null; + try { + resolved = fs.realpathSync.native(entryPath); + digest = hashEntry(entryPath); + } catch { + /* broken links retain their raw destination as the precondition */ + } + return { + type: "symlink", + linkTarget: fs.readlinkSync(entryPath), + resolved, + digest, + }; + } + + return { + type: stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "other", + digest: stat.isDirectory() || stat.isFile() ? hashEntry(entryPath) : null, + }; +} + +function signaturesEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function listEntries(rootPath, entryMode) { + if (!pathExists(rootPath)) return []; + let rootStat; + try { + rootStat = fs.statSync(rootPath); + } catch { + throw new Error(`Managed collection root is not a readable directory: ${rootPath}`); + } + if (!rootStat.isDirectory()) { + throw new Error(`Managed collection root is not a directory: ${rootPath}`); + } + + return fs + .readdirSync(rootPath, { withFileTypes: true }) + .filter((entry) => { + if (entry.name === ".DS_Store") return false; + const entryPath = path.join(rootPath, entry.name); + if (entry.isSymbolicLink()) { + try { + const resolvedStat = fs.statSync(entryPath); + return entryMode === "files" ? resolvedStat.isFile() : resolvedStat.isDirectory(); + } catch { + return false; + } + } + return entryMode === "files" ? entry.isFile() : entry.isDirectory(); + }) + .map((entry) => { + const entryPath = path.join(rootPath, entry.name); + return { + name: entry.name, + path: entryPath, + digest: hashEntry(entryPath), + signature: captureSignature(entryPath), + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function endpointKey(endpoint) { + return `${endpoint.collectionKey}::${endpoint.targetClass}::${endpoint.path}`; +} + +function collectionKeyFor(kind, canonicalPath, entries) { + return `${kind}::${entries}::${canonicalPath}`; +} + +function scanReorg({ rules, detection = {}, sourceRoot, expandHome }) { + if (!sourceRoot) throw new Error("A source root is required for reorganization."); + + const resolvedSourceRoot = path.resolve(sourceRoot); + const collections = new Map(); + const endpoints = new Map(); + const endpointOwners = new Map(); + const toolAssets = []; + + for (const rule of rules) { + if (rule.enabled === false || rule.reorgAssets.length === 0) continue; + const installed = detection[rule.name] !== false; + + for (const asset of rule.reorgAssets) { + const canonicalPath = ensureCanonicalChild(sourceRoot, asset.canonical); + const collectionKey = collectionKeyFor(asset.kind, canonicalPath, asset.entries); + if (!collections.has(collectionKey)) { + collections.set(collectionKey, { + key: collectionKey, + kind: asset.kind, + entries: asset.entries, + canonicalPath, + items: listEntries(canonicalPath, asset.entries), + }); + } + + const locations = []; + for (const location of asset.locations) { + const expandedPath = expandHome(location.path); + if (!expandedPath) continue; + const resolvedEndpointPath = path.resolve(expandedPath); + if (pathsOverlap(resolvedSourceRoot, resolvedEndpointPath)) { + throw new Error( + `Source root overlaps a managed harness location: ${resolvedSourceRoot} and ${resolvedEndpointPath}`, + ); + } + const ownerKey = `${location.targetClass}::${resolvedEndpointPath}`; + const existingOwner = endpointOwners.get(ownerKey); + if (existingOwner && existingOwner !== collectionKey) { + throw new Error(`Managed harness location maps to multiple canonical collections: ${resolvedEndpointPath}`); + } + endpointOwners.set(ownerKey, collectionKey); + const endpoint = { + collectionKey, + kind: asset.kind, + entries: asset.entries, + path: resolvedEndpointPath, + targetClass: location.targetClass, + tools: [rule.name], + toolLabels: [rule.label], + installed, + }; + const key = endpointKey(endpoint); + const existing = endpoints.get(key); + if (existing) { + if (!existing.tools.includes(rule.name)) existing.tools.push(rule.name); + if (!existing.toolLabels.includes(rule.label)) existing.toolLabels.push(rule.label); + existing.installed = existing.installed || installed; + locations.push(existing); + } else { + endpoint.items = installed ? listEntries(endpoint.path, asset.entries) : []; + endpoints.set(key, endpoint); + locations.push(endpoint); + } + } + + toolAssets.push({ + tool: rule.name, + toolLabel: rule.label, + installed, + collectionKey, + kind: asset.kind, + locations, + }); + } + } + + const sortedEndpoints = Array.from(endpoints.values()).sort( + (left, right) => + left.collectionKey.localeCompare(right.collectionKey) || + left.targetClass.localeCompare(right.targetClass) || + left.path.localeCompare(right.path), + ); + for (const endpoint of sortedEndpoints) { + endpoint.tools.sort(); + endpoint.toolLabels.sort(); + } + + return { + sourceRoot: resolvedSourceRoot, + collections: Array.from(collections.values()).sort((left, right) => left.key.localeCompare(right.key)), + endpoints: sortedEndpoints, + toolAssets: toolAssets.sort( + (left, right) => + left.tool.localeCompare(right.tool) || + left.kind.localeCompare(right.kind) || + left.collectionKey.localeCompare(right.collectionKey), + ), + }; +} + +function selectEndpoints(scan, strategy) { + const selected = new Map(); + const coverage = []; + + for (const toolAsset of scan.toolAssets) { + if (!toolAsset.installed) continue; + const universal = toolAsset.locations.filter((location) => location.targetClass === "universal"); + const provider = toolAsset.locations.filter((location) => location.targetClass === "provider"); + const chosen = strategy === "provider-only" ? provider : universal.length > 0 ? universal : provider; + + coverage.push({ + tool: toolAsset.tool, + toolLabel: toolAsset.toolLabel, + kind: toolAsset.kind, + targetClass: chosen.length > 0 ? chosen[0].targetClass : null, + endpoints: chosen.map((endpoint) => endpoint.path), + supported: chosen.length > 0, + }); + + for (const endpoint of chosen) selected.set(endpointKey(endpoint), endpoint); + } + + coverage.sort((left, right) => left.tool.localeCompare(right.tool) || left.kind.localeCompare(right.kind)); + return { + selected: Array.from(selected.values()).sort( + (left, right) => + left.targetClass.localeCompare(right.targetClass) || + left.path.localeCompare(right.path) || + left.collectionKey.localeCompare(right.collectionKey), + ), + coverage, + }; +} + +function itemMap(items) { + return new Map(items.map((item) => [item.name, item])); +} + +function pointsTo(targetPath, sourcePath) { + if (!pathExists(targetPath)) return false; + const stat = fs.lstatSync(targetPath); + if (!stat.isSymbolicLink()) return false; + try { + return fs.realpathSync.native(targetPath) === fs.realpathSync.native(sourcePath); + } catch { + return false; + } +} + +function buildReorgPlan({ scan, strategy = "universal-first" }) { + if (strategy !== "universal-first" && strategy !== "provider-only") { + throw new Error(`Unknown link strategy: ${strategy}`); + } + + const selection = selectEndpoints(scan, strategy); + const selectedKeys = new Set(selection.selected.map(endpointKey)); + const actions = []; + const conflicts = []; + const unchanged = []; + + for (const collection of scan.collections) { + const canonicalItems = itemMap(collection.items); + const collectionEndpoints = scan.endpoints.filter( + (endpoint) => endpoint.collectionKey === collection.key && endpoint.installed, + ); + const selectedEndpoints = collectionEndpoints.filter((endpoint) => selectedKeys.has(endpointKey(endpoint))); + const suppressedEndpoints = collectionEndpoints.filter((endpoint) => !selectedKeys.has(endpointKey(endpoint))); + const names = new Set(collection.items.map((item) => item.name)); + for (const endpoint of collectionEndpoints) { + for (const item of endpoint.items) names.add(item.name); + } + + for (const name of Array.from(names).sort()) { + const canonicalItem = canonicalItems.get(name) || null; + const occurrences = collectionEndpoints.flatMap((endpoint) => { + const item = endpoint.items.find((candidate) => candidate.name === name); + return item ? [{ endpoint, item }] : []; + }); + const uniqueDigests = new Set(occurrences.map(({ item }) => item.digest)); + const canonicalTarget = path.join(collection.canonicalPath, name); + let expectedDigest = canonicalItem ? canonicalItem.digest : null; + + if (canonicalItem) uniqueDigests.add(canonicalItem.digest); + if (uniqueDigests.size > 1) { + conflicts.push({ + id: `${collection.key}::${name}`, + kind: collection.kind, + name, + canonicalPath: canonicalItem ? canonicalItem.path : null, + sources: [ + ...(canonicalItem + ? [ + { + path: canonicalItem.path, + digest: canonicalItem.digest, + targetClass: "canonical", + tools: [], + }, + ] + : []), + ...occurrences.map(({ endpoint, item }) => ({ + path: item.path, + digest: item.digest, + targetClass: endpoint.targetClass, + tools: endpoint.tools, + })), + ], + reason: "Different content claims the same canonical name", + }); + continue; + } + + if (!canonicalItem) { + const origin = occurrences.slice().sort((left, right) => { + const leftRank = left.endpoint.targetClass === "universal" ? 0 : 1; + const rightRank = right.endpoint.targetClass === "universal" ? 0 : 1; + return leftRank - rightRank || left.item.path.localeCompare(right.item.path); + })[0]; + if (!origin) continue; + expectedDigest = origin.item.digest; + actions.push({ + id: `import::${collection.key}::${name}`, + type: "import", + kind: collection.kind, + name, + source: origin.item.path, + target: canonicalTarget, + targetClass: "canonical", + tools: origin.endpoint.tools, + precondition: captureSignature(canonicalTarget), + expectedDigest, + }); + } + + for (const endpoint of selectedEndpoints) { + const target = path.join(endpoint.path, name); + const existing = endpoint.items.find((item) => item.name === name) || null; + if (pointsTo(target, canonicalTarget)) { + unchanged.push({ + kind: collection.kind, + name, + target, + source: canonicalTarget, + targetClass: endpoint.targetClass, + tools: endpoint.tools, + }); + continue; + } + + if (existing && existing.digest !== expectedDigest) { + conflicts.push({ + id: `target::${endpointKey(endpoint)}::${name}`, + kind: collection.kind, + name, + canonicalPath: canonicalTarget, + sources: [{ path: existing.path, digest: existing.digest, tools: endpoint.tools }], + reason: "Selected target differs from canonical content", + }); + continue; + } + + actions.push({ + id: `link::${endpointKey(endpoint)}::${name}`, + type: "link", + kind: collection.kind, + name, + source: canonicalTarget, + target, + targetClass: endpoint.targetClass, + tools: endpoint.tools, + precondition: captureSignature(target), + expectedDigest, + }); + } + + for (const endpoint of suppressedEndpoints) { + const existing = endpoint.items.find((item) => item.name === name); + if (!existing || selectedEndpoints.some((selectedEndpoint) => selectedEndpoint.path === endpoint.path)) + continue; + if (existing.digest !== expectedDigest) continue; + actions.push({ + id: `remove::${endpointKey(endpoint)}::${name}`, + type: "remove-duplicate", + kind: collection.kind, + name, + source: canonicalTarget, + target: existing.path, + targetClass: endpoint.targetClass, + tools: endpoint.tools, + precondition: existing.signature, + expectedDigest, + }); + } + } + } + + actions.sort((left, right) => { + const leftPriority = left.type === "link" && left.targetClass === "provider" ? 30 : ACTION_PRIORITY[left.type]; + const rightPriority = right.type === "link" && right.targetClass === "provider" ? 30 : ACTION_PRIORITY[right.type]; + return leftPriority - rightPriority || left.target.localeCompare(right.target); + }); + conflicts.sort((left, right) => left.id.localeCompare(right.id)); + unchanged.sort((left, right) => left.target.localeCompare(right.target)); + + const planSeed = JSON.stringify({ + sourceRoot: scan.sourceRoot, + strategy, + actions: actions.map(({ id, type, source, target, precondition }) => ({ id, type, source, target, precondition })), + conflicts, + }); + + return { + version: PLAN_VERSION, + id: crypto.createHash("sha256").update(planSeed).digest("hex").slice(0, 16), + createdAt: new Date().toISOString(), + sourceRoot: scan.sourceRoot, + strategy, + actions, + conflicts, + unchanged, + coverage: selection.coverage, + canApply: conflicts.length === 0, + hasChanges: actions.length > 0, + }; +} + +function removeEntry(entryPath) { + if (!pathExists(entryPath)) return; + const stat = fs.lstatSync(entryPath); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + fs.rmSync(entryPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(entryPath); + } +} + +function copyEntry(sourcePath, targetPath) { + const contentPath = resolveContentPath(sourcePath); + const stat = fs.lstatSync(contentPath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + if (stat.isDirectory()) { + fs.cpSync(contentPath, targetPath, { recursive: true, dereference: false, preserveTimestamps: true }); + return; + } + if (stat.isFile()) { + fs.copyFileSync(contentPath, targetPath); + fs.chmodSync(targetPath, stat.mode & 0o777); + return; + } + throw new Error(`Unsupported entry type: ${sourcePath}`); +} + +function backupEntry(targetPath, backupPath) { + if (!pathExists(targetPath)) return { type: "missing" }; + const stat = fs.lstatSync(targetPath); + if (stat.isSymbolicLink()) { + return { type: "symlink", linkTarget: fs.readlinkSync(targetPath) }; + } + copyEntry(targetPath, backupPath); + return { type: stat.isDirectory() ? "directory" : "file", path: backupPath }; +} + +function restoreBackup(targetPath, backup) { + removeEntry(targetPath); + if (!backup || backup.type === "missing") return; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + if (backup.type === "symlink") { + fs.symlinkSync(backup.linkTarget, targetPath); + return; + } + copyEntry(backup.path, targetPath); +} + +function relativeLinkTarget(sourcePath, targetPath) { + const targetDir = path.dirname(targetPath); + try { + return path.relative(fs.realpathSync(targetDir), sourcePath); + } catch { + return path.relative(targetDir, sourcePath); + } +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +async function applyReorgPlan( + plan, + { configDir = CONFIG_DIR, onEvent = () => {}, beforeAction = () => {}, yieldAfterAction = true } = {}, +) { + if (!plan.canApply) throw new Error("The reorganization plan has unresolved conflicts."); + if (!plan.hasChanges) return { applied: 0, transactionId: null }; + + for (const action of plan.actions) { + const current = captureSignature(action.target); + if (!signaturesEqual(current, action.precondition)) { + throw new Error(`Filesystem changed after planning: ${action.target}`); + } + if (pathExists(action.source) && hashEntry(action.source) !== action.expectedDigest) { + throw new Error(`Filesystem changed after planning: ${action.source}`); + } + } + + const transactionId = `${new Date().toISOString().replace(/[:.]/g, "-")}-${plan.id}`; + const transactionDir = path.join(configDir, "transactions", transactionId); + const backupDir = path.join(transactionDir, "backup"); + const manifestPath = path.join(transactionDir, "manifest.json"); + const completed = []; + const manifest = { + version: STATE_VERSION, + id: transactionId, + planId: plan.id, + sourceRoot: plan.sourceRoot, + strategy: plan.strategy, + status: "applying", + startedAt: new Date().toISOString(), + completed: [], + rollbackErrors: [], + }; + writeJson(manifestPath, manifest); + + try { + for (let index = 0; index < plan.actions.length; index += 1) { + const action = plan.actions[index]; + const current = captureSignature(action.target); + if (!signaturesEqual(current, action.precondition)) { + throw new Error(`Filesystem changed while applying the plan: ${action.target}`); + } + if (pathExists(action.source) && hashEntry(action.source) !== action.expectedDigest) { + throw new Error(`Filesystem changed while applying the plan: ${action.source}`); + } + onEvent({ type: "action-start", action, index, total: plan.actions.length }); + beforeAction({ action, index, total: plan.actions.length }); + + if (action.type === "import") { + const tempTarget = `${action.target}.saddle-tmp-${plan.id}`; + if (pathExists(tempTarget)) { + throw new Error(`Import staging path already exists: ${tempTarget}`); + } + try { + copyEntry(action.source, tempTarget); + if (hashEntry(tempTarget) !== action.expectedDigest) { + throw new Error(`Import verification failed: ${action.source}`); + } + fs.mkdirSync(path.dirname(action.target), { recursive: true }); + fs.renameSync(tempTarget, action.target); + } catch (importError) { + removeEntry(tempTarget); + throw importError; + } + completed.push({ action, backup: { type: "missing" } }); + } else if (action.type === "link") { + const backupPath = path.join(backupDir, String(index)); + const backup = backupEntry(action.target, backupPath); + completed.push({ action, backup }); + removeEntry(action.target); + fs.mkdirSync(path.dirname(action.target), { recursive: true }); + const linkTarget = relativeLinkTarget(action.source, action.target); + const sourceStat = fs.statSync(action.source); + const linkType = process.platform === "win32" ? (sourceStat.isDirectory() ? "junction" : "file") : undefined; + fs.symlinkSync(linkTarget, action.target, linkType); + if (!pointsTo(action.target, action.source)) { + throw new Error(`Link verification failed: ${action.target}`); + } + if (hashEntry(action.target) !== action.expectedDigest) { + throw new Error(`Link content verification failed: ${action.target}`); + } + } else if (action.type === "remove-duplicate") { + const backupPath = path.join(backupDir, String(index)); + const backup = backupEntry(action.target, backupPath); + completed.push({ action, backup }); + removeEntry(action.target); + } else { + throw new Error(`Unsupported reorganization action: ${action.type}`); + } + + manifest.completed = completed.map(({ action, backup }) => ({ + actionId: action.id, + target: action.target, + backup, + })); + writeJson(manifestPath, manifest); + onEvent({ type: "action-complete", action, index: index + 1, total: plan.actions.length }); + if (yieldAfterAction) await new Promise((resolve) => setImmediate(resolve)); + } + + manifest.status = "complete"; + manifest.completedAt = new Date().toISOString(); + writeJson(manifestPath, manifest); + writeJson(path.join(configDir, "reorg-state.json"), { + version: STATE_VERSION, + updatedAt: manifest.completedAt, + sourceRoot: plan.sourceRoot, + strategy: plan.strategy, + lastTransactionId: transactionId, + links: [...plan.actions, ...plan.unchanged] + .filter((action) => action.type === "link" || !action.type) + .map((action) => ({ + source: action.source, + target: action.target, + targetClass: action.targetClass, + tools: action.tools, + kind: action.kind, + })), + }); + onEvent({ type: "complete", transactionId, total: plan.actions.length }); + return { applied: plan.actions.length, transactionId }; + } catch (error) { + for (const record of completed.slice().reverse()) { + try { + restoreBackup(record.action.target, record.backup); + } catch (rollbackError) { + manifest.rollbackErrors.push({ + target: record.action.target, + message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + } + } + manifest.status = manifest.rollbackErrors.length > 0 ? "rollback-failed" : "rolled-back"; + manifest.completed = completed.map(({ action, backup }) => ({ + actionId: action.id, + target: action.target, + backup, + })); + manifest.failedAt = new Date().toISOString(); + manifest.error = error instanceof Error ? error.message : String(error); + writeJson(manifestPath, manifest); + onEvent({ type: "rollback", error: manifest.error }); + throw error; + } +} + +function summarizePlan(plan) { + const counts = { + import: 0, + universal: 0, + provider: 0, + remove: 0, + unchanged: plan.unchanged.length, + conflicts: plan.conflicts.length, + }; + for (const action of plan.actions) { + if (action.type === "import") counts.import += 1; + if (action.type === "link" && action.targetClass === "universal") counts.universal += 1; + if (action.type === "link" && action.targetClass === "provider") counts.provider += 1; + if (action.type === "remove-duplicate") counts.remove += 1; + } + return counts; +} + +module.exports = { + PLAN_VERSION, + STATE_VERSION, + applyReorgPlan, + buildReorgPlan, + captureSignature, + hashEntry, + listEntries, + pathExists, + scanReorg, + selectEndpoints, + signaturesEqual, + summarizePlan, +}; diff --git a/scripts/reorg.js b/scripts/reorg.js new file mode 100644 index 0000000..7ace0c8 --- /dev/null +++ b/scripts/reorg.js @@ -0,0 +1,255 @@ +#!/usr/bin/env node + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const readline = require("readline/promises"); +const { detectInstalledTools } = require("./install-core.js"); +const { applyReorgPlan, buildReorgPlan, scanReorg, summarizePlan } = require("./reorg-core.js"); +const { CONFIG_PATH, writeReorgSettings } = require("./load-config.js"); + +function parseReorgArgs(argv) { + const options = { + dryRun: false, + assumeYes: false, + json: false, + check: false, + help: false, + quiet: false, + sourceRoot: null, + strategy: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "reorg") continue; + if (arg === "--dry-run") options.dryRun = true; + else if (arg === "--yes") options.assumeYes = true; + else if (arg === "--json") { + options.json = true; + options.dryRun = true; + } else if (arg === "--check") options.check = true; + else if (arg === "--quiet") options.quiet = true; + else if (arg === "--help" || arg === "-h") options.help = true; + else if (arg === "--source") { + if (!argv[index + 1]) throw new Error("--source requires a path"); + options.sourceRoot = argv[index + 1]; + index += 1; + } else if (arg.startsWith("--source=")) { + options.sourceRoot = arg.slice("--source=".length); + } else if (arg === "--strategy") { + if (!argv[index + 1]) throw new Error("--strategy requires universal-first or provider-only"); + options.strategy = argv[index + 1]; + index += 1; + } else if (arg.startsWith("--strategy=")) { + options.strategy = arg.slice("--strategy=".length); + } else { + throw new Error(`Unknown reorg argument: ${arg}`); + } + } + + if (options.strategy && !["universal-first", "provider-only"].includes(options.strategy)) { + throw new Error("--strategy must be universal-first or provider-only"); + } + return options; +} + +function printReorgUsage() { + process.stdout.write( + [ + "Usage: saddle reorg [options]", + "", + "Detect existing agent configuration, move it into one source root, and create symlinks.", + "", + "Options:", + " --source path Canonical source root for imported configuration", + " --strategy value universal-first or provider-only", + " --dry-run Print the plan without changing files", + " --json Print the plan as JSON without changing files", + " --check Exit 1 when reorganization work or conflicts remain", + " --yes Apply a conflict-free plan without prompting", + " --quiet Suppress per-action output", + " --help, -h Show this help", + "", + "Configuration precedence:", + " --source, SADDLE_SOURCE_ROOT, sourceRoot in config.yaml, interactive prompt", + " --strategy, SADDLE_LINK_STRATEGY, linkStrategy in config.yaml", + "", + ].join("\n"), + ); +} + +function expandUserPath(value) { + if (!value) return null; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2)); + return path.resolve(value); +} + +async function promptForSourceRoot() { + const suggested = path.join(os.homedir(), ".config", "saddle", "shared"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question(`Canonical source root [${suggested}]: `)).trim(); + return expandUserPath(answer || suggested); + } finally { + rl.close(); + } +} + +function printPlan(plan) { + const counts = summarizePlan(plan); + process.stdout.write(`\nReorganization plan ${plan.id}\n`); + process.stdout.write(`Source: ${plan.sourceRoot}\n`); + process.stdout.write(`Strategy: ${plan.strategy}\n\n`); + + const supportedCoverage = plan.coverage.filter((item) => item.supported); + const unsupportedCoverage = plan.coverage.filter((item) => !item.supported); + if (supportedCoverage.length > 0) { + process.stdout.write("Coverage\n"); + for (const item of supportedCoverage) { + process.stdout.write(` ${item.toolLabel} / ${item.kind}: ${item.targetClass} -> ${item.endpoints.join(", ")}\n`); + } + } + if (unsupportedCoverage.length > 0) { + process.stdout.write("Not covered\n"); + for (const item of unsupportedCoverage) { + process.stdout.write(` ${item.toolLabel} / ${item.kind}: no target for ${plan.strategy}\n`); + } + } + + process.stdout.write("\nChanges\n"); + process.stdout.write(` ${counts.import} import\n`); + process.stdout.write(` ${counts.universal} universal link\n`); + process.stdout.write(` ${counts.provider} provider link\n`); + process.stdout.write(` ${counts.remove} duplicate removal\n`); + process.stdout.write(` ${counts.unchanged} unchanged\n`); + process.stdout.write(` ${counts.conflicts} conflict\n`); + + if (plan.actions.length > 0) { + process.stdout.write("\nActions\n"); + for (const action of plan.actions) { + const arrow = action.type === "remove-duplicate" ? "remove" : `${action.source} ->`; + process.stdout.write(` ${action.type.padEnd(16)} ${arrow} ${action.target}\n`); + } + } + + if (plan.conflicts.length > 0) { + process.stdout.write("\nConflicts\n"); + for (const conflict of plan.conflicts) { + process.stdout.write(` ${conflict.kind}/${conflict.name}: ${conflict.reason}\n`); + for (const source of conflict.sources) process.stdout.write(` ${source.path}\n`); + } + } + process.stdout.write("\n"); +} + +function applyWithOutput(plan, options) { + return applyReorgPlan(plan, { + onEvent: (event) => { + if (options.quiet) return; + if (event.type === "action-complete") { + process.stdout.write(`${event.action.type.padEnd(16)} ${event.action.target}\n`); + } + if (event.type === "rollback") process.stderr.write(`Rollback: ${event.error}\n`); + }, + }); +} + +async function runReorg(argv, config) { + const options = parseReorgArgs(argv); + if (options.help) { + printReorgUsage(); + return { status: "help" }; + } + + let sourceRoot = expandUserPath(options.sourceRoot) || expandUserPath(config.configuredSourceRoot); + const strategy = options.strategy || config.linkStrategy || "universal-first"; + let shouldPersistSettings = Boolean(options.sourceRoot || options.strategy); + + if (!sourceRoot && process.stdin.isTTY && process.stdout.isTTY) { + sourceRoot = await promptForSourceRoot(); + shouldPersistSettings = true; + } + if (!sourceRoot) { + throw new Error( + `No source root configured. Use --source, SADDLE_SOURCE_ROOT, or set sourceRoot in ${CONFIG_PATH}.`, + ); + } + + const detection = detectInstalledTools(config); + const scan = scanReorg({ + rules: config.rules, + detection, + sourceRoot, + expandHome: config.expandHome, + }); + const plan = buildReorgPlan({ scan, strategy }); + + if (options.json) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + return { status: "planned", plan }; + } + + if (options.check) { + printPlan(plan); + process.exitCode = plan.hasChanges || plan.conflicts.length > 0 ? 1 : 0; + return { status: process.exitCode === 0 ? "clean" : "drift", plan }; + } + + if (options.dryRun) { + printPlan(plan); + return { status: "planned", plan }; + } + + if (plan.conflicts.length > 0) { + if (!options.assumeYes && process.stdin.isTTY && process.stdout.isTTY) { + const { runInkReorg } = await import("./install-ui.mjs"); + await runInkReorg({ plan, applyReorgPlan }); + } else { + printPlan(plan); + } + process.exitCode = 1; + return { status: "conflicts", plan }; + } + + if (!plan.hasChanges) { + process.stdout.write(`Already unified. No changes for ${sourceRoot}.\n`); + return { status: "clean", plan }; + } + + let result; + if (!options.assumeYes && process.stdin.isTTY && process.stdout.isTTY) { + const { runInkReorg } = await import("./install-ui.mjs"); + result = await runInkReorg({ plan, applyReorgPlan }); + if (result.error) { + process.exitCode = 1; + return { status: "failed", plan, result }; + } + if (!result.applied) return { status: "cancelled", plan }; + } else { + if (!options.assumeYes) { + throw new Error("Non-interactive reorganization requires --yes, --dry-run, --json, or --check."); + } + printPlan(plan); + result = await applyWithOutput(plan, options); + } + + if (shouldPersistSettings || !fs.existsSync(CONFIG_PATH)) { + writeReorgSettings({ sourceRoot, linkStrategy: strategy }); + } + process.stdout.write( + `Reorganization complete. ${result.applied} action${result.applied === 1 ? "" : "s"} applied.\n`, + ); + return { status: "applied", plan, result }; +} + +module.exports = { + expandUserPath, + parseReorgArgs, + printPlan, + printReorgUsage, + runReorg, +}; diff --git a/scripts/tui/ReorgApp.mjs b/scripts/tui/ReorgApp.mjs new file mode 100644 index 0000000..9b60bb3 --- /dev/null +++ b/scripts/tui/ReorgApp.mjs @@ -0,0 +1,314 @@ +import path from "path"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useApp, useInput } from "ink"; +import { ProgressBar, Spinner, StatusMessage } from "@inkjs/ui"; +import { theme } from "./theme/index.mjs"; +import { palette } from "./theme/catalog.mjs"; +import { Frame, ShortPath, TerminalTag } from "./ui/primitives.mjs"; +import { h } from "./ui/react-helpers.mjs"; + +const toneByType = { + import: "magenta", + link: "cyan", + "remove-duplicate": "orange", +}; + +const labelByType = { + import: "IMPORT", + link: "LINK", + "remove-duplicate": "REMOVE", +}; + +function StrategySummary({ plan, height }) { + const universal = plan.coverage.filter((item) => item.supported && item.targetClass === "universal"); + const provider = plan.coverage.filter((item) => item.supported && item.targetClass === "provider"); + const unsupported = plan.coverage.filter((item) => !item.supported); + const roots = new Map(); + + for (const item of plan.coverage.filter((candidate) => candidate.supported)) { + for (const endpoint of item.endpoints) { + const key = `${item.targetClass}:${endpoint}`; + if (!roots.has(key)) roots.set(key, { path: endpoint, targetClass: item.targetClass }); + } + } + + const selectedRoots = Array.from(roots.values()).sort((left, right) => { + const leftRank = left.targetClass === "universal" ? 0 : 1; + const rightRank = right.targetClass === "universal" ? 0 : 1; + return leftRank - rightRank || left.path.localeCompare(right.path); + }); + const visibleLimit = Math.max(3, height - 11); + const visibleRoots = selectedRoots.slice(0, visibleLimit); + const hidden = selectedRoots.length - visibleRoots.length; + + return h( + Frame, + { title: "Routing", color: plan.strategy === "universal-first" ? "cyan" : "blue", flexGrow: 1 }, + h( + Box, + { marginBottom: 1, columnGap: 1, flexWrap: "wrap" }, + h(TerminalTag, { tone: plan.strategy === "universal-first" ? "cyan" : "blue" }, plan.strategy), + universal.length > 0 ? h(TerminalTag, { tone: "cyan" }, `${universal.length} shared`) : null, + provider.length > 0 ? h(TerminalTag, { tone: "blue" }, `${provider.length} native`) : null, + unsupported.length > 0 ? h(TerminalTag, { tone: "orange" }, `${unsupported.length} unsupported`) : null, + ), + h(Text, { color: theme.color.fg.secondary }, "Each harness asset uses one endpoint class."), + h(Box, { height: 1 }), + h(Text, { color: theme.color.fg.primary, bold: true }, "Selected roots"), + ...visibleRoots.map((root) => + h( + Box, + { key: `${root.targetClass}-${root.path}`, columnGap: 1 }, + h( + Text, + { color: root.targetClass === "universal" ? palette.cyan : palette.blue, bold: true }, + root.targetClass === "universal" ? "SHR" : "NAT", + ), + h(ShortPath, { pathText: root.path, color: "gray" }), + ), + ), + hidden > 0 ? h(Text, { color: theme.color.fg.dim }, `${hidden} more selected roots`) : null, + ); +} + +function PlanReview({ plan, height }) { + const visibleLimit = Math.max(4, height - 12); + const visibleActions = plan.actions.slice(0, visibleLimit); + const hidden = Math.max(0, plan.actions.length - visibleActions.length); + const counts = useMemo(() => { + const result = { import: 0, universal: 0, provider: 0, remove: 0 }; + for (const action of plan.actions) { + if (action.type === "import") result.import += 1; + if (action.type === "link" && action.targetClass === "universal") result.universal += 1; + if (action.type === "link" && action.targetClass === "provider") result.provider += 1; + if (action.type === "remove-duplicate") result.remove += 1; + } + return result; + }, [plan.actions]); + + return h( + Frame, + { title: "Review changes", color: "orange", flexGrow: 1 }, + h( + Box, + { columnGap: 1, flexWrap: "wrap", marginBottom: 1 }, + counts.import > 0 ? h(TerminalTag, { tone: "magenta" }, `${counts.import} import`) : null, + counts.universal > 0 ? h(TerminalTag, { tone: "cyan" }, `${counts.universal} universal`) : null, + counts.provider > 0 ? h(TerminalTag, { tone: "blue" }, `${counts.provider} provider`) : null, + counts.remove > 0 ? h(TerminalTag, { tone: "orange" }, `${counts.remove} remove`) : null, + h(TerminalTag, { tone: "gray" }, `${plan.unchanged.length} unchanged`), + ), + ...visibleActions.map((action) => + h( + Box, + { key: action.id, height: 1, justifyContent: "space-between", columnGap: 2 }, + h( + Box, + { minWidth: 0, columnGap: 1 }, + h(Text, { color: theme.color.fg.primary }, path.basename(action.target)), + h(Text, { color: theme.color.fg.dim }, action.tools.join(", ")), + ), + h( + Box, + { flexShrink: 0, columnGap: 1 }, + action.type === "link" ? h(Text, { color: theme.color.fg.muted }, action.targetClass.toUpperCase()) : null, + h( + Text, + { color: palette[toneByType[action.type]] || theme.color.accent.primary, bold: true }, + labelByType[action.type] || action.type.toUpperCase(), + ), + ), + ), + ), + hidden > 0 ? h(Text, { color: theme.color.fg.dim }, `${hidden} more actions`) : null, + h(Box, { flexGrow: 1 }), + h( + Box, + { + borderStyle: "single", + borderColor: theme.color.accent.primary, + justifyContent: "center", + paddingX: 1, + }, + h( + Text, + { color: theme.color.accent.bright, bold: true }, + "Enter applies this plan. Esc cancels without writing.", + ), + ), + ); +} + +function ConflictReview({ plan, height }) { + const visibleLimit = Math.max(3, height - 10); + const visibleConflicts = plan.conflicts.slice(0, visibleLimit); + const hidden = plan.conflicts.length - visibleConflicts.length; + + return h( + Frame, + { title: "Conflicts require attention", color: "red", flexGrow: 1 }, + h( + Text, + { color: theme.color.fg.secondary }, + "Saddle found different content with the same canonical name. Nothing can be applied until each conflict is resolved.", + ), + h(Box, { height: 1 }), + ...visibleConflicts.map((conflict) => + h( + Box, + { key: conflict.id, flexDirection: "column", marginBottom: 1 }, + h(Text, { color: theme.color.state.error, bold: true }, `${conflict.kind}/${conflict.name}`), + ...conflict.sources.map((source) => + h(ShortPath, { key: `${conflict.id}-${source.path}`, pathText: source.path, color: "gray" }), + ), + ), + ), + hidden > 0 ? h(Text, { color: theme.color.fg.dim }, `${hidden} more conflicts`) : null, + h(Box, { flexGrow: 1 }), + h( + Box, + { borderStyle: "single", borderColor: theme.color.state.error, justifyContent: "center", paddingX: 1 }, + h( + Text, + { color: theme.color.fg.secondary }, + "Resolve the files above, then run reorg again. Enter, Esc, or q exits.", + ), + ), + ); +} + +function ApplyProgress({ plan, completed, current, error, done }) { + const percent = plan.actions.length > 0 ? Math.round((completed / plan.actions.length) * 100) : 100; + return h( + Frame, + { + title: error ? "Reorganization failed" : done ? "Reorganization complete" : "Applying plan", + color: error ? "red" : "cyan", + flexGrow: 1, + }, + error + ? h( + Box, + { flexDirection: "column" }, + h(Text, { color: theme.color.state.error, bold: true }, error), + h( + Text, + { color: theme.color.fg.muted }, + "Apply stopped. Review the error and transaction manifest, if one was created, before retrying.", + ), + ) + : done + ? h(StatusMessage, { variant: "success" }, `${completed} actions applied and verified.`) + : h(Spinner, { label: current ? `Applying ${path.basename(current.target)}` : "Preparing transaction" }), + h(Box, { marginTop: 1 }, h(ProgressBar, { value: percent })), + h(Text, { color: theme.color.fg.muted }, `${completed}/${plan.actions.length} actions`), + current + ? h( + Box, + { marginTop: 1, flexDirection: "column" }, + h(Text, { color: theme.color.fg.primary }, labelByType[current.type] || current.type.toUpperCase()), + h(ShortPath, { pathText: current.target, color: "gray" }), + ) + : null, + h(Box, { flexGrow: 1 }), + done || error ? h(Text, { color: theme.color.fg.muted }, "Press enter or q to exit.") : null, + ); +} + +export function ReorgApp({ plan, applyReorgPlan, onFinish }) { + const { exit } = useApp(); + const [stage, setStage] = useState(plan.conflicts.length > 0 ? "conflicts" : "review"); + const [completed, setCompleted] = useState(0); + const [current, setCurrent] = useState(null); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const startedRef = useRef(false); + const height = Math.max(18, (process.stdout.rows || 24) - 4); + const width = process.stdout.columns || 100; + const gap = 1; + const availableWidth = Math.max(27, width - gap - 2); + const leftWidth = Math.max(12, Math.floor(availableWidth * 0.36)); + const rightWidth = Math.max(12, availableWidth - leftWidth); + + useInput((input, key) => { + const requestedExit = key.escape || input === "q" || (key.ctrl && input === "c"); + + if (stage === "conflicts") { + if (key.return || requestedExit) { + onFinish({ applied: false, conflicts: true }); + exit(); + } + return; + } + if (stage === "review") { + if (key.return) setStage("apply"); + if (requestedExit) { + onFinish({ applied: false }); + exit(); + } + return; + } + if ((done || error) && (key.return || requestedExit)) { + onFinish(error ? { applied: false, error } : { applied: true, ...result }); + exit(); + } + }); + + useEffect(() => { + if (stage !== "apply" || startedRef.current) return; + startedRef.current = true; + let active = true; + + applyReorgPlan(plan, { + onEvent: (event) => { + if (!active) return; + if (event.action) setCurrent(event.action); + if (event.type === "action-complete") setCompleted(event.index); + }, + }) + .then((appliedResult) => { + if (!active) return; + setResult(appliedResult); + setDone(true); + }) + .catch((applyError) => { + if (!active) return; + setError(applyError instanceof Error ? applyError.message : String(applyError)); + }); + + return () => { + active = false; + }; + }, [stage, plan, applyReorgPlan]); + + return h( + Box, + { flexDirection: "column", paddingX: 1 }, + h( + Box, + { + borderStyle: "single", + borderColor: theme.color.border.subtle, + backgroundColor: theme.color.bg.elevated, + paddingX: 1, + marginBottom: 1, + justifyContent: "space-between", + }, + h(Box, { columnGap: 1 }, h(Text, { bold: true }, "Saddle"), h(Text, { color: theme.color.fg.muted }, "/ reorg")), + h(ShortPath, { pathText: plan.sourceRoot, color: "cyan" }), + ), + stage === "review" || stage === "conflicts" + ? h( + Box, + { height, columnGap: gap }, + h(Box, { width: leftWidth }, h(StrategySummary, { plan, height })), + h( + Box, + { width: rightWidth }, + stage === "conflicts" ? h(ConflictReview, { plan, height }) : h(PlanReview, { plan, height }), + ), + ) + : h(Box, { height }, h(ApplyProgress, { plan, completed, current, error, done })), + ); +} diff --git a/scripts/tui/index.mjs b/scripts/tui/index.mjs index cdad207..2e76eaa 100644 --- a/scripts/tui/index.mjs +++ b/scripts/tui/index.mjs @@ -1,8 +1,8 @@ - import { render } from "ink"; import { ThemeProvider } from "@inkjs/ui"; import { uiTheme } from "./theme/index.mjs"; import { InstallerApp } from "./App.mjs"; +import { ReorgApp } from "./ReorgApp.mjs"; import { h } from "./ui/react-helpers.mjs"; function createForcedColorStdout(stdout) { @@ -26,7 +26,17 @@ function createForcedColorStdout(stdout) { }); } -export async function runInkInstaller({ profiles, options, initialSelectedIds, runInstallation, inspectProfile, buildInspectionCache, sourceRoot, configPath, writeSourceRoot }) { +export async function runInkInstaller({ + profiles, + options, + initialSelectedIds, + runInstallation, + inspectProfile, + buildInspectionCache, + sourceRoot, + configPath, + writeSourceRoot, +}) { process.env.FORCE_COLOR = process.env.FORCE_COLOR || "3"; const stdout = createForcedColorStdout(process.stdout); @@ -54,3 +64,25 @@ export async function runInkInstaller({ profiles, options, initialSelectedIds, r instance.waitUntilExit().catch(reject); }); } + +export async function runInkReorg({ plan, applyReorgPlan }) { + process.env.FORCE_COLOR = process.env.FORCE_COLOR || "3"; + const stdout = createForcedColorStdout(process.stdout); + + return await new Promise((resolve, reject) => { + const instance = render( + h( + ThemeProvider, + { theme: uiTheme }, + h(ReorgApp, { + plan, + applyReorgPlan, + onFinish: resolve, + }), + ), + { exitOnCtrlC: false, stdout, incrementalRendering: true }, + ); + + instance.waitUntilExit().catch(reject); + }); +} diff --git a/tests/load-config.test.js b/tests/load-config.test.js index b37bdf0..a8fa638 100644 --- a/tests/load-config.test.js +++ b/tests/load-config.test.js @@ -86,13 +86,12 @@ describe("load-config", () => { assert.strictEqual(seededFiles.length, bundledFiles.length); }); - it("returns expanded DEFAULT_SOURCE_ROOT as sourceRoot", () => { - const { loadConfig, DEFAULT_SOURCE_ROOT } = fresh(); + it("uses the caller fallback when sourceRoot is not configured", () => { + const { loadConfig } = fresh(); const config = loadConfig("/fallback"); - const expected = DEFAULT_SOURCE_ROOT.startsWith("~/") - ? path.join(os.homedir(), DEFAULT_SOURCE_ROOT.slice(1)) - : DEFAULT_SOURCE_ROOT; - assert.strictEqual(config.sourceRoot, expected); + assert.strictEqual(config.sourceRoot, "/fallback"); + assert.strictEqual(config.configuredSourceRoot, null); + assert.strictEqual(config.linkStrategy, "universal-first"); }); it("returns rules array from seeded rule files", () => { @@ -135,14 +134,44 @@ describe("load-config", () => { assert.strictEqual(config.sourceRoot, path.join(os.homedir(), "my/path")); }); - it("falls back to DEFAULT_SOURCE_ROOT when sourceRoot key is absent", () => { + it("falls back to the caller source root when sourceRoot key is absent", () => { mkfile(configPath, "ignore: []\n"); - const { loadConfig, DEFAULT_SOURCE_ROOT } = fresh(); + const { loadConfig } = fresh(); const config = loadConfig("/fallback"); - const expected = DEFAULT_SOURCE_ROOT.startsWith("~/") - ? path.join(os.homedir(), DEFAULT_SOURCE_ROOT.slice(1)) - : DEFAULT_SOURCE_ROOT; - assert.strictEqual(config.sourceRoot, expected); + assert.strictEqual(config.sourceRoot, "/fallback"); + assert.strictEqual(config.configuredSourceRoot, null); + }); + + it("reads provider-only as the link strategy", () => { + mkfile(configPath, "sourceRoot: /custom/source\nlinkStrategy: provider-only\nignore: []\n"); + const config = fresh().loadConfig("/fallback"); + assert.strictEqual(config.linkStrategy, "provider-only"); + }); + + it("normalizes unknown link strategies to universal-first", () => { + mkfile(configPath, "sourceRoot: /custom/source\nlinkStrategy: both\nignore: []\n"); + const config = fresh().loadConfig("/fallback"); + assert.strictEqual(config.linkStrategy, "universal-first"); + }); + + it("lets SADDLE_SOURCE_ROOT override the config file", () => { + mkfile(configPath, "sourceRoot: /custom/source\nignore: []\n"); + process.env.SADDLE_SOURCE_ROOT = "/environment/source"; + try { + const config = fresh().loadConfig("/fallback"); + assert.strictEqual(config.sourceRoot, "/environment/source"); + assert.strictEqual(config.configuredSourceRoot, "/environment/source"); + } finally { + delete process.env.SADDLE_SOURCE_ROOT; + } + }); + + it("can inspect absent config without creating files", () => { + const { loadConfig } = fresh(); + const config = loadConfig("/fallback", { initialize: false }); + assert.strictEqual(config.sourceRoot, "/fallback"); + assert.strictEqual(fs.existsSync(configPath), false); + assert.strictEqual(fs.existsSync(rulesDir), false); }); it("does not rewrite the config file if it already exists", () => { @@ -432,6 +461,122 @@ describe("load-config", () => { assert.strictEqual(rules.length, 1); assert.strictEqual(rules[0].mode, "multi-select"); }); + + it("loads valid version 2 reorganization assets", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "reorg.yaml"), + [ + "schemaVersion: 2", + "tool: test", + "home: /tmp/test", + "reorg:", + " assets:", + " - kind: skill", + " canonical: skills", + " entries: directories", + " locations:", + " - path: ~/.agents/skills", + " targetClass: universal", + "mappings: []", + ].join("\n"), + ); + const [rule] = fresh().loadRules(); + assert.strictEqual(rule.schemaVersion, 2); + assert.deepStrictEqual(rule.reorgAssets[0], { + kind: "skill", + canonical: "skills", + entries: "directories", + locations: [{ path: "~/.agents/skills", targetClass: "universal" }], + }); + }); + + it("ignores reorganization data on legacy rule schemas", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "legacy.yaml"), + [ + "tool: legacy", + "home: /tmp/legacy", + "reorg:", + " assets:", + " - kind: skill", + " canonical: skills", + " entries: directories", + " locations:", + " - path: ~/.agents/skills", + " targetClass: universal", + "mappings: []", + ].join("\n"), + ); + assert.deepStrictEqual(fresh().loadRules()[0].reorgAssets, []); + }); + + it("fails closed on unknown future reorganization schemas", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "future.yaml"), + [ + "schemaVersion: 3", + "tool: future", + "home: /tmp/future", + "reorg:", + " assets:", + " - kind: skill", + " canonical: skills", + " entries: directories", + " locations:", + " - path: ~/.agents/skills", + " targetClass: universal", + "mappings: []", + ].join("\n"), + ); + assert.deepStrictEqual(fresh().loadRules()[0].reorgAssets, []); + }); + + it("rejects unsafe canonical paths and mistyped target classes", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "unsafe.yaml"), + [ + "schemaVersion: 2", + "tool: unsafe", + "home: /tmp/unsafe", + "reorg:", + " assets:", + " - kind: skill", + " canonical: ../../outside", + " entries: directories", + " locations:", + " - path: ~/.agents/skills", + " targetClass: universl", + "mappings: []", + ].join("\n"), + ); + assert.deepStrictEqual(fresh().loadRules()[0].reorgAssets, []); + }); + + it("rejects relative harness locations", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "relative.yaml"), + [ + "schemaVersion: 2", + "tool: relative", + "home: /tmp/relative", + "reorg:", + " assets:", + " - kind: skill", + " canonical: skills", + " entries: directories", + " locations:", + " - path: .agents/skills", + " targetClass: universal", + "mappings: []", + ].join("\n"), + ); + assert.deepStrictEqual(fresh().loadRules()[0].reorgAssets, []); + }); }); describe("seedDefaultRules", () => { @@ -521,6 +666,20 @@ describe("load-config", () => { }); }); + describe("writeReorgSettings", () => { + it("persists the source root and exactly one supported link strategy", () => { + fresh().writeReorgSettings({ sourceRoot: "/canonical", linkStrategy: "provider-only" }); + const raw = fs.readFileSync(configPath, "utf8"); + assert.ok(raw.includes("sourceRoot: /canonical")); + assert.ok(raw.includes("linkStrategy: provider-only")); + }); + + it("normalizes an invalid strategy before writing", () => { + fresh().writeReorgSettings({ sourceRoot: "/canonical", linkStrategy: "both" }); + assert.ok(fs.readFileSync(configPath, "utf8").includes("linkStrategy: universal-first")); + }); + }); + describe("bundled rules", () => { it("BUNDLED_RULES_DIR points to a directory with YAML files", () => { const { BUNDLED_RULES_DIR } = fresh(); @@ -529,25 +688,87 @@ describe("load-config", () => { assert.ok(files.length > 0); }); - it("bundled rules contain claude, codex, cursor, opencode, gemini, copilot", () => { + it("bundled rules contain the supported harness catalog", () => { const { BUNDLED_RULES_DIR } = fresh(); const files = fs.readdirSync(BUNDLED_RULES_DIR).filter((f) => f.endsWith(".yaml")); const names = files.map((f) => f.replace(".yaml", "")); - for (const name of ["claude", "codex", "cursor", "opencode", "gemini", "copilot"]) { + for (const name of ["claude", "codex", "cursor", "opencode", "gemini", "copilot", "goose", "reasonix"]) { assert.ok(names.includes(name), `missing bundled rule: ${name}`); } }); + + it("merges current reorg metadata into legacy user copies without replacing custom sync mappings", () => { + const explicitRulesDir = process.env.SADDLE_RULES_DIR; + const explicitSaddleDir = process.env.SADDLE_DIR; + const standardDir = path.join(tmpDir, "standard-config"); + + try { + delete process.env.SADDLE_RULES_DIR; + process.env.SADDLE_DIR = standardDir; + mkfile( + path.join(standardDir, "rules", "copilot.yaml"), + [ + "tool: copilot", + "label: My Copilot", + "binary: custom-copilot", + "home: ~/.copilot", + "mappings:", + " - type: directory", + " source: my-agents", + " target: agents", + ].join("\n"), + ); + + const copilot = fresh() + .loadRules({ initialize: false }) + .find((rule) => rule.name === "copilot"); + + assert.strictEqual(copilot.label, "My Copilot"); + assert.strictEqual(copilot.binary.which, "custom-copilot"); + assert.deepStrictEqual(copilot.mappings, [{ type: "directory", source: "my-agents", target: "agents" }]); + assert.strictEqual(copilot.schemaVersion, 2); + assert.ok( + copilot.reorgAssets[0].locations.some( + (location) => location.path === "~/.agents/skills" && location.targetClass === "universal", + ), + ); + + mkfile( + path.join(standardDir, "rules", "copilot.yaml"), + [ + "tool: copilot", + "home: ~/.copilot", + "reorg:", + " assets:", + " - kind: skill", + " canonical: unsafe-custom-skills", + " entries: directories", + " locations:", + " - path: ~/.copilot/custom-skills", + " targetClass: provider", + "mappings: []", + ].join("\n"), + ); + const unversionedCustom = fresh() + .loadRules({ initialize: false }) + .find((rule) => rule.name === "copilot"); + assert.strictEqual(unversionedCustom.schemaVersion, 1); + assert.deepStrictEqual(unversionedCustom.reorgAssets, []); + } finally { + process.env.SADDLE_RULES_DIR = explicitRulesDir; + process.env.SADDLE_DIR = explicitSaddleDir; + clearConfigModules(); + } + }); }); describe("loadConfig — invalid YAML", () => { - it("falls back to DEFAULT_SOURCE_ROOT when YAML is unparseable", () => { + it("falls back to the caller source root when YAML is unparseable", () => { mkfile(configPath, "key: [unclosed bracket"); - const { loadConfig, DEFAULT_SOURCE_ROOT } = fresh(); + const { loadConfig } = fresh(); const config = loadConfig("/fallback"); - const expected = DEFAULT_SOURCE_ROOT.startsWith("~/") - ? path.join(os.homedir(), DEFAULT_SOURCE_ROOT.slice(1)) - : DEFAULT_SOURCE_ROOT; - assert.strictEqual(config.sourceRoot, expected); + assert.strictEqual(config.sourceRoot, "/fallback"); + assert.strictEqual(config.configuredSourceRoot, null); }); it("returns the parse error message on config.configError", () => { diff --git a/tests/reorg-core.test.js b/tests/reorg-core.test.js new file mode 100644 index 0000000..8a6c4bb --- /dev/null +++ b/tests/reorg-core.test.js @@ -0,0 +1,488 @@ +"use strict"; + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { spawnSync } = require("node:child_process"); +const { makeTempDir, rmrf, mkfile, mkdir } = require("./helpers.js"); +const { applyReorgPlan, buildReorgPlan, hashEntry, scanReorg, selectEndpoints } = require("../scripts/reorg-core.js"); +const { parseReorgArgs } = require("../scripts/reorg.js"); + +function makeRule({ name, label = name, assets }) { + return { + name, + label, + enabled: true, + reorgAssets: assets, + }; +} + +function skillAsset(canonical, locations) { + return { + kind: "skill", + canonical, + entries: "directories", + locations, + }; +} + +function writeSkill(root, name, body = "Use this skill.\n") { + mkfile(path.join(root, name, "SKILL.md"), `---\nname: ${name}\ndescription: Test ${name}\n---\n\n${body}`); +} + +function expandHome(value) { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2)); + return value; +} + +describe("reorganization core", () => { + let root; + let sourceRoot; + let universalRoot; + let opencodeRoot; + let claudeRoot; + let transactionRoot; + + beforeEach(() => { + root = makeTempDir("saddle-reorg-"); + sourceRoot = path.join(root, "source"); + universalRoot = path.join(root, "home", ".agents", "skills"); + opencodeRoot = path.join(root, "home", ".config", "opencode", "skills"); + claudeRoot = path.join(root, "home", ".claude", "skills"); + transactionRoot = path.join(root, "state"); + }); + + afterEach(() => rmrf(root)); + + function rules() { + return [ + makeRule({ + name: "codex", + label: "Codex", + assets: [skillAsset("skills", [{ path: universalRoot, targetClass: "universal" }])], + }), + makeRule({ + name: "opencode", + label: "OpenCode", + assets: [ + skillAsset("skills", [ + { path: universalRoot, targetClass: "universal" }, + { path: opencodeRoot, targetClass: "provider" }, + ]), + ], + }), + makeRule({ + name: "claude", + label: "Claude Code", + assets: [skillAsset("skills", [{ path: claudeRoot, targetClass: "provider" }])], + }), + ]; + } + + function scan() { + return scanReorg({ + rules: rules(), + detection: { codex: true, opencode: true, claude: true }, + sourceRoot, + expandHome, + }); + } + + it("deduplicates a universal endpoint declared by several tools", () => { + const result = scan(); + const universal = result.endpoints.filter((endpoint) => endpoint.targetClass === "universal"); + assert.strictEqual(universal.length, 1); + assert.deepStrictEqual(universal[0].tools.sort(), ["codex", "opencode"]); + }); + + it("universal-first selects the shared endpoint and only native fallbacks", () => { + const selection = selectEndpoints(scan(), "universal-first"); + assert.deepStrictEqual( + selection.selected.map((endpoint) => endpoint.path).sort(), + [universalRoot, claudeRoot].sort(), + ); + assert.ok(!selection.selected.some((endpoint) => endpoint.path === opencodeRoot)); + }); + + it("provider-only selects provider endpoints and reports universal-only tools as unsupported", () => { + const selection = selectEndpoints(scan(), "provider-only"); + assert.deepStrictEqual( + selection.selected.map((endpoint) => endpoint.path).sort(), + [opencodeRoot, claudeRoot].sort(), + ); + const codex = selection.coverage.find((item) => item.tool === "codex"); + assert.strictEqual(codex.supported, false); + }); + + it("universal-first never links both target classes for one compatible tool", () => { + writeSkill(path.join(sourceRoot, "skills"), "release"); + writeSkill(universalRoot, "release"); + writeSkill(opencodeRoot, "release"); + writeSkill(claudeRoot, "release"); + + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + const linkTargets = plan.actions.filter((action) => action.type === "link").map((action) => action.target); + assert.ok(linkTargets.includes(path.join(universalRoot, "release"))); + assert.ok(linkTargets.includes(path.join(claudeRoot, "release"))); + assert.ok(!linkTargets.includes(path.join(opencodeRoot, "release"))); + assert.ok( + plan.actions.some( + (action) => action.type === "remove-duplicate" && action.target === path.join(opencodeRoot, "release"), + ), + ); + }); + + it("provider-only links provider targets and removes matching universal duplicates", () => { + writeSkill(path.join(sourceRoot, "skills"), "release"); + writeSkill(universalRoot, "release"); + writeSkill(opencodeRoot, "release"); + writeSkill(claudeRoot, "release"); + + const plan = buildReorgPlan({ scan: scan(), strategy: "provider-only" }); + const linkTargets = plan.actions.filter((action) => action.type === "link").map((action) => action.target); + assert.ok(linkTargets.includes(path.join(opencodeRoot, "release"))); + assert.ok(linkTargets.includes(path.join(claudeRoot, "release"))); + assert.ok(!linkTargets.includes(path.join(universalRoot, "release"))); + assert.ok( + plan.actions.some( + (action) => action.type === "remove-duplicate" && action.target === path.join(universalRoot, "release"), + ), + ); + }); + + it("orders universal links before provider fallbacks", () => { + writeSkill(path.join(sourceRoot, "skills"), "release"); + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + const links = plan.actions.filter((action) => action.type === "link"); + assert.strictEqual(links[0].targetClass, "universal"); + assert.strictEqual(links.at(-1).targetClass, "provider"); + }); + + it("produces the same ordered plan when rule discovery order changes", () => { + writeSkill(path.join(sourceRoot, "skills"), "release"); + const detection = { codex: true, opencode: true, claude: true }; + const build = (inputRules) => + buildReorgPlan({ + scan: scanReorg({ rules: inputRules, detection, sourceRoot, expandHome }), + strategy: "universal-first", + }); + + const forward = build(rules()); + const reverse = build(rules().reverse()); + assert.strictEqual(forward.id, reverse.id); + assert.deepStrictEqual( + forward.actions.map(({ id, target }) => ({ id, target })), + reverse.actions.map(({ id, target }) => ({ id, target })), + ); + assert.deepStrictEqual( + forward.coverage.map(({ tool, kind }) => ({ tool, kind })), + reverse.coverage.map(({ tool, kind }) => ({ tool, kind })), + ); + }); + + it("imports provider content, links it, and becomes idempotent", async () => { + writeSkill(claudeRoot, "review"); + const firstPlan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + assert.strictEqual(firstPlan.conflicts.length, 0); + assert.ok(firstPlan.actions.some((action) => action.type === "import")); + + const result = await applyReorgPlan(firstPlan, { configDir: transactionRoot }); + assert.ok(result.transactionId); + const canonical = path.join(sourceRoot, "skills", "review"); + assert.ok(fs.statSync(canonical).isDirectory()); + assert.ok(fs.lstatSync(path.join(claudeRoot, "review")).isSymbolicLink()); + assert.strictEqual(hashEntry(canonical), hashEntry(path.join(claudeRoot, "review"))); + + const secondPlan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + assert.strictEqual(secondPlan.actions.length, 0); + assert.strictEqual(secondPlan.conflicts.length, 0); + }); + + it("follows an existing provider root symlink without replacing the root", async () => { + const physicalClaudeRoot = path.join(root, "dotfiles", "claude-skills"); + writeSkill(physicalClaudeRoot, "review"); + mkdir(path.dirname(claudeRoot)); + fs.symlinkSync(physicalClaudeRoot, claudeRoot); + + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + await applyReorgPlan(plan, { configDir: transactionRoot }); + + assert.ok(fs.lstatSync(claudeRoot).isSymbolicLink()); + assert.ok(fs.lstatSync(path.join(physicalClaudeRoot, "review")).isSymbolicLink()); + assert.ok(fs.statSync(path.join(sourceRoot, "skills", "review")).isDirectory()); + }); + + it("rejects a regular file at a managed collection root", () => { + mkfile(claudeRoot, "not a directory\n"); + assert.throws(() => scan(), /Managed collection root is not a directory/); + }); + + it("plans only new drift after an idempotent run", async () => { + writeSkill(claudeRoot, "review"); + const initialPlan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + await applyReorgPlan(initialPlan, { configDir: transactionRoot }); + assert.strictEqual(buildReorgPlan({ scan: scan(), strategy: "universal-first" }).actions.length, 0); + + writeSkill(claudeRoot, "release"); + const driftPlan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + assert.ok(driftPlan.actions.length > 0); + assert.ok(driftPlan.actions.every((action) => action.name === "release")); + assert.ok(driftPlan.unchanged.every((action) => action.name === "review")); + }); + + it("blocks differing content with the same canonical name", async () => { + writeSkill(path.join(sourceRoot, "skills"), "review", "Canonical version.\n"); + writeSkill(claudeRoot, "review", "Claude version.\n"); + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + assert.strictEqual(plan.canApply, false); + assert.strictEqual(plan.conflicts.length, 1); + await assert.rejects(applyReorgPlan(plan, { configDir: transactionRoot }), /unresolved conflicts/); + }); + + it("rolls back completed imports when a later action fails", async () => { + writeSkill(claudeRoot, "review"); + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + const canonical = path.join(sourceRoot, "skills", "review"); + + await assert.rejects( + applyReorgPlan(plan, { + configDir: transactionRoot, + beforeAction: ({ index }) => { + if (index === 1) throw new Error("injected failure"); + }, + }), + /injected failure/, + ); + assert.strictEqual(fs.existsSync(canonical), false); + assert.ok(fs.statSync(path.join(claudeRoot, "review")).isDirectory()); + + const manifests = fs + .readdirSync(path.join(transactionRoot, "transactions")) + .map((name) => + JSON.parse(fs.readFileSync(path.join(transactionRoot, "transactions", name, "manifest.json"), "utf8")), + ); + assert.strictEqual(manifests[0].status, "rolled-back"); + }); + + it("restores a replaced target when link verification fails", async () => { + writeSkill(claudeRoot, "review"); + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + const link = plan.actions.find( + (action) => action.type === "link" && action.target === path.join(claudeRoot, "review"), + ); + link.source = path.join(root, "missing-source"); + + await assert.rejects(applyReorgPlan(plan, { configDir: transactionRoot }), /ENOENT/); + assert.ok(fs.statSync(path.join(claudeRoot, "review")).isDirectory()); + assert.strictEqual(fs.existsSync(path.join(sourceRoot, "skills", "review")), false); + }); + + it("rejects a plan when canonical content changes before apply", async () => { + writeSkill(path.join(sourceRoot, "skills"), "review", "Original.\n"); + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + writeSkill(path.join(sourceRoot, "skills"), "review", "Changed after planning.\n"); + + await assert.rejects(applyReorgPlan(plan, { configDir: transactionRoot }), /Filesystem changed after planning/); + assert.strictEqual(fs.existsSync(path.join(universalRoot, "review")), false); + assert.strictEqual(fs.existsSync(transactionRoot), false); + }); + + it("rejects source roots that overlap managed harness locations", () => { + assert.throws( + () => + scanReorg({ + rules: rules(), + detection: { codex: true, opencode: true, claude: true }, + sourceRoot: path.dirname(universalRoot), + expandHome, + }), + /overlaps a managed harness location/, + ); + }); + + it("rejects overlapping source roots through a symlinked parent", () => { + mkdir(path.dirname(universalRoot)); + const sourceAlias = path.join(root, "source-alias"); + fs.symlinkSync(path.dirname(universalRoot), sourceAlias); + + assert.throws( + () => + scanReorg({ + rules: rules(), + detection: { codex: true, opencode: true, claude: true }, + sourceRoot: sourceAlias, + expandHome, + }), + /overlaps a managed harness location/, + ); + }); + + it("rejects canonical collections that escape the source root through a symlink", () => { + mkdir(sourceRoot); + const externalSkills = path.join(root, "external-skills"); + mkdir(externalSkills); + fs.symlinkSync(externalSkills, path.join(sourceRoot, "skills")); + + assert.throws( + () => + scanReorg({ + rules: rules(), + detection: { codex: true, opencode: true, claude: true }, + sourceRoot, + expandHome, + }), + /escapes source root through a symlink/, + ); + }); + + it("rejects one harness location claiming different canonical collections", () => { + const ambiguousRule = makeRule({ + name: "ambiguous", + assets: [ + skillAsset("skills", [{ path: universalRoot, targetClass: "universal" }]), + skillAsset("other-skills", [{ path: universalRoot, targetClass: "universal" }]), + ], + }); + assert.throws( + () => + scanReorg({ + rules: [ambiguousRule], + detection: { ambiguous: true }, + sourceRoot, + expandHome, + }), + /maps to multiple canonical collections/, + ); + }); +}); + +describe("parseReorgArgs", () => { + it("parses source and strategy", () => { + const options = parseReorgArgs(["reorg", "--source", "/tmp/shared", "--strategy=provider-only"]); + assert.strictEqual(options.sourceRoot, "/tmp/shared"); + assert.strictEqual(options.strategy, "provider-only"); + }); + + it("makes JSON output read-only", () => { + const options = parseReorgArgs(["reorg", "--json"]); + assert.strictEqual(options.json, true); + assert.strictEqual(options.dryRun, true); + }); + + it("rejects unknown strategies", () => { + assert.throws(() => parseReorgArgs(["reorg", "--strategy", "both"]), /must be universal-first/); + }); +}); + +describe("reorg CLI", () => { + it("does not initialize Saddle state during a dry run", () => { + const root = makeTempDir("saddle-reorg-cli-"); + const configDir = path.join(root, "state"); + const configPath = path.join(configDir, "config.yaml"); + const sourceRoot = path.join(root, "canonical"); + const fakeHome = path.join(root, "home"); + mkdir(sourceRoot); + mkdir(fakeHome); + + try { + const result = spawnSync( + process.execPath, + [path.join(__dirname, "..", "bin", "saddle.js"), "reorg", "--source", sourceRoot, "--dry-run"], + { + cwd: path.join(__dirname, ".."), + encoding: "utf8", + env: { + ...process.env, + HOME: fakeHome, + SADDLE_DIR: configDir, + SADDLE_CONFIG: configPath, + }, + }, + ); + + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Reorganization plan/); + assert.strictEqual(fs.existsSync(configPath), false); + assert.strictEqual(fs.existsSync(configDir), false); + } finally { + rmrf(root); + } + }); + + it("applies a provider rule end to end, then reports only later drift", () => { + const root = makeTempDir("saddle-reorg-cli-live-"); + const fakeHome = path.join(root, "home"); + const configDir = path.join(root, "state"); + const configPath = path.join(configDir, "config.yaml"); + const rulesDir = path.join(root, "rules"); + const sourceRoot = path.join(root, "canonical"); + const providerSkills = path.join(fakeHome, ".claude", "skills"); + mkdir(rulesDir); + writeSkill(providerSkills, "review"); + mkfile( + path.join(rulesDir, "claude.yaml"), + [ + "schemaVersion: 2", + "tool: claude", + "label: Claude Code", + "home: ~/.claude", + "enabled: true", + "reorg:", + " assets:", + " - kind: skill", + " canonical: skills", + " entries: directories", + " locations:", + " - path: ~/.claude/skills", + " targetClass: provider", + "mappings: []", + ].join("\n"), + ); + + const env = { + ...process.env, + HOME: fakeHome, + SADDLE_DIR: configDir, + SADDLE_CONFIG: configPath, + SADDLE_RULES_DIR: rulesDir, + }; + const run = (args) => + spawnSync(process.execPath, [path.join(__dirname, "..", "bin", "saddle.js"), ...args], { + cwd: path.join(__dirname, ".."), + encoding: "utf8", + env, + }); + + try { + const unconfirmedResult = run(["reorg", "--source", sourceRoot, "--strategy", "provider-only"]); + assert.strictEqual(unconfirmedResult.status, 1); + assert.match(unconfirmedResult.stderr, /requires --yes/); + assert.ok(fs.statSync(path.join(providerSkills, "review")).isDirectory()); + assert.strictEqual(fs.existsSync(configPath), false); + + const applyResult = run(["reorg", "--source", sourceRoot, "--strategy", "provider-only", "--yes"]); + assert.strictEqual(applyResult.status, 0, applyResult.stderr); + assert.ok(fs.statSync(path.join(sourceRoot, "skills", "review")).isDirectory()); + assert.ok(fs.lstatSync(path.join(providerSkills, "review")).isSymbolicLink()); + assert.ok(fs.existsSync(path.join(configDir, "reorg-state.json"))); + + const cleanResult = run(["reorg", "--json"]); + assert.strictEqual(cleanResult.status, 0, cleanResult.stderr); + assert.strictEqual(JSON.parse(cleanResult.stdout).actions.length, 0); + assert.strictEqual(run(["reorg", "--check"]).status, 0); + + writeSkill(providerSkills, "release"); + const driftResult = run(["reorg", "--json"]); + assert.strictEqual(driftResult.status, 0, driftResult.stderr); + const driftPlan = JSON.parse(driftResult.stdout); + assert.ok(driftPlan.actions.length > 0); + assert.ok(driftPlan.actions.every((action) => action.name === "release")); + assert.strictEqual(run(["reorg", "--check"]).status, 1); + } finally { + rmrf(root); + } + }); +}); From 7d28307a29b8f11d142806ca107f5b5536809043 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Sun, 30 Aug 2026 23:33:20 -0400 Subject: [PATCH 2/5] fix: allow canonical collection aliases --- CHANGELOG.md | 4 ++++ scripts/reorg-core.js | 32 +++++++++++++++++++++++++++++++- tests/reorg-core.test.js | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af3e593..bbf124c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Bundled reorganization metadata is merged into older user-copied provider rules without overwriting custom sync mappings - Dry-run, JSON, check, and rejected TUI plans do not initialize Saddle config or state +### Fixed + +- Managed collection symlinks that already point to their canonical collection no longer trigger a false overlap error or risk changes through the alias + ### Security - Canonical paths are constrained to the selected source root, overlapping managed roots are rejected, and rule locations fail closed on malformed classifications diff --git a/scripts/reorg-core.js b/scripts/reorg-core.js index 881354f..b295ad7 100644 --- a/scripts/reorg-core.js +++ b/scripts/reorg-core.js @@ -64,6 +64,19 @@ function pathsOverlap(leftPath, rightPath) { return left === right || left.startsWith(`${right}${path.sep}`) || right.startsWith(`${left}${path.sep}`); } +function isCanonicalCollectionAlias(endpointPath, canonicalPath) { + if (!pathExists(endpointPath)) return false; + + const stat = fs.lstatSync(endpointPath); + if (!stat.isSymbolicLink()) return false; + + try { + return fs.realpathSync.native(endpointPath) === resolveThroughExistingAncestor(canonicalPath); + } catch { + return false; + } +} + function resolveContentPath(entryPath) { const stat = fs.lstatSync(entryPath); return stat.isSymbolicLink() ? fs.realpathSync(entryPath) : entryPath; @@ -216,7 +229,8 @@ function scanReorg({ rules, detection = {}, sourceRoot, expandHome }) { const expandedPath = expandHome(location.path); if (!expandedPath) continue; const resolvedEndpointPath = path.resolve(expandedPath); - if (pathsOverlap(resolvedSourceRoot, resolvedEndpointPath)) { + const canonicalAlias = isCanonicalCollectionAlias(resolvedEndpointPath, canonicalPath); + if (!canonicalAlias && pathsOverlap(resolvedSourceRoot, resolvedEndpointPath)) { throw new Error( `Source root overlaps a managed harness location: ${resolvedSourceRoot} and ${resolvedEndpointPath}`, ); @@ -233,6 +247,7 @@ function scanReorg({ rules, detection = {}, sourceRoot, expandHome }) { entries: asset.entries, path: resolvedEndpointPath, targetClass: location.targetClass, + canonicalAlias, tools: [rule.name], toolLabels: [rule.label], installed, @@ -361,6 +376,7 @@ function buildReorgPlan({ scan, strategy = "universal-first" }) { for (const name of Array.from(names).sort()) { const canonicalItem = canonicalItems.get(name) || null; const occurrences = collectionEndpoints.flatMap((endpoint) => { + if (endpoint.canonicalAlias) return []; const item = endpoint.items.find((candidate) => candidate.name === name); return item ? [{ endpoint, item }] : []; }); @@ -422,6 +438,19 @@ function buildReorgPlan({ scan, strategy = "universal-first" }) { for (const endpoint of selectedEndpoints) { const target = path.join(endpoint.path, name); + if (endpoint.canonicalAlias) { + if (canonicalItem) { + unchanged.push({ + kind: collection.kind, + name, + target, + source: canonicalTarget, + targetClass: endpoint.targetClass, + tools: endpoint.tools, + }); + } + continue; + } const existing = endpoint.items.find((item) => item.name === name) || null; if (pointsTo(target, canonicalTarget)) { unchanged.push({ @@ -462,6 +491,7 @@ function buildReorgPlan({ scan, strategy = "universal-first" }) { } for (const endpoint of suppressedEndpoints) { + if (endpoint.canonicalAlias) continue; const existing = endpoint.items.find((item) => item.name === name); if (!existing || selectedEndpoints.some((selectedEndpoint) => selectedEndpoint.path === endpoint.path)) continue; diff --git a/tests/reorg-core.test.js b/tests/reorg-core.test.js index 8a6c4bb..5f846b3 100644 --- a/tests/reorg-core.test.js +++ b/tests/reorg-core.test.js @@ -320,6 +320,39 @@ describe("reorganization core", () => { ); }); + it("accepts a managed collection symlink that already points to its canonical collection", () => { + writeSkill(path.join(sourceRoot, "skills"), "review"); + mkdir(path.dirname(universalRoot)); + fs.symlinkSync(path.join(sourceRoot, "skills"), universalRoot); + + const plan = buildReorgPlan({ scan: scan(), strategy: "universal-first" }); + + assert.strictEqual(plan.conflicts.length, 0); + assert.ok(!plan.actions.some((action) => action.target.startsWith(universalRoot))); + assert.ok(plan.unchanged.some((item) => item.target === path.join(universalRoot, "review"))); + }); + + it("does not remove canonical content through a suppressed collection alias", async () => { + writeSkill(path.join(sourceRoot, "skills"), "review"); + mkdir(path.dirname(universalRoot)); + fs.symlinkSync(path.join(sourceRoot, "skills"), universalRoot); + + const plan = buildReorgPlan({ scan: scan(), strategy: "provider-only" }); + assert.ok(!plan.actions.some((action) => action.type === "remove-duplicate" && action.target.startsWith(universalRoot))); + + await applyReorgPlan(plan, { configDir: transactionRoot }); + assert.ok(fs.statSync(path.join(sourceRoot, "skills", "review")).isDirectory()); + assert.ok(fs.lstatSync(universalRoot).isSymbolicLink()); + }); + + it("rejects a managed collection symlink into the wrong canonical directory", () => { + mkdir(path.join(sourceRoot, "other-skills")); + mkdir(path.dirname(universalRoot)); + fs.symlinkSync(path.join(sourceRoot, "other-skills"), universalRoot); + + assert.throws(() => scan(), /overlaps a managed harness location/); + }); + it("rejects canonical collections that escape the source root through a symlink", () => { mkdir(sourceRoot); const externalSkills = path.join(root, "external-skills"); From 566b67d619d82732e260b38de8994570e3525462 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Sun, 30 Aug 2026 23:43:23 -0400 Subject: [PATCH 3/5] feat: clarify reorg plan review --- CHANGELOG.md | 1 + package.json | 2 +- scripts/tui/ReorgApp.mjs | 417 ++++++++++++++++++++++++++++++--------- tests/reorg-ui.test.mjs | 54 +++++ 4 files changed, 375 insertions(+), 99 deletions(-) create mode 100644 tests/reorg-ui.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf124c..d6703b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Copilot detection now uses the standalone `copilot` CLI; skills support both current personal roots and the unsupported `~/.copilot/commands` mapping has been removed - Bundled reorganization metadata is merged into older user-copied provider rules without overwriting custom sync mappings - Dry-run, JSON, check, and rejected TUI plans do not initialize Saddle config or state +- The reorganization review now explains the final state, apply order, grouped path changes, and exact files before confirmation ### Fixed diff --git a/package.json b/package.json index 819a95f..eba5499 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "lint:agents": "bash ./scripts/agent-lint.sh", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "node --test tests/load-config.test.js tests/install-core.test.js tests/reorg-core.test.js tests/format.test.mjs", + "test": "node --test tests/load-config.test.js tests/install-core.test.js tests/reorg-core.test.js tests/reorg-ui.test.mjs tests/format.test.mjs", "prepare": "husky || true" }, "engines": { diff --git a/scripts/tui/ReorgApp.mjs b/scripts/tui/ReorgApp.mjs index 9b60bb3..f9197c9 100644 --- a/scripts/tui/ReorgApp.mjs +++ b/scripts/tui/ReorgApp.mjs @@ -19,123 +19,286 @@ const labelByType = { "remove-duplicate": "REMOVE", }; -function StrategySummary({ plan, height }) { - const universal = plan.coverage.filter((item) => item.supported && item.targetClass === "universal"); - const provider = plan.coverage.filter((item) => item.supported && item.targetClass === "provider"); - const unsupported = plan.coverage.filter((item) => !item.supported); - const roots = new Map(); +function compactPath(value) { + const home = process.env.HOME; + if (!home) return value; + if (value === home) return "~"; + return value.startsWith(`${home}${path.sep}`) ? `~${value.slice(home.length)}` : value; +} + +function pluralizeKind(kind, count) { + return count === 1 ? kind : `${kind}s`; +} + +function countPlanActions(actions) { + const counts = { import: 0, link: 0, remove: 0 }; + for (const action of actions) { + if (action.type === "import") counts.import += 1; + if (action.type === "link") counts.link += 1; + if (action.type === "remove-duplicate") counts.remove += 1; + } + return counts; +} + +export function buildChangeGroups(actions) { + const groups = new Map(); - for (const item of plan.coverage.filter((candidate) => candidate.supported)) { - for (const endpoint of item.endpoints) { - const key = `${item.targetClass}:${endpoint}`; - if (!roots.has(key)) roots.set(key, { path: endpoint, targetClass: item.targetClass }); + for (const action of actions) { + const sourceRoot = path.dirname(action.source); + const targetRoot = path.dirname(action.target); + const key = [action.type, action.kind, sourceRoot, targetRoot].join("::"); + if (!groups.has(key)) { + groups.set(key, { + id: key, + type: action.type, + kind: action.kind, + sourceRoot, + targetRoot, + targetClass: action.targetClass, + tools: new Set(), + actions: [], + }); } + const group = groups.get(key); + group.actions.push(action); + for (const tool of action.tools) group.tools.add(tool); } - const selectedRoots = Array.from(roots.values()).sort((left, right) => { - const leftRank = left.targetClass === "universal" ? 0 : 1; - const rightRank = right.targetClass === "universal" ? 0 : 1; - return leftRank - rightRank || left.path.localeCompare(right.path); - }); - const visibleLimit = Math.max(3, height - 11); - const visibleRoots = selectedRoots.slice(0, visibleLimit); - const hidden = selectedRoots.length - visibleRoots.length; + const priority = { import: 0, link: 1, "remove-duplicate": 2 }; + return Array.from(groups.values()) + .map((group) => ({ ...group, tools: Array.from(group.tools).sort() })) + .sort( + (left, right) => + priority[left.type] - priority[right.type] || + left.targetRoot.localeCompare(right.targetRoot) || + left.sourceRoot.localeCompare(right.sourceRoot), + ); +} + +function OutcomeSummary({ plan, counts, compact }) { + const sourceRoot = compactPath(plan.sourceRoot); + if (compact) { + return h( + Frame, + { + title: plan.actions.length > 0 ? "What will change" : "No changes needed", + color: plan.actions.length > 0 ? "orange" : "green", + flexGrow: 1, + }, + h( + Box, + { columnGap: 1, flexWrap: "wrap" }, + h(TerminalTag, { tone: "orange" }, `${plan.actions.length} changes`), + h(TerminalTag, { tone: "gray" }, `${plan.unchanged.length} untouched`), + ), + h( + Text, + { color: theme.color.fg.primary }, + plan.actions.length > 0 + ? `${counts.import} copy -> ${counts.link} link -> ${counts.remove} remove after verification.` + : `Every selected agent already points to ${sourceRoot}.`, + ), + plan.actions.length > 0 + ? h(Text, { color: theme.color.fg.muted }, `Canonical source: ${sourceRoot}. Failures restore earlier changes.`) + : null, + ); + } return h( Frame, - { title: "Routing", color: plan.strategy === "universal-first" ? "cyan" : "blue", flexGrow: 1 }, + { + title: plan.actions.length > 0 ? "What will change" : "No changes needed", + color: plan.actions.length > 0 ? "orange" : "green", + flexGrow: 1, + }, h( Box, - { marginBottom: 1, columnGap: 1, flexWrap: "wrap" }, - h(TerminalTag, { tone: plan.strategy === "universal-first" ? "cyan" : "blue" }, plan.strategy), - universal.length > 0 ? h(TerminalTag, { tone: "cyan" }, `${universal.length} shared`) : null, - provider.length > 0 ? h(TerminalTag, { tone: "blue" }, `${provider.length} native`) : null, - unsupported.length > 0 ? h(TerminalTag, { tone: "orange" }, `${unsupported.length} unsupported`) : null, + { columnGap: 1, flexWrap: "wrap" }, + h(TerminalTag, { tone: "orange" }, `${plan.actions.length} filesystem changes`), + counts.import > 0 ? h(TerminalTag, { tone: "magenta" }, `${counts.import} copied in`) : null, + counts.link > 0 ? h(TerminalTag, { tone: "cyan" }, `${counts.link} linked back`) : null, + counts.remove > 0 ? h(TerminalTag, { tone: "orange" }, `${counts.remove} duplicates removed`) : null, + h(TerminalTag, { tone: "gray" }, `${plan.unchanged.length} untouched`), ), - h(Text, { color: theme.color.fg.secondary }, "Each harness asset uses one endpoint class."), - h(Box, { height: 1 }), - h(Text, { color: theme.color.fg.primary, bold: true }, "Selected roots"), - ...visibleRoots.map((root) => - h( - Box, - { key: `${root.targetClass}-${root.path}`, columnGap: 1 }, - h( - Text, - { color: root.targetClass === "universal" ? palette.cyan : palette.blue, bold: true }, - root.targetClass === "universal" ? "SHR" : "NAT", - ), - h(ShortPath, { pathText: root.path, color: "gray" }), - ), + h( + Text, + { color: theme.color.fg.primary }, + plan.actions.length > 0 + ? `Saddle will collect unique files in ${sourceRoot}, then point each selected agent back to them.` + : `Every selected agent already points to ${sourceRoot}.`, ), - hidden > 0 ? h(Text, { color: theme.color.fg.dim }, `${hidden} more selected roots`) : null, + counts.remove > 0 + ? h( + Text, + { color: theme.color.state.warning }, + "Removals happen last, after Saddle verifies the canonical copy and replacement links.", + ) + : null, ); } -function PlanReview({ plan, height }) { - const visibleLimit = Math.max(4, height - 12); - const visibleActions = plan.actions.slice(0, visibleLimit); - const hidden = Math.max(0, plan.actions.length - visibleActions.length); - const counts = useMemo(() => { - const result = { import: 0, universal: 0, provider: 0, remove: 0 }; - for (const action of plan.actions) { - if (action.type === "import") result.import += 1; - if (action.type === "link" && action.targetClass === "universal") result.universal += 1; - if (action.type === "link" && action.targetClass === "provider") result.provider += 1; - if (action.type === "remove-duplicate") result.remove += 1; - } - return result; - }, [plan.actions]); +function ExecutionOrder({ plan, counts }) { + const steps = [ + counts.import > 0 + ? { + title: `Copy ${counts.import} ${counts.import === 1 ? "item" : "items"} into ${compactPath(plan.sourceRoot)}`, + detail: "Existing files stay in place while Saddle verifies each copy.", + tone: "magenta", + } + : null, + counts.link > 0 + ? { + title: `Create ${counts.link} ${counts.link === 1 ? "symlink" : "symlinks"} from agent folders`, + detail: "Matching local entries become links to the canonical files.", + tone: "cyan", + } + : null, + counts.remove > 0 + ? { + title: `Remove ${counts.remove} verified ${counts.remove === 1 ? "duplicate" : "duplicates"}`, + detail: "The canonical copy remains. Saddle removes only redundant entries.", + tone: "orange", + } + : null, + ].filter(Boolean); return h( Frame, - { title: "Review changes", color: "orange", flexGrow: 1 }, - h( + { title: "How Saddle applies it", color: "cyan", flexGrow: 1 }, + ...steps.flatMap((step, index) => [ + h( + Box, + { key: `${step.title}-title`, columnGap: 1 }, + h(Text, { color: palette[step.tone], bold: true }, `${index + 1}.`), + h(Text, { color: theme.color.fg.primary, bold: true }, step.title), + ), + h( + Box, + { key: `${step.title}-detail`, marginLeft: 3, marginBottom: 1 }, + h(Text, { color: theme.color.fg.muted }, step.detail), + ), + ]), + h(Box, { flexGrow: 1 }), + h(Text, { color: theme.color.state.success }, "If a step fails, Saddle restores the earlier changes."), + ); +} + +function GroupPath({ group }) { + if (group.type === "remove-duplicate") { + return h( Box, - { columnGap: 1, flexWrap: "wrap", marginBottom: 1 }, - counts.import > 0 ? h(TerminalTag, { tone: "magenta" }, `${counts.import} import`) : null, - counts.universal > 0 ? h(TerminalTag, { tone: "cyan" }, `${counts.universal} universal`) : null, - counts.provider > 0 ? h(TerminalTag, { tone: "blue" }, `${counts.provider} provider`) : null, - counts.remove > 0 ? h(TerminalTag, { tone: "orange" }, `${counts.remove} remove`) : null, - h(TerminalTag, { tone: "gray" }, `${plan.unchanged.length} unchanged`), - ), - ...visibleActions.map((action) => + { marginLeft: 2, columnGap: 1 }, + h(ShortPath, { pathText: compactPath(group.targetRoot), color: "gray" }), + h(Text, { color: theme.color.fg.dim }, "removed; canonical copy stays in"), + h(ShortPath, { pathText: compactPath(group.sourceRoot), color: "cyan" }), + ); + } + + return h( + Box, + { marginLeft: 2, columnGap: 1 }, + h(ShortPath, { pathText: compactPath(group.sourceRoot), color: "gray" }), + h(Text, { color: theme.color.fg.dim }, "->"), + h(ShortPath, { pathText: compactPath(group.targetRoot), color: "cyan" }), + ); +} + +function LocationReview({ groups, selectedIndex, height, compact = false }) { + if (groups.length === 0) { + return h( + Frame, + { title: "Affected locations", color: "green", flexGrow: 1 }, + h(StatusMessage, { variant: "success" }, "No files or links need to change."), + ); + } + + const selected = groups[selectedIndex]; + if (compact) { + const firstAction = selected.actions[0]; + return h( + Frame, + { title: "Affected locations", color: "orange", flexGrow: 1 }, + h(Text, { color: theme.color.fg.dim }, `Path group ${selectedIndex + 1} of ${groups.length}`), h( Box, - { key: action.id, height: 1, justifyContent: "space-between", columnGap: 2 }, + { columnGap: 1 }, + h(Text, { color: theme.color.accent.bright, bold: true }, ">"), + h(Text, { color: palette[toneByType[selected.type]], bold: true }, labelByType[selected.type]), h( - Box, - { minWidth: 0, columnGap: 1 }, - h(Text, { color: theme.color.fg.primary }, path.basename(action.target)), - h(Text, { color: theme.color.fg.dim }, action.tools.join(", ")), + Text, + { color: theme.color.fg.primary, bold: true }, + `${selected.actions.length} ${pluralizeKind(selected.kind, selected.actions.length)}`, ), + h(Text, { color: theme.color.fg.dim }, selected.tools.join(", ")), + ), + h(GroupPath, { group: selected }), + h( + Box, + { marginLeft: 2, columnGap: 1 }, + h(Text, { color: theme.color.fg.muted }, "First file"), + h(Text, { color: theme.color.fg.primary, wrap: "truncate-end" }, path.basename(firstAction.target)), + ), + selected.actions.length > 1 + ? h(Text, { color: theme.color.fg.dim, marginLeft: 2 }, `${selected.actions.length - 1} more in this group`) + : null, + ); + } + + const maxVisibleGroups = Math.max(2, Math.floor((height - 11) / 2)); + const start = Math.min( + Math.max(0, selectedIndex - maxVisibleGroups + 1), + Math.max(0, groups.length - maxVisibleGroups), + ); + const visibleGroups = groups.slice(start, start + maxVisibleGroups); + const detailLimit = Math.max(1, height - 11 - visibleGroups.length * 2); + const visibleActions = selected.actions.slice(0, detailLimit); + + return h( + Frame, + { title: "Affected locations", color: "orange", flexGrow: 1 }, + h(Text, { color: theme.color.fg.muted }, "Use Up/Down to inspect a path group."), + start > 0 ? h(Text, { color: theme.color.fg.dim }, `${start} groups above`) : null, + ...visibleGroups.flatMap((group, visibleIndex) => { + const index = start + visibleIndex; + const active = index === selectedIndex; + return [ h( Box, - { flexShrink: 0, columnGap: 1 }, - action.type === "link" ? h(Text, { color: theme.color.fg.muted }, action.targetClass.toUpperCase()) : null, + { key: `${group.id}-label`, columnGap: 1 }, + h(Text, { color: active ? theme.color.accent.bright : theme.color.fg.dim, bold: active }, active ? ">" : " "), + h( + Text, + { color: palette[toneByType[group.type]], bold: true }, + labelByType[group.type], + ), h( Text, - { color: palette[toneByType[action.type]] || theme.color.accent.primary, bold: true }, - labelByType[action.type] || action.type.toUpperCase(), + { color: active ? theme.color.fg.primary : theme.color.fg.secondary, bold: active }, + `${group.actions.length} ${pluralizeKind(group.kind, group.actions.length)}`, ), + h(Text, { color: theme.color.fg.dim }, group.tools.join(", ")), ), - ), + h(GroupPath, { key: `${group.id}-path`, group }), + ]; + }), + start + visibleGroups.length < groups.length + ? h(Text, { color: theme.color.fg.dim }, `${groups.length - start - visibleGroups.length} groups below`) + : null, + h(Box, { marginTop: 1, columnGap: 1 }, + h(Text, { color: theme.color.fg.primary, bold: true }, "Exact files"), + h(Text, { color: theme.color.fg.dim }, `${selected.actions.length} in selected group`), ), - hidden > 0 ? h(Text, { color: theme.color.fg.dim }, `${hidden} more actions`) : null, - h(Box, { flexGrow: 1 }), - h( - Box, - { - borderStyle: "single", - borderColor: theme.color.accent.primary, - justifyContent: "center", - paddingX: 1, - }, + ...visibleActions.map((action) => h( - Text, - { color: theme.color.accent.bright, bold: true }, - "Enter applies this plan. Esc cancels without writing.", + Box, + { key: action.id, marginLeft: 2, justifyContent: "space-between", columnGap: 2 }, + h(Text, { color: theme.color.fg.primary, wrap: "truncate-end" }, path.basename(action.target)), + h(Text, { color: theme.color.fg.dim, flexShrink: 0 }, action.kind), ), ), + selected.actions.length > visibleActions.length + ? h(Text, { color: theme.color.fg.dim, marginLeft: 2 }, `${selected.actions.length - visibleActions.length} more files`) + : null, ); } @@ -218,18 +381,28 @@ function ApplyProgress({ plan, completed, current, error, done }) { export function ReorgApp({ plan, applyReorgPlan, onFinish }) { const { exit } = useApp(); const [stage, setStage] = useState(plan.conflicts.length > 0 ? "conflicts" : "review"); + const [selectedGroupIndex, setSelectedGroupIndex] = useState(0); const [completed, setCompleted] = useState(0); const [current, setCurrent] = useState(null); const [done, setDone] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); const startedRef = useRef(false); - const height = Math.max(18, (process.stdout.rows || 24) - 4); + const terminalHeight = process.stdout.rows || 24; const width = process.stdout.columns || 100; + const compact = width < 100 || terminalHeight < 30; + const reviewHeight = compact ? Math.max(9, terminalHeight - 14) : Math.max(9, terminalHeight - 15); const gap = 1; const availableWidth = Math.max(27, width - gap - 2); - const leftWidth = Math.max(12, Math.floor(availableWidth * 0.36)); + const leftWidth = Math.max(12, Math.floor(availableWidth * 0.38)); const rightWidth = Math.max(12, availableWidth - leftWidth); + const counts = useMemo(() => countPlanActions(plan.actions), [plan.actions]); + const groups = useMemo(() => buildChangeGroups(plan.actions), [plan.actions]); + + useEffect(() => { + if (groups.length === 0) return; + if (selectedGroupIndex >= groups.length) setSelectedGroupIndex(groups.length - 1); + }, [groups, selectedGroupIndex]); useInput((input, key) => { const requestedExit = key.escape || input === "q" || (key.ctrl && input === "c"); @@ -242,7 +415,22 @@ export function ReorgApp({ plan, applyReorgPlan, onFinish }) { return; } if (stage === "review") { - if (key.return) setStage("apply"); + if (key.upArrow && groups.length > 0) { + setSelectedGroupIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow && groups.length > 0) { + setSelectedGroupIndex((current) => Math.min(groups.length - 1, current + 1)); + return; + } + if (key.return) { + if (plan.actions.length === 0) { + onFinish({ applied: false, unchanged: true }); + exit(); + } else { + setStage("apply"); + } + } if (requestedExit) { onFinish({ applied: false }); exit(); @@ -299,16 +487,49 @@ export function ReorgApp({ plan, applyReorgPlan, onFinish }) { h(ShortPath, { pathText: plan.sourceRoot, color: "cyan" }), ), stage === "review" || stage === "conflicts" - ? h( - Box, - { height, columnGap: gap }, - h(Box, { width: leftWidth }, h(StrategySummary, { plan, height })), - h( + ? stage === "conflicts" + ? h(Box, { height: Math.max(18, terminalHeight - 4) }, h(ConflictReview, { plan, height: terminalHeight - 4 })) + : h( Box, - { width: rightWidth }, - stage === "conflicts" ? h(ConflictReview, { plan, height }) : h(PlanReview, { plan, height }), - ), - ) - : h(Box, { height }, h(ApplyProgress, { plan, completed, current, error, done })), + { flexDirection: "column" }, + h(Box, { marginBottom: 1 }, h(OutcomeSummary, { plan, counts, compact })), + compact + ? h( + Box, + { height: reviewHeight }, + h(LocationReview, { groups, selectedIndex: selectedGroupIndex, height: reviewHeight, compact: true }), + ) + : h( + Box, + { height: reviewHeight, columnGap: gap }, + h(Box, { width: leftWidth }, h(ExecutionOrder, { plan, counts })), + h( + Box, + { width: rightWidth }, + h(LocationReview, { groups, selectedIndex: selectedGroupIndex, height: reviewHeight }), + ), + ), + h( + Box, + { + borderStyle: "single", + borderColor: plan.actions.length > 0 ? theme.color.accent.primary : theme.color.state.success, + justifyContent: "space-between", + paddingX: 1, + }, + h( + Text, + { color: plan.actions.length > 0 ? theme.color.accent.bright : theme.color.state.success, bold: true }, + plan.actions.length > 0 ? `Enter Apply ${plan.actions.length} changes` : "Enter Exit", + ), + groups.length > 1 ? h(Text, { color: theme.color.fg.muted }, "Up/Down Inspect") : null, + h(Text, { color: theme.color.fg.muted }, "Esc Cancel without changes"), + ), + ) + : h( + Box, + { height: Math.max(18, terminalHeight - 4) }, + h(ApplyProgress, { plan, completed, current, error, done }), + ), ); } diff --git a/tests/reorg-ui.test.mjs b/tests/reorg-ui.test.mjs new file mode 100644 index 0000000..e4339fb --- /dev/null +++ b/tests/reorg-ui.test.mjs @@ -0,0 +1,54 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildChangeGroups } from "../scripts/tui/ReorgApp.mjs"; + +describe("reorganization review groups", () => { + it("groups actions by operation, kind, and path pair in apply order", () => { + const actions = [ + { + id: "remove-review", + type: "remove-duplicate", + kind: "skill", + source: "/shared/skills/review", + target: "/home/.cursor/skills/review", + targetClass: "provider", + tools: ["cursor"], + }, + { + id: "link-review", + type: "link", + kind: "skill", + source: "/shared/skills/review", + target: "/home/.claude/skills/review", + targetClass: "provider", + tools: ["claude"], + }, + { + id: "import-plan", + type: "import", + kind: "agent", + source: "/home/.claude/agents/plan.md", + target: "/shared/agents/claude/plan.md", + targetClass: "canonical", + tools: ["claude"], + }, + { + id: "import-review", + type: "import", + kind: "agent", + source: "/home/.claude/agents/review.md", + target: "/shared/agents/claude/review.md", + targetClass: "canonical", + tools: ["claude"], + }, + ]; + + const groups = buildChangeGroups(actions); + + assert.deepStrictEqual(groups.map((group) => group.type), ["import", "link", "remove-duplicate"]); + assert.strictEqual(groups[0].actions.length, 2); + assert.strictEqual(groups[0].sourceRoot, "/home/.claude/agents"); + assert.strictEqual(groups[0].targetRoot, "/shared/agents/claude"); + assert.deepStrictEqual(groups[0].tools, ["claude"]); + }); +}); From 87d853e2388f9c238582ea4d15cb50b08ce1c8b0 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Sun, 30 Aug 2026 23:44:43 -0400 Subject: [PATCH 4/5] fix: keep reorg ui tests node 18 compatible --- scripts/tui/ReorgApp.mjs | 36 +------------------------------- scripts/tui/reorg-view-model.mjs | 36 ++++++++++++++++++++++++++++++++ tests/reorg-ui.test.mjs | 2 +- 3 files changed, 38 insertions(+), 36 deletions(-) create mode 100644 scripts/tui/reorg-view-model.mjs diff --git a/scripts/tui/ReorgApp.mjs b/scripts/tui/ReorgApp.mjs index f9197c9..53b460a 100644 --- a/scripts/tui/ReorgApp.mjs +++ b/scripts/tui/ReorgApp.mjs @@ -6,6 +6,7 @@ import { theme } from "./theme/index.mjs"; import { palette } from "./theme/catalog.mjs"; import { Frame, ShortPath, TerminalTag } from "./ui/primitives.mjs"; import { h } from "./ui/react-helpers.mjs"; +import { buildChangeGroups } from "./reorg-view-model.mjs"; const toneByType = { import: "magenta", @@ -40,41 +41,6 @@ function countPlanActions(actions) { return counts; } -export function buildChangeGroups(actions) { - const groups = new Map(); - - for (const action of actions) { - const sourceRoot = path.dirname(action.source); - const targetRoot = path.dirname(action.target); - const key = [action.type, action.kind, sourceRoot, targetRoot].join("::"); - if (!groups.has(key)) { - groups.set(key, { - id: key, - type: action.type, - kind: action.kind, - sourceRoot, - targetRoot, - targetClass: action.targetClass, - tools: new Set(), - actions: [], - }); - } - const group = groups.get(key); - group.actions.push(action); - for (const tool of action.tools) group.tools.add(tool); - } - - const priority = { import: 0, link: 1, "remove-duplicate": 2 }; - return Array.from(groups.values()) - .map((group) => ({ ...group, tools: Array.from(group.tools).sort() })) - .sort( - (left, right) => - priority[left.type] - priority[right.type] || - left.targetRoot.localeCompare(right.targetRoot) || - left.sourceRoot.localeCompare(right.sourceRoot), - ); -} - function OutcomeSummary({ plan, counts, compact }) { const sourceRoot = compactPath(plan.sourceRoot); if (compact) { diff --git a/scripts/tui/reorg-view-model.mjs b/scripts/tui/reorg-view-model.mjs new file mode 100644 index 0000000..0dfbce5 --- /dev/null +++ b/scripts/tui/reorg-view-model.mjs @@ -0,0 +1,36 @@ +import path from "path"; + +export function buildChangeGroups(actions) { + const groups = new Map(); + + for (const action of actions) { + const sourceRoot = path.dirname(action.source); + const targetRoot = path.dirname(action.target); + const key = [action.type, action.kind, sourceRoot, targetRoot].join("::"); + if (!groups.has(key)) { + groups.set(key, { + id: key, + type: action.type, + kind: action.kind, + sourceRoot, + targetRoot, + targetClass: action.targetClass, + tools: new Set(), + actions: [], + }); + } + const group = groups.get(key); + group.actions.push(action); + for (const tool of action.tools) group.tools.add(tool); + } + + const priority = { import: 0, link: 1, "remove-duplicate": 2 }; + return Array.from(groups.values()) + .map((group) => ({ ...group, tools: Array.from(group.tools).sort() })) + .sort( + (left, right) => + priority[left.type] - priority[right.type] || + left.targetRoot.localeCompare(right.targetRoot) || + left.sourceRoot.localeCompare(right.sourceRoot), + ); +} diff --git a/tests/reorg-ui.test.mjs b/tests/reorg-ui.test.mjs index e4339fb..0e1370b 100644 --- a/tests/reorg-ui.test.mjs +++ b/tests/reorg-ui.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { buildChangeGroups } from "../scripts/tui/ReorgApp.mjs"; +import { buildChangeGroups } from "../scripts/tui/reorg-view-model.mjs"; describe("reorganization review groups", () => { it("groups actions by operation, kind, and path pair in apply order", () => { From d9fd3f438cc3bfad7d968bc54c8b35b20134f726 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Mon, 31 Aug 2026 00:21:34 -0400 Subject: [PATCH 5/5] feat: map reorg destinations by agent --- CHANGELOG.md | 2 +- scripts/tui/ReorgApp.mjs | 357 ++++++++++++++++++------------- scripts/tui/reorg-view-model.mjs | 120 +++++++++++ tests/reorg-ui.test.mjs | 91 +++++++- 4 files changed, 414 insertions(+), 156 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6703b2..5a13e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Copilot detection now uses the standalone `copilot` CLI; skills support both current personal roots and the unsupported `~/.copilot/commands` mapping has been removed - Bundled reorganization metadata is merged into older user-copied provider rules without overwriting custom sync mappings - Dry-run, JSON, check, and rejected TUI plans do not initialize Saddle config or state -- The reorganization review now explains the final state, apply order, grouped path changes, and exact files before confirmation +- The reorganization review now maps the canonical source to installed-agent destination blocks, dims paths already in place, and highlights links or cleanup still required ### Fixed diff --git a/scripts/tui/ReorgApp.mjs b/scripts/tui/ReorgApp.mjs index 53b460a..efed1d7 100644 --- a/scripts/tui/ReorgApp.mjs +++ b/scripts/tui/ReorgApp.mjs @@ -6,13 +6,7 @@ import { theme } from "./theme/index.mjs"; import { palette } from "./theme/catalog.mjs"; import { Frame, ShortPath, TerminalTag } from "./ui/primitives.mjs"; import { h } from "./ui/react-helpers.mjs"; -import { buildChangeGroups } from "./reorg-view-model.mjs"; - -const toneByType = { - import: "magenta", - link: "cyan", - "remove-duplicate": "orange", -}; +import { buildCanonicalCollections, buildHarnessBlocks } from "./reorg-view-model.mjs"; const labelByType = { import: "IMPORT", @@ -27,10 +21,6 @@ function compactPath(value) { return value.startsWith(`${home}${path.sep}`) ? `~${value.slice(home.length)}` : value; } -function pluralizeKind(kind, count) { - return count === 1 ? kind : `${kind}s`; -} - function countPlanActions(actions) { const counts = { import: 0, link: 0, remove: 0 }; for (const action of actions) { @@ -103,167 +93,198 @@ function OutcomeSummary({ plan, counts, compact }) { ); } -function ExecutionOrder({ plan, counts }) { - const steps = [ - counts.import > 0 - ? { - title: `Copy ${counts.import} ${counts.import === 1 ? "item" : "items"} into ${compactPath(plan.sourceRoot)}`, - detail: "Existing files stay in place while Saddle verifies each copy.", - tone: "magenta", - } - : null, - counts.link > 0 - ? { - title: `Create ${counts.link} ${counts.link === 1 ? "symlink" : "symlinks"} from agent folders`, - detail: "Matching local entries become links to the canonical files.", - tone: "cyan", - } - : null, - counts.remove > 0 - ? { - title: `Remove ${counts.remove} verified ${counts.remove === 1 ? "duplicate" : "duplicates"}`, - detail: "The canonical copy remains. Saddle removes only redundant entries.", - tone: "orange", - } - : null, - ].filter(Boolean); - +function CanonicalSource({ plan, collections, counts }) { return h( Frame, - { title: "How Saddle applies it", color: "cyan", flexGrow: 1 }, - ...steps.flatMap((step, index) => [ - h( - Box, - { key: `${step.title}-title`, columnGap: 1 }, - h(Text, { color: palette[step.tone], bold: true }, `${index + 1}.`), - h(Text, { color: theme.color.fg.primary, bold: true }, step.title), - ), + { title: "Canonical source", color: "cyan", flexGrow: 1 }, + h(ShortPath, { pathText: compactPath(plan.sourceRoot), color: "cyan" }), + h(Text, { color: theme.color.fg.muted }, "Installed agents will read from these collections."), + h(Box, { height: 1 }), + ...collections.map((collection) => h( Box, - { key: `${step.title}-detail`, marginLeft: 3, marginBottom: 1 }, - h(Text, { color: theme.color.fg.muted }, step.detail), + { key: `${collection.kind}:${collection.path}`, columnGap: 1 }, + h(Text, { color: collection.changed ? theme.color.accent.primary : theme.color.fg.dim, bold: true }, collection.kind.toUpperCase().padEnd(7)), + h(ShortPath, { pathText: compactPath(collection.path), color: collection.changed ? "white" : "gray" }), ), - ]), + ), + h(Box, { height: 1 }), + h(Text, { color: theme.color.fg.primary, bold: true }, "Status"), + h(Box, { columnGap: 1 }, h(TerminalTag, { tone: "cyan" }, "ACTIVE"), h(Text, { color: theme.color.fg.muted }, "work remains")), + h(Box, { columnGap: 1 }, h(TerminalTag, { tone: "gray" }, "READY"), h(Text, { color: theme.color.fg.dim }, "already in place")), + h(Box, { height: 1 }), + h(Text, { color: theme.color.fg.primary, bold: true }, "Apply order"), + h(Text, { color: theme.color.fg.muted }, `1. Copy and verify ${counts.import}`), + h(Text, { color: theme.color.fg.muted }, `2. Link ${counts.link} destinations`), + h(Text, { color: theme.color.fg.muted }, `3. Remove ${counts.remove} verified duplicates`), h(Box, { flexGrow: 1 }), - h(Text, { color: theme.color.state.success }, "If a step fails, Saddle restores the earlier changes."), + h(Text, { color: theme.color.state.success }, "A failed step restores earlier changes."), ); } -function GroupPath({ group }) { - if (group.type === "remove-duplicate") { - return h( - Box, - { marginLeft: 2, columnGap: 1 }, - h(ShortPath, { pathText: compactPath(group.targetRoot), color: "gray" }), - h(Text, { color: theme.color.fg.dim }, "removed; canonical copy stays in"), - h(ShortPath, { pathText: compactPath(group.sourceRoot), color: "cyan" }), - ); - } +function operationTone(operation) { + if (operation === "CLEAN") return "orange"; + if (operation === "MOVE" || operation === "MOVE+LINK") return "magenta"; + return "cyan"; +} + +function HarnessBlock({ harness, selected, width, height, compact = false, position, total }) { + const active = harness.hasChanges; + const borderColor = active + ? selected + ? theme.color.accent.bright + : theme.color.accent.soft + : selected + ? theme.color.border.strong + : theme.color.border.subtle; return h( Box, - { marginLeft: 2, columnGap: 1 }, - h(ShortPath, { pathText: compactPath(group.sourceRoot), color: "gray" }), - h(Text, { color: theme.color.fg.dim }, "->"), - h(ShortPath, { pathText: compactPath(group.targetRoot), color: "cyan" }), + { + width, + height: compact ? undefined : height, + borderStyle: "single", + borderColor, + paddingX: 1, + flexDirection: "column", + }, + h( + Box, + { justifyContent: "space-between", columnGap: 1 }, + h( + Text, + { color: active ? theme.color.fg.primary : theme.color.fg.dim, bold: active || selected }, + `${selected ? "> " : " "}${harness.label}`, + ), + h( + Text, + { color: active ? theme.color.accent.primary : theme.color.fg.dim, bold: active }, + active ? `${harness.changeCount} CHANGES` : "READY", + ), + ), + ...harness.rows.map((row) => { + const rowActive = row.status === "change"; + return h( + Box, + { key: `${harness.tool}:${row.kind}:${row.endpoint}`, columnGap: 1 }, + h( + Text, + { color: rowActive ? theme.color.fg.secondary : theme.color.fg.dim }, + (row.displayKind || row.kind).toUpperCase().padEnd(7), + ), + h( + Box, + { minWidth: 0, flexGrow: 1 }, + h( + Text, + { color: rowActive ? theme.color.fg.primary : theme.color.fg.dim, wrap: "truncate-middle" }, + row.endpoint ? compactPath(row.endpoint) : "No destination", + ), + ), + h( + Text, + { + color: rowActive ? palette[operationTone(row.operation)] : theme.color.fg.dim, + bold: rowActive, + flexShrink: 0, + }, + row.count > 0 ? `${row.count} ${row.operation}` : row.operation, + ), + ); + }), + compact + ? h(Text, { color: theme.color.fg.dim }, `Installed agent ${position} of ${total}`) + : null, ); } -function LocationReview({ groups, selectedIndex, height, compact = false }) { - if (groups.length === 0) { +function harnessItems(harness) { + const items = new Map(); + for (const row of harness.rows) { + for (const action of row.actions) { + const key = `${action.kind}:${action.name}`; + if (!items.has(key)) items.set(key, { kind: action.kind, name: action.name, operations: new Set() }); + items.get(key).operations.add(row.operation); + } + } + return Array.from(items.values()).sort((left, right) => left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name)); +} + +function HarnessMap({ harnesses, selectedIndex, width, height, compact = false }) { + if (harnesses.length === 0) { return h( Frame, - { title: "Affected locations", color: "green", flexGrow: 1 }, - h(StatusMessage, { variant: "success" }, "No files or links need to change."), + { title: "Installed agents", color: "green", flexGrow: 1 }, + h(StatusMessage, { variant: "success" }, "No installed agents need destination paths."), ); } - const selected = groups[selectedIndex]; + const selected = harnesses[selectedIndex]; if (compact) { - const firstAction = selected.actions[0]; return h( - Frame, - { title: "Affected locations", color: "orange", flexGrow: 1 }, - h(Text, { color: theme.color.fg.dim }, `Path group ${selectedIndex + 1} of ${groups.length}`), - h( - Box, - { columnGap: 1 }, - h(Text, { color: theme.color.accent.bright, bold: true }, ">"), - h(Text, { color: palette[toneByType[selected.type]], bold: true }, labelByType[selected.type]), - h( - Text, - { color: theme.color.fg.primary, bold: true }, - `${selected.actions.length} ${pluralizeKind(selected.kind, selected.actions.length)}`, - ), - h(Text, { color: theme.color.fg.dim }, selected.tools.join(", ")), - ), - h(GroupPath, { group: selected }), - h( - Box, - { marginLeft: 2, columnGap: 1 }, - h(Text, { color: theme.color.fg.muted }, "First file"), - h(Text, { color: theme.color.fg.primary, wrap: "truncate-end" }, path.basename(firstAction.target)), - ), - selected.actions.length > 1 - ? h(Text, { color: theme.color.fg.dim, marginLeft: 2 }, `${selected.actions.length - 1} more in this group`) - : null, + HarnessBlock, + { + harness: selected, + selected: true, + width, + compact: true, + position: selectedIndex + 1, + total: harnesses.length, + }, ); } - const maxVisibleGroups = Math.max(2, Math.floor((height - 11) / 2)); - const start = Math.min( - Math.max(0, selectedIndex - maxVisibleGroups + 1), - Math.max(0, groups.length - maxVisibleGroups), - ); - const visibleGroups = groups.slice(start, start + maxVisibleGroups); - const detailLimit = Math.max(1, height - 11 - visibleGroups.length * 2); - const visibleActions = selected.actions.slice(0, detailLimit); + const columns = width >= 72 ? 2 : 1; + const blockWidth = columns === 2 ? Math.floor((width - 1) / 2) : width; + const blockHeight = Math.max(5, ...harnesses.map((harness) => harness.rows.length + 3)); + const rows = []; + for (let index = 0; index < harnesses.length; index += columns) rows.push(harnesses.slice(index, index + columns)); + const selectedItems = harnessItems(selected); + const detailLimit = Math.max(1, height - rows.length * blockHeight - 8); + const visibleItems = selectedItems.slice(0, detailLimit); return h( Frame, - { title: "Affected locations", color: "orange", flexGrow: 1 }, - h(Text, { color: theme.color.fg.muted }, "Use Up/Down to inspect a path group."), - start > 0 ? h(Text, { color: theme.color.fg.dim }, `${start} groups above`) : null, - ...visibleGroups.flatMap((group, visibleIndex) => { - const index = start + visibleIndex; - const active = index === selectedIndex; - return [ - h( - Box, - { key: `${group.id}-label`, columnGap: 1 }, - h(Text, { color: active ? theme.color.accent.bright : theme.color.fg.dim, bold: active }, active ? ">" : " "), - h( - Text, - { color: palette[toneByType[group.type]], bold: true }, - labelByType[group.type], - ), - h( - Text, - { color: active ? theme.color.fg.primary : theme.color.fg.secondary, bold: active }, - `${group.actions.length} ${pluralizeKind(group.kind, group.actions.length)}`, - ), - h(Text, { color: theme.color.fg.dim }, group.tools.join(", ")), - ), - h(GroupPath, { key: `${group.id}-path`, group }), - ]; - }), - start + visibleGroups.length < groups.length - ? h(Text, { color: theme.color.fg.dim }, `${groups.length - start - visibleGroups.length} groups below`) - : null, - h(Box, { marginTop: 1, columnGap: 1 }, - h(Text, { color: theme.color.fg.primary, bold: true }, "Exact files"), - h(Text, { color: theme.color.fg.dim }, `${selected.actions.length} in selected group`), - ), - ...visibleActions.map((action) => + { title: "Installed agent destinations", color: "orange", flexGrow: 1 }, + h(Text, { color: theme.color.fg.muted }, "Arrow keys move between agents. Bright rows change; dim rows stay as they are."), + ...rows.map((row, rowIndex) => h( Box, - { key: action.id, marginLeft: 2, justifyContent: "space-between", columnGap: 2 }, - h(Text, { color: theme.color.fg.primary, wrap: "truncate-end" }, path.basename(action.target)), - h(Text, { color: theme.color.fg.dim, flexShrink: 0 }, action.kind), + { key: `harness-row-${rowIndex}`, columnGap: 1 }, + ...row.map((harness) => { + const index = harnesses.indexOf(harness); + return h(HarnessBlock, { + key: harness.tool, + harness, + selected: index === selectedIndex, + width: blockWidth, + height: blockHeight, + }); + }), + ), + ), + h( + Box, + { marginTop: 1, columnGap: 1 }, + h(Text, { color: selected.hasChanges ? theme.color.fg.primary : theme.color.fg.dim, bold: true }, selected.label), + h( + Text, + { color: selected.hasChanges ? theme.color.accent.primary : theme.color.fg.dim }, + selected.hasChanges ? `${selected.changeCount} files need work` : "all destination paths are ready", ), ), - selected.actions.length > visibleActions.length - ? h(Text, { color: theme.color.fg.dim, marginLeft: 2 }, `${selected.actions.length - visibleActions.length} more files`) + selected.hasChanges + ? visibleItems.map((item) => + h( + Box, + { key: `${selected.tool}:${item.kind}:${item.name}`, marginLeft: 2, justifyContent: "space-between", columnGap: 2 }, + h(Text, { color: theme.color.fg.primary, wrap: "truncate-end" }, item.name), + h(Text, { color: theme.color.fg.dim, flexShrink: 0 }, `${item.kind} ${Array.from(item.operations).join("+")}`), + ), + ) + : h(Text, { color: theme.color.fg.dim, marginLeft: 2 }, "Saddle will not touch this agent."), + selectedItems.length > visibleItems.length + ? h(Text, { color: theme.color.fg.dim, marginLeft: 2 }, `${selectedItems.length - visibleItems.length} more files`) : null, ); } @@ -347,7 +368,7 @@ function ApplyProgress({ plan, completed, current, error, done }) { export function ReorgApp({ plan, applyReorgPlan, onFinish }) { const { exit } = useApp(); const [stage, setStage] = useState(plan.conflicts.length > 0 ? "conflicts" : "review"); - const [selectedGroupIndex, setSelectedGroupIndex] = useState(0); + const [selectedHarnessIndex, setSelectedHarnessIndex] = useState(0); const [completed, setCompleted] = useState(0); const [current, setCurrent] = useState(null); const [done, setDone] = useState(false); @@ -360,15 +381,24 @@ export function ReorgApp({ plan, applyReorgPlan, onFinish }) { const reviewHeight = compact ? Math.max(9, terminalHeight - 14) : Math.max(9, terminalHeight - 15); const gap = 1; const availableWidth = Math.max(27, width - gap - 2); - const leftWidth = Math.max(12, Math.floor(availableWidth * 0.38)); + const leftWidth = Math.max(12, Math.floor(availableWidth * 0.3)); const rightWidth = Math.max(12, availableWidth - leftWidth); + const mapWidth = Math.max(8, rightWidth - 4); + const mapColumns = !compact && mapWidth >= 72 ? 2 : 1; const counts = useMemo(() => countPlanActions(plan.actions), [plan.actions]); - const groups = useMemo(() => buildChangeGroups(plan.actions), [plan.actions]); + const harnesses = useMemo( + () => buildHarnessBlocks({ coverage: plan.coverage, actions: plan.actions, unchanged: plan.unchanged }), + [plan.coverage, plan.actions, plan.unchanged], + ); + const collections = useMemo( + () => buildCanonicalCollections({ sourceRoot: plan.sourceRoot, actions: plan.actions, unchanged: plan.unchanged }), + [plan.sourceRoot, plan.actions, plan.unchanged], + ); useEffect(() => { - if (groups.length === 0) return; - if (selectedGroupIndex >= groups.length) setSelectedGroupIndex(groups.length - 1); - }, [groups, selectedGroupIndex]); + if (harnesses.length === 0) return; + if (selectedHarnessIndex >= harnesses.length) setSelectedHarnessIndex(harnesses.length - 1); + }, [harnesses, selectedHarnessIndex]); useInput((input, key) => { const requestedExit = key.escape || input === "q" || (key.ctrl && input === "c"); @@ -381,12 +411,20 @@ export function ReorgApp({ plan, applyReorgPlan, onFinish }) { return; } if (stage === "review") { - if (key.upArrow && groups.length > 0) { - setSelectedGroupIndex((current) => Math.max(0, current - 1)); + if (key.leftArrow && harnesses.length > 0) { + setSelectedHarnessIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.rightArrow && harnesses.length > 0) { + setSelectedHarnessIndex((current) => Math.min(harnesses.length - 1, current + 1)); + return; + } + if (key.upArrow && harnesses.length > 0) { + setSelectedHarnessIndex((current) => Math.max(0, current - mapColumns)); return; } - if (key.downArrow && groups.length > 0) { - setSelectedGroupIndex((current) => Math.min(groups.length - 1, current + 1)); + if (key.downArrow && harnesses.length > 0) { + setSelectedHarnessIndex((current) => Math.min(harnesses.length - 1, current + mapColumns)); return; } if (key.return) { @@ -463,16 +501,27 @@ export function ReorgApp({ plan, applyReorgPlan, onFinish }) { ? h( Box, { height: reviewHeight }, - h(LocationReview, { groups, selectedIndex: selectedGroupIndex, height: reviewHeight, compact: true }), + h(HarnessMap, { + harnesses, + selectedIndex: selectedHarnessIndex, + width: availableWidth, + height: reviewHeight, + compact: true, + }), ) : h( Box, { height: reviewHeight, columnGap: gap }, - h(Box, { width: leftWidth }, h(ExecutionOrder, { plan, counts })), + h(Box, { width: leftWidth }, h(CanonicalSource, { plan, collections, counts })), h( Box, { width: rightWidth }, - h(LocationReview, { groups, selectedIndex: selectedGroupIndex, height: reviewHeight }), + h(HarnessMap, { + harnesses, + selectedIndex: selectedHarnessIndex, + width: mapWidth, + height: reviewHeight, + }), ), ), h( @@ -488,7 +537,7 @@ export function ReorgApp({ plan, applyReorgPlan, onFinish }) { { color: plan.actions.length > 0 ? theme.color.accent.bright : theme.color.state.success, bold: true }, plan.actions.length > 0 ? `Enter Apply ${plan.actions.length} changes` : "Enter Exit", ), - groups.length > 1 ? h(Text, { color: theme.color.fg.muted }, "Up/Down Inspect") : null, + harnesses.length > 1 ? h(Text, { color: theme.color.fg.muted }, "Arrows Inspect agent") : null, h(Text, { color: theme.color.fg.muted }, "Esc Cancel without changes"), ), ) diff --git a/scripts/tui/reorg-view-model.mjs b/scripts/tui/reorg-view-model.mjs index 0dfbce5..5853509 100644 --- a/scripts/tui/reorg-view-model.mjs +++ b/scripts/tui/reorg-view-model.mjs @@ -34,3 +34,123 @@ export function buildChangeGroups(actions) { left.sourceRoot.localeCompare(right.sourceRoot), ); } + +function actionPathMatchesEndpoint(action, endpoint) { + if (action.type === "import") return path.dirname(action.source) === endpoint; + return path.dirname(action.target) === endpoint; +} + +function operationFor(actions) { + const types = new Set(actions.map((action) => action.type)); + if (types.has("import") && types.has("link")) return "MOVE+LINK"; + if (types.has("link")) return "LINK"; + if (types.has("remove-duplicate")) return "CLEAN"; + return "MOVE"; +} + +export function buildHarnessBlocks({ coverage, actions, unchanged }) { + const harnesses = new Map(); + + for (const item of coverage) { + if (!harnesses.has(item.tool)) { + harnesses.set(item.tool, { + tool: item.tool, + label: item.toolLabel, + rows: [], + }); + } + + const harness = harnesses.get(item.tool); + const relevantActions = actions.filter( + (action) => action.kind === item.kind && action.tools.includes(item.tool), + ); + const relevantUnchanged = unchanged.filter( + (entry) => entry.kind === item.kind && entry.tools.includes(item.tool), + ); + const endpoints = item.endpoints.length > 0 ? item.endpoints : [null]; + + for (const endpoint of endpoints) { + const matchingActions = endpoint + ? relevantActions.filter((action) => actionPathMatchesEndpoint(action, endpoint)) + : []; + const rowUnchanged = endpoint + ? relevantUnchanged.filter((entry) => path.dirname(entry.target) === endpoint) + : []; + const changedNames = new Set(matchingActions.map((action) => action.name)); + const readyNames = new Set(rowUnchanged.map((entry) => entry.name)); + + harness.rows.push({ + kind: item.kind, + endpoint, + targetClass: item.targetClass, + supported: item.supported, + status: !item.supported ? "unsupported" : matchingActions.length > 0 ? "change" : "ready", + operation: matchingActions.length > 0 ? operationFor(matchingActions) : item.supported ? "READY" : "UNSUPPORTED", + count: matchingActions.length > 0 ? changedNames.size : readyNames.size, + actions: matchingActions, + }); + } + + const unmatched = relevantActions.filter( + (action) => !item.endpoints.some((endpoint) => actionPathMatchesEndpoint(action, endpoint)), + ); + const cleanupRoots = new Map(); + for (const action of unmatched) { + const cleanupRoot = action.type === "import" ? path.dirname(action.source) : path.dirname(action.target); + if (!cleanupRoots.has(cleanupRoot)) cleanupRoots.set(cleanupRoot, []); + cleanupRoots.get(cleanupRoot).push(action); + } + for (const [cleanupRoot, cleanupActions] of cleanupRoots) { + harness.rows.push({ + kind: item.kind, + displayKind: "cleanup", + auxiliary: true, + endpoint: cleanupRoot, + targetClass: "cleanup", + supported: true, + status: "change", + operation: operationFor(cleanupActions), + count: new Set(cleanupActions.map((action) => action.name)).size, + actions: cleanupActions, + }); + } + } + + const kindRank = { skill: 0, agent: 1, command: 2 }; + return Array.from(harnesses.values()) + .map((harness) => { + harness.rows.sort( + (left, right) => + kindRank[left.kind] - kindRank[right.kind] || Number(left.auxiliary) - Number(right.auxiliary), + ); + harness.hasChanges = harness.rows.some((row) => row.status === "change"); + harness.changeCount = new Set( + harness.rows.flatMap((row) => row.actions.map((action) => `${action.kind}:${action.name}`)), + ).size; + return harness; + }) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +export function buildCanonicalCollections({ sourceRoot, actions, unchanged }) { + const collections = new Map(); + + const add = (kind, itemPath, changed) => { + if (!itemPath.startsWith(`${sourceRoot}${path.sep}`)) return; + const root = path.dirname(itemPath); + const key = `${kind}:${root}`; + if (!collections.has(key)) collections.set(key, { kind, path: root, changed: false }); + if (changed) collections.get(key).changed = true; + }; + + for (const action of actions) { + const canonicalPath = action.targetClass === "canonical" ? action.target : action.source; + add(action.kind, canonicalPath, true); + } + for (const entry of unchanged) add(entry.kind, entry.source, false); + + const kindRank = { skill: 0, agent: 1, command: 2 }; + return Array.from(collections.values()).sort( + (left, right) => kindRank[left.kind] - kindRank[right.kind] || left.path.localeCompare(right.path), + ); +} diff --git a/tests/reorg-ui.test.mjs b/tests/reorg-ui.test.mjs index 0e1370b..0396127 100644 --- a/tests/reorg-ui.test.mjs +++ b/tests/reorg-ui.test.mjs @@ -1,6 +1,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { buildChangeGroups } from "../scripts/tui/reorg-view-model.mjs"; +import { + buildCanonicalCollections, + buildChangeGroups, + buildHarnessBlocks, +} from "../scripts/tui/reorg-view-model.mjs"; describe("reorganization review groups", () => { it("groups actions by operation, kind, and path pair in apply order", () => { @@ -52,3 +56,88 @@ describe("reorganization review groups", () => { assert.deepStrictEqual(groups[0].tools, ["claude"]); }); }); + +describe("reorganization harness map", () => { + const coverage = [ + { + tool: "claude", + toolLabel: "Claude Code", + kind: "skill", + targetClass: "provider", + endpoints: ["/home/.claude/skills"], + supported: true, + }, + { + tool: "codex", + toolLabel: "Codex", + kind: "skill", + targetClass: "universal", + endpoints: ["/home/.agents/skills"], + supported: true, + }, + { + tool: "cursor", + toolLabel: "Cursor", + kind: "skill", + targetClass: "universal", + endpoints: ["/home/.agents/skills"], + supported: true, + }, + ]; + + const actions = [ + { + id: "link-review", + type: "link", + kind: "skill", + name: "review", + source: "/shared/skills/review", + target: "/home/.claude/skills/review", + targetClass: "provider", + tools: ["claude"], + }, + { + id: "remove-cursor-review", + type: "remove-duplicate", + kind: "skill", + name: "review", + source: "/shared/skills/review", + target: "/home/.cursor/skills/review", + targetClass: "provider", + tools: ["cursor"], + }, + ]; + + const unchanged = [ + { + kind: "skill", + name: "review", + source: "/shared/skills/review", + target: "/home/.agents/skills/review", + targetClass: "universal", + tools: ["codex", "cursor"], + }, + ]; + + it("marks changed destinations as active and existing destinations as ready", () => { + const harnesses = buildHarnessBlocks({ coverage, actions, unchanged }); + + assert.strictEqual(harnesses[0].label, "Claude Code"); + assert.strictEqual(harnesses[0].hasChanges, true); + assert.strictEqual(harnesses[0].rows[0].operation, "LINK"); + assert.strictEqual(harnesses[1].label, "Codex"); + assert.strictEqual(harnesses[1].hasChanges, false); + assert.strictEqual(harnesses[1].rows[0].operation, "READY"); + assert.strictEqual(harnesses[2].label, "Cursor"); + assert.strictEqual(harnesses[2].rows[0].operation, "READY"); + assert.strictEqual(harnesses[2].rows[1].displayKind, "cleanup"); + assert.strictEqual(harnesses[2].rows[1].operation, "CLEAN"); + }); + + it("finds canonical collection paths from changed and unchanged items", () => { + assert.deepStrictEqual( + buildCanonicalCollections({ sourceRoot: "/shared", actions, unchanged }), + [{ kind: "skill", path: "/shared/skills", changed: true }], + ); + }); +});