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:
Executable
+18
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 24–72h 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."
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Executable
+209
@@ -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())
|
||||
Reference in New Issue
Block a user