feat(repo): plugin version-bump hook + CI backstop; claude-in-office Wef sideload debug (#208)

* feat(repo): plugin version-bump hook + CI backstop, fix install docs/remote

* docs(claude-in-office): add Wef registry + wef-folder sideload (Windows + macOS) for local debugging

* ci: use actions/checkout@v4 instead of pinned SHA

* docs(claude-in-office): scope sideload cleanup to the specific manifest, not the wef folder

* docs(claude-in-office): scope cache-clear to wef contents with guards, not rm -rf of caches/wef dirs

* docs(claude-in-office): target cache-clear by add-in Id, leave other add-ins in wef untouched

* feat(claude-in-office): add clear-addin-cache scripts (mac/win), ID-targeted, dry-run by default

* fix(claude-in-office): Windows sideload cleanup is registry-based, ID-first; direct edits (no office-addin-dev-settings); document deployed-cache caveat

* docs(claude-in-office): clarify surgical per-ID deletion of local/sideloaded manifests is safe; MS warning applies only to the deployed cache

* feat(claude-in-office): add sideload-addin scripts (mac/win), ID-derived, dry-run by default; debug.md points to script pair

* feat(claude-in-office): sideload installs directly (drop dry-run); dry-run stays only on destructive clear-addin-cache

* docs(claude-in-office): make the post-clear app restart an explicit step, not a buried line
This commit is contained in:
Omar Mihilmy
2026-05-15 17:57:20 -04:00
committed by GitHub
parent ac4c5b4c91
commit 379e414f98
13 changed files with 672 additions and 23 deletions
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Auto patch-bump any plugin with staged changes so it ends up exactly one
# patch ahead of main — bumped once per branch, not once per commit.
#
# Install (one-time per clone): git config core.hooksPath .githooks
# (scripts/check.py self-installs this for you on first run.)
#
# Bypass for a single commit: git commit --no-verify
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
if ! command -v python3 >/dev/null 2>&1; then
echo "[pre-commit] python3 not found; skipping version-bump." >&2
exit 0
fi
python3 "$REPO_ROOT/scripts/version_bump.py" --apply
+26
View File
@@ -0,0 +1,26 @@
name: version-bump
# Backstop for contributors without the local pre-commit hook: every PR that
# modifies a plugin must bump that plugin's .claude-plugin/plugin.json version,
# otherwise already-installed users won't receive the change.
on:
pull_request:
permissions:
contents: read
jobs:
version-bump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check plugin version bumps
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "$BASE_REF"
python3 scripts/version_bump.py --check --base "origin/$BASE_REF"
+2
View File
@@ -30,6 +30,8 @@ Cowork plugins and Claude Managed Agent templates for financial services. Each n
Run `python3 scripts/check.py` before committing — it lints every manifest, verifies all `system.file` / `skills.path` / `callable_agents.manifest` references resolve, and fails if any `agent-plugins/<slug>/skills/` copy has drifted from its `vertical-plugins/` source. **Edit skills in `vertical-plugins/`**, then run `python3 scripts/sync-agent-skills.py` to propagate into the agent bundles.
`check.py` also self-installs a `pre-commit` hook (`git config core.hooksPath .githooks` — no Husky/Node). The hook patch-bumps any plugin's `.claude-plugin/plugin.json` `version` so a branch ends up exactly one patch ahead of `main` (bumped once, not per commit — a plugin's `version` gates update delivery to already-installed users). The `version-bump` GitHub Action enforces the same rule on PRs as a backstop. Bypass a single commit with `git commit --no-verify`; bump logic lives in `scripts/version_bump.py`.
## Key Files
- `marketplace.json`: Marketplace manifest - registers all plugins with source paths
+2 -2
View File
@@ -51,14 +51,14 @@ scripts/ # deploy-managed-agent.sh · check.py · validate
In Cowork, open **Settings → Plugins → Add plugin** and either:
- **Paste this repo URL** — `https://github.com/anthropics/claude-for-financial-services` — then pick the agents and verticals you want from the marketplace list, or
- **Paste this repo URL** — `https://github.com/anthropics/financial-services` — then pick the agents and verticals you want from the marketplace list, or
- **Upload a zip** — zip any directory under `plugins/` (e.g. `plugins/agent-plugins/pitch-agent/`) and drop it in.
### Claude Code
```bash
# Add the marketplace
claude plugin marketplace add anthropics/claude-for-financial-services
claude plugin marketplace add anthropics/financial-services
# Core skills + connectors (install first)
claude plugin install financial-analysis@claude-for-financial-services
@@ -1,7 +1,7 @@
{
"name": "claude-for-msft-365-install",
"description": "Provision direct cloud access (Vertex AI, Bedrock, or LLM gateway) for the Claude Office add-in. Generates the customized add-in manifest, walks through Azure admin consent, and writes per-user config via Microsoft Graph extension attributes.",
"version": "0.1.2",
"version": "0.1.3",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
+2 -2
View File
@@ -6,8 +6,8 @@ Admin tooling for configuring the Claude Office add-in to call your own cloud
## Install
```bash
claude plugin marketplace add anthropics/financial-services-plugins
claude plugin install claude-for-msft-365-install@financial-services-plugins
claude plugin marketplace add anthropics/financial-services
claude plugin install claude-for-msft-365-install@claude-for-financial-services
```
Then inside the session: `/claude-for-msft-365-install:setup`
+126 -12
View File
@@ -16,6 +16,7 @@ Ask the admin to describe the symptom. Route by answer:
| Updated the manifest but users still see old config | [Stale config after update](#stale-config-after-update) |
| Add-in shows "Connection failed" | [Read the error paste](#read-the-error-paste) |
| Add-in doesn't appear in Excel/PowerPoint at all | [Add-in not visible](#add-in-not-visible) |
| Want to test/iterate a manifest locally before deploying | [Sideload a manifest for local debugging](#sideload-a-manifest-for-local-debugging) |
| Sign-in popup fails or loops | [Admin consent](#admin-consent) |
| Need to see the browser console | [Opening browser devtools](#opening-browser-devtools-on-the-add-in) |
@@ -73,7 +74,7 @@ Two caches, two clocks:
| Layer | Who holds it | TTL | How to clear |
|---|---|---|---|
| Service | M365 Admin Center → Exchange Online → client | Up to **72h** for updates (24h for fresh deploys) | Wait, or redeploy with a fresh `<Id>` |
| Client | Office app's Wef folder on each machine | Until app restart, sometimes longer | Delete the folder |
| Client | Office app's Wef folder on each machine | Until app restart, sometimes longer | Clear the cached manifests (see below) |
Microsoft's own FAQ:
> It can take up to 72 hours for add-in updates, changes from turn on or turn off to reflect for users.
@@ -87,23 +88,78 @@ Admin Center → Integrated apps → your add-in → check the listed version.
### Force a client-side refresh
Quit Excel/PowerPoint first, then:
A stale **sideloaded** manifest is stored differently per platform:
- **macOS** — a file `<addin-id>.manifest-*.xml` in each app's
`Documents/wef` folder, alongside every other add-in.
- **Windows** — a **registry** value under
`HKCU:\SOFTWARE\Microsoft\Office\16.0\Wef\Developer` (there is no
per-add-in file to delete; clearing the `Wef` *folder* is a different,
blunter operation — see the caveat below).
Use the helper scripts. They target **only** your add-in's `<Id>` and do
direct `rm`/registry edits — they do **not** shell out to
`office-addin-dev-settings` (its removal path has burned us on customer
calls):
- macOS: [`scripts/clear-addin-cache.sh`](../scripts/clear-addin-cache.sh)
- Windows: [`scripts/clear-addin-cache.ps1`](../scripts/clear-addin-cache.ps1)
Quit Excel/Word/PowerPoint first. The scripts are **ID-first** — pass the
add-in `<Id>` directly (handy when iterating across new/multiple IDs); the
manifest path is an optional convenience that just reads `<Id>` for you.
**macOS:**
```bash
rm -rf ~/Library/Containers/com.microsoft.Excel/Data/Library/Caches/
rm -rf ~/Library/Containers/com.microsoft.Powerpoint/Data/Library/Caches/
rm -rf ~/Library/Containers/com.microsoft.Excel/Data/Documents/wef
rm -rf ~/Library/Containers/com.microsoft.Powerpoint/Data/Documents/wef
# macOS — list everything, do nothing:
./scripts/clear-addin-cache.sh
# Dry-run by ID (preferred), or via the manifest:
./scripts/clear-addin-cache.sh --id <GUID>
./scripts/clear-addin-cache.sh ~/path/to/manifest.xml
# Actually remove (only this ID's files):
./scripts/clear-addin-cache.sh --id <GUID> --apply
```
**Windows:**
```cmd
rd /s /q "%LOCALAPPDATA%\Microsoft\Office\16.0\Wef"
```powershell
# Windows — same flow, registry-scoped:
.\scripts\clear-addin-cache.ps1 # list, do nothing
.\scripts\clear-addin-cache.ps1 -Id <GUID> # dry-run
.\scripts\clear-addin-cache.ps1 -Id <GUID> -Apply
```
Relaunch. If still stale, the service-side cache hasn't caught up. Wait, or
use a fresh `<Id>` (below).
Both **dry-run by default** — nothing is removed without `--apply` /
`-Apply`. No-args lists every registered add-in so you confirm the ID
first. Other add-ins are never affected.
**You must fully restart the Office app after clearing.** Removing the
file/registry entry does nothing until the app re-reads it on launch — and
a *backgrounded* app counts as still running. Quit **and reopen** Excel /
Word / PowerPoint, confirming no lingering process first:
`pkill -f "Microsoft Excel"` (macOS) / check Task Manager (Windows). The
script also prints this reminder when it finishes.
**Deleting local/sideloaded manifests by ID is safe and works.** In
practice, removing just the one add-in's file (macOS) or registry value
(Windows) cleanly drops that add-in and leaves the rest loading normally —
we do this routinely. Microsoft's "don't delete individual files" warning
is about a *different* cache (below), not these local dev/sideload entries;
don't let it scare you off the surgical path here.
> **Centrally-deployed (Admin Center) staleness on Windows** is a
> *different* cache: `%LOCALAPPDATA%\Microsoft\Office\16.0\Wef\<guid>\…`,
> stored under opaque hashes, **not** by add-in ID. Microsoft's official
> guidance is conservative — clear that folder's contents as a whole
> because *"deleting individual manifest files can cause all add-ins to
> stop loading."* In practice targeted deletion there can work too, but
> the filenames aren't ID-mapped so it's hard to be surgical — which is
> why these scripts deliberately do **not** touch it. If a
> centrally-deployed update is
> stale, prefer waiting out the service TTL or redeploying with a fresh
> `<Id>` (below) over hand-deleting that cache.
If it's still stale after the restart, the service-side cache hasn't caught
up. Wait, or use a fresh `<Id>` (below).
Microsoft's cache-clear doc: https://learn.microsoft.com/en-us/office/dev/add-ins/testing/clear-cache
@@ -129,6 +185,64 @@ faster). Edit `manifest.xml`, replace the text inside `<Id>` with a new UUID
---
## Sideload a manifest for local debugging
For iterating on a manifest **without going through Admin Center deployment**
(no 2472h cache wait), point Office at a local manifest file directly. The
manifest stays wherever it is on disk; you just tell Office where to find it.
Pick the recipe for the customer's OS.
Use the helper scripts — they read the `<Id>` from the manifest and
install it the right way per platform (macOS: a `<Id>.manifest.xml` file in
each app's `Documents/wef`; Windows: a registry value under
`HKCU:\SOFTWARE\Microsoft\Office\16.0\Wef\Developer` named by the `<Id>`).
Both do direct file/registry writes — **not** `office-addin-dev-settings`.
- macOS install: [`scripts/sideload-addin.sh`](../scripts/sideload-addin.sh)
- Windows install: [`scripts/sideload-addin.ps1`](../scripts/sideload-addin.ps1)
- Remove (either OS): `clear-addin-cache.{sh,ps1}` — see
[Force a client-side refresh](#force-a-client-side-refresh)
```bash
# macOS — installs directly:
./scripts/sideload-addin.sh ~/path/to/manifest.xml
```
```powershell
# Windows — installs directly:
.\scripts\sideload-addin.ps1 C:\path\to\manifest.xml
```
Sideloading is additive and idempotent, so it installs directly — **no
dry-run** (unlike the destructive `clear-addin-cache`, which stays dry-run
by default). The install names the entry by the add-in `<Id>`, so removal
later is the exact inverse: `clear-addin-cache.{sh,ps1} --id <GUID>
--apply` (the sideload script prints the precise remove command on
completion).
Then **fully quit and reopen** Excel / Word / PowerPoint — check Task
Manager (Windows) / `pkill -f "Microsoft Excel"` (macOS) first; a
backgrounded app won't re-read the registry or rescan the folder. The
add-in appears under **Insert → My Add-ins** (Windows also shows it on the
**Home** tab / **Shared Folder** group); pin it.
**Notes (both platforms):**
- This is per-user and per-machine — it doesn't touch tenant deployment. It's
purely for the customer to debug/iterate on their own box.
- A locally sideloaded manifest **wins over** a centrally deployed one with
the same `<Id>`, so this is also a fast way to test a manifest fix before
re-uploading to Admin Center.
- Pair this with [browser devtools](#opening-browser-devtools-on-the-add-in)
to see console/network while iterating.
- If a stale copy keeps loading, also clear the cache — see
[Force a client-side refresh](#force-a-client-side-refresh).
Microsoft's sideloading references:
- Windows: https://learn.microsoft.com/en-us/office/dev/add-ins/testing/create-a-network-shared-folder-catalog-for-task-pane-and-content-add-ins
- macOS: https://learn.microsoft.com/en-us/office/dev/add-ins/testing/sideload-an-office-add-in-on-mac
---
## Admin consent
If the user sees a sign-in popup that closes immediately or loops, the tenant
@@ -0,0 +1,96 @@
<#
.SYNOPSIS
Remove a sideloaded Office add-in's dev registration on Windows.
.DESCRIPTION
On Windows, sideloaded (developer) add-ins are NOT files in a Wef folder —
office-addin-dev-settings registers them as registry values under
HKCU:\SOFTWARE\Microsoft\Office\16.0\Wef\Developer. Each value's name is
either the add-in <Id> or the manifest path; its data is the manifest path.
Per-add-in settings live in a subkey Developer\<Id>.
This removes ONLY the registration(s) matching one add-in ID (the Developer
value whose name == ID or whose manifest data has that <Id>, plus the
Developer\<Id> settings subkey). Other add-ins are untouched.
NOTE: This targets the developer/sideload registry — the analog of the
macOS Documents/wef files. It does NOT touch the centrally-deployed
manifest cache (%LOCALAPPDATA%\Microsoft\Office\16.0\Wef\<guid>\...),
which Microsoft says must be cleared as a whole folder, never per-file
("deleting individual manifest files can stop all add-ins from loading").
.EXAMPLE
clear-addin-cache.ps1 # list every sideloaded add-in, do nothing
clear-addin-cache.ps1 -Id <GUID> # dry-run: show what would be removed
clear-addin-cache.ps1 -Manifest C:\m.xml # dry-run, read <Id> from the manifest
clear-addin-cache.ps1 -Id <GUID> -Apply # actually remove the registration
#>
[CmdletBinding()]
param(
[string]$Id,
[string]$Manifest,
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$devKey = 'HKCU:\SOFTWARE\Microsoft\Office\16.0\Wef\Developer'
if (-not (Test-Path $devKey)) {
Write-Host "No developer key at $devKey — nothing is sideloaded on Windows."
return
}
function Get-Registrations {
$props = Get-ItemProperty -Path $devKey
$props.PSObject.Properties |
Where-Object { $_.Name -notmatch '^PS' -and $_.Name -ne 'RefreshAddins' } |
ForEach-Object { [pscustomobject]@{ Name = $_.Name; Data = $_.Value } }
}
# Resolve the add-in <Id> from a manifest if -Id wasn't given.
if (-not $Id -and $Manifest) {
if (-not (Test-Path $Manifest)) { throw "manifest not found: $Manifest" }
$Id = ([xml](Get-Content $Manifest)).OfficeApp.Id
}
# No ID at all -> list what's registered and exit (no deletion).
if (-not $Id) {
Write-Host "Sideloaded add-ins registered under Developer (name -> manifest):"
$regs = Get-Registrations
if (-not $regs) { Write-Host " (none)" }
foreach ($r in $regs) {
$guid = ''
if (Test-Path $r.Data) { try { $guid = ([xml](Get-Content $r.Data)).OfficeApp.Id } catch {} }
" {0} -> {1}{2}" -f $r.Name, $r.Data, $(if ($guid) { " [<Id> $guid]" } else { '' })
}
Write-Host "`nRe-run with -Id <GUID> (or -Manifest <path>) to remove one (add -Apply to delete)."
return
}
# Match a Developer value whose name IS the ID, or whose manifest data has that <Id>.
$toRemove = @()
foreach ($r in (Get-Registrations)) {
if ($r.Name -ieq $Id) { $toRemove += $r; continue }
if (Test-Path $r.Data) {
try { if ((([xml](Get-Content $r.Data)).OfficeApp.Id) -ieq $Id) { $toRemove += $r } } catch {}
}
}
$settingsSubkey = Join-Path $devKey $Id
$hasSubkey = Test-Path $settingsSubkey
if ($Apply) { Write-Host "Removing sideload registration for add-in $Id" }
else { Write-Host "DRY RUN — would remove (re-run with -Apply to delete):" }
if (-not $toRemove -and -not $hasSubkey) {
Write-Host " (nothing registered for $Id — already clear)"
} else {
foreach ($r in $toRemove) {
if ($Apply) { Remove-ItemProperty -Path $devKey -Name $r.Name -Force; Write-Host " removed value: $($r.Name) -> $($r.Data)" }
else { Write-Host " would remove value: $($r.Name) -> $($r.Data)" }
}
if ($hasSubkey) {
if ($Apply) { Remove-Item -Path $settingsSubkey -Recurse -Force; Write-Host " removed settings subkey: $settingsSubkey" }
else { Write-Host " would remove settings subkey: $settingsSubkey" }
}
}
Write-Host "Quit and reopen the Office apps so they re-read the registry."
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Clear a single Office add-in's cached / sideloaded manifest on macOS.
#
# The Wef cache holds every add-in side by side, each file named
# <addin-id>.manifest-*.xml. This removes ONLY the files matching one
# add-in ID across Excel/Word/PowerPoint -- it never wipes the folder.
#
# Usage:
# clear-addin-cache.sh # list every add-in found, do nothing
# clear-addin-cache.sh /path/to/manifest.xml # dry-run: show what would be removed
# clear-addin-cache.sh --id <GUID> # dry-run by ID (no manifest needed)
# clear-addin-cache.sh /path/manifest.xml --apply # actually delete
set -euo pipefail
APPS=(Excel Word Powerpoint)
wef_dir() { echo "$HOME/Library/Containers/com.microsoft.$1/Data/Documents/wef"; }
MANIFEST="" ADDIN_ID="" APPLY=0
while [ $# -gt 0 ]; do
case "$1" in
--id) ADDIN_ID="${2:-}"; shift 2 ;;
--apply) APPLY=1; shift ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) MANIFEST="$1"; shift ;;
esac
done
# No args -> just list what's cached, then exit.
if [ -z "$MANIFEST" ] && [ -z "$ADDIN_ID" ]; then
echo "Add-ins currently cached in wef (id <- filename):"
for app in "${APPS[@]}"; do
d="$(wef_dir "$app")"; [ -d "$d" ] || continue
echo " [$app]"
for f in "$d"/*.xml; do
[ -f "$f" ] || continue
b="$(basename "$f")"; printf " %s <- %s\n" "${b%%.*}" "$b"
done
done
echo
echo "Re-run with the manifest path or --id <GUID> to clear one (add --apply to delete)."
exit 0
fi
# Resolve the add-in ID from the manifest if not given explicitly.
if [ -z "$ADDIN_ID" ]; then
[ -f "$MANIFEST" ] || { echo "ERROR: manifest not found: $MANIFEST" >&2; exit 1; }
ADDIN_ID="$(xmllint --xpath 'string(/*[local-name()="OfficeApp"]/*[local-name()="Id"])' "$MANIFEST" 2>/dev/null \
|| grep -oE '<Id>[^<]+</Id>' "$MANIFEST" | head -1 | sed -E 's#</?Id>##g')"
fi
[ -n "$ADDIN_ID" ] || { echo "ERROR: could not determine add-in ID" >&2; exit 1; }
[ "$APPLY" -eq 1 ] && echo "Removing cached/sideloaded manifests for add-in $ADDIN_ID" \
|| echo "DRY RUN -- would remove these (re-run with --apply to delete):"
found=0
for app in "${APPS[@]}"; do
d="$(wef_dir "$app")"; [ -d "$d" ] || continue
for f in "$d/$ADDIN_ID."*.xml "$d/$ADDIN_ID.xml"; do
[ -f "$f" ] || continue
found=1
if [ "$APPLY" -eq 1 ]; then rm -f "$f" && echo " removed $f"
else echo " would remove $f"; fi
done
done
[ "$found" -eq 0 ] && echo " (nothing found for $ADDIN_ID -- already clear)"
echo "Quit and reopen the Office apps so they re-fetch the manifest."
@@ -0,0 +1,42 @@
<#
.SYNOPSIS
Sideload an Office add-in manifest for local debugging on Windows.
.DESCRIPTION
Registers the manifest as a developer add-in via a registry value under
HKCU:\SOFTWARE\Microsoft\Office\16.0\Wef\Developer whose NAME is the
add-in <Id> and whose DATA is the absolute manifest path. Naming the
value by <Id> means clear-addin-cache.ps1 -Id <GUID> removes it cleanly.
This writes the registry value directly — it does NOT use
office-addin-dev-settings.
Sideloading is additive and idempotent — it installs directly (no
dry-run). Reverse it any time with clear-addin-cache.ps1 -Id <GUID> -Apply.
.EXAMPLE
sideload-addin.ps1 C:\path\to\manifest.xml
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)][string]$Manifest
)
$ErrorActionPreference = 'Stop'
$devKey = 'HKCU:\SOFTWARE\Microsoft\Office\16.0\Wef\Developer'
if (-not (Test-Path $Manifest)) { throw "manifest not found: $Manifest" }
$manifestPath = (Resolve-Path $Manifest).Path
$addinId = ([xml](Get-Content $manifestPath)).OfficeApp.Id
if (-not $addinId) { throw "could not read <Id> from manifest" }
Write-Host "Sideloading add-in $addinId"
if (-not (Test-Path $devKey)) { New-Item -Path $devKey -Force | Out-Null }
New-ItemProperty -Path $devKey -Name $addinId -Value $manifestPath `
-PropertyType String -Force | Out-Null
Write-Host " registered $devKey\$addinId -> $manifestPath"
Write-Host "Quit and reopen Excel/Word/PowerPoint. The add-in appears under"
Write-Host "Insert -> My Add-ins. Remove later with:"
Write-Host " .\clear-addin-cache.ps1 -Id $addinId -Apply"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Sideload an Office add-in manifest for local debugging on macOS.
#
# Copies the manifest into each app's Documents/wef folder named
# <addin-id>.manifest.xml, so it (a) loads in Excel/Word/PowerPoint and
# (b) is later removable by ID with clear-addin-cache.sh.
#
# This does direct file copies — it does NOT use office-addin-dev-settings.
#
# Sideloading is additive and idempotent — it installs directly (no
# dry-run). Reverse it any time with clear-addin-cache.sh --id <GUID> --apply.
#
# Usage:
# sideload-addin.sh /path/to/manifest.xml
set -euo pipefail
APPS=(Excel Word Powerpoint)
wef_dir() { echo "$HOME/Library/Containers/com.microsoft.$1/Data/Documents/wef"; }
MANIFEST=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) MANIFEST="$1"; shift ;;
esac
done
[ -n "$MANIFEST" ] || { echo "usage: sideload-addin.sh /path/to/manifest.xml" >&2; exit 1; }
[ -f "$MANIFEST" ] || { echo "ERROR: manifest not found: $MANIFEST" >&2; exit 1; }
ADDIN_ID="$(xmllint --xpath 'string(/*[local-name()="OfficeApp"]/*[local-name()="Id"])' "$MANIFEST" 2>/dev/null \
|| grep -oE '<Id>[^<]+</Id>' "$MANIFEST" | head -1 | sed -E 's#</?Id>##g')"
[ -n "$ADDIN_ID" ] || { echo "ERROR: could not read <Id> from manifest" >&2; exit 1; }
DEST_NAME="$ADDIN_ID.manifest.xml"
echo "Sideloading add-in $ADDIN_ID"
for app in "${APPS[@]}"; do
d="$(wef_dir "$app")"
mkdir -p "$d"
cp "$MANIFEST" "$d/$DEST_NAME"
echo " installed $d/$DEST_NAME"
done
echo "Quit and reopen Excel/Word/PowerPoint. The add-in appears under"
echo "Insert -> My Add-ins. Remove later with:"
echo " ./clear-addin-cache.sh --id $ADDIN_ID --apply"
+34 -6
View File
@@ -13,15 +13,10 @@ Checks:
Exit 0 if clean, 1 otherwise. Requires: pyyaml.
"""
import json
import subprocess
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("ERROR: requires pyyaml (pip install pyyaml)", file=sys.stderr)
sys.exit(2)
ROOT = Path(__file__).resolve().parents[1]
PLUGINS = ROOT / "plugins"
MANAGED = ROOT / "managed-agent-cookbooks"
@@ -29,6 +24,39 @@ errors: list[str] = []
checked = 0
def ensure_hooks_installed() -> None:
"""Point git at .githooks so the version-bump pre-commit runs.
Native equivalent of Husky's `prepare`, piggybacked on the script
everyone already runs before committing. Best-effort: never fatal.
"""
want = ".githooks"
try:
cur = subprocess.run(
["git", "-C", str(ROOT), "config", "--get", "core.hooksPath"],
capture_output=True, text=True,
).stdout.strip()
if cur != want:
subprocess.run(
["git", "-C", str(ROOT), "config", "core.hooksPath", want],
check=True, capture_output=True,
)
print(f"[check.py] installed git hooks (core.hooksPath -> {want})")
except (subprocess.SubprocessError, OSError):
pass # not a git checkout / git unavailable — ignore
# Install hooks before anything that can exit early (e.g. missing pyyaml),
# so a fresh checkout still gets the version-bump hook wired up.
ensure_hooks_installed()
try:
import yaml
except ImportError:
print("ERROR: requires pyyaml (pip install pyyaml)", file=sys.stderr)
sys.exit(2)
def err(msg: str) -> None:
errors.append(msg)
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
Single source of truth for plugin version-bump enforcement.
A plugin's `.claude-plugin/plugin.json` `version` gates update delivery to
already-installed users (Claude Code only re-delivers a plugin when its
version changes). This script guarantees that any plugin modified on a
branch ends up exactly one patch ahead of `main` — bumped once, not once
per commit.
Modes
--apply Mutating. For every plugin with staged changes whose version is
not yet ahead of the base ref, patch-bump it (base + 1) and
`git add` the result. Idempotent: a plugin already ahead of base
is left untouched, so repeated commits on a branch bump once.
Used by .githooks/pre-commit.
--check Read-only. For every plugin changed between the base ref and
HEAD, fail (exit 1) if its version is not strictly greater than
the base ref's version. Used by .github/workflows/version-bump.yml
as a backstop for contributors without the local hook.
Base ref resolution (in order): explicit --base, origin/main, main.
Exit 0 clean, 1 on a --check violation, 2 on an internal error.
Requires: git, python3 (stdlib only).
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def git(*args: str) -> str:
"""Run a git command, returning stdout (stripped). Raises on failure."""
return subprocess.run(
["git", "-C", str(ROOT), *args],
capture_output=True,
text=True,
check=True,
).stdout.strip()
def git_ok(*args: str) -> str | None:
"""Run a git command, returning stdout or None if it fails."""
try:
return git(*args)
except subprocess.CalledProcessError:
return None
def resolve_base(explicit: str | None) -> str | None:
for ref in (explicit, "origin/main", "main"):
if ref and git_ok("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"):
return ref
return None
def all_plugin_jsons() -> list[Path]:
"""Every <plugin>/.claude-plugin/plugin.json in the repo."""
return sorted(
p for p in ROOT.glob("**/.claude-plugin/plugin.json")
if ".git/" not in str(p)
)
def plugin_root(plugin_json: Path) -> Path:
# <root>/.claude-plugin/plugin.json -> <root>
return plugin_json.parent.parent
def rel(p: Path) -> str:
return str(p.relative_to(ROOT))
def parse_semver(v: str) -> tuple[int, int, int] | None:
"""Parse 'x.y.z' into a comparable tuple. None if not parseable."""
parts = (v or "").split(".")
if len(parts) != 3:
return None
try:
return tuple(int(x) for x in parts) # type: ignore[return-value]
except ValueError:
return None
def patch_bump(v: str) -> str:
sv = parse_semver(v)
if sv is None:
# Unparseable base — start a clean patch series.
return "0.0.1"
return f"{sv[0]}.{sv[1]}.{sv[2] + 1}"
def base_version(base: str, plugin_json: Path) -> str | None:
"""The plugin's version at the base ref, or None if it didn't exist there."""
raw = git_ok("show", f"{base}:{rel(plugin_json)}")
if raw is None:
return None
try:
return json.loads(raw).get("version")
except json.JSONDecodeError:
return None
def working_version(plugin_json: Path) -> str | None:
try:
return json.loads(plugin_json.read_text()).get("version")
except (OSError, json.JSONDecodeError):
return None
def is_ahead(work: str | None, base: str | None) -> bool:
"""True if the working version is strictly greater than the base version."""
if base is None:
# Plugin is new on this branch — nothing to be 'ahead' of.
return True
wv, bv = parse_semver(work or ""), parse_semver(base)
if wv is None or bv is None:
# Can't compare numerically — fall back to 'changed string == bump'.
return (work or "") != base
return wv > bv
def changed_plugins(base: str, staged_only: bool) -> list[Path]:
"""Plugin roots touched relative to base (staged set, or base...HEAD)."""
if staged_only:
files = git_ok("diff", "--cached", "--name-only") or ""
else:
files = git_ok("diff", "--name-only", f"{base}...HEAD") or ""
changed = {Path(line) for line in files.splitlines() if line}
hits: list[Path] = []
for pj in all_plugin_jsons():
root_rel = Path(rel(plugin_root(pj)))
if any(
c == root_rel or root_rel in c.parents or str(c).startswith(f"{root_rel}/")
for c in changed
):
hits.append(pj)
return hits
def cmd_apply(base: str) -> int:
bumped = []
for pj in changed_plugins(base, staged_only=True):
work = working_version(pj)
bv = base_version(base, pj)
if is_ahead(work, bv):
continue # already bumped on this branch — idempotent no-op
new = patch_bump(bv or work or "0.0.0")
data = json.loads(pj.read_text())
data["version"] = new
pj.write_text(json.dumps(data, indent=2) + "\n")
git("add", rel(pj))
bumped.append((rel(plugin_root(pj)), bv, new))
for name, old, new in bumped:
print(f"[version-bump] {name}: {old or '(new)'} -> {new}")
return 0
def cmd_check(base: str) -> int:
violations = []
for pj in changed_plugins(base, staged_only=False):
work = working_version(pj)
bv = base_version(base, pj)
if not is_ahead(work, bv):
violations.append(
f"{rel(plugin_root(pj))}: changed but version not bumped "
f"({bv} -> {work}). Bump .claude-plugin/plugin.json version "
f"(or run scripts/check.py once to install the pre-commit hook)."
)
if violations:
print(
f"FAIL — {len(violations)} plugin(s) changed without a version bump:\n",
file=sys.stderr,
)
for v in violations:
print(f"{v}", file=sys.stderr)
return 1
print("OK — all changed plugins have a version bump.")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="Plugin version-bump enforcement.")
mode = ap.add_mutually_exclusive_group(required=True)
mode.add_argument("--apply", action="store_true", help="bump staged plugins (pre-commit)")
mode.add_argument("--check", action="store_true", help="verify changed plugins are bumped (CI)")
ap.add_argument("--base", help="base ref (default: origin/main, then main)")
args = ap.parse_args()
base = resolve_base(args.base)
if base is None:
# No base to compare against (e.g. fresh shallow clone offline).
# Never block a commit over this; CI has full history as the backstop.
print("[version-bump] no base ref found; skipping.", file=sys.stderr)
return 0
return cmd_apply(base) if args.apply else cmd_check(base)
if __name__ == "__main__":
sys.exit(main())