mirror of
https://github.com/anthropics/financial-services.git
synced 2026-09-28 13:24:31 +08:00
* 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.
220 lines
8.2 KiB
Python
Executable File
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.")
|