#!/usr/bin/env python3 """Static pass over a Claude Code plugin's definitions -> one JSON document. Usage: plugin-inventory [--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 `##
` 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()