From 0154a11c68d67deb83f09e96722ac7423e91773a Mon Sep 17 00:00:00 2001 From: Nick DiZazzo <728690+ndizazzo@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:12:28 -0400 Subject: [PATCH] feat: add support for rules to define multiple or single selection (#4) * add support for rules to define multiple or single selection * update CHANGELOG.md --- CHANGELOG.md | 16 + CONTRIBUTING.md | 26 +- README.md | 179 +++++- rules/claude.yaml | 1 + rules/codex.yaml | 1 + rules/copilot.yaml | 1 + rules/cursor.yaml | 1 + rules/gemini.yaml | 1 + rules/oh-my-opencode.yaml | 1 + rules/opencode.yaml | 1 + scripts/install-core.js | 59 +- scripts/load-config.js | 81 +-- scripts/tui/App.mjs | 610 ++++++++++++++------- scripts/tui/components/SelectionScreen.mjs | 212 ++++--- scripts/tui/ui/format.mjs | 1 + tests/install-core.test.js | 441 ++++++++++----- tests/load-config.test.js | 238 +++++--- 17 files changed, 1294 insertions(+), 576 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e01d8e..aab8a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [0.9.2] — 2026-03-25 + +### Added + +- `mode` configuration option in rules: `multi-select` (default, allows selecting any combination of profiles) or `single-select` (enforces one-at-a-time selection) +- Radio button indicators `(•)` / `( )` in TUI for single-select rule groups (vs. checkboxes `[x]` / `[ ]` for multi-select) +- Single-select enforcement in headless mode (`--all` flag now respects mode for each rule) +- "Writing Rules" section in README.md with complete YAML schema documentation, mode explanation, and real-world examples +- All bundled rules now explicitly declare their selection mode in YAML + +### Changed + +- `oh-my-opencode.yaml` now uses `mode: single-select` to prevent accidental installation of multiple conflicting provider configs + +--- + ## [0.9.1] — 2026-03-12 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74d1811..98398b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,16 +20,16 @@ Husky will install a pre-commit hook automatically during `npm install`. ## Project Structure -| Path | Purpose | -|------|---------| -| `bin/saddle.js` | CLI entry point | -| `scripts/install.js` | Main installer orchestrator | +| Path | Purpose | +| ------------------------- | ------------------------------------------------- | +| `bin/saddle.js` | CLI entry point | +| `scripts/install.js` | Main installer orchestrator | | `scripts/install-core.js` | Core logic (profile discovery, linking, lockfile) | -| `scripts/install-ui.mjs` | Ink TUI (ESM) | -| `scripts/load-config.js` | Config loading + rule normalisation | -| `scripts/tui/` | TUI components and utilities | -| `rules/` | Bundled YAML rules, one per supported tool | -| `tests/` | Node built-in test runner suites | +| `scripts/install-ui.mjs` | Ink TUI (ESM) | +| `scripts/load-config.js` | Config loading + rule normalisation | +| `scripts/tui/` | TUI components and utilities | +| `rules/` | Bundled YAML rules, one per supported tool | +| `tests/` | Node built-in test runner suites | ## Running Tests @@ -37,7 +37,7 @@ Husky will install a pre-commit hook automatically during `npm install`. npm test ``` -All 171 tests must pass before any PR is merged. The suite uses the Node.js built-in `node:test` runner — no additional test dependencies. +All tests must pass before any PR is merged. The suite uses the Node.js built-in `node:test` runner — no additional test dependencies. ## Running the Linter @@ -85,6 +85,12 @@ To add support for a new AI coding tool: The installer picks up new rule files automatically via `loadRules()`. +See the **Writing Rules** section in `README.md` for the complete YAML schema, including: + +- `tool`, `label`, `home`, `binary`, `enabled`, `mode` +- `mappings` for skills, files, and directories +- `mode: single-select` for mutually exclusive options (vs. `multi-select` default) + ## 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 3f8b11f..e640cd0 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ The interactive TUI detects which AI tools are installed and walks you through l ## What Gets Synced -| Tool | Home | Skills | Agents | Commands | Root File | Config Files | -|------|------|:------:|:------:|:--------:|:---------:|:------------:| -| Claude Code | `~/.claude` | ✓ | ✓ | ✓ | — | — | -| Codex | `~/.codex` | ✓ | ✓ | ✓ | `AGENTS.md` | — | -| Copilot | `~/.copilot` | ✓ | ✓ | ✓ | — | — | -| Cursor | `~/.cursor` | ✓ | ✓ | ✓ | — | — | -| Gemini | `~/.gemini` | ✓ | ✓ | ✓ | `GEMINI.md` | `configurations/gemini/` → `~/.gemini/` | -| OpenCode | `~/.config/opencode` | ✓ | ✓ | ✓ | `AGENTS.md` | `opencode/` → `~/.config/opencode/` | +| Tool | Home | Skills | Agents | Commands | Root File | Config Files | +| ----------- | -------------------- | :----: | :----: | :------: | :---------: | :-------------------------------------: | +| Claude Code | `~/.claude` | ✓ | ✓ | ✓ | — | — | +| Codex | `~/.codex` | ✓ | ✓ | ✓ | `AGENTS.md` | — | +| Copilot | `~/.copilot` | ✓ | ✓ | ✓ | — | — | +| Cursor | `~/.cursor` | ✓ | ✓ | ✓ | — | — | +| Gemini | `~/.gemini` | ✓ | ✓ | ✓ | `GEMINI.md` | `configurations/gemini/` → `~/.gemini/` | +| OpenCode | `~/.config/opencode` | ✓ | ✓ | ✓ | `AGENTS.md` | `opencode/` → `~/.config/opencode/` | --- @@ -121,17 +121,17 @@ Keep the real files in this repo and rebuild tool-specific links on each machine saddle [options] ``` -| Flag | Description | -|------|-------------| -| `--dry-run` | Preview changes without writing to disk | -| `--yes` | Auto-confirm replacements without prompting | -| `--all` | Select every available profile | -| `--profile id1,id2` | Apply specific profile IDs by name | -| `--list` | Print available profiles and exit | -| `--check` | Verify installed symlinks are in sync (exit 0 clean, 1 drift) | -| `--uninstall` | Remove symlinks recorded in lockfile | -| `--verbose` | Show extra detail (source paths, resolved targets) | -| `--quiet` | Suppress ok/link/skip/mkdir output; errors and summary only | +| Flag | Description | +| ------------------- | ------------------------------------------------------------- | +| `--dry-run` | Preview changes without writing to disk | +| `--yes` | Auto-confirm replacements without prompting | +| `--all` | Select every available profile | +| `--profile id1,id2` | Apply specific profile IDs by name | +| `--list` | Print available profiles and exit | +| `--check` | Verify installed symlinks are in sync (exit 0 clean, 1 drift) | +| `--uninstall` | Remove symlinks recorded in lockfile | +| `--verbose` | Show extra detail (source paths, resolved targets) | +| `--quiet` | Suppress ok/link/skip/mkdir output; errors and summary only | ### Interactive Mode @@ -158,11 +158,142 @@ 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 | + +--- + +## Writing Rules + +Rules are YAML files that define how to sync a tool's configurations. Each rule describes what to link and where. Place custom rules in `~/.config/saddle/rules/` (or set `SADDLE_RULES_DIR` to override). + +### Rule Schema + +```yaml +tool: claude # Unique identifier for this tool +label: Claude Code # Display name in the TUI +binary: # How to detect if tool is installed (optional) + which: claude # Try `which claude` to detect + # OR + paths: # Or check these paths on specific platforms + darwin: /Applications/Claude.app + linux: /usr/bin/claude +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 + +mappings: # List of what to link + - type: skills # Type: skills | file | directory + source: skills # Path relative to repo root + target: skills # Path relative to home (or . for home itself) + itemType: skill # Optional: type hint for skills mapping + + - type: file # Link a single file + source: agents/claude/AGENTS.md + target: AGENTS.md # File name in home + + - type: directory # Link files from a directory + source: configs/claude + target: . # Flatten files directly into home +``` + +### 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 | + +### Selection Mode + +Control how users can select items from this rule: + +- **`multi-select`** (default) — User can select any combination of profiles. UI shows checkboxes `[x]` / `[ ]`. Useful for skills, agents, commands where you might want multiple at once. + +- **`single-select`** — User can select only one profile from this rule at a time. UI shows radio buttons `(•)` / `( )`. Useful when alternatives are mutually exclusive (e.g., multiple config files targeting the same destination). + +**Example:** `oh-my-opencode.yaml` has 3 file mappings all targeting `oh-my-opencode.json`. Setting `mode: single-select` ensures only one alternative config gets installed: + +```yaml +tool: oh-my-opencode +label: OpenCode Config +home: ~/.config/opencode +enabled: true +mode: single-select # Only allow ONE of the three files + +mappings: + - type: file + source: oh-my-opencode/config.openai.json + target: oh-my-opencode.json + + - type: file + source: oh-my-opencode/config.claude.json + target: oh-my-opencode.json + + - type: file + source: oh-my-opencode/config.copilot.json + target: oh-my-opencode.json +``` + +### Mapping Types + +**`skills`** — Discovers subdirectories in source and creates one action per skill. + +```yaml +- type: skills + source: skills + target: skills + itemType: skill # optional type hint +``` + +**`file`** — Links a single file. Source file must exist. + +```yaml +- type: file + source: agents/claude/AGENTS.md + target: AGENTS.md +``` + +**`directory`** — Discovers files in source directory (non-recursive) and creates one action per file. + +```yaml +- type: directory + source: configs/claude + target: . # Flatten into home + # OR + target: config/ # Put into subdirectory +``` + +### Binary Detection + +Detect if a tool is installed: + +```yaml +# Method 1: `which` command (cross-platform) +binary: + which: claude + +# Method 2: Platform-specific paths +binary: + paths: + darwin: /Applications/Claude.app + linux: /usr/bin/claude + win32: C:\Program Files\Claude\claude.exe + +# Method 3: Both (tries `which` first, falls back to paths) +binary: + which: cursor + paths: + darwin: /Applications/Cursor.app +``` --- diff --git a/rules/claude.yaml b/rules/claude.yaml index 36a5015..609342f 100644 --- a/rules/claude.yaml +++ b/rules/claude.yaml @@ -3,6 +3,7 @@ label: Claude Code binary: claude home: ~/.claude enabled: true +mode: multi-select mappings: - type: skills diff --git a/rules/codex.yaml b/rules/codex.yaml index 2108f75..7b05d0c 100644 --- a/rules/codex.yaml +++ b/rules/codex.yaml @@ -6,6 +6,7 @@ binary: darwin: /Applications/Codex.app home: ~/.codex enabled: true +mode: multi-select mappings: - type: skills diff --git a/rules/copilot.yaml b/rules/copilot.yaml index 477fc7c..ca035f1 100644 --- a/rules/copilot.yaml +++ b/rules/copilot.yaml @@ -3,6 +3,7 @@ label: Copilot binary: gh home: ~/.copilot enabled: true +mode: multi-select mappings: - type: skills diff --git a/rules/cursor.yaml b/rules/cursor.yaml index a2ed4a5..7fcbaff 100644 --- a/rules/cursor.yaml +++ b/rules/cursor.yaml @@ -6,6 +6,7 @@ binary: darwin: /Applications/Cursor.app home: ~/.cursor enabled: true +mode: multi-select mappings: - type: skills diff --git a/rules/gemini.yaml b/rules/gemini.yaml index 1813e89..de66b15 100644 --- a/rules/gemini.yaml +++ b/rules/gemini.yaml @@ -3,6 +3,7 @@ label: Gemini binary: gemini home: ~/.gemini enabled: true +mode: multi-select mappings: - type: file diff --git a/rules/oh-my-opencode.yaml b/rules/oh-my-opencode.yaml index 2a70acc..93e3d1a 100644 --- a/rules/oh-my-opencode.yaml +++ b/rules/oh-my-opencode.yaml @@ -3,6 +3,7 @@ label: Oh My Opencode binary: "" home: ~/.config/opencode enabled: true +mode: single-select mappings: - type: file diff --git a/rules/opencode.yaml b/rules/opencode.yaml index c40dd0f..ffe9315 100644 --- a/rules/opencode.yaml +++ b/rules/opencode.yaml @@ -3,6 +3,7 @@ label: OpenCode binary: opencode home: ~/.config/opencode enabled: true +mode: multi-select mappings: - type: file diff --git a/scripts/install-core.js b/scripts/install-core.js index c505769..b01f039 100755 --- a/scripts/install-core.js +++ b/scripts/install-core.js @@ -47,6 +47,7 @@ const { loadConfig, CONFIG_DIR } = require("./load-config"); * @property {boolean} [informational] - When true the profile is display-only and never installed * @property {string} tool - Tool identifier matching the rule name (e.g. "claude") * @property {string} toolLabel - Human-readable tool name (e.g. "Claude Code") + * @property {'multi-select'|'single-select'} mode - Selection mode inherited from the parent rule * @property {Action[]} actions - Resolved list of source→target symlink actions */ @@ -194,7 +195,8 @@ function snapshotDirectory(rootPath) { const lines = []; function walk(currentPath, relativePathname) { - const entries = fs.readdirSync(currentPath, { withFileTypes: true }) + const entries = fs + .readdirSync(currentPath, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)); lines.push(`D:${relativePathname}`); @@ -244,9 +246,10 @@ function contentMatches(sourcePath, targetPath) { function previewDiff(sourcePath, targetPath) { const sourceStat = fs.lstatSync(sourcePath); const targetStat = fs.lstatSync(targetPath); - const args = (sourceStat.isDirectory() || targetStat.isDirectory()) - ? ["-qr", targetPath, sourcePath] - : ["-u", targetPath, sourcePath]; + const args = + sourceStat.isDirectory() || targetStat.isDirectory() + ? ["-qr", targetPath, sourcePath] + : ["-u", targetPath, sourcePath]; const result = spawnSync("diff", args, { encoding: "utf8" }); if (result.error) { @@ -268,7 +271,8 @@ function resolveMappingActions(mapping, repoRoot, targetHome) { if (mapping.type === "skills") { if (!fileExists(sourcePath)) return []; - const dirs = fs.readdirSync(sourcePath, { withFileTypes: true }) + const dirs = fs + .readdirSync(sourcePath, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); @@ -281,17 +285,20 @@ function resolveMappingActions(mapping, repoRoot, targetHome) { if (mapping.type === "file") { if (!fileExists(sourcePath)) return []; - return [{ - source: sourcePath, - target: path.join(targetHome, mapping.target), - itemType, - }]; + return [ + { + source: sourcePath, + target: path.join(targetHome, mapping.target), + itemType, + }, + ]; } if (mapping.type === "directory") { if (!fileExists(sourcePath)) return []; const resolvedTarget = mapping.target === "." ? targetHome : path.join(targetHome, mapping.target); - const entries = fs.readdirSync(sourcePath, { withFileTypes: true }) + const entries = fs + .readdirSync(sourcePath, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)); const actions = []; for (const entry of entries) { @@ -362,7 +369,7 @@ function discoverProfiles(repoRoot = getDefaultRepoRoot(), detection = null) { const targetHome = getConfig().expandHome(rule.home); if (!targetHome) continue; - const isInstalled = detection ? (detection[rule.name] !== false) : true; + const isInstalled = detection ? detection[rule.name] !== false : true; const isEnabled = rule.enabled !== false; for (const mapping of rule.mappings) { @@ -380,6 +387,7 @@ function discoverProfiles(repoRoot = getDefaultRepoRoot(), detection = null) { enabled: isEnabled, tool: rule.name, toolLabel: rule.label, + mode: rule.mode || "multi-select", actions, }); } @@ -459,13 +467,20 @@ function parseArgs(argv) { if (!value) { throw new Error("--profile requires a comma-separated value"); } - options.profileIds = value.split(",").map((item) => item.trim()).filter(Boolean); + options.profileIds = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); index += 1; continue; } if (arg.startsWith("--profile=")) { - options.profileIds = arg.slice("--profile=".length).split(",").map((item) => item.trim()).filter(Boolean); + options.profileIds = arg + .slice("--profile=".length) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); continue; } @@ -476,7 +491,9 @@ function parseArgs(argv) { } function printUsage(profiles) { - console.log("Usage: saddle [--dry-run] [--uninstall] [--check] [--yes] [--all] [--profile id1,id2] [--list] [--verbose] [--quiet]"); + console.log( + "Usage: saddle [--dry-run] [--uninstall] [--check] [--yes] [--all] [--profile id1,id2] [--list] [--verbose] [--quiet]", + ); console.log(""); console.log("Interactive Ink UI by default when running in a TTY."); console.log(""); @@ -505,7 +522,9 @@ function printProfiles(profiles, selectedIds = new Set()) { console.log("Setup profiles"); for (const [index, profile] of profiles.entries()) { const mark = selectedIds.has(profile.id) ? "x" : " "; - const details = profile.informational ? "informational" : `${profile.actions.length} link${profile.actions.length === 1 ? "" : "s"}`; + const details = profile.informational + ? "informational" + : `${profile.actions.length} link${profile.actions.length === 1 ? "" : "s"}`; console.log(`${index + 1}. [${mark}] ${profile.label} (${profile.id})`); console.log(` ${profile.description}`); console.log(` ${details}`); @@ -634,9 +653,7 @@ async function buildInspectionCache(profiles, onProgress = null) { function inspectProfile(profile, inspectionCache = null) { const actions = profile.actions.map((action) => { const key = `${action.source}::${action.target}`; - const inspected = inspectionCache - ? (inspectionCache.get(key) || inspectAction(action)) - : inspectAction(action); + const inspected = inspectionCache ? inspectionCache.get(key) || inspectAction(action) : inspectAction(action); return { ...inspected, itemType: action.itemType || "config" }; }); const counts = { @@ -730,9 +747,7 @@ async function runInstallation({ return; } - const confirmed = assumeYes - ? true - : await confirmReplacement(prompt); + const confirmed = assumeYes ? true : await confirmReplacement(prompt); if (!confirmed) { emit("skip", { profile, source, target, reason: "user-declined" }); diff --git a/scripts/load-config.js b/scripts/load-config.js index 36e850a..3cd42d2 100644 --- a/scripts/load-config.js +++ b/scripts/load-config.js @@ -28,6 +28,7 @@ const { parse, stringify } = require("yaml"); * @property {BinarySpec|null} binary - Binary detection spec; null when detection is home-only * @property {string|null} home - Tilde-prefixed home directory path for the tool (e.g. "~/.claude") * @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 */ @@ -106,12 +107,17 @@ function normalizeRule(raw) { binary: normalizeBinary(raw.binary), home: raw.home || null, enabled: raw.enabled !== false, - mappings: Array.isArray(raw.mappings) ? raw.mappings.filter((m) => m && m.type && m.source && m.target !== undefined).map((m) => ({ - type: m.type, - source: m.source, - target: m.target, - ...(m.itemType ? { itemType: m.itemType } : {}), - })) : [], + mode: raw.mode === "single-select" ? "single-select" : "multi-select", + mappings: Array.isArray(raw.mappings) + ? raw.mappings + .filter((m) => m && m.type && m.source && m.target !== undefined) + .map((m) => ({ + type: m.type, + source: m.source, + target: m.target, + ...(m.itemType ? { itemType: m.itemType } : {}), + })) + : [], }; } @@ -141,19 +147,19 @@ function loadRules() { const files = fs.readdirSync(RULES_DIR).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); const rules = []; - for (const file of files) { - const filePath = path.join(RULES_DIR, file); - try { - const raw = fs.readFileSync(filePath, "utf8"); - const parsed = parse(raw); - const rule = normalizeRule(parsed); - if (rule) { - rules.push(rule); - } - } catch { - /* skip unparseable rule file — malformed YAML should not crash the whole config load */ - } - } + for (const file of files) { + const filePath = path.join(RULES_DIR, file); + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = parse(raw); + const rule = normalizeRule(parsed); + if (rule) { + rules.push(rule); + } + } catch { + /* skip unparseable rule file — malformed YAML should not crash the whole config load */ + } + } return rules; } @@ -193,16 +199,27 @@ function loadConfig(fallbackSourceRoot) { } function writeSourceRoot(newPath) { - let parsed = {}; - try { - const raw = fs.readFileSync(CONFIG_PATH, "utf8"); - parsed = parse(raw) || {}; - } catch { - /* config file doesn't exist yet — writing fresh config, start with empty object */ - } - parsed.sourceRoot = newPath; - fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); - fs.writeFileSync(CONFIG_PATH, stringify(parsed, { lineWidth: 120 }), "utf8"); - } - -module.exports = { loadConfig, loadRules, writeSourceRoot, writeDefaultConfig, seedDefaultRules, CONFIG_PATH, CONFIG_DIR, RULES_DIR, DEFAULT_SOURCE_ROOT, BUNDLED_RULES_DIR }; + let parsed = {}; + try { + const raw = fs.readFileSync(CONFIG_PATH, "utf8"); + parsed = parse(raw) || {}; + } catch { + /* config file doesn't exist yet — writing fresh config, start with empty object */ + } + parsed.sourceRoot = newPath; + fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); + fs.writeFileSync(CONFIG_PATH, stringify(parsed, { lineWidth: 120 }), "utf8"); +} + +module.exports = { + loadConfig, + loadRules, + writeSourceRoot, + writeDefaultConfig, + seedDefaultRules, + CONFIG_PATH, + CONFIG_DIR, + RULES_DIR, + DEFAULT_SOURCE_ROOT, + BUNDLED_RULES_DIR, +}; diff --git a/scripts/tui/App.mjs b/scripts/tui/App.mjs index 5f0bd24..ef09b12 100644 --- a/scripts/tui/App.mjs +++ b/scripts/tui/App.mjs @@ -4,12 +4,30 @@ import { Box, useApp, useInput } from "ink"; import { theme } from "./theme/index.mjs"; import { groupByTool } from "./ui/format.mjs"; import { ChromeBar, FooterBar } from "./ui/chrome.mjs"; -import { SelectionScreen, ConfirmScreen, PathEditOverlay, RunScreen, LoadingScreen, DiffOverlay } from "./components/index.mjs"; +import { + SelectionScreen, + ConfirmScreen, + PathEditOverlay, + RunScreen, + LoadingScreen, + DiffOverlay, +} from "./components/index.mjs"; import { h } from "./ui/react-helpers.mjs"; const standaloneProfileLabel = (profile) => `${profile.toolLabel} / ${profile.label}`; -export function InstallerApp({ profiles, options, initialSelectedIds, runInstallation, inspectProfile, buildInspectionCache, sourceRoot, configPath, writeSourceRoot, onFinish }) { +export function InstallerApp({ + profiles, + options, + initialSelectedIds, + runInstallation, + inspectProfile, + buildInspectionCache, + sourceRoot, + configPath, + writeSourceRoot, + onFinish, +}) { const { exit } = useApp(); const [stage, setStage] = useState("loading"); const [inspectionCache, setInspectionCache] = useState(null); @@ -21,12 +39,21 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall const keys = new Set(); const selectedProfileIds = new Set(initialSelectedIds); + const singleSelectUsed = new Set(); for (const profile of profiles) { if (profile.informational || profile.installed === false || profile.enabled === false) continue; if (!selectedProfileIds.has(profile.id)) continue; - for (const action of profile.actions) { - keys.add(`${profile.id}::${action.target}`); + if (profile.mode === "single-select") { + if (singleSelectUsed.has(profile.tool)) continue; + if (profile.actions.length > 0) { + keys.add(`${profile.id}::${profile.actions[0].target}`); + singleSelectUsed.add(profile.tool); + } + } else { + for (const action of profile.actions) { + keys.add(`${profile.id}::${action.target}`); + } } } @@ -38,7 +65,15 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall const [actionScrollOffset, setActionScrollOffset] = useState(0); const [logs, setLogs] = useState([]); const [prompt, setPrompt] = useState(null); - const [summary, setSummary] = useState({ totalActions: 0, completedActions: 0, linked: 0, skipped: 0, unchanged: 0, backedUp: 0, createdDirectories: 0 }); + const [summary, setSummary] = useState({ + totalActions: 0, + completedActions: 0, + linked: 0, + skipped: 0, + unchanged: 0, + backedUp: 0, + createdDirectories: 0, + }); const [currentProfile, setCurrentProfile] = useState(null); const [completedProfileIds, setCompletedProfileIds] = useState(new Set()); const [done, setDone] = useState(false); @@ -72,6 +107,22 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall } } } + + // Enforce single-select: keep first linked item per group, or auto-select first if none linked. + const groups = groupByTool(profiles.filter((p) => !p.informational)); + for (const group of groups) { + if (group.mode !== "single-select") continue; + const groupKeys = group.profiles + .filter((p) => p.installed !== false && p.enabled !== false) + .flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)); + const selectedInGroup = groupKeys.filter((k) => activeKeys.has(k)); + if (selectedInGroup.length > 1) { + for (let i = 1; i < selectedInGroup.length; i++) activeKeys.delete(selectedInGroup[i]); + } else if (selectedInGroup.length === 0 && groupKeys.length > 0) { + activeKeys.add(groupKeys[0]); + } + } + setSelectedActionKeys(activeKeys); } @@ -86,15 +137,23 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall ); const toolGroups = useMemo(() => groupByTool(profiles.filter((profile) => !profile.informational)), [profiles]); - const allActionKeys = useMemo(() => profiles - .filter((profile) => !profile.informational && profile.installed !== false && profile.enabled !== false) - .flatMap((profile) => profile.actions.map((action) => `${profile.id}::${action.target}`)), [profiles]); - const selectedProfiles = useMemo(() => profiles - .map((profile) => ({ - ...profile, - actions: profile.actions.filter((action) => selectedActionKeys.has(`${profile.id}::${action.target}`)), - })) - .filter((profile) => profile.actions.length > 0), [profiles, selectedActionKeys]); + const allActionKeys = useMemo( + () => + profiles + .filter((profile) => !profile.informational && profile.installed !== false && profile.enabled !== false) + .flatMap((profile) => profile.actions.map((action) => `${profile.id}::${action.target}`)), + [profiles], + ); + const selectedProfiles = useMemo( + () => + profiles + .map((profile) => ({ + ...profile, + actions: profile.actions.filter((action) => selectedActionKeys.has(`${profile.id}::${action.target}`)), + })) + .filter((profile) => profile.actions.length > 0), + [profiles, selectedActionKeys], + ); const confirmCanApply = useMemo(() => { if (!inspectionCache || selectedProfiles.length === 0) return false; return selectedProfiles.some((profile) => { @@ -123,7 +182,8 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall return items; }, [toolGroups, toolIndex, inspectProfileFn, inspectionCache]); - const progressPercent = summary.totalActions > 0 ? Math.round((summary.completedActions / summary.totalActions) * 100) : 0; + const progressPercent = + summary.totalActions > 0 ? Math.round((summary.completedActions / summary.totalActions) * 100) : 0; const totalHeight = Math.max(process.stdout.rows || 24, 24); const totalWidth = process.stdout.columns || 80; const mainHeight = Math.max(18, totalHeight - 6); @@ -165,183 +225,269 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall } }, [actionIndex, actionScrollOffset, depth, layout.mainHeight, toolGroups, toolIndex]); - useInput((input, key) => { - if (stage === "select") { - if (input === "q") { - onFinish({ completed: false }); - exit(); - return; - } - - if (depth === 0) { - if (key.tab) { - setFocusedPane((current) => current === "tools" ? "source" : "tools"); + useInput( + (input, key) => { + if (stage === "select") { + if (input === "q") { + onFinish({ completed: false }); + exit(); return; } - if (focusedPane === "source") { - if (key.return) { setEditingSourceRoot(true); return; } - return; - } + if (depth === 0) { + if (key.tab) { + setFocusedPane((current) => (current === "tools" ? "source" : "tools")); + return; + } - if (toolGroups.length === 0) { - if (input === "a") { setSelectedActionKeys(new Set(allActionKeys)); return; } - if (input === "n") { setSelectedActionKeys(new Set()); } - return; - } + if (focusedPane === "source") { + if (key.return) { + setEditingSourceRoot(true); + return; + } + return; + } - if (key.upArrow) { setToolIndex((current) => Math.max(0, current - 1)); return; } - if (key.downArrow) { setToolIndex((current) => Math.min(toolGroups.length - 1, current + 1)); return; } + if (toolGroups.length === 0) { + if (input === "a") { + setSelectedActionKeys(new Set(allActionKeys)); + return; + } + if (input === "n") { + setSelectedActionKeys(new Set()); + } + return; + } - if (key.rightArrow || key.return) { - const group = toolGroups[toolIndex]; - const groupActionKeys = group - ? group.profiles.filter((p) => p.installed !== false && p.enabled !== false).flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)) - : []; - if (group && group.enabled !== false && groupActionKeys.length > 0) { - setDepth(1); - setActionIndex(0); - setActionScrollOffset(0); + if (key.upArrow) { + setToolIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow) { + setToolIndex((current) => Math.min(toolGroups.length - 1, current + 1)); + return; } - return; - } - if (input === "s" && key.ctrl) { - if (selectedActionKeys.size > 0) setStage("confirm"); - return; - } + if (key.rightArrow || key.return) { + const group = toolGroups[toolIndex]; + const groupActionKeys = group + ? group.profiles + .filter((p) => p.installed !== false && p.enabled !== false) + .flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)) + : []; + if (group && group.enabled !== false && groupActionKeys.length > 0) { + setDepth(1); + setActionIndex(0); + setActionScrollOffset(0); + } + return; + } - if (input === " ") { - const group = toolGroups[toolIndex]; - if (!group || !group.installed || group.enabled === false) return; - const groupActionKeys = group.profiles - .filter((p) => p.installed !== false && p.enabled !== false) - .flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)); - setSelectedActionKeys((current) => { - const next = new Set(current); - const shouldSelect = !groupActionKeys.every((k) => current.has(k)); - for (const k of groupActionKeys) { - if (shouldSelect) next.add(k); else next.delete(k); + if (input === "s" && key.ctrl) { + if (selectedActionKeys.size > 0) setStage("confirm"); + return; + } + + if (input === " ") { + const group = toolGroups[toolIndex]; + if (!group || !group.installed || group.enabled === false) return; + const groupActionKeys = group.profiles + .filter((p) => p.installed !== false && p.enabled !== false) + .flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)); + if (group.mode === "single-select") { + setSelectedActionKeys((current) => { + const next = new Set(current); + const hasAny = groupActionKeys.some((k) => current.has(k)); + if (hasAny) { + for (const k of groupActionKeys) next.delete(k); + } else if (groupActionKeys.length > 0) { + next.add(groupActionKeys[0]); + } + return next; + }); + } else { + setSelectedActionKeys((current) => { + const next = new Set(current); + const shouldSelect = !groupActionKeys.every((k) => current.has(k)); + for (const k of groupActionKeys) { + if (shouldSelect) next.add(k); + else next.delete(k); + } + return next; + }); } - return next; - }); - return; + return; + } + + if (input === "a") { + setSelectedActionKeys((current) => { + const next = new Set(); + for (const g of toolGroups) { + if (!g.installed || g.enabled === false) continue; + const gKeys = g.profiles + .filter((p) => p.installed !== false && p.enabled !== false) + .flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)); + if (g.mode === "single-select") { + const existing = gKeys.find((k) => current.has(k)); + if (existing) next.add(existing); + else if (gKeys.length > 0) next.add(gKeys[0]); + } else { + for (const k of gKeys) next.add(k); + } + } + return next; + }); + return; + } + if (input === "n") { + setSelectedActionKeys(new Set()); + return; + } } - if (input === "a") { setSelectedActionKeys(new Set(allActionKeys)); return; } - if (input === "n") { setSelectedActionKeys(new Set()); return; } - } + if (depth === 1) { + const group = toolGroups[toolIndex]; + const groupActionKeys = group + ? group.profiles + .filter((p) => p.installed !== false && p.enabled !== false) + .flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)) + : []; + const hasActions = flatActions.length > 0; - if (depth === 1) { - const group = toolGroups[toolIndex]; - const groupActionKeys = group - ? group.profiles.filter((p) => p.installed !== false && p.enabled !== false).flatMap((p) => p.actions.map((a) => `${p.id}::${a.target}`)) - : []; - const hasActions = flatActions.length > 0; + if (key.upArrow) { + if (hasActions) setActionIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow) { + if (hasActions) setActionIndex((current) => Math.min(flatActions.length - 1, current + 1)); + return; + } - if (key.upArrow) { if (hasActions) setActionIndex((current) => Math.max(0, current - 1)); return; } - if (key.downArrow) { if (hasActions) setActionIndex((current) => Math.min(flatActions.length - 1, current + 1)); return; } + if (key.leftArrow || key.escape || input === "\u001b") { + setDepth(0); + setActionIndex(0); + setActionScrollOffset(0); + return; + } - if (key.leftArrow || key.escape || input === "\u001b") { - setDepth(0); - setActionIndex(0); - setActionScrollOffset(0); - return; - } + if (input === "s" && key.ctrl) { + if (selectedActionKeys.size > 0) setStage("confirm"); + return; + } - if (input === "s" && key.ctrl) { - if (selectedActionKeys.size > 0) setStage("confirm"); - return; - } + if (input === " ") { + const focusedAction = flatActions[actionIndex]; + if (!focusedAction) return; + if (group?.mode === "single-select") { + setSelectedActionKeys((current) => { + const next = new Set(current); + if (next.has(focusedAction.key)) { + next.delete(focusedAction.key); + } else { + for (const k of groupActionKeys) next.delete(k); + next.add(focusedAction.key); + } + return next; + }); + } else { + setSelectedActionKeys((current) => { + const next = new Set(current); + if (next.has(focusedAction.key)) next.delete(focusedAction.key); + else next.add(focusedAction.key); + return next; + }); + } + return; + } - if (input === " ") { - const focusedAction = flatActions[actionIndex]; - if (!focusedAction) return; - setSelectedActionKeys((current) => { - const next = new Set(current); - if (next.has(focusedAction.key)) next.delete(focusedAction.key); - else next.add(focusedAction.key); - return next; - }); - return; - } + if (input === "a") { + if (group?.mode === "single-select") { + setSelectedActionKeys((current) => { + const next = new Set(current); + const hasAny = groupActionKeys.some((k) => current.has(k)); + if (!hasAny && groupActionKeys.length > 0) { + next.add(groupActionKeys[0]); + } + return next; + }); + } else { + setSelectedActionKeys((current) => { + const next = new Set(current); + for (const k of groupActionKeys) next.add(k); + return next; + }); + } + return; + } - if (input === "a") { - setSelectedActionKeys((current) => { - const next = new Set(current); - for (const k of groupActionKeys) next.add(k); - return next; - }); - return; + if (input === "n") { + setSelectedActionKeys((current) => { + const next = new Set(current); + for (const k of groupActionKeys) next.delete(k); + return next; + }); + return; + } + + if (input === "d") { + const focusedAction = flatActions[actionIndex]; + if (!focusedAction) return; + const inspected = focusedAction.inspectedAction; + const isDiffable = inspected.kind === "replace-diff" || inspected.kind === "replace-link"; + if (!isDiffable) return; + setDiffModal({ + beforePath: inspected.beforePath, + afterPath: inspected.afterPath, + label: path.basename(focusedAction.action.target), + }); + return; + } } + } - if (input === "n") { - setSelectedActionKeys((current) => { - const next = new Set(current); - for (const k of groupActionKeys) next.delete(k); - return next; - }); + if (stage === "confirm") { + if (key.return) { + if (confirmCanApply) setStage("run"); return; } - - if (input === "d") { - const focusedAction = flatActions[actionIndex]; - if (!focusedAction) return; - const inspected = focusedAction.inspectedAction; - const isDiffable = inspected.kind === "replace-diff" || inspected.kind === "replace-link"; - if (!isDiffable) return; - setDiffModal({ - beforePath: inspected.beforePath, - afterPath: inspected.afterPath, - label: path.basename(focusedAction.action.target), - }); + if (key.escape || input === "\u001b" || key.backspace || input === "q") { + setStage("select"); return; } } - } - if (stage === "confirm") { - if (key.return) { - if (confirmCanApply) setStage("run"); - return; - } - if (key.escape || input === "\u001b" || key.backspace || input === "q") { - setStage("select"); - return; + if (done && (input === "q" || key.return)) { + onFinish({ completed: !error, error, summary }); + exit(); } - } - - if (done && (input === "q" || key.return)) { - onFinish({ completed: !error, error, summary }); - exit(); - } - }, { isActive: !editingSourceRoot && !diffModal }); + }, + { isActive: !editingSourceRoot && !diffModal }, + ); const handleSourceRootSubmit = (newPath) => { - const expanded = newPath.startsWith("~/") - ? newPath.replace("~", process.env.HOME || "") - : newPath; + const expanded = newPath.startsWith("~/") ? newPath.replace("~", process.env.HOME || "") : newPath; const finalPath = expanded || liveSourceRoot; writeSourceRoot(finalPath); setLiveSourceRoot(finalPath); setEditingSourceRoot(false); }; - useEffect(() => { - if (stage !== "run" || startedRef.current) return; - startedRef.current = true; - - const profilesToInstall = selectedProfiles; + useEffect(() => { + if (stage !== "run" || startedRef.current) return; + startedRef.current = true; + + const profilesToInstall = selectedProfiles; const appendLog = (kind, message) => { sequenceRef.current += 1; setLogs((current) => [...current, { kind, message, sequence: sequenceRef.current }]); }; - const confirmReplacement = (info) => new Promise((resolve) => { - promptResolverRef.current = resolve; - setPrompt(info); - }); + const confirmReplacement = (info) => + new Promise((resolve) => { + promptResolverRef.current = resolve; + setPrompt(info); + }); const begin = async () => { try { @@ -358,16 +504,69 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall appendLog("info", `Starting ${event.selectedProfiles.length} profile(s).`); return; } - if (event.type === "profile-start") { appendLog("info", `Profile: ${standaloneProfileLabel(event.profile)}`); return; } - if (event.type === "profile-complete") { setCompletedProfileIds((prev) => new Set([...prev, event.profile.id])); return; } - if (event.type === "mkdir") { appendLog("mkdir", `mkdir ${event.path}`); setSummary((current) => ({ ...current, createdDirectories: current.createdDirectories + 1 })); return; } - if (event.type === "ok") { appendLog("ok", `ok ${event.target}`); setSummary((current) => ({ ...current, completedActions: current.completedActions + 1, unchanged: current.unchanged + 1 })); return; } - if (event.type === "prompt") { const mode = event.dryRun ? "would prompt" : event.autoConfirm ? "auto confirm" : "needs confirmation"; appendLog("prompt", `${mode}: ${event.target}`); return; } - if (event.type === "skip") { appendLog("skip", `skip ${event.target}`); setSummary((current) => ({ ...current, completedActions: current.completedActions + 1, skipped: current.skipped + 1 })); return; } - if (event.type === "backup") { appendLog("backup", `backup ${event.path} -> ${event.backup}`); setSummary((current) => ({ ...current, backedUp: current.backedUp + 1 })); return; } - if (event.type === "link") { appendLog("link", `link ${event.target} -> ${event.linkTarget}`); setSummary((current) => ({ ...current, completedActions: current.completedActions + 1, linked: current.linked + 1 })); return; } - if (event.type === "error") { appendLog("error", `error ${event.target}: ${event.message}`); setSummary((current) => ({ ...current, completedActions: current.completedActions + 1, errors: (current.errors || 0) + 1 })); return; } - if (event.type === "session-complete") { setSummary(event.summary); appendLog("complete", "Install session complete."); } + if (event.type === "profile-start") { + appendLog("info", `Profile: ${standaloneProfileLabel(event.profile)}`); + return; + } + if (event.type === "profile-complete") { + setCompletedProfileIds((prev) => new Set([...prev, event.profile.id])); + return; + } + if (event.type === "mkdir") { + appendLog("mkdir", `mkdir ${event.path}`); + setSummary((current) => ({ ...current, createdDirectories: current.createdDirectories + 1 })); + return; + } + if (event.type === "ok") { + appendLog("ok", `ok ${event.target}`); + setSummary((current) => ({ + ...current, + completedActions: current.completedActions + 1, + unchanged: current.unchanged + 1, + })); + return; + } + if (event.type === "prompt") { + const mode = event.dryRun ? "would prompt" : event.autoConfirm ? "auto confirm" : "needs confirmation"; + appendLog("prompt", `${mode}: ${event.target}`); + return; + } + if (event.type === "skip") { + appendLog("skip", `skip ${event.target}`); + setSummary((current) => ({ + ...current, + completedActions: current.completedActions + 1, + skipped: current.skipped + 1, + })); + return; + } + if (event.type === "backup") { + appendLog("backup", `backup ${event.path} -> ${event.backup}`); + setSummary((current) => ({ ...current, backedUp: current.backedUp + 1 })); + return; + } + if (event.type === "link") { + appendLog("link", `link ${event.target} -> ${event.linkTarget}`); + setSummary((current) => ({ + ...current, + completedActions: current.completedActions + 1, + linked: current.linked + 1, + })); + return; + } + if (event.type === "error") { + appendLog("error", `error ${event.target}: ${event.message}`); + setSummary((current) => ({ + ...current, + completedActions: current.completedActions + 1, + errors: (current.errors || 0) + 1, + })); + return; + } + if (event.type === "session-complete") { + setSummary(event.summary); + appendLog("complete", "Install session complete."); + } }, }); @@ -380,41 +579,57 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall } }; - void begin(); - }, [selectedProfiles, options.dryRun, runInstallation, stage]); - - const content = stage === "loading" - ? h(LoadingScreen, { scanProgress, totalHeight }) - : stage === "select" - ? h(SelectionScreen, { toolGroups, depth, toolIndex, selectedActionKeys, inspectProfile: inspectProfileFn, sourceRoot: liveSourceRoot, configPath, focusedPane, layout, flatActions, actionIndex, actionScrollOffset }) - : stage === "confirm" - ? h(ConfirmScreen, { selectedProfiles, inspectProfile: inspectProfileFn, options, layout, canApply: confirmCanApply }) - : h(RunScreen, { - options, - selectedProfiles, - currentProfile, - completedProfileIds, - logs, - prompt, - summary, - progressPercent, - done, - error, - layout, - onDecision: (decision) => { - const resolver = promptResolverRef.current; - promptResolverRef.current = null; - setPrompt(null); - if (resolver) resolver(decision); - }, - }); + void begin(); + }, [selectedProfiles, options.dryRun, runInstallation, stage]); + + const content = + stage === "loading" + ? h(LoadingScreen, { scanProgress, totalHeight }) + : stage === "select" + ? h(SelectionScreen, { + toolGroups, + depth, + toolIndex, + selectedActionKeys, + inspectProfile: inspectProfileFn, + sourceRoot: liveSourceRoot, + configPath, + focusedPane, + layout, + flatActions, + actionIndex, + actionScrollOffset, + }) + : stage === "confirm" + ? h(ConfirmScreen, { + selectedProfiles, + inspectProfile: inspectProfileFn, + options, + layout, + canApply: confirmCanApply, + }) + : h(RunScreen, { + options, + selectedProfiles, + currentProfile, + completedProfileIds, + logs, + prompt, + summary, + progressPercent, + done, + error, + layout, + onDecision: (decision) => { + const resolver = promptResolverRef.current; + promptResolverRef.current = null; + setPrompt(null); + if (resolver) resolver(decision); + }, + }); if (stage === "loading") { - return h( - Box, - { flexDirection: "column", backgroundColor: theme.color.bg.base, height: totalHeight }, - content, - ); + return h(Box, { flexDirection: "column", backgroundColor: theme.color.bg.base, height: totalHeight }, content); } return h( @@ -428,7 +643,12 @@ export function InstallerApp({ profiles, options, initialSelectedIds, runInstall }, h(ChromeBar, { sourceRoot: liveSourceRoot }), editingSourceRoot - ? h(PathEditOverlay, { currentValue: liveSourceRoot, onSubmit: handleSourceRootSubmit, onCancel: () => setEditingSourceRoot(false), layout }) + ? h(PathEditOverlay, { + currentValue: liveSourceRoot, + onSubmit: handleSourceRootSubmit, + onCancel: () => setEditingSourceRoot(false), + layout, + }) : diffModal ? h(DiffOverlay, { ...diffModal, onClose: () => setDiffModal(null), layout }) : content, diff --git a/scripts/tui/components/SelectionScreen.mjs b/scripts/tui/components/SelectionScreen.mjs index fe736c3..bd64418 100644 --- a/scripts/tui/components/SelectionScreen.mjs +++ b/scripts/tui/components/SelectionScreen.mjs @@ -4,7 +4,15 @@ import { Box, Text } from "ink"; import { palette } from "../theme/catalog.mjs"; import { theme } from "../theme/index.mjs"; import { Frame, ShortLabel, ShortPath } from "../ui/primitives.mjs"; -import { ActionLine, ActionLineHeader, actionKindMeta, itemTypeMeta, ACTION_COL, VIA_COL, TYPE_COL } from "../ui/actions.mjs"; +import { + ActionLine, + ActionLineHeader, + actionKindMeta, + itemTypeMeta, + ACTION_COL, + VIA_COL, + TYPE_COL, +} from "../ui/actions.mjs"; import { h } from "../ui/react-helpers.mjs"; export function ToolList({ toolGroups, depth, toolIndex, selectedActionKeys, focusedPane, inspectProfile }) { @@ -25,6 +33,11 @@ export function ToolList({ toolGroups, depth, toolIndex, selectedActionKeys, foc return "[ ]"; }; + const radioForState = (state) => { + if (state === "all" || state === "partial") return "(•)"; + return "( )"; + }; + const isGroupSelectable = (group) => group.installed && group.enabled; const getGroupCounts = (group) => { @@ -45,13 +58,7 @@ export function ToolList({ toolGroups, depth, toolIndex, selectedActionKeys, foc h( Box, { height: 1, justifyContent: "space-between" }, - h( - Box, - { columnGap: 1 }, - h(Text, {}, " "), - h(Text, {}, " "), - h(Text, { color: theme.color.fg.dim }, "TOOL"), - ), + h(Box, { columnGap: 1 }, h(Text, {}, " "), h(Text, {}, " "), h(Text, { color: theme.color.fg.dim }, "TOOL")), h( Box, { columnGap: 2 }, @@ -69,8 +76,8 @@ export function ToolList({ toolGroups, depth, toolIndex, selectedActionKeys, foc const badgeElement = !group.enabled ? h(Text, { color: palette.graySoft }, "DISABLED") : !group.installed - ? h(Text, { color: palette.red }, "NOT INSTALLED") - : null; + ? h(Text, { color: palette.red }, "NOT INSTALLED") + : null; return h( Box, @@ -83,20 +90,47 @@ export function ToolList({ toolGroups, depth, toolIndex, selectedActionKeys, foc h( Box, { columnGap: 1 }, - h(Text, { color: isFocused ? theme.color.accent.bright : theme.color.fg.dim, bold: isFocused }, isDrilled ? "▸" : isFocused ? "▍" : " "), - h(Text, { color: selectable ? (depth === 0 && isFocused ? theme.color.fg.primary : theme.color.fg.muted) : theme.color.fg.dim, bold: depth === 0 && isFocused && selectable }, checkboxForState(state)), - h(ShortLabel, { text: group.label, color: selectable ? (depth === 0 && isFocused ? "white" : "gray") : "graySoft", bold: depth === 0 && isFocused && selectable }), + h( + Text, + { color: isFocused ? theme.color.accent.bright : theme.color.fg.dim, bold: isFocused }, + isDrilled ? "▸" : isFocused ? "▍" : " ", + ), + h( + Text, + { + color: selectable + ? depth === 0 && isFocused + ? theme.color.fg.primary + : theme.color.fg.muted + : theme.color.fg.dim, + bold: depth === 0 && isFocused && selectable, + }, + group.mode === "single-select" ? radioForState(state) : checkboxForState(state), + ), + h(ShortLabel, { + text: group.label, + color: selectable ? (depth === 0 && isFocused ? "white" : "gray") : "graySoft", + bold: depth === 0 && isFocused && selectable, + }), ), badgeElement ? badgeElement : h( Box, { columnGap: 2 }, - h(Box, { width: 6, justifyContent: "flex-end" }, + h( + Box, + { width: 6, justifyContent: "flex-end" }, h(Text, { color: theme.color.fg.muted }, `${counts.existing}`), ), - h(Box, { width: 5, justifyContent: "flex-end" }, - h(Text, { color: counts.actions > 0 ? theme.color.accent.primary : theme.color.fg.dim }, `${counts.actions}`), + h( + Box, + { width: 5, justifyContent: "flex-end" }, + h( + Text, + { color: counts.actions > 0 ? theme.color.accent.primary : theme.color.fg.dim }, + `${counts.actions}`, + ), ), ), ); @@ -145,7 +179,7 @@ export function ToolDetail({ toolGroups, toolIndex, inspectProfile, layout }) { const dimColor = isDisabled ? theme.color.fg.dim : theme.color.fg.muted; const titleColor = isDisabled ? theme.color.fg.dim : theme.color.fg.primary; const summary = `${previewData.counts.total} actions • ${previewData.counts.create} create • ${previewData.counts.replace} replace • ${previewData.counts.noChange} no change`; - const maxActionLines = Math.max(1, layout.previewLineLimit - (previewData.profiles.length * 2) - 9); + const maxActionLines = Math.max(1, layout.previewLineLimit - previewData.profiles.length * 2 - 9); const visibleActions = previewData.actions.slice(0, maxActionLines); const hiddenBelow = Math.max(0, previewData.actions.length - visibleActions.length); @@ -155,21 +189,33 @@ export function ToolDetail({ toolGroups, toolIndex, inspectProfile, layout }) { h(Text, { color: titleColor, bold: !isDisabled }, previewData.title), isDisabled ? h(Text, { color: palette.graySoft }, "This rule is disabled. Edit the rule file to re-enable.") : null, h(Box, { height: 1 }), - ...previewData.profiles.map((profile) => h( - Box, - { key: `profile-meta-${profile.id}`, flexDirection: "column", marginBottom: 1 }, - h(Text, { color: titleColor }, profile.label), - h(Text, { color: dimColor }, profile.description), - )), + ...previewData.profiles.map((profile) => + h( + Box, + { key: `profile-meta-${profile.id}`, flexDirection: "column", marginBottom: 1 }, + h(Text, { color: titleColor }, profile.label), + h(Text, { color: dimColor }, profile.description), + ), + ), h(Text, { color: dimColor }, summary), h(Box, { height: 1 }), h(ActionLineHeader, null), - ...visibleActions.map((action, index) => h(ActionLine, { key: `tool-detail-action-${index}-${action.target}-${action.kind}`, action, dimmed: isDisabled })), + ...visibleActions.map((action, index) => + h(ActionLine, { key: `tool-detail-action-${index}-${action.target}-${action.kind}`, action, dimmed: isDisabled }), + ), hiddenBelow > 0 ? h(Text, { color: theme.color.fg.dim }, `▼ ${hiddenBelow} more below`) : null, ); } -export function ActionSelector({ toolGroups, toolIndex, flatActions, selectedActionKeys, actionIndex, actionScrollOffset, layout }) { +export function ActionSelector({ + toolGroups, + toolIndex, + flatActions, + selectedActionKeys, + actionIndex, + actionScrollOffset, + layout, +}) { const group = toolGroups[toolIndex] || null; if (!group) { return h( @@ -179,35 +225,28 @@ export function ActionSelector({ toolGroups, toolIndex, flatActions, selectedAct ); } - const profileIntroLines = group.profiles.length === 1 - ? [group.profiles[0].description] - : group.profiles.map((profile) => `${profile.label}: ${profile.description}`); + const profileIntroLines = + group.profiles.length === 1 + ? [group.profiles[0].description] + : group.profiles.map((profile) => `${profile.label}: ${profile.description}`); const actionAreaHeight = Math.max(1, layout.mainHeight - profileIntroLines.length - 8); const visibleActions = flatActions.slice(actionScrollOffset, actionScrollOffset + actionAreaHeight); const hiddenAbove = actionScrollOffset; const hiddenBelow = Math.max(0, flatActions.length - actionScrollOffset - visibleActions.length); - - const columnHeader = h( - Box, - { key: "col-header", height: 1, justifyContent: "space-between" }, - h( - Box, - { columnGap: 1 }, - h(Text, {}, " "), - h(Text, {}, " "), - h(Text, { color: theme.color.fg.dim }, "NAME"), - ), - h( - Box, - { columnGap: 2 }, - h(Box, { width: VIA_COL }, h(Text, { color: theme.color.fg.dim }, "VIA")), - h(Box, { width: TYPE_COL }, h(Text, { color: theme.color.fg.dim }, "TYPE")), - h(Box, { width: ACTION_COL }, h(Text, { color: theme.color.fg.dim }, "ACTION")), - ), - ); + Box, + { key: "col-header", height: 1, justifyContent: "space-between" }, + h(Box, { columnGap: 1 }, h(Text, {}, " "), h(Text, {}, " "), h(Text, { color: theme.color.fg.dim }, "NAME")), + h( + Box, + { columnGap: 2 }, + h(Box, { width: VIA_COL }, h(Text, { color: theme.color.fg.dim }, "VIA")), + h(Box, { width: TYPE_COL }, h(Text, { color: theme.color.fg.dim }, "TYPE")), + h(Box, { width: ACTION_COL }, h(Text, { color: theme.color.fg.dim }, "ACTION")), + ), + ); const rows = []; let lastProfileId = null; @@ -224,29 +263,43 @@ export function ActionSelector({ toolGroups, toolIndex, flatActions, selectedAct } const type = itemTypeMeta(item.inspectedAction.itemType); - rows.push(h( - Box, - { - key: item.key, - height: 1, - justifyContent: "space-between", - backgroundColor: isFocused ? theme.color.selectionBg : undefined, - }, + rows.push( h( Box, - { columnGap: 1 }, - h(Text, { color: isFocused ? theme.color.accent.bright : theme.color.fg.dim, bold: isFocused }, isFocused ? "▍" : " "), - h(Text, { color: isSelected ? theme.color.accent.primary : theme.color.fg.muted }, isSelected ? "[x]" : "[ ]"), - h(Text, { color: isFocused ? theme.color.fg.primary : theme.color.fg.muted, bold: isFocused }, targetBasename), + { + key: item.key, + height: 1, + justifyContent: "space-between", + backgroundColor: isFocused ? theme.color.selectionBg : undefined, + }, + h( + Box, + { columnGap: 1 }, + h( + Text, + { color: isFocused ? theme.color.accent.bright : theme.color.fg.dim, bold: isFocused }, + isFocused ? "▍" : " ", + ), + h( + Text, + { color: isSelected ? theme.color.accent.primary : theme.color.fg.muted }, + group.mode === "single-select" ? (isSelected ? "(•)" : "( )") : isSelected ? "[x]" : "[ ]", + ), + h( + Text, + { color: isFocused ? theme.color.fg.primary : theme.color.fg.muted, bold: isFocused }, + targetBasename, + ), + ), + h( + Box, + { columnGap: 2 }, + h(Box, { width: VIA_COL }, h(Text, { color: theme.color.fg.dim }, effectLabel)), + h(Box, { width: TYPE_COL }, h(Text, { color: type.color }, type.label)), + h(Box, { width: ACTION_COL }, h(Text, { color: meta.color, bold: true }, meta.label)), + ), ), - h( - Box, - { columnGap: 2 }, - h(Box, { width: VIA_COL }, h(Text, { color: theme.color.fg.dim }, effectLabel)), - h(Box, { width: TYPE_COL }, h(Text, { color: type.color }, type.label)), - h(Box, { width: ACTION_COL }, h(Text, { color: meta.color, bold: true }, meta.label)), - ), - )); + ); } return h( @@ -282,7 +335,20 @@ export function SourceDefinitions({ sourceRoot, configPath, focused }) { ); } -export function SelectionScreen({ toolGroups, depth, toolIndex, selectedActionKeys, inspectProfile, sourceRoot, configPath, focusedPane, layout, flatActions, actionIndex, actionScrollOffset }) { +export function SelectionScreen({ + toolGroups, + depth, + toolIndex, + selectedActionKeys, + inspectProfile, + sourceRoot, + configPath, + focusedPane, + layout, + flatActions, + actionIndex, + actionScrollOffset, +}) { return h( Box, { columnGap: 1, height: layout.mainHeight }, @@ -297,7 +363,15 @@ export function SelectionScreen({ toolGroups, depth, toolIndex, selectedActionKe { width: layout.rightWidth, flexDirection: "column" }, depth === 0 ? h(ToolDetail, { toolGroups, toolIndex, inspectProfile, layout }) - : h(ActionSelector, { toolGroups, toolIndex, flatActions, selectedActionKeys, actionIndex, actionScrollOffset, layout }), + : h(ActionSelector, { + toolGroups, + toolIndex, + flatActions, + selectedActionKeys, + actionIndex, + actionScrollOffset, + layout, + }), ), ); } diff --git a/scripts/tui/ui/format.mjs b/scripts/tui/ui/format.mjs index d7af1c6..2847b8a 100644 --- a/scripts/tui/ui/format.mjs +++ b/scripts/tui/ui/format.mjs @@ -11,6 +11,7 @@ export function groupByTool(profiles) { label: profile.toolLabel || tool.charAt(0).toUpperCase() + tool.slice(1), installed: false, enabled: true, + mode: profile.mode || "multi-select", profiles: [], actionCount: 0, }); diff --git a/tests/install-core.test.js b/tests/install-core.test.js index 14207f6..613c53c 100644 --- a/tests/install-core.test.js +++ b/tests/install-core.test.js @@ -20,7 +20,7 @@ before(() => { testConfigPath = path.join(globalTmpDir, "config.yaml"); testRulesDir = path.join(globalTmpDir, "rules"); homeAlpha = path.join(globalTmpDir, "home-alpha"); - homeBeta = path.join(globalTmpDir, "home-beta"); + homeBeta = path.join(globalTmpDir, "home-beta"); homeGamma = path.join(globalTmpDir, "home-gamma"); homeDelta = path.join(globalTmpDir, "home-delta"); mkdir(homeAlpha); @@ -28,66 +28,74 @@ before(() => { mkdir(homeGamma); mkdir(homeDelta); - mkfile(testConfigPath, [ - `sourceRoot: ${globalTmpDir}`, - "ignore:", - " - SKIP.txt", - " - '*.bak.*'", - ].join("\n")); + mkfile(testConfigPath, [`sourceRoot: ${globalTmpDir}`, "ignore:", " - SKIP.txt", " - '*.bak.*'"].join("\n")); mkdir(testRulesDir); - mkfile(path.join(testRulesDir, "alpha.yaml"), [ - "tool: alpha", - "label: Alpha", - `home: ${homeAlpha}`, - "enabled: true", - "mappings:", - " - type: skills", - " source: skills", - " target: skills", - ].join("\n")); - - mkfile(path.join(testRulesDir, "beta.yaml"), [ - "tool: beta", - "label: Beta", - `home: ${homeBeta}`, - "enabled: true", - "mappings:", - " - type: file", - " source: agents/beta/AGENTS.md", - " target: AGENTS.md", - " - type: directory", - " source: configs/beta", - " target: .", - ].join("\n")); - - mkfile(path.join(testRulesDir, "gamma.yaml"), [ - "tool: gamma", - "label: Gamma", - `home: ${homeGamma}`, - "enabled: true", - "mappings:", - " - type: skills", - " source: skills", - " target: skills", - ].join("\n")); - - mkfile(path.join(testRulesDir, "delta.yaml"), [ - "tool: delta", - "label: Oh My Opencode", - `home: ${homeDelta}`, - "enabled: true", - "mappings:", - " - type: file", - " source: oh-my-opencode/oh-my-opencode.json.openai", - " target: oh-my-opencode.json", - " - type: file", - " source: oh-my-opencode/oh-my-opencode.json.claude", - " target: oh-my-opencode.json", - " - type: file", - " source: oh-my-opencode/oh-my-opencode.json.copilot", - " target: oh-my-opencode.json", - ].join("\n")); + mkfile( + path.join(testRulesDir, "alpha.yaml"), + [ + "tool: alpha", + "label: Alpha", + `home: ${homeAlpha}`, + "enabled: true", + "mappings:", + " - type: skills", + " source: skills", + " target: skills", + ].join("\n"), + ); + + mkfile( + path.join(testRulesDir, "beta.yaml"), + [ + "tool: beta", + "label: Beta", + `home: ${homeBeta}`, + "enabled: true", + "mappings:", + " - type: file", + " source: agents/beta/AGENTS.md", + " target: AGENTS.md", + " - type: directory", + " source: configs/beta", + " target: .", + ].join("\n"), + ); + + mkfile( + path.join(testRulesDir, "gamma.yaml"), + [ + "tool: gamma", + "label: Gamma", + `home: ${homeGamma}`, + "enabled: true", + "mappings:", + " - type: skills", + " source: skills", + " target: skills", + ].join("\n"), + ); + + mkfile( + path.join(testRulesDir, "delta.yaml"), + [ + "tool: delta", + "label: Oh My Opencode", + `home: ${homeDelta}`, + "enabled: true", + "mode: single-select", + "mappings:", + " - type: file", + " source: oh-my-opencode/oh-my-opencode.json.openai", + " target: oh-my-opencode.json", + " - type: file", + " source: oh-my-opencode/oh-my-opencode.json.claude", + " target: oh-my-opencode.json", + " - type: file", + " source: oh-my-opencode/oh-my-opencode.json.copilot", + " target: oh-my-opencode.json", + ].join("\n"), + ); process.env.SADDLE_CONFIG = testConfigPath; process.env.SADDLE_RULES_DIR = testRulesDir; @@ -204,8 +212,12 @@ describe("parseArgs", () => { describe("fileExists", () => { let tmpDir; - before(() => { tmpDir = makeTempDir("saddle-fe-"); }); - after(() => { rmrf(tmpDir); }); + before(() => { + tmpDir = makeTempDir("saddle-fe-"); + }); + after(() => { + rmrf(tmpDir); + }); it("returns true for an existing file", () => { const f = path.join(tmpDir, "file.txt"); @@ -234,8 +246,12 @@ describe("fileExists", () => { describe("contentMatches", () => { let tmpDir; - before(() => { tmpDir = makeTempDir("saddle-cm-"); }); - after(() => { rmrf(tmpDir); }); + before(() => { + tmpDir = makeTempDir("saddle-cm-"); + }); + after(() => { + rmrf(tmpDir); + }); it("returns true for two files with identical content", () => { const a = path.join(tmpDir, "a.txt"); @@ -280,8 +296,12 @@ describe("contentMatches", () => { describe("inspectAction", () => { let tmpDir; - beforeEach(() => { tmpDir = makeTempDir("saddle-ia-"); }); - afterEach(() => { rmrf(tmpDir); }); + beforeEach(() => { + tmpDir = makeTempDir("saddle-ia-"); + }); + afterEach(() => { + rmrf(tmpDir); + }); it("returns kind:new-link when target does not exist", () => { const src = path.join(tmpDir, "src.txt"); @@ -301,9 +321,9 @@ describe("inspectAction", () => { }); it("returns kind:replace-link when target symlink points elsewhere", () => { - const src = path.join(tmpDir, "src.txt"); - const other = path.join(tmpDir, "other.txt"); - const tgt = path.join(tmpDir, "tgt.txt"); + const src = path.join(tmpDir, "src.txt"); + const other = path.join(tmpDir, "other.txt"); + const tgt = path.join(tmpDir, "tgt.txt"); mkfile(src, "new"); mkfile(other, "old"); fs.symlinkSync(other, tgt); @@ -349,8 +369,12 @@ describe("inspectAction", () => { describe("inspectProfile", () => { let tmpDir; - before(() => { tmpDir = makeTempDir("saddle-ip-"); }); - after(() => { rmrf(tmpDir); }); + before(() => { + tmpDir = makeTempDir("saddle-ip-"); + }); + after(() => { + rmrf(tmpDir); + }); it("returns correct counts for a mix of action kinds", () => { const newSrc = path.join(tmpDir, "new-src.txt"); @@ -379,7 +403,20 @@ describe("inspectProfile", () => { const src = path.join(tmpDir, "cached-src.txt"); const tgt = path.join(tmpDir, "cached-tgt.txt"); mkfile(src, "x"); - const fakeResult = { kind: "already-linked", label: "no change", color: "gray", source: src, target: tgt, detail: "", effectLabel: "symlink", beforePath: tgt, beforeDetail: "", afterPath: src, afterDetail: "", preview: null }; + const fakeResult = { + kind: "already-linked", + label: "no change", + color: "gray", + source: src, + target: tgt, + detail: "", + effectLabel: "symlink", + beforePath: tgt, + beforeDetail: "", + afterPath: src, + afterDetail: "", + preview: null, + }; const cache = new Map([[`${src}::${tgt}`, fakeResult]]); const profile = { id: "cp", actions: [{ source: src, target: tgt }] }; const result = core.inspectProfile(profile, cache); @@ -399,8 +436,12 @@ describe("inspectProfile", () => { describe("buildInspectionCache", () => { let tmpDir; - before(() => { tmpDir = makeTempDir("saddle-bic-"); }); - after(() => { rmrf(tmpDir); }); + before(() => { + tmpDir = makeTempDir("saddle-bic-"); + }); + after(() => { + rmrf(tmpDir); + }); it("creates a Map with source::target keys", async () => { const src = path.join(tmpDir, "s.txt"); @@ -426,19 +467,33 @@ describe("buildInspectionCache", () => { }); it("caches multiple distinct pairs", async () => { - const s1 = path.join(tmpDir, "s1.txt"); mkfile(s1, "1"); - const s2 = path.join(tmpDir, "s2.txt"); mkfile(s2, "2"); + const s1 = path.join(tmpDir, "s1.txt"); + mkfile(s1, "1"); + const s2 = path.join(tmpDir, "s2.txt"); + mkfile(s2, "2"); const t1 = path.join(tmpDir, "t1.txt"); const t2 = path.join(tmpDir, "t2.txt"); - const profiles = [{ id: "p", actions: [{ source: s1, target: t1 }, { source: s2, target: t2 }] }]; + const profiles = [ + { + id: "p", + actions: [ + { source: s1, target: t1 }, + { source: s2, target: t2 }, + ], + }, + ]; assert.strictEqual((await core.buildInspectionCache(profiles)).size, 2); }); }); describe("discoverProfiles", () => { let repoRoot; - beforeEach(() => { repoRoot = makeTempDir("saddle-dp-"); }); - afterEach(() => { rmrf(repoRoot); }); + beforeEach(() => { + repoRoot = makeTempDir("saddle-dp-"); + }); + afterEach(() => { + rmrf(repoRoot); + }); it("skills mapping: returns one action per subdirectory", () => { mkdir(path.join(repoRoot, "skills", "skill-a")); @@ -502,15 +557,16 @@ describe("discoverProfiles", () => { const profiles = core.discoverProfiles(repoRoot, { alpha: false, beta: false, gamma: false, delta: true }); const deltaProfiles = profiles.filter((p) => p.tool === "delta"); - assert.deepStrictEqual( - deltaProfiles.map((profile) => profile.label).sort(), - [ - "oh-my-opencode.json.claude", - "oh-my-opencode.json.copilot", - "oh-my-opencode.json.openai", - ], + assert.deepStrictEqual(deltaProfiles.map((profile) => profile.label).sort(), [ + "oh-my-opencode.json.claude", + "oh-my-opencode.json.copilot", + "oh-my-opencode.json.openai", + ]); + assert.ok( + deltaProfiles.every( + (profile) => profile.description === `Links to ${path.join(homeDelta, "oh-my-opencode.json")}`, + ), ); - assert.ok(deltaProfiles.every((profile) => profile.description === `Links to ${path.join(homeDelta, "oh-my-opencode.json")}`)); }); it("file mapping: produces no profile when source file is absent", () => { @@ -582,12 +638,35 @@ describe("discoverProfiles", () => { const alpha = profiles.find((p) => p.tool === "alpha"); assert.ok(alpha.actions[0].target.startsWith(homeAlpha)); }); + + it("profiles inherit mode from their rule (defaults to multi-select)", () => { + mkdir(path.join(repoRoot, "skills", "skill-a")); + const profiles = core.discoverProfiles(repoRoot, { alpha: true, beta: false, gamma: false, delta: false }); + const alpha = profiles.find((p) => p.tool === "alpha"); + assert.strictEqual(alpha.mode, "multi-select"); + }); + + it("profiles inherit single-select mode from their rule", () => { + mkfile(path.join(repoRoot, "oh-my-opencode", "oh-my-opencode.json.openai"), "{}"); + mkfile(path.join(repoRoot, "oh-my-opencode", "oh-my-opencode.json.claude"), "{}"); + const profiles = core.discoverProfiles(repoRoot, { alpha: false, beta: false, gamma: false, delta: true }); + const deltaProfiles = profiles.filter((p) => p.tool === "delta"); + assert.ok(deltaProfiles.length > 0, "should have delta profiles"); + assert.ok( + deltaProfiles.every((p) => p.mode === "single-select"), + "all delta profiles should be single-select", + ); + }); }); describe("binaryDetected", () => { let tmpDir; - beforeEach(() => { tmpDir = makeTempDir("saddle-bd-"); }); - afterEach(() => { rmrf(tmpDir); }); + beforeEach(() => { + tmpDir = makeTempDir("saddle-bd-"); + }); + afterEach(() => { + rmrf(tmpDir); + }); it("returns false for null binary", () => { assert.strictEqual(core.binaryDetected(null), false); @@ -612,7 +691,10 @@ describe("binaryDetected", () => { }); it("returns false when platform path does not exist", () => { - assert.strictEqual(core.binaryDetected({ which: null, paths: { [process.platform]: path.join(tmpDir, "NoApp.app") } }), false); + assert.strictEqual( + core.binaryDetected({ which: null, paths: { [process.platform]: path.join(tmpDir, "NoApp.app") } }), + false, + ); }); it("returns false when path is only defined for another platform", () => { @@ -625,7 +707,10 @@ describe("binaryDetected", () => { it("returns true via platform path even when which fails", () => { const appPath = path.join(tmpDir, "MyTool.app"); mkdir(appPath); - assert.strictEqual(core.binaryDetected({ which: "__no_such_binary__", paths: { [process.platform]: appPath } }), true); + assert.strictEqual( + core.binaryDetected({ which: "__no_such_binary__", paths: { [process.platform]: appPath } }), + true, + ); }); }); @@ -668,11 +753,13 @@ describe("runInstallation — dry-run", () => { }); function makeProfiles(pairs) { - return [{ - id: "test-profile", - label: "Test Profile", - actions: pairs.map(([src, tgt]) => ({ source: src, target: tgt })), - }]; + return [ + { + id: "test-profile", + label: "Test Profile", + actions: pairs.map(([src, tgt]) => ({ source: src, target: tgt })), + }, + ]; } it("emits session-start and session-complete events", async () => { @@ -773,9 +860,9 @@ describe("runInstallation — live", () => { }); it("replaces an existing symlink pointing elsewhere without creating a backup", async () => { - const src = path.join(srcDir, "src.txt"); - const other = path.join(srcDir, "other.txt"); - const tgt = path.join(tgtDir, "tgt.txt"); + const src = path.join(srcDir, "src.txt"); + const other = path.join(srcDir, "other.txt"); + const tgt = path.join(tgtDir, "tgt.txt"); mkfile(src, "new"); mkfile(other, "old"); fs.symlinkSync(other, tgt); @@ -792,12 +879,17 @@ describe("runInstallation — live", () => { }); it("summary.linked reflects number of created symlinks", async () => { - const s1 = path.join(srcDir, "s1.txt"); mkfile(s1, "1"); - const s2 = path.join(srcDir, "s2.txt"); mkfile(s2, "2"); + const s1 = path.join(srcDir, "s1.txt"); + mkfile(s1, "1"); + const s2 = path.join(srcDir, "s2.txt"); + mkfile(s2, "2"); const t1 = path.join(tgtDir, "t1.txt"); const t2 = path.join(tgtDir, "t2.txt"); const summary = await core.runInstallation({ - selectedProfiles: makeProfiles([[s1, t1], [s2, t2]]), + selectedProfiles: makeProfiles([ + [s1, t1], + [s2, t2], + ]), dryRun: false, assumeYes: true, onEvent: () => {}, @@ -822,20 +914,32 @@ describe("runInstallation — multi-profile", () => { }); it("processes all profiles when multiple tool groups are selected", async () => { - const s1 = path.join(srcDir, "a1.txt"); mkfile(s1, "1"); - const s2 = path.join(srcDir, "a2.txt"); mkfile(s2, "2"); - const s3 = path.join(srcDir, "b1.txt"); mkfile(s3, "3"); - const s4 = path.join(srcDir, "b2.txt"); mkfile(s4, "4"); + const s1 = path.join(srcDir, "a1.txt"); + mkfile(s1, "1"); + const s2 = path.join(srcDir, "a2.txt"); + mkfile(s2, "2"); + const s3 = path.join(srcDir, "b1.txt"); + mkfile(s3, "3"); + const s4 = path.join(srcDir, "b2.txt"); + mkfile(s4, "4"); const selectedProfiles = [ - { id: "toolA-skills", label: "Tool A skills", actions: [ - { source: s1, target: path.join(tgtDirA, "a1.txt") }, - { source: s2, target: path.join(tgtDirA, "a2.txt") }, - ]}, - { id: "toolB-skills", label: "Tool B skills", actions: [ - { source: s3, target: path.join(tgtDirB, "b1.txt") }, - { source: s4, target: path.join(tgtDirB, "b2.txt") }, - ]}, + { + id: "toolA-skills", + label: "Tool A skills", + actions: [ + { source: s1, target: path.join(tgtDirA, "a1.txt") }, + { source: s2, target: path.join(tgtDirA, "a2.txt") }, + ], + }, + { + id: "toolB-skills", + label: "Tool B skills", + actions: [ + { source: s3, target: path.join(tgtDirB, "b1.txt") }, + { source: s4, target: path.join(tgtDirB, "b2.txt") }, + ], + }, ]; const profileStarts = []; @@ -862,22 +966,27 @@ describe("runInstallation — multi-profile", () => { }); it("continues processing remaining profiles when an action in the first profile errors", async () => { - const s1 = path.join(srcDir, "good.txt"); mkfile(s1, "good"); - const s2 = path.join(srcDir, "bad.txt"); mkfile(s2, "bad"); - const s3 = path.join(srcDir, "also-good.txt"); mkfile(s3, "also good"); + const s1 = path.join(srcDir, "good.txt"); + mkfile(s1, "good"); + const s2 = path.join(srcDir, "bad.txt"); + mkfile(s2, "bad"); + const s3 = path.join(srcDir, "also-good.txt"); + mkfile(s3, "also good"); const badTarget = path.join(tgtDirA, "no-such-parent", "deep", "bad.txt"); // Create a FILE where the parent directory needs to be, so mkdirSync will fail mkfile(path.join(tgtDirA, "no-such-parent"), "blocker"); const selectedProfiles = [ - { id: "toolA", label: "Tool A", actions: [ - { source: s1, target: path.join(tgtDirA, "good.txt") }, - { source: s2, target: badTarget }, - ]}, - { id: "toolB", label: "Tool B", actions: [ - { source: s3, target: path.join(tgtDirB, "also-good.txt") }, - ]}, + { + id: "toolA", + label: "Tool A", + actions: [ + { source: s1, target: path.join(tgtDirA, "good.txt") }, + { source: s2, target: badTarget }, + ], + }, + { id: "toolB", label: "Tool B", actions: [{ source: s3, target: path.join(tgtDirB, "also-good.txt") }] }, ]; const errors = []; @@ -899,7 +1008,7 @@ describe("runInstallation — multi-profile", () => { // Tool B's action still gets processed despite Tool A's error assert.ok(fs.lstatSync(path.join(tgtDirB, "also-good.txt")).isSymbolicLink()); - assert.deepStrictEqual(profileCompletes, ["toolA", "toolB"]); + assert.deepStrictEqual(profileCompletes, ["toolA", "toolB"]); assert.strictEqual(summary.errors, 1); assert.strictEqual(summary.linked, 2); }); @@ -923,17 +1032,21 @@ describe("runInstallation — symlinked parent directories", () => { mkdir(path.join(tmpRoot, "home")); fs.symlinkSync(realConfigDir, symlinkedConfig); }); - afterEach(() => { rmrf(tmpRoot); }); + afterEach(() => { + rmrf(tmpRoot); + }); it("creates working symlinks when target is under a symlinked parent directory", async () => { const skillSource = path.join(srcDir, "skills", "my-skill"); const skillTarget = path.join(symlinkedConfig, "tool", "skills", "my-skill"); - const selectedProfiles = [{ - id: "symlinked-tool-skills", - label: "Symlinked Tool skills", - actions: [{ source: skillSource, target: skillTarget }], - }]; + const selectedProfiles = [ + { + id: "symlinked-tool-skills", + label: "Symlinked Tool skills", + actions: [{ source: skillSource, target: skillTarget }], + }, + ]; await core.runInstallation({ selectedProfiles, @@ -954,14 +1067,22 @@ describe("runInstallation — symlinked parent directories", () => { mkdir(path.join(srcDir, "skills", "skill-b")); mkfile(path.join(srcDir, "skills", "skill-b", "SKILL.md"), "b content"); - const selectedProfiles = [{ - id: "symlinked-tool-skills", - label: "Symlinked Tool skills", - actions: [ - { source: path.join(srcDir, "skills", "my-skill"), target: path.join(symlinkedConfig, "tool", "skills", "my-skill") }, - { source: path.join(srcDir, "skills", "skill-b"), target: path.join(symlinkedConfig, "tool", "skills", "skill-b") }, - ], - }]; + const selectedProfiles = [ + { + id: "symlinked-tool-skills", + label: "Symlinked Tool skills", + actions: [ + { + source: path.join(srcDir, "skills", "my-skill"), + target: path.join(symlinkedConfig, "tool", "skills", "my-skill"), + }, + { + source: path.join(srcDir, "skills", "skill-b"), + target: path.join(symlinkedConfig, "tool", "skills", "skill-b"), + }, + ], + }, + ]; const summary = await core.runInstallation({ selectedProfiles, @@ -978,8 +1099,12 @@ describe("runInstallation — symlinked parent directories", () => { describe("inspectAction — broken symlinks (null canonical path)", () => { let tmpDir; - beforeEach(() => { tmpDir = makeTempDir("saddle-null-cp-"); }); - afterEach(() => { rmrf(tmpDir); }); + beforeEach(() => { + tmpDir = makeTempDir("saddle-null-cp-"); + }); + afterEach(() => { + rmrf(tmpDir); + }); it("returns replace-link when both source and target symlinks are broken", () => { const src = path.join(tmpDir, "src.txt"); @@ -1040,7 +1165,9 @@ describe("readLockfile / writeLockfile", () => { lockfilePath = path.join(globalTmpDir, "installed.json"); }); afterEach(() => { - try { fs.unlinkSync(lockfilePath); } catch {} + try { + fs.unlinkSync(lockfilePath); + } catch {} }); it("writeLockfile writes valid JSON to CONFIG_DIR/installed.json", () => { @@ -1106,7 +1233,9 @@ describe("runUninstall", () => { afterEach(() => { rmrf(srcDir); rmrf(tgtDir); - try { fs.unlinkSync(lockfilePath); } catch {} + try { + fs.unlinkSync(lockfilePath); + } catch {} }); it("removes symlinks pointing into sourceRoot", async () => { @@ -1160,7 +1289,10 @@ describe("runUninstall", () => { it("exits 1 when no lockfile exists", async () => { const originalExit = process.exit; let exitCode; - process.exit = (code) => { exitCode = code; throw new Error(`process.exit(${code})`); }; + process.exit = (code) => { + exitCode = code; + throw new Error(`process.exit(${code})`); + }; try { await core.runUninstall({ dryRun: false, quiet: true }); } catch (e) { @@ -1198,7 +1330,9 @@ describe("runCheck", () => { afterEach(() => { rmrf(srcDir); rmrf(tgtDir); - try { fs.unlinkSync(lockfilePath); } catch {} + try { + fs.unlinkSync(lockfilePath); + } catch {} }); it("exits 0 when all symlinks are in sync (already-linked)", async () => { @@ -1210,7 +1344,10 @@ describe("runCheck", () => { const originalExit = process.exit; let exitCode; - process.exit = (code) => { exitCode = code; throw new Error(`process.exit(${code})`); }; + process.exit = (code) => { + exitCode = code; + throw new Error(`process.exit(${code})`); + }; try { await core.runCheck({}); } catch (e) { @@ -1229,7 +1366,10 @@ describe("runCheck", () => { const originalExit = process.exit; let exitCode; - process.exit = (code) => { exitCode = code; throw new Error(`process.exit(${code})`); }; + process.exit = (code) => { + exitCode = code; + throw new Error(`process.exit(${code})`); + }; try { await core.runCheck({}); } catch (e) { @@ -1243,7 +1383,10 @@ describe("runCheck", () => { it("works without lockfile (discovers profiles, calls process.exit)", async () => { const originalExit = process.exit; let exitCode; - process.exit = (code) => { exitCode = code; throw new Error(`process.exit(${code})`); }; + process.exit = (code) => { + exitCode = code; + throw new Error(`process.exit(${code})`); + }; try { await core.runCheck({}); } catch (e) { @@ -1267,7 +1410,9 @@ describe("runCheck", () => { return origWrite.apply(process.stderr, [msg, ...args]); }; const originalExit = process.exit; - process.exit = (code) => { throw new Error(`process.exit(${code})`); }; + process.exit = (code) => { + throw new Error(`process.exit(${code})`); + }; try { await core.runCheck({ verbose: true }); } catch (e) { diff --git a/tests/load-config.test.js b/tests/load-config.test.js index 95cfcf5..b37bdf0 100644 --- a/tests/load-config.test.js +++ b/tests/load-config.test.js @@ -35,8 +35,12 @@ describe("load-config", () => { }); beforeEach(() => { - try { fs.unlinkSync(configPath); } catch {} - try { fs.rmSync(rulesDir, { recursive: true, force: true }); } catch {} + try { + fs.unlinkSync(configPath); + } catch {} + try { + fs.rmSync(rulesDir, { recursive: true, force: true }); + } catch {} clearConfigModules(); }); @@ -167,7 +171,11 @@ describe("load-config", () => { const mod = fresh(); const origBundled = mod.BUNDLED_RULES_DIR; try { - Object.defineProperty(mod, "BUNDLED_RULES_DIR", { value: "/nonexistent-bundled", writable: true, configurable: true }); + Object.defineProperty(mod, "BUNDLED_RULES_DIR", { + value: "/nonexistent-bundled", + writable: true, + configurable: true, + }); const rules = mod.loadRules(); assert.ok(Array.isArray(rules)); } finally { @@ -177,7 +185,10 @@ describe("load-config", () => { it("loads rules from YAML files in rules directory", () => { mkdir(rulesDir); - mkfile(path.join(rulesDir, "test-tool.yaml"), "tool: test\nlabel: Test Tool\nbinary: test-bin\nhome: /tmp/test\nenabled: true\nmappings:\n - type: skills\n source: skills\n target: skills\n"); + mkfile( + path.join(rulesDir, "test-tool.yaml"), + "tool: test\nlabel: Test Tool\nbinary: test-bin\nhome: /tmp/test\nenabled: true\nmappings:\n - type: skills\n source: skills\n target: skills\n", + ); const rules = fresh().loadRules(); assert.strictEqual(rules.length, 1); assert.strictEqual(rules[0].name, "test"); @@ -195,16 +206,19 @@ describe("load-config", () => { it("preserves object binary with which and paths", () => { mkdir(rulesDir); - mkfile(path.join(rulesDir, "obj-bin.yaml"), [ - "tool: t", - "home: /tmp/t", - "binary:", - " which: mytool", - " paths:", - " darwin: /Applications/MyTool.app", - " linux: /usr/bin/mytool", - "mappings: []", - ].join("\n")); + mkfile( + path.join(rulesDir, "obj-bin.yaml"), + [ + "tool: t", + "home: /tmp/t", + "binary:", + " which: mytool", + " paths:", + " darwin: /Applications/MyTool.app", + " linux: /usr/bin/mytool", + "mappings: []", + ].join("\n"), + ); const rules = fresh().loadRules(); assert.deepStrictEqual(rules[0].binary, { which: "mytool", @@ -214,14 +228,12 @@ describe("load-config", () => { it("normalizes object binary missing which to null which", () => { mkdir(rulesDir); - mkfile(path.join(rulesDir, "no-which.yaml"), [ - "tool: t", - "home: /tmp/t", - "binary:", - " paths:", - " darwin: /Applications/T.app", - "mappings: []", - ].join("\n")); + mkfile( + path.join(rulesDir, "no-which.yaml"), + ["tool: t", "home: /tmp/t", "binary:", " paths:", " darwin: /Applications/T.app", "mappings: []"].join( + "\n", + ), + ); const rules = fresh().loadRules(); assert.strictEqual(rules[0].binary.which, null); assert.deepStrictEqual(rules[0].binary.paths, { darwin: "/Applications/T.app" }); @@ -251,7 +263,10 @@ describe("load-config", () => { it("respects enabled: false", () => { mkdir(rulesDir); - mkfile(path.join(rulesDir, "disabled.yaml"), "tool: disabled\nlabel: Disabled\nhome: /tmp/d\nenabled: false\nmappings: []\n"); + mkfile( + path.join(rulesDir, "disabled.yaml"), + "tool: disabled\nlabel: Disabled\nhome: /tmp/d\nenabled: false\nmappings: []\n", + ); const rules = fresh().loadRules(); assert.strictEqual(rules[0].enabled, false); }); @@ -274,17 +289,20 @@ describe("load-config", () => { it("filters mappings missing type or source", () => { mkdir(rulesDir); - mkfile(path.join(rulesDir, "partial.yaml"), [ - "tool: partial", - "label: Partial", - "home: /tmp/p", - "mappings:", - " - type: skills", - " - source: foo.md", - " - type: file", - " source: ok.md", - " target: ok.md", - ].join("\n")); + mkfile( + path.join(rulesDir, "partial.yaml"), + [ + "tool: partial", + "label: Partial", + "home: /tmp/p", + "mappings:", + " - type: skills", + " - source: foo.md", + " - type: file", + " source: ok.md", + " target: ok.md", + ].join("\n"), + ); const rules = fresh().loadRules(); assert.strictEqual(rules[0].mappings.length, 1); assert.strictEqual(rules[0].mappings[0].source, "ok.md"); @@ -311,47 +329,112 @@ describe("load-config", () => { mkfile(path.join(rulesDir, "valid.yaml"), "tool: valid\nlabel: Valid\nhome: /tmp/v\nmappings: []\n"); const rules = fresh().loadRules(); assert.strictEqual(rules.length, 1); - }); - }); - - describe("normalizeRule", () => { - it("preserves itemType from mapping when present", () => { - mkdir(rulesDir); - mkfile(path.join(rulesDir, "with-itemtype.yaml"), [ - "tool: test", - "label: Test", - "home: /tmp/test", - "mappings:", - " - type: skills", - " source: skills", - " target: skills", - " itemType: skill", - ].join("\n")); - const rules = fresh().loadRules(); - assert.strictEqual(rules.length, 1); - assert.strictEqual(rules[0].mappings.length, 1); - assert.strictEqual(rules[0].mappings[0].itemType, "skill"); - }); - - it("omits itemType when absent (backward compat)", () => { - mkdir(rulesDir); - mkfile(path.join(rulesDir, "no-itemtype.yaml"), [ - "tool: test", - "label: Test", - "home: /tmp/test", - "mappings:", - " - type: skills", - " source: skills", - " target: skills", - ].join("\n")); - const rules = fresh().loadRules(); - assert.strictEqual(rules.length, 1); - assert.strictEqual(rules[0].mappings.length, 1); - assert.strictEqual(rules[0].mappings[0].itemType, undefined); - }); - }); - - describe("seedDefaultRules", () => { + }); + }); + + describe("normalizeRule", () => { + it("preserves itemType from mapping when present", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "with-itemtype.yaml"), + [ + "tool: test", + "label: Test", + "home: /tmp/test", + "mappings:", + " - type: skills", + " source: skills", + " target: skills", + " itemType: skill", + ].join("\n"), + ); + const rules = fresh().loadRules(); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].mappings.length, 1); + assert.strictEqual(rules[0].mappings[0].itemType, "skill"); + }); + + it("omits itemType when absent (backward compat)", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "no-itemtype.yaml"), + [ + "tool: test", + "label: Test", + "home: /tmp/test", + "mappings:", + " - type: skills", + " source: skills", + " target: skills", + ].join("\n"), + ); + const rules = fresh().loadRules(); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].mappings.length, 1); + assert.strictEqual(rules[0].mappings[0].itemType, undefined); + }); + + it("defaults mode to 'multi-select' when not specified", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "no-mode.yaml"), + [ + "tool: test", + "label: Test", + "home: /tmp/test", + "mappings:", + " - type: skills", + " source: skills", + " target: skills", + ].join("\n"), + ); + const rules = fresh().loadRules(); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].mode, "multi-select"); + }); + + it("preserves mode 'single-select' when specified", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "single.yaml"), + [ + "tool: test", + "label: Test", + "home: /tmp/test", + "mode: single-select", + "mappings:", + " - type: skills", + " source: skills", + " target: skills", + ].join("\n"), + ); + const rules = fresh().loadRules(); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].mode, "single-select"); + }); + + it("treats unknown mode values as 'multi-select'", () => { + mkdir(rulesDir); + mkfile( + path.join(rulesDir, "bad-mode.yaml"), + [ + "tool: test", + "label: Test", + "home: /tmp/test", + "mode: bogus-value", + "mappings:", + " - type: skills", + " source: skills", + " target: skills", + ].join("\n"), + ); + const rules = fresh().loadRules(); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].mode, "multi-select"); + }); + }); + + describe("seedDefaultRules", () => { it("copies bundled rules into rules directory", () => { const { seedDefaultRules, BUNDLED_RULES_DIR } = fresh(); seedDefaultRules(); @@ -363,7 +446,10 @@ describe("load-config", () => { it("does not overwrite existing rule files", () => { mkdir(rulesDir); - mkfile(path.join(rulesDir, "claude.yaml"), "tool: claude\nlabel: My Custom Claude\nhome: /custom\nmappings: []\n"); + mkfile( + path.join(rulesDir, "claude.yaml"), + "tool: claude\nlabel: My Custom Claude\nhome: /custom\nmappings: []\n", + ); const { seedDefaultRules } = fresh(); seedDefaultRules(); const content = fs.readFileSync(path.join(rulesDir, "claude.yaml"), "utf8");