---
name: maxvision-update
description: Owner-only — detects upstream drift of this plugin and applies the update via `claude plugin update` CLI (CC-native auto-update). The architectural equivalent of MaxVision's `/maxvision:update` for a Claude Code plugin (which is not an npm package). Non-owners get silent exit.
allowed-tools:
  - Bash
---

# Maxv Update

Owner-only worker that:

1. Runs `scripts/check_update.py --json` to fetch the latest drift state (24h cache,
   bypassed with `--force`).
2. If `maxvision` is behind its latest GitHub release, invokes
   `claude plugin update maxvision@maxvision --scope user` via Bash to apply
   the update.
3. Emits a single-line summary to stderr: either `"Updated to v<new>. Restart Claude
   Code to apply."` or `"Already at latest v<local>."`.

Owner-gate: inherited from `scripts/check_update.py` — only the GitHub user
`produtoramaxvision` runs the worker. All other users get silent exit 0 with no
banner and no `claude plugin update` invocation (because `check_update.py` returns
empty drift JSON for non-owners).

Cache: same 24h cache as check-update (`~/.claude/cache/maxvision/upstream-drift.json`).
Bypass with `--force` flag.

## Usage

```bash
set -euo pipefail

# Detect --json flag in original args (so we can short-circuit the apply step).
WANT_JSON=0
for arg in "$@"; do
    case "$arg" in
        --json) WANT_JSON=1 ;;
    esac
done

# Step 1: detect drift (always emit JSON for parsing). Preserve check_update.py
# stderr to a tmp file so owners see real errors instead of a silent skip.
DRIFT_ERR=$(mktemp 2>/dev/null || echo "/tmp/maxvision-update-drift.err.$$")
trap 'rm -f "$DRIFT_ERR"' EXIT

if ! DRIFT_JSON=$(python3 "${CLAUDE_PLUGIN_ROOT}/scripts/check_update.py" --json "$@" 2>"$DRIFT_ERR"); then
    # Non-zero exit from check_update.py. Surface the diagnostic to stderr
    # rather than masking it as "non-owner skip".
    if [ -s "$DRIFT_ERR" ]; then
        echo "ERROR: check_update.py failed:" >&2
        cat "$DRIFT_ERR" >&2
    else
        echo "ERROR: check_update.py exited non-zero with no diagnostic output." >&2
    fi
    exit 1
fi

# Non-owners get empty stdout (check_update.py is_owner=False → return 0,
# nothing printed). This branch is the silent-skip path.
if [ -z "$DRIFT_JSON" ] || [ "$DRIFT_JSON" = '{}' ]; then
    exit 0
fi

# If --json was requested, print raw drift and exit (no apply).
if [ "$WANT_JSON" -eq 1 ]; then
    echo "$DRIFT_JSON"
    exit 0
fi

# Step 2: parse the actual JSON shape emitted by check_update.py:
#   { "fetched_at": <float>,
#     "local_plugin_version": "X.Y.Z" | null,
#     "upstream": { "maxvision": "vX.Y.Z" | null, ... } }
# Use the same version-normalization rule as emit_drift_banner: strip leading
# 'v', take dotted ints up to the first non-numeric, compare tuples.
PLUGIN_DRIFT=$(echo "$DRIFT_JSON" | python3 -c '
import sys, json

def normalize(v):
    if not v:
        return (0,)
    v = v.lstrip("v").split("-")[0].split("+")[0]
    parts = []
    for p in v.split("."):
        try:
            parts.append(int(p))
        except ValueError:
            break
    return tuple(parts) if parts else (0,)

try:
    d = json.load(sys.stdin)
except Exception:
    sys.exit(0)
local = d.get("local_plugin_version") or ""
upstream = (d.get("upstream") or {}).get("maxvision") or ""
if local and upstream and normalize(upstream) > normalize(local):
    # Output: local<TAB>upstream<TAB>upstream_stripped_v
    upstream_clean = upstream.lstrip("v")
    print(f"{local}\t{upstream}\t{upstream_clean}")
')

if [ -z "$PLUGIN_DRIFT" ]; then
    # No drift on the plugin itself. Read local version from plugin.json for
    # the user-facing message (VERSION may lag plugin.json on dev builds).
    LOCAL_VERSION=$(python3 -c '
import json, os, sys
root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
try:
    print(json.loads(open(f"{root}/.claude-plugin/plugin.json", encoding="utf-8").read()).get("version", "unknown"))
except Exception:
    print("unknown")
')
    echo "Already at latest v${LOCAL_VERSION}." >&2
    exit 0
fi

LOCAL=$(echo "$PLUGIN_DRIFT" | cut -f1)
REMOTE_TAG=$(echo "$PLUGIN_DRIFT" | cut -f2)
REMOTE_CLEAN=$(echo "$PLUGIN_DRIFT" | cut -f3)

echo "Plugin drift detected: v${LOCAL} → ${REMOTE_TAG}. Applying via 'claude plugin update'..." >&2

# Step 3: apply update via CC-native CLI. --scope user matches installed_plugins.json scope.
if claude plugin update maxvision@maxvision --scope user 2>&1; then
    echo "Updated to v${REMOTE_CLEAN}. Restart Claude Code to apply." >&2
    exit 0
else
    echo "ERROR: 'claude plugin update' failed. Run manually: claude plugin update maxvision@maxvision --scope user" >&2
    exit 1
fi
```

## Why CC-native, not wipe+npx

MaxVision's `/maxvision:update` upstream uses `npx -y maxvision-cc@latest` to wipe and
reinstall `${CLAUDE_PLUGIN_ROOT}`. That model works for MaxVision because MaxVision is
an npm package.

This plugin is a Claude Code plugin in
`~/.claude/plugins/cache/maxvision/maxvision/<version>/`.
CC manages it natively: side-by-side install (multiple versions coexist), with
`.in_use/<PID>` files marking the active version and `.orphaned_at` markers on
versions being cleaned up. The CC-native equivalent of "wipe and reinstall" is
`claude plugin update <name>`, which:

- Fetches the new version side-by-side
- Updates `installed_plugins.json` (version, gitCommitSha, lastUpdated, installPath)
- Tells the user "restart required to apply" (the active session keeps using the
  pre-update version safely until restart)

Reimplementing wipe+reinstall manually would fight CC's lifecycle and risk
corrupting `installed_plugins.json` state. The skill uses CC's CLI directly.

## Restart guidance

`claude plugin update` does NOT auto-restart Claude Code. The user must close and
reopen the session to pick up the new version. The skill emits a single-line
notice to stderr; the next SessionStart hook (`maxvision-update-banner.py`) will then
report no drift, confirming the update landed.
