Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions scripts/claude-mem-heal.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
28 changes: 28 additions & 0 deletions scripts/claude-mem-heal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions specs/BUG-012-claude-mem-marketplace-junction/proposal.md
Original file line number Diff line number Diff line change
@@ -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).
45 changes: 45 additions & 0 deletions specs/BUG-012-claude-mem-marketplace-junction/tasks.md
Original file line number Diff line number Diff line change
@@ -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".
61 changes: 61 additions & 0 deletions specs/BUG-012-claude-mem-marketplace-junction/verification.md
Original file line number Diff line number Diff line change
@@ -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".
30 changes: 30 additions & 0 deletions tests/setup-linux.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/setup-windows.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading