Files
claude-plugin-inference-arb…/bin/plugin-inventory
T

440 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Static pass over a Claude Code plugin's definitions -> one JSON document.
Usage: plugin-inventory <path|name> [--json] [--catalog PATH]
Reads a plugin's skills/, agents/, hooks/, and bin/, and reports the static
signals defined in design/spec.md §5.1 (verb ratios, procedural density,
duplicated command blocks, rule tables, script coverage, tool-allowlist
shape). Stdlib only, offline, no PyYAML — this file itself is the plugin's
own thesis (FR-8): reading a plugin's structure is a pure-script step by
every one of the five determinism tests in references/boundary-rubric.md.
No --json: pretty-printed (indent=2), for humans.
--json: compact single line, for piping into bin/offload-scan or tests.
"""
import argparse
import glob
import json
import os
import re
import sys
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_CATALOG = os.path.join(PLUGIN_ROOT, "references", "signals-catalog.md")
CACHE_GLOB = os.path.expanduser("~/.claude/plugins/cache/*/{name}/*/")
# --------------------------------------------------------------------------
# signals-catalog.md loader
# --------------------------------------------------------------------------
KNOWN_SECTIONS = {"script-verbs", "judgment-verbs", "mechanical-tools", "thresholds"}
def load_catalog(path):
"""Reads only the fenced code block immediately following each
recognized `## <section>` heading; everything else is prose for humans
and is never parsed (see references/signals-catalog.md's own header for
why: markdown and a hand-rolled comment syntax collide on bare `#`)."""
with open(path, encoding="utf-8") as f:
text = f.read()
sections = {}
heading_re = re.compile(r"^##\s+([a-z-]+)\s*$", re.MULTILINE)
for m in heading_re.finditer(text):
name = m.group(1)
if name not in KNOWN_SECTIONS:
continue
block = re.search(r"```\S*\n(.*?)```", text[m.end():], re.DOTALL)
if not block:
continue
body = block.group(1)
if name == "thresholds":
values = {}
for raw in body.split("\n"):
line = raw.split("#", 1)[0].strip()
if ":" in line:
k, v = line.split(":", 1)
values[k.strip()] = float(v.strip())
sections[name] = values
else:
items = set()
for raw in body.split("\n"):
for item in raw.split(","):
item = item.strip()
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", item):
items.add(item)
sections[name] = items
missing = KNOWN_SECTIONS - sections.keys()
if missing:
sys.exit(f"error: {path} is missing section(s): {', '.join(sorted(missing))}")
return sections
# --------------------------------------------------------------------------
# target resolution
# --------------------------------------------------------------------------
def semver_key(dirname):
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", dirname)
return tuple(int(g) for g in m.groups()) if m else (0, 0, 0)
def resolve_target(target):
if os.sep in target or os.path.isdir(target):
path = os.path.abspath(target)
if not os.path.isdir(path):
sys.exit(f"error: no such directory: {path}")
return path, "path"
candidates = []
for entry in glob.glob(CACHE_GLOB.format(name=re.escape(target)).replace(r"\*", "*")):
if os.path.exists(os.path.join(entry, ".orphaned_at")):
continue
candidates.append(entry.rstrip("/"))
if not candidates:
sys.exit(f"error: no cached plugin found for name {target!r} "
f"(and not a directory)")
candidates.sort(key=lambda p: semver_key(os.path.basename(p)))
return candidates[-1], "oleks-local"
# --------------------------------------------------------------------------
# frontmatter (no PyYAML — this environment's SKILL.md/agent.md frontmatter
# is a narrow subset: scalar `key: value` lines and `key: |` block scalars)
# --------------------------------------------------------------------------
def parse_frontmatter(text):
if not text.startswith("---"):
return {}, text
lines = text.split("\n")
if lines[0].strip() != "---":
return {}, text
end = None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end = i
break
if end is None:
return {}, text
meta = {}
i = 1
while i < end:
line = lines[i]
if not line.strip() or line.lstrip().startswith("#"):
i += 1
continue
m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line)
if not m:
i += 1
continue
key, val = m.group(1), m.group(2).strip()
if val in ("|", "|-", ">", ">-"):
block = []
i += 1
while i < end and (lines[i].startswith(" ") or lines[i].startswith("\t") or not lines[i].strip()):
block.append(lines[i].strip())
i += 1
meta[key] = " ".join(b for b in block if b)
continue
meta[key] = val.strip('"\'')
i += 1
body = "\n".join(lines[end + 1:])
return meta, body
def split_list(value):
if not value:
return []
return [v.strip() for v in value.split(",") if v.strip()]
# --------------------------------------------------------------------------
# signal computation
# --------------------------------------------------------------------------
def verb_counts(body, verbs):
total = 0
for verb in verbs:
total += len(re.findall(r"\b" + re.escape(verb) + r"\b", body, re.IGNORECASE))
return total
def procedural_density(body):
list_item_re = re.compile(r"^\s*(?:\d+[.)]|[-*])\s+.+$", re.MULTILINE)
items = list_item_re.findall(body)
if not items:
return 0.0
with_command = sum(1 for item in items if "`" in item)
return round(with_command / len(items), 3)
def extract_command_blocks(body):
blocks = re.findall(r"```(?:bash|sh|shell)?\n(.*?)```", body, re.DOTALL)
sigs = {}
for block in blocks:
first_line = next((ln.strip() for ln in block.splitlines() if ln.strip()), None)
if not first_line:
continue
sig = re.sub(r"\s+", " ", first_line)
sigs[sig] = sigs.get(sig, 0) + 1
return [{"sig": sig, "count": count} for sig, count in sorted(sigs.items())]
def count_rule_tables(body):
return len(re.findall(r"^\|.+\|\s*\n\|[\s:|-]+\|\s*$", body, re.MULTILINE))
def find_references(body, references_dir):
if not os.path.isdir(references_dir):
return []
known = set(os.listdir(references_dir))
found = set()
for m in re.finditer(r"([A-Za-z0-9_-]+\.md)", body):
if m.group(1) in known:
found.add(m.group(1))
return sorted(found)
def wc(text):
return len(text.split())
# --------------------------------------------------------------------------
# per-plugin walk
# --------------------------------------------------------------------------
def walk_skills(plugin_path, catalog):
"""Returns (skills, bodies) — bodies maps skill name -> body text, kept
out of the JSON output but needed by build_aggregate for drift detection."""
skills = []
bodies = {}
skills_dir = os.path.join(plugin_path, "skills")
if not os.path.isdir(skills_dir):
return skills, bodies
for name in sorted(os.listdir(skills_dir)):
skill_md = os.path.join(skills_dir, name, "SKILL.md")
if not os.path.isfile(skill_md):
continue
with open(skill_md, encoding="utf-8") as f:
text = f.read()
meta, body = parse_frontmatter(text)
script_v = verb_counts(body, catalog["script-verbs"])
judgment_v = verb_counts(body, catalog["judgment-verbs"])
ratio = round(script_v / (script_v + judgment_v), 3) if (script_v + judgment_v) else 0.0
skill_name = meta.get("name", name)
bodies[skill_name] = body
skills.append({
"name": skill_name,
"description_words": wc(meta.get("description", "")),
"body_words": wc(body),
"allowed_tools": split_list(meta.get("allowed-tools", meta.get("tools", ""))),
"procedural_density": procedural_density(body),
"script_verbs": script_v,
"judgment_verbs": judgment_v,
"verb_ratio": ratio,
"command_blocks": extract_command_blocks(body),
"rule_tables": count_rule_tables(body),
"references": find_references(body, os.path.join(skills_dir, name, "references")),
})
return skills, bodies
def walk_agents(plugin_path, catalog):
agents = []
agents_dir = os.path.join(plugin_path, "agents")
if not os.path.isdir(agents_dir):
return agents
mechanical = catalog["mechanical-tools"]
for fname in sorted(os.listdir(agents_dir)):
if not fname.endswith(".md"):
continue
with open(os.path.join(agents_dir, fname), encoding="utf-8") as f:
text = f.read()
meta, body = parse_frontmatter(text)
tools = split_list(meta.get("tools", ""))
mech = sum(1 for t in tools if t in mechanical)
agents.append({
"name": meta.get("name", fname[:-3]),
"model": meta.get("model", ""),
"tools": tools,
"body_words": wc(body),
"mechanical_tool_share": round(mech / len(tools), 3) if tools else 0.0,
})
return agents
def walk_hooks(plugin_path):
"""Returns (hooks, texts) — texts maps hook filename -> file content, kept
out of the JSON output but needed by build_aggregate for drift detection."""
hooks = []
texts = {}
hooks_dir = os.path.join(plugin_path, "hooks")
if not os.path.isdir(hooks_dir):
return hooks, texts
hooks_json = os.path.join(plugin_path, ".claude-plugin", "hooks.json")
events_by_file = {}
if os.path.isfile(hooks_json):
try:
with open(hooks_json, encoding="utf-8") as f:
data = json.load(f)
for event, entries in (data.get("hooks") or {}).items():
for entry in entries:
for hook in entry.get("hooks", []):
cmd = hook.get("command", "")
m = re.search(r"([\w.-]+\.sh)\b", cmd)
if m:
events_by_file[m.group(1)] = event
except (json.JSONDecodeError, OSError):
pass
for fname in sorted(os.listdir(hooks_dir)):
fpath = os.path.join(hooks_dir, fname)
if not os.path.isfile(fpath):
continue
with open(fpath, encoding="utf-8", errors="ignore") as f:
content = f.read()
texts[fname] = content
hooks.append({"file": fname, "event": events_by_file.get(fname, "unknown"),
"loc": content.count("\n") + 1})
return hooks, texts
def detect_lang(fpath):
try:
with open(fpath, encoding="utf-8", errors="ignore") as f:
first = f.readline()
except OSError:
return "unknown"
if first.startswith("#!"):
if "python" in first:
return "python"
if "bash" in first:
return "bash"
if "sh" in first:
return "sh"
if fpath.endswith(".py"):
return "python"
return "unknown"
def walk_scripts(plugin_path):
scripts = []
bin_dir = os.path.join(plugin_path, "bin")
if not os.path.isdir(bin_dir):
return scripts
for fname in sorted(os.listdir(bin_dir)):
fpath = os.path.join(bin_dir, fname)
if not os.path.isfile(fpath):
continue
with open(fpath, encoding="utf-8", errors="ignore") as f:
loc = sum(1 for _ in f)
scripts.append({"file": f"bin/{fname}", "loc": loc, "lang": detect_lang(fpath)})
return scripts
def distinctive_tokens(text):
"""Identifier-shaped tokens only (snake_case, SCREAMING_CASE, or
file/tool names with an underscore or a dot) — deliberately NOT plain
English words, which overlap constantly between topically-related prose
and produce false positives (e.g. every worktree-discipline skill shares
"already"/"session"/"branch" with every worktree-discipline hook,
regardless of whether any actual logic is restated). Real drift shows up
as the same *specific* identifier — an env var, a function name, a
literal filename — named in both places."""
snake = re.findall(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+){1,}\b", text)
screaming = re.findall(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+){1,}\b", text)
dotted = re.findall(r"\b[a-zA-Z][a-zA-Z0-9_-]{3,}\.(?:sh|py|json|md)\b", text)
return {t.lower() for t in snake + screaming + dotted}
def build_aggregate(skills, agents, hooks, scripts, catalog, bodies=None, hook_texts=None):
skill_body_words = sum(s["body_words"] for s in skills)
agent_body_words = sum(a["body_words"] for a in agents)
script_loc = sum(s["loc"] for s in scripts) + sum(h["loc"] for h in hooks)
total_words = skill_body_words + agent_body_words
coverage = round(script_loc / total_words, 3) if total_words else 0.0
sig_skills = {}
for skill in skills:
for block in skill["command_blocks"]:
sig_skills.setdefault(block["sig"], set()).add(skill["name"])
min_skills = int(catalog["thresholds"].get("duplicate_block_min_skills", 2))
duplicated = [
{"sig": sig, "skills": sorted(names)}
for sig, names in sorted(sig_skills.items())
if len(names) >= min_skills
]
drift = []
bodies = bodies or {}
hook_texts = hook_texts or {}
min_shared = int(catalog["thresholds"].get("hook_prose_drift_min_shared_tokens", 3))
for hook in hooks:
hook_tokens = distinctive_tokens(hook_texts.get(hook["file"], ""))
if not hook_tokens:
continue
for skill_name, body in bodies.items():
shared = hook_tokens & distinctive_tokens(body)
if len(shared) >= min_shared:
drift.append({
"hook": hook["file"],
"skill": skill_name,
"shared_tokens": sorted(shared)[:10],
})
return {
"skill_body_words": skill_body_words,
"agent_body_words": agent_body_words,
"script_loc": script_loc,
"script_coverage": coverage,
"duplicated_command_blocks": duplicated,
"hook_prose_drift": drift,
}
def main():
parser = argparse.ArgumentParser(prog="plugin-inventory")
parser.add_argument("target", help="plugin directory path, or a bare name to resolve from the plugin cache")
parser.add_argument("--json", action="store_true", help="compact single-line JSON instead of pretty-printed")
parser.add_argument("--catalog", default=DEFAULT_CATALOG, help="path to signals-catalog.md")
args = parser.parse_args()
catalog = load_catalog(args.catalog)
plugin_path, source = resolve_target(args.target)
manifest_path = os.path.join(plugin_path, ".claude-plugin", "plugin.json")
manifest = {}
if os.path.isfile(manifest_path):
with open(manifest_path, encoding="utf-8") as f:
manifest = json.load(f)
skills, bodies = walk_skills(plugin_path, catalog)
agents = walk_agents(plugin_path, catalog)
hooks, hook_texts = walk_hooks(plugin_path)
scripts = walk_scripts(plugin_path)
aggregate = build_aggregate(skills, agents, hooks, scripts, catalog, bodies, hook_texts)
doc = {
"plugin": {
"name": manifest.get("name", os.path.basename(plugin_path)),
"version": manifest.get("version", "unknown"),
"source": source,
"path": plugin_path,
},
"skills": skills,
"agents": agents,
"hooks": hooks,
"scripts": scripts,
"aggregate": aggregate,
}
if args.json:
print(json.dumps(doc))
else:
print(json.dumps(doc, indent=2))
if __name__ == "__main__":
main()