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 }],
+ );
+ });
+});