Files
Omar Mihilmy eb0c1ea962 Add read-only add-in data export; harden cache-clear scripts (#302)
* Add read-only add-in data export; harden cache-clear scripts

Chat history, skills, MCP registrations and memory live only in browser
storage on the user's machine. Add export-addin-data.{sh,ps1} to copy them
before a rebuild, and fix the cache-clear scripts that sit next to them.

clear-addin-cache.sh matched the add-in ID case-sensitively, but Office names
the wef file with whatever casing the manifest <Id> carried. A lowercase GUID
reported "already clear" and exited 0, sending admins to the folder-wide wipe
that destroys chat history on Windows. Match case-insensitively, and make a
miss print NOT cleared and exit non-zero on both platforms.

clear-addin-cache.ps1 never parsed on Windows PowerShell 5.1: em dashes in a
BOM-less .ps1 are decoded as ANSI into mojibake containing a quote, which
terminates a string mid-file. All .ps1 files are now pure ASCII, and check.py
rejects non-ASCII bytes in a .ps1 without a UTF-8 BOM.

Also:
- export: parse the port out of WebKit's binary origin file. Dropping it made
  two origins on the same host collide in one export folder, silently
  overwriting the first.
- export: include localStorage, which holds settings, the inference config,
  and the onboarding/terms flags.
- export: exporting nothing while --out was given now exits non-zero, so
  "export then wipe" cannot treat an empty backup as success.
- export: a failed database copy no longer aborts the run; paths containing
  an apostrophe work.
- export.ps1: clear the destination before Copy-Item -Recurse, which nested
  instead of refreshing on a re-run; add -IncludeAll for stores whose names
  compaction has hidden; scan uncompressed LevelDB files first.
- clear.ps1: require a GUID for -Id, since Remove-Item -Path expands wildcards;
  escape value names for Remove-ItemProperty.
- clear.sh: scan Outlook too, and guard flags that take a value.
- Document why replacing or reinstalling a manifest never moves the data.

Verified on macOS against live add-in data, and on a Windows host against
Windows PowerShell 5.1.

* Export Windows localStorage as the whole per-profile store

localStorage holds the settings, the inference/customer config, and the
onboarding + terms-accepted flags -- none of it in IndexedDB -- so an export
without it left a rebuilt machine re-doing onboarding.

Chromium keeps one LevelDB per profile shared by every origin, so it cannot be
filtered down to one add-in. Copy it whole rather than drop it, once per Office
account, beside that account's origin folders. It therefore also contains other
origins' settings: the size is printed on every run including the argument-less
preview, the help text says so plainly, and the folder can simply be deleted
from the export if a data policy forbids it.

- Copy to a scratch name and swap only on success. Deleting the destination
  first meant a locked LOCK file (Office still running) left the export with
  neither the new copy nor the previous good one. A failure is now counted and
  reported as INCOMPLETE with a non-zero exit, matching the macOS script.
- Drive the copy off the unique profile list instead of the per-store loop, so
  a profile whose only store fails the origin-name parse keeps its settings.
- Report in list mode too, matching macOS -- the argument-less run is
  advertised as printing what it found.
- .SYNOPSIS/.DESCRIPTION no longer claim only Claude's data is copied.
- Correct the export-data doc: the export tree is relabelled for readability,
  not a path mirror, so it cannot be copied back verbatim.

Verified on a Windows host against Windows PowerShell 5.1: copied once per
profile, flat on re-run, real LevelDB contents present, disclosed in list mode
without writing anything.
2026-07-22 12:12:21 -04:00

220 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Lint all plugin + managed-agent manifests and verify cross-file references.
Checks:
1. Every *.yaml under managed-agents/ parses.
2. Every plugin.json / marketplace.json / steering-examples.json parses.
3. Every <vertical>/agents/*.md has valid YAML frontmatter with name + description.
4. Every system.file, skills[].path, callable_agents[].manifest in agent.yaml
and subagent yamls resolves to an existing file/dir.
5. Every managed-agents/<slug>/ has agent.yaml, README.md, steering-examples.json.
Exit 0 if clean, 1 otherwise. Requires: pyyaml.
"""
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PLUGINS = ROOT / "plugins"
MANAGED = ROOT / "managed-agent-cookbooks"
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)
def rel(p: Path) -> str:
return str(p.relative_to(ROOT))
# --- 1. YAML parse ----------------------------------------------------------
for yml in sorted(MANAGED.rglob("*.yaml")):
checked += 1
try:
with open(yml) as f:
yaml.safe_load(f)
except yaml.YAMLError as e:
err(f"YAML parse: {rel(yml)}: {e}")
# --- 2. JSON parse ----------------------------------------------------------
json_globs = [
".claude-plugin/marketplace.json",
"plugins/**/.claude-plugin/plugin.json",
"managed-agent-cookbooks/*/steering-examples.json",
]
for pat in json_globs:
for jf in sorted(ROOT.glob(pat)):
checked += 1
try:
json.loads(jf.read_text())
except json.JSONDecodeError as e:
err(f"JSON parse: {rel(jf)}: {e}")
# --- 3. agent.md frontmatter -----------------------------------------------
for md in sorted(PLUGINS.glob("agent-plugins/*/agents/*.md")):
checked += 1
text = md.read_text()
if not text.startswith("---"):
err(f"frontmatter: {rel(md)}: missing leading ---")
continue
try:
_, fm, _ = text.split("---", 2)
meta = yaml.safe_load(fm)
for k in ("name", "description"):
if k not in meta:
err(f"frontmatter: {rel(md)}: missing '{k}'")
except (ValueError, yaml.YAMLError) as e:
err(f"frontmatter: {rel(md)}: {e}")
# --- 4. reference resolution -----------------------------------------------
def check_refs(yml: Path) -> None:
try:
data = yaml.safe_load(yml.read_text()) or {}
except yaml.YAMLError:
return # already reported above
base = yml.parent
sys_spec = data.get("system")
if isinstance(sys_spec, dict) and "file" in sys_spec:
p = (base / sys_spec["file"]).resolve()
if not p.is_file():
err(f"ref: {rel(yml)}: system.file -> {sys_spec['file']} (not found)")
for s in data.get("skills") or []:
if isinstance(s, dict) and "path" in s:
p = (base / s["path"]).resolve()
if not p.exists():
err(f"ref: {rel(yml)}: skills.path -> {s['path']} (not found)")
if isinstance(s, dict) and "from_plugin" in s:
p = (base / s["from_plugin"]).resolve()
if not (p / "skills").is_dir():
err(f"ref: {rel(yml)}: skills.from_plugin -> {s['from_plugin']} (no skills/ dir)")
for c in data.get("callable_agents") or []:
if isinstance(c, dict) and "manifest" in c:
p = (base / c["manifest"]).resolve()
if not p.is_file():
err(f"ref: {rel(yml)}: callable_agents.manifest -> {c['manifest']} (not found)")
for yml in sorted(MANAGED.rglob("*.yaml")):
check_refs(yml)
# --- 4b. agent-plugin bundled skills match vertical source -----------------
import filecmp # noqa: E402
import re # noqa: E402
src_by_name = {p.name: p for p in PLUGINS.glob("vertical-plugins/*/skills/*") if p.is_dir()}
for bundled in sorted(PLUGINS.glob("agent-plugins/*/skills/*")):
if not bundled.is_dir():
continue
src = src_by_name.get(bundled.name)
if not src:
err(f"bundled-skill: {rel(bundled)}: no vertical-plugins source named '{bundled.name}'")
continue
cmp = filecmp.dircmp(src, bundled)
if cmp.diff_files or cmp.left_only or cmp.right_only:
err(
f"bundled-skill: {rel(bundled)}: drifted from {rel(src)} "
f"(run scripts/sync-agent-skills.py)"
)
# --- 4b2. agent.md skill references exist in the agent's own bundle --------
for md in sorted(PLUGINS.glob("agent-plugins/*/agents/*.md")):
slug = md.parents[1].name
sk_dir = PLUGINS / "agent-plugins" / slug / "skills"
bundle = {p.name for p in sk_dir.iterdir() if p.is_dir()} if sk_dir.is_dir() else set()
for ref in set(re.findall(r"`([a-z0-9]+(?:-[a-z0-9]+)+)`", md.read_text())):
if ref in src_by_name and ref not in bundle:
err(
f"agent-prose: {rel(md)}: references `{ref}` but "
f"plugins/agent-plugins/{slug}/skills/{ref}/ is not bundled"
)
# --- 4c. marketplace source paths resolve ----------------------------------
mp = ROOT / ".claude-plugin" / "marketplace.json"
for p in json.loads(mp.read_text()).get("plugins", []):
src = (ROOT / p["source"]).resolve()
if not (src / ".claude-plugin" / "plugin.json").is_file():
err(f"marketplace: {p['name']} source -> {p['source']} (no plugin.json)")
# --- 5. required files per managed-agent -----------------------------------
for d in sorted(MANAGED.iterdir()):
if not d.is_dir():
continue
for req in ("agent.yaml", "README.md", "steering-examples.json"):
if not (d / req).is_file():
err(f"missing: {rel(d)}/{req}")
# --- 6. PowerShell scripts must be pure ASCII -------------------------------
# Windows PowerShell 5.1 -- still the default shell on managed Windows -- reads
# a .ps1 with no BOM using the machine's ANSI code page, not UTF-8. A smart dash
# or curly quote then decodes to mojibake that can contain a literal '"',
# which terminates a string mid-file and makes the whole script fail to PARSE.
# It is invisible on macOS and total on Windows, so gate it here.
ASCII_ONLY_SUFFIXES = {".ps1", ".psm1", ".psd1"}
for ps in sorted(ROOT.rglob("*.ps1")):
if any(part in {".git", "node_modules"} for part in ps.parts):
continue
checked += 1
raw = ps.read_bytes()
if raw.startswith(b"\xef\xbb\xbf"):
continue # an explicit BOM tells PS 5.1 it is UTF-8; then non-ASCII is fine
for lineno, line in enumerate(raw.split(b"\n"), 1):
bad = sorted({b for b in line if b > 0x7F})
if bad:
chars = ", ".join(f"0x{b:02x}" for b in bad[:5])
err(
f"non-ascii: {rel(ps)}:{lineno}: byte(s) {chars} in a .ps1 with no "
f"UTF-8 BOM -- Windows PowerShell 5.1 will mis-decode this and may "
f"fail to parse the file. Use ASCII (-- for an em dash) or add a BOM."
)
break
# --- report ----------------------------------------------------------------
if errors:
print(f"FAIL — {len(errors)} issue(s) across {checked} file(s):\n", file=sys.stderr)
for e in errors:
print(f" ✗ {e}", file=sys.stderr)
sys.exit(1)
print(f"OK — {checked} file(s) checked, 0 issues.")