diff --git a/scripts/claude-mem-heal.ps1 b/scripts/claude-mem-heal.ps1 index 91da339..42f1fa7 100644 --- a/scripts/claude-mem-heal.ps1 +++ b/scripts/claude-mem-heal.ps1 @@ -50,6 +50,7 @@ $script:VerboseOutput = $VerboseOutput.IsPresent $ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $env:USERPROFILE '.claude' } $CacheRoot = Join-Path $ClaudeDir 'plugins\cache\thedotmack\claude-mem' $MarketplaceDir = Join-Path $ClaudeDir 'plugins\marketplaces\thedotmack\plugin' +$MarketplaceDirActual = Join-Path $ClaudeDir 'plugins\marketplaces\thedotmack-claude-mem\plugin' function Write-HealLog { param([string]$Message) @@ -61,6 +62,34 @@ function Write-HealVerbose { if ($script:VerboseOutput) { Write-HealLog $Message } } +# BUG-012: Claude Code clones the claude-mem marketplace under the GitHub repo +# name `thedotmack-claude-mem\`, but the plugin's bundled hooks.json hardcodes +# the legacy fallback `marketplaces\thedotmack\plugin\scripts\...`. Without a +# compatibility junction, plugin hooks fail discovery when CLAUDE_PLUGIN_ROOT +# is unset (UserPromptSubmit blocked with `printf: write error: Permission +# denied` under Git Bash on Windows). Create a Junction so the legacy path +# resolves to the actual install. Idempotent: skip if source dir missing or +# target path already present. Junction requires no admin privileges on NTFS. +function Repair-MarketplaceCompatJunction { + $legacy = Join-Path $ClaudeDir 'plugins\marketplaces\thedotmack' + $actual = Join-Path $ClaudeDir 'plugins\marketplaces\thedotmack-claude-mem' + + if (-not (Test-Path $actual -PathType Container)) { + Write-HealVerbose "no thedotmack-claude-mem marketplace at $actual" + return + } + if (Test-Path $legacy) { + Write-HealVerbose "legacy marketplace path already present: $legacy" + return + } + try { + $null = New-Item -ItemType Junction -Path $legacy -Target $actual -ErrorAction Stop + Write-HealLog "created legacy marketplace junction: $legacy -> thedotmack-claude-mem" + } catch { + Write-HealLog "ERROR: failed to create junction ${legacy}: $($_.Exception.Message)" + } +} + # Replace a broken .mcp.json with the v10.6.3 form. Idempotent: only # rewrites if the file contains the offending ${_R%/} pattern. function Repair-McpJson { @@ -158,6 +187,8 @@ if (Test-Path $CacheRoot -PathType Container) { Write-HealVerbose "no cache dir at $CacheRoot" } +Repair-MarketplaceCompatJunction Repair-PluginDir -Dir $MarketplaceDir +Repair-PluginDir -Dir $MarketplaceDirActual exit 0 diff --git a/scripts/claude-mem-heal.sh b/scripts/claude-mem-heal.sh index fbb8dd2..4ff9b84 100755 --- a/scripts/claude-mem-heal.sh +++ b/scripts/claude-mem-heal.sh @@ -31,10 +31,36 @@ VERBOSE=0 CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" CACHE_ROOT="$CLAUDE_DIR/plugins/cache/thedotmack/claude-mem" MARKETPLACE_DIR="$CLAUDE_DIR/plugins/marketplaces/thedotmack/plugin" +MARKETPLACE_DIR_ACTUAL="$CLAUDE_DIR/plugins/marketplaces/thedotmack-claude-mem/plugin" log() { printf '[claude-mem-heal] %s\n' "$1"; } verbose() { if [ "$VERBOSE" -eq 1 ]; then log "$1"; fi; } +# BUG-012: Claude Code clones the claude-mem marketplace under the GitHub repo +# name `thedotmack-claude-mem/`, but the plugin's bundled hooks.json hardcodes +# the legacy fallback `marketplaces/thedotmack/plugin/scripts/...`. Without a +# compatibility symlink, plugin hooks fail discovery when CLAUDE_PLUGIN_ROOT +# is unset (UserPromptSubmit blocked). Create the legacy path as a symlink to +# the actual install. Idempotent: only acts when source dir exists and target +# path is absent. +ensure_marketplace_compat_symlink() { + legacy="$CLAUDE_DIR/plugins/marketplaces/thedotmack" + actual="$CLAUDE_DIR/plugins/marketplaces/thedotmack-claude-mem" + if [ ! -d "$actual" ]; then + verbose "no thedotmack-claude-mem marketplace at $actual" + return 0 + fi + if [ -e "$legacy" ] || [ -L "$legacy" ]; then + verbose "legacy marketplace path already present: $legacy" + return 0 + fi + if ln -s "$actual" "$legacy" 2>/dev/null; then + log "created legacy marketplace symlink: $legacy -> thedotmack-claude-mem" + else + log "ERROR: failed to create $legacy symlink" + fi +} + # Replace a broken .mcp.json with the v10.6.3 form. Idempotent: only # rewrites if the file contains the offending ${_R%/} pattern. heal_mcp_json() { @@ -115,6 +141,8 @@ else verbose "no cache dir at $CACHE_ROOT" fi +ensure_marketplace_compat_symlink heal_dir "$MARKETPLACE_DIR" +heal_dir "$MARKETPLACE_DIR_ACTUAL" exit 0 diff --git a/specs/BUG-012-claude-mem-marketplace-junction/proposal.md b/specs/BUG-012-claude-mem-marketplace-junction/proposal.md new file mode 100644 index 0000000..be1e596 --- /dev/null +++ b/specs/BUG-012-claude-mem-marketplace-junction/proposal.md @@ -0,0 +1,60 @@ +--- +id: "BUG-012-claude-mem-marketplace-junction" +type: spec +status: draft +created: "2026-05-20" +tags: [spec, proposal, claude-mem, plugin-discovery, cross-os-parity] +template_version: "1.0" +--- + +# BUG-012-claude-mem-marketplace-junction + +## Why + +Discovered while diagnosing a `UserPromptSubmit operation blocked by hook` failure during the BUG-011 session. The `claude-mem@thedotmack` plugin (installed by `setup-{linux,windows}` ~line 428) ships hooks that look up its scripts at fallback path `~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/` — the literal marketplace `name` declared in `marketplace.json`. Claude Code, however, clones the marketplace directory under the GitHub *repository* name `thedotmack-claude-mem/`. The fallback path therefore never resolves on machines where `CLAUDE_PLUGIN_ROOT` is unset/stale (the user's case), and the hook exits non-zero with `claude-mem: plugin scripts not found` (or its Git-Bash-on-Windows symptom `printf: write error: Permission denied`), blocking every `UserPromptSubmit`. + +Same root cause silently neuters `scripts/claude-mem-heal.{sh,ps1}`: both hard-code `marketplaces/thedotmack/plugin` (sh:33, ps1:52), find the dir absent, and no-op — so the upstream `.mcp.json ${_R%/}` bug and missing-zod patches never reach the marketplace source. + +The user's manual workaround was to delete the plugin, but `setup-windows.ps1:428` reinstalls it every run. Without a fix, the loop repeats. + +## What + +Defense in depth — two changes, both in `scripts/claude-mem-heal.{sh,ps1}`: + +1. **Create the legacy marketplace junction/symlink** if missing. When `~/.claude/plugins/marketplaces/thedotmack/` does not exist but `~/.claude/plugins/marketplaces/thedotmack-claude-mem/` does, create a junction (Windows) / symlink (Linux) so the plugin's hardcoded fallback paths resolve. Idempotent: skip if either condition fails. +2. **Make the heal script's `MARKETPLACE_DIR` resolution path-aware**. Walk both `thedotmack/plugin` and `thedotmack-claude-mem/plugin` and heal whichever exists. The junction from step 1 effectively unifies these, but the path-aware walk is a backstop in case Claude Code changes the install naming again or the junction creation fails. + +Tests: bats assertions in `tests/setup-linux.bats` + `tests/setup-windows.bats` that lock the presence of the junction-creation block and the path-aware healing in both heal scripts. + +## Out of scope + +- The upstream plugin's `hooks.json` hardcoded fallback — out of repo control; we paper over from our side. +- Removing `claude-mem@thedotmack` from `setup-{linux,windows}` plugin list — the user wants the plugin, just not the breakage. +- The `printf: write error: Permission denied` Git-Bash-on-Windows symptom — diagnosed as secondary (pipe-closed-during-write under Claude Code's hook sandbox). Fixing the discovery resolves the underlying `exit 1`; the printf symptom disappears as a consequence. +- Modifying `setup-{linux,windows}` to skip the plugin install when broken — too brittle; let the heal script run on session start and self-correct. + +## Risks / open questions + +- **Risk: Claude Code in a future version starts creating `marketplaces/thedotmack/` itself, colliding with the junction.** Mitigation: junction creation is gated on `marketplaces/thedotmack/` NOT existing. If a future Claude Code creates a real dir, we leave it alone. +- **Risk: junction on Windows requires the source directory to exist at junction-create time.** Mitigation: we only create the junction when `marketplaces/thedotmack-claude-mem/` exists. The whole block is skipped otherwise. +- **Risk: junction creation fails (permissions, anti-virus).** Mitigation: heal script always exits 0 and logs the failure; the user falls back to the path-aware heal (step 2). Plugin hook fallback is still broken in this case, but no regression vs current behavior. +- **Risk: bats assertions are pattern-based grep checks.** Same caveat as BUG-011 — they lock presence, not correctness. Acceptable for this defense-in-depth tier. + +## Acceptance criteria + +- [ ] `scripts/claude-mem-heal.sh`: new block creates `marketplaces/thedotmack` symlink → `marketplaces/thedotmack-claude-mem` when source exists and target missing. Idempotent on re-run. +- [ ] `scripts/claude-mem-heal.ps1`: equivalent block using `New-Item -ItemType Junction`. +- [ ] Both heal scripts walk both legacy and current marketplace paths for healing (path-aware backstop). +- [ ] `tests/setup-linux.bats`: assertion locks the junction-creation block in `claude-mem-heal.sh`. +- [ ] `tests/setup-windows.bats`: assertion locks the junction-creation block in `claude-mem-heal.ps1`. +- [ ] `shellcheck --severity=error scripts/claude-mem-heal.sh` clean. +- [ ] `pwsh -Command "Invoke-ScriptAnalyzer -Path scripts/claude-mem-heal.ps1 -Severity Error"` clean. +- [ ] `bash -n scripts/claude-mem-heal.sh` and PowerShell AST parse clean. +- [ ] verification.md ships with manual repro before/after for the user's current machine. + +## References + +- Sibling: BUG-011 (PR [#69](https://github.com/mlorentedev/dotfiles/pull/69)) — discovered this bug during BUG-011 hook-failure diagnosis. +- Predecessor: BUG-004 (PR #57) — established the snapshot/restore guard, but didn't address the plugin-discovery path mismatch. +- Upstream context: `thedotmack/claude-mem` plugin ships `hooks.json` with hardcoded fallback `marketplaces/thedotmack/plugin/scripts/...`. +- Pattern: defense-in-depth heal at session start (mirrors the `.mcp.json` + zod patches the heal scripts already apply). diff --git a/specs/BUG-012-claude-mem-marketplace-junction/tasks.md b/specs/BUG-012-claude-mem-marketplace-junction/tasks.md new file mode 100644 index 0000000..0a249ee --- /dev/null +++ b/specs/BUG-012-claude-mem-marketplace-junction/tasks.md @@ -0,0 +1,45 @@ +--- +tags: [spec, tasks, claude-mem, plugin-discovery] +created: "2026-05-20" +--- + +# Tasks - BUG-012-claude-mem-marketplace-junction + +## Setup + +- [x] Branch: `fix/BUG-012-claude-mem-marketplace-junction` (off main). +- [x] Spec scaffold (manual — bypassed init-spec.ps1 vault gate for small-scope bugfix). + +## Implementation (TDD order) + +### Tests first + +- [ ] `tests/setup-linux.bats`: assertion that `scripts/claude-mem-heal.sh` contains a guarded `ln -s ... thedotmack-claude-mem ... thedotmack` block. +- [ ] `tests/setup-windows.bats`: assertion that `scripts/claude-mem-heal.ps1` contains a guarded `New-Item -ItemType Junction ...` block referencing both marketplace names. +- [ ] Cross-OS parity assertion: both scripts implement the same junction-creation guard (mirrors existing BUG-004/BUG-011 parity pattern). +- [ ] Run bats — assertions should FAIL (red). + +### Implementation + +- [ ] `scripts/claude-mem-heal.sh`: add `ensure_marketplace_compat_symlink()` function. Guard: only create if `marketplaces/thedotmack/` missing AND `marketplaces/thedotmack-claude-mem/` exists. Logs one line on creation; silent if already present or sources missing. +- [ ] `scripts/claude-mem-heal.sh`: extend `MARKETPLACE_DIR` walk to BOTH legacy (`thedotmack/plugin`) and current (`thedotmack-claude-mem/plugin`) paths. +- [ ] `scripts/claude-mem-heal.ps1`: equivalent `Ensure-MarketplaceCompatJunction` function using `New-Item -ItemType Junction`. +- [ ] `scripts/claude-mem-heal.ps1`: extend `$MarketplaceDir` walk similarly. +- [ ] Run bats — assertions now PASS (green). + +### Lint + cross-check + +- [ ] `shellcheck --severity=error scripts/claude-mem-heal.sh` clean. +- [ ] `pwsh -Command "Invoke-ScriptAnalyzer -Path scripts/claude-mem-heal.ps1 -Severity Error"` clean. +- [ ] `bash -n scripts/claude-mem-heal.sh` parse clean. +- [ ] PowerShell AST parse of `scripts/claude-mem-heal.ps1` clean. +- [ ] Manual repro on user's Windows machine: pre-fix `Test-Path marketplaces/thedotmack` = false; post-fix = true; junction target = `thedotmack-claude-mem`. + +## Closing + +- [ ] verification.md filled (before/after manual evidence, bats output, junction `Get-Item .LinkType` confirmation). +- [ ] PR opened referencing `specs/BUG-012-claude-mem-marketplace-junction/`. +- [ ] PR body documents that the printf symptom is a Windows secondary; the underlying discovery failure is cross-OS. +- [ ] Post-merge: archive `specs/BUG-012-...` to `specs/archive/`. +- [ ] Post-merge: vault `11-tasks.md` entry (created retroactively for traceability — small-scope bugfix exception per AGENTS.md). +- [ ] Post-merge: vault lesson "plugin discovery: marketplace dir name follows GitHub repo, not declared `name` field — always heal both paths". diff --git a/specs/BUG-012-claude-mem-marketplace-junction/verification.md b/specs/BUG-012-claude-mem-marketplace-junction/verification.md new file mode 100644 index 0000000..a13377c --- /dev/null +++ b/specs/BUG-012-claude-mem-marketplace-junction/verification.md @@ -0,0 +1,61 @@ +--- +tags: [spec, verification, claude-mem, plugin-discovery] +created: "2026-05-20" +--- + +# Verification - BUG-012-claude-mem-marketplace-junction + +## Evidence (per acceptance criterion) + +- [x] **`claude-mem-heal.sh` symlink block**: `ensure_marketplace_compat_symlink()` defined at `scripts/claude-mem-heal.sh:39-56`, invoked at l.135 before `heal_dir` walks. Guards: source dir exists, target absent. `ln -s` with both `-e` and `-L` checks on the legacy path. +- [x] **`claude-mem-heal.ps1` junction block**: `Repair-MarketplaceCompatJunction` at `scripts/claude-mem-heal.ps1:69-88`, invoked at l.180. Uses `New-Item -ItemType Junction` (no admin required). Guards: `Test-Path -PathType Container` on source, `Test-Path` on target. +- [x] **Path-aware walk**: both heal scripts now iterate the legacy AND actual marketplace paths (`MARKETPLACE_DIR_ACTUAL` / `$MarketplaceDirActual`). Idempotent — internal heal functions grep before write. +- [x] **Bats assertions**: 4 new asserts in `tests/setup-linux.bats` (BUG-012 block) + 2 in `tests/setup-windows.bats`. Manual grep equivalents PASS post-implementation. + +## Test status + +- **Pre-fix state on user's Windows machine** (captured during diagnosis): + ``` + marketplaces/claude-plugins-official/ ← exists (matches name) + marketplaces/thedotmack-claude-mem/ ← exists (Claude Code's repo-based naming) + marketplaces/thedotmack/ ← MISSING (what plugin's hooks.json fallback expects) + cache/thedotmack/claude-mem/ ← MISSING (cache never populated) + ``` + Hook failure consequence: + ``` + UserPromptSubmit operation blocked by hook: + /usr/bin/bash: line 1: printf: write error: Permission denied + ``` + Translation: hook discovery script fell through all fallback paths → `_P` empty → `exit 1` → operation blocked. + +- **Post-fix empirical result on user's Windows machine** (run 1, dry repair): + ``` + BEFORE: thedotmack exists = False + [claude-mem-heal] created legacy marketplace junction: + C:\Users\Manu\.claude\plugins\marketplaces\thedotmack -> thedotmack-claude-mem + AFTER: thedotmack exists = True + LinkType: Junction + Target: C:\Users\Manu\.claude\plugins\marketplaces\thedotmack-claude-mem + ``` +- **Idempotency** (run 2, no -VerboseOutput): no output, exit 0. With -VerboseOutput: `legacy marketplace path already present`. Confirms the silent-on-healthy contract is preserved. +- **PowerShell AST parse** of `claude-mem-heal.ps1`: clean. **PSScriptAnalyzer -Severity Error**: clean. +- **`bash -n` of `claude-mem-heal.sh`**: clean. +- **Linux empirical**: not run (no Linux test machine in this session). The symlink logic mirrors the PowerShell Junction logic with identical guards; bash syntax checked. + +## Decisions made during implementation + +- **Junction vs symbolic link on Windows**: junction (no admin required, works on NTFS, directory-only — sufficient here). Matches the precedent in `setup-windows.ps1` auto-memory deploy. +- **Skip the upstream plugin's `hooks.json` rewrite**: it would be reverted on every `/plugin update`. Junction is one-shot and outlasts plugin updates. +- **Heal cache root only when present**: `cache/thedotmack/claude-mem/` populated by Claude Code on plugin install/load; if missing we simply don't iterate it (existing behavior preserved). +- **Cross-OS parity**: applied identical logic to both heal scripts even though only Windows symptom was empirically observed. Marketplace dir naming is Claude Code-driven, not OS-driven; Linux exposure is theoretical but real. + +## Promotion candidates + +To be assessed post-merge. + +## Archive checklist + +- [ ] `proposal.md` frontmatter set to `status: archived` (post-merge). +- [ ] Folder moved to `specs/archive/BUG-012-claude-mem-marketplace-junction/` (post-merge). +- [ ] Vault `11-tasks.md` entry created with PR link (retroactive, post-merge). +- [ ] Vault `90-lessons.md` appended: "plugin discovery: marketplace dir name follows GitHub repo, not declared `name` field — heal both paths". diff --git a/tests/setup-linux.bats b/tests/setup-linux.bats index 9d4c3e8..e5a5776 100644 --- a/tests/setup-linux.bats +++ b/tests/setup-linux.bats @@ -174,6 +174,36 @@ setup() { grep -q 'claude-mem-heal\.ps1' "$DOTFILES_DIR/scripts/claude-session-start.ps1" } +# --- BUG-012: legacy marketplace junction/symlink for plugin discovery --- +# Claude Code clones the claude-mem marketplace under the GitHub repo name +# `thedotmack-claude-mem/`, but the plugin's bundled hooks.json hardcodes +# the legacy fallback `marketplaces/thedotmack/plugin/scripts/...`. Without +# a compatibility junction/symlink, UserPromptSubmit hooks fail to find +# bun-runner.js. Both heal scripts create the link defensively at session +# start, gated on `thedotmack/` missing AND `thedotmack-claude-mem/` present. + +@test "claude-mem-heal.sh creates legacy marketplace symlink (BUG-012)" { + grep -qF 'thedotmack-claude-mem' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" + grep -Eq 'ln -s' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" +} + +@test "claude-mem-heal.sh symlink creation is guarded on both source+target (BUG-012)" { + # Source dir (thedotmack-claude-mem) must be checked; target (thedotmack) + # must be checked too -- both guards required for idempotence. + grep -Eq '\[ ! -d "\$actual" \]' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" + grep -Eq '\[ -e "\$legacy" \]' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" +} + +@test "claude-mem-heal.ps1 creates legacy marketplace junction (BUG-012)" { + grep -qF 'thedotmack-claude-mem' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1" + grep -qF -- '-ItemType Junction' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1" +} + +@test "parity: both heal scripts implement legacy marketplace link (BUG-012)" { + grep -qF 'thedotmack-claude-mem' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" + grep -qF 'thedotmack-claude-mem' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1" +} + # --- BUG-004: defense-in-depth around claude plugin install (truncate guard) --- # Linux mirror of the Windows guard. Every `claude plugin install` call triggers # upstream anthropics/claude-code#59870, dropping subscription fields out of diff --git a/tests/setup-windows.bats b/tests/setup-windows.bats index 0bf3620..81a104b 100644 --- a/tests/setup-windows.bats +++ b/tests/setup-windows.bats @@ -91,6 +91,23 @@ setup() { grep -q 'claude-mem-heal.ps1' "$PS1_SCRIPT" } +# BUG-012: claude-mem-heal.ps1 creates a Junction `marketplaces\thedotmack` +# pointing at `marketplaces\thedotmack-claude-mem` so the plugin's hardcoded +# `marketplaces/thedotmack/plugin/scripts/...` fallback paths resolve when +# CLAUDE_PLUGIN_ROOT is unset/stale (the UserPromptSubmit hook failure mode). +@test "claude-mem-heal.ps1 creates legacy marketplace junction (BUG-012)" { + local heal="$DOTFILES_DIR/scripts/claude-mem-heal.ps1" + grep -qF 'thedotmack-claude-mem' "$heal" + grep -qF -- '-ItemType Junction' "$heal" +} + +# Junction creation must be guarded: only create when target dir missing AND +# source dir present. Mirrors the heal script's idempotence pattern. +@test "claude-mem-heal.ps1 junction creation is idempotent-guarded (BUG-012)" { + local heal="$DOTFILES_DIR/scripts/claude-mem-heal.ps1" + grep -B5 -A5 -- '-ItemType Junction' "$heal" | grep -qE 'Test-Path|-not' +} + @test "setup-windows.ps1 deploys dotfiles-sync.ps1" { grep -q 'dotfiles-sync.ps1' "$PS1_SCRIPT" }