diff --git a/.github/standards/pyright/pyrightconfig.json b/.github/standards/pyright/pyrightconfig.json new file mode 100644 index 000000000..077efeddf --- /dev/null +++ b/.github/standards/pyright/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "typeCheckingMode": "strict", + "reportMissingTypeStubs": "error", + "reportUnnecessaryTypeIgnoreComment": "error", + "reportImplicitOverride": "error", + "reportImplicitStringConcatenation": "error", + "reportCallInDefaultInitializer": "error", + "reportPropertyTypeMismatch": "error", + "reportUninitializedInstanceVariable": "error", + "reportImportCycles": "error", + "reportUnreachable": "error", + "reportUnusedImport": "none", + "reportUnusedVariable": "none" +} diff --git a/.github/standards/runner-policy/policy.json b/.github/standards/runner-policy/policy.json index 7613fe699..69ece062b 100644 --- a/.github/standards/runner-policy/policy.json +++ b/.github/standards/runner-policy/policy.json @@ -749,7 +749,8 @@ "name": "runner", "expression": "${{ inputs.runner }}", "default": "ubuntu-24.04", - "failureSentinel": "ci-runner-selection-failed" + "failureSentinel": "ubuntu-24.04", + "failureSentinelMarker": "ci-runner-selection-failed" }, "fallbackLabelAllowlist": ["ubuntu-24.04", "melodic-ubuntu-24.04-x64"], "forbiddenHostedRunnerLabels": ["macos-latest", "ubuntu-latest", "windows-latest"], diff --git a/.github/standards/runner-policy/policy.schema.json b/.github/standards/runner-policy/policy.schema.json index 438f06597..8cd1c68a2 100644 --- a/.github/standards/runner-policy/policy.schema.json +++ b/.github/standards/runner-policy/policy.schema.json @@ -73,12 +73,13 @@ "governedReusableRunnerInput": { "type": "object", "additionalProperties": false, - "required": ["name", "expression", "default", "failureSentinel"], + "required": ["name", "expression", "default", "failureSentinel", "failureSentinelMarker"], "properties": { "name": { "$ref": "#/$defs/nonWhitespaceString" }, "expression": { "$ref": "#/$defs/nonWhitespaceString" }, "default": { "$ref": "#/$defs/nonWhitespaceString" }, - "failureSentinel": { "const": "ci-runner-selection-failed" } + "failureSentinel": { "const": "ubuntu-24.04" }, + "failureSentinelMarker": { "const": "ci-runner-selection-failed" } } }, "fallbackLabelAllowlist": { "$ref": "#/$defs/nonEmptyUniqueStringArray" }, diff --git a/.github/standards/runner-policy/runner-policy.mjs b/.github/standards/runner-policy/runner-policy.mjs index 842148a83..cb6d9f5f5 100755 --- a/.github/standards/runner-policy/runner-policy.mjs +++ b/.github/standards/runner-policy/runner-policy.mjs @@ -346,17 +346,22 @@ export function validatePolicy(value) { ); } } + if (!approvedHostedRunnerLabels.has(value.governedReusableRunnerInput.failureSentinel)) { + throw new ConfigurationError( + "policy.governedReusableRunnerInput.failureSentinel must be an approved hosted runner label", + ); + } if ( - approvedHostedRunnerLabels.has(value.governedReusableRunnerInput.failureSentinel) || + approvedHostedRunnerLabels.has(value.governedReusableRunnerInput.failureSentinelMarker) || forbiddenHostedRunnerLabels.has( - value.governedReusableRunnerInput.failureSentinel.toLowerCase(), + value.governedReusableRunnerInput.failureSentinelMarker.toLowerCase(), ) || managedLabelRegexes.some((pattern) => - pattern.test(value.governedReusableRunnerInput.failureSentinel), + pattern.test(value.governedReusableRunnerInput.failureSentinelMarker), ) ) { throw new ConfigurationError( - "policy.governedReusableRunnerInput.failureSentinel must remain outside every hosted and managed runner label set", + "policy.governedReusableRunnerInput.failureSentinelMarker must remain outside every hosted and managed runner label set", ); } const hostedMatrixAxes = new Map(); @@ -2000,15 +2005,52 @@ function selfHostedSelectorConditionStatus(job, selectorId) { return { approved: true }; } -function unroutableFailureStatus(jobId, target, job, jobs, policy) { - if (target !== policy.governedReusableRunnerInput.failureSentinel) { +function selectorFailureSentinelStepShape(step, policy, { requireMarkerName }) { + const stepKeys = isMapping(step) ? Object.keys(step) : []; + const allowedStepKeys = new Set(["name", "run", "shell"]); + const lines = typeof step?.run === "string" ? step.run.trim().split(/\r?\n/) : []; + if ( + stepKeys.some((key) => !allowedStepKeys.has(key)) || + typeof step?.name !== "string" || + step.name.trim() === "" || + (requireMarkerName && step.name !== policy.governedReusableRunnerInput.failureSentinelMarker) || + (requireMarkerName && step.shell !== "bash") || + (Object.hasOwn(step, "shell") && step.shell !== "bash") || + lines.length !== 2 || + !/^echo "::error::[A-Za-z0-9][A-Za-z0-9 .:_-]*"$/.test(lines[0].trim()) || + lines[1].trim() !== "exit 1" + ) { + return { + approved: false, + reason: requireMarkerName + ? "the selector failure sentinel step must use the declared marker name, pin shell: bash, emit a static error annotation, and exit 1" + : "the legacy selector failure sentinel step must only emit a static error annotation and exit 1", + }; + } + return { approved: true }; +} + +function selectorFailureSentinelStatus(jobId, target, job, jobs, policy) { + const { failureSentinel, failureSentinelMarker } = policy.governedReusableRunnerInput; + const legacySentinel = target === failureSentinelMarker; + const hostedSentinel = target === failureSentinel; + if (!legacySentinel && !hostedSentinel) { return undefined; } const prerequisites = normalizeNeeds(job.needs); + if (hostedSentinel) { + const steps = Array.isArray(job.steps) ? job.steps : []; + const [step] = steps; + const hasMarkerStep = + steps.length === 1 && isMapping(step) && step.name === failureSentinelMarker; + if (!hasMarkerStep) { + return undefined; + } + } if (prerequisites.length !== 1) { return { approved: false, - reason: `${jobId} must declare exactly one selector job in needs to use the unroutable failure sentinel`, + reason: `${jobId} must declare exactly one selector job in needs to use the selector failure sentinel`, }; } const [selectorId] = prerequisites; @@ -2029,7 +2071,7 @@ function unroutableFailureStatus(jobId, target, job, jobs, policy) { return { approved: false, reason: - "the unroutable failure sentinel requires the exact complement of a successful governed self-hosted selector route", + "the selector failure sentinel requires the exact complement of a successful governed self-hosted selector route", }; } const allowedJobKeys = new Set([ @@ -2045,7 +2087,7 @@ function unroutableFailureStatus(jobId, target, job, jobs, policy) { if (extraJobKeys.length > 0) { return { approved: false, - reason: `the unroutable failure sentinel job has forbidden keys: ${extraJobKeys.join(", ")}`, + reason: `the selector failure sentinel job has forbidden keys: ${extraJobKeys.join(", ")}`, }; } if ( @@ -2055,33 +2097,23 @@ function unroutableFailureStatus(jobId, target, job, jobs, policy) { ) { return { approved: false, - reason: "the unroutable failure sentinel job requires timeout-minutes: 1 and permissions: {}", + reason: "the selector failure sentinel job requires timeout-minutes: 1 and permissions: {}", }; } if (!Array.isArray(job.steps) || job.steps.length !== 1) { return { approved: false, - reason: "the unroutable failure sentinel job requires exactly one rejecting shell step", + reason: "the selector failure sentinel job requires exactly one rejecting shell step", }; } const [step] = job.steps; - const stepKeys = isMapping(step) ? Object.keys(step) : []; - const lines = typeof step?.run === "string" ? step.run.trim().split(/\r?\n/) : []; - if ( - stepKeys.some((key) => key !== "name" && key !== "run") || - typeof step?.name !== "string" || - step.name.trim() === "" || - lines.length !== 2 || - !/^echo "::error::[A-Za-z0-9][A-Za-z0-9 .:_-]*"$/.test(lines[0].trim()) || - lines[1].trim() !== "exit 1" - ) { - return { - approved: false, - reason: - "the unroutable failure sentinel step must only emit a static error annotation and exit 1", - }; + const stepShape = selectorFailureSentinelStepShape(step, policy, { + requireMarkerName: hostedSentinel, + }); + if (!stepShape.approved) { + return stepShape; } - return { approved: true, selectorId }; + return { approved: true, selectorId, legacy: legacySentinel }; } function routeStatus(jobId, target, job, jobs, policy, reusableContract, localRunnerInputMode) { @@ -2366,13 +2398,14 @@ function runnerTargetStatus(jobId, job, jobs, workflow, policy, file, workflowIn }; } - const unroutableFailure = unroutableFailureStatus(jobId, target, job, jobs, policy); - if (unroutableFailure) { + const selectorFailureSentinel = selectorFailureSentinelStatus(jobId, target, job, jobs, policy); + if (selectorFailureSentinel) { return { - approved: unroutableFailure.approved, - kind: unroutableFailure.approved ? "unroutable-failure" : "invalid", - route: { attempted: true, selectorId: unroutableFailure.selectorId }, - ...(unroutableFailure.reason ? { reason: unroutableFailure.reason } : {}), + approved: selectorFailureSentinel.approved, + kind: selectorFailureSentinel.approved ? "selector-failure-sentinel" : "invalid", + route: { attempted: true, selectorId: selectorFailureSentinel.selectorId }, + ...(selectorFailureSentinel.legacy ? { legacySentinel: true } : {}), + ...(selectorFailureSentinel.reason ? { reason: selectorFailureSentinel.reason } : {}), }; } @@ -3375,10 +3408,20 @@ export async function auditRepository({ callers.push(jobId); requiredNoDefaultCallers.set(target.route.selectorId, callers); } - if (routingEnabled && target?.kind === "unroutable-failure" && target.approved) { + if (routingEnabled && target?.kind === "selector-failure-sentinel" && target.approved) { const sentinels = approvedFailureSentinels.get(target.route.selectorId) ?? []; sentinels.push(jobId); approvedFailureSentinels.set(target.route.selectorId, sentinels); + if (target.legacySentinel) { + findings.push( + finding( + "selector-failure-sentinel-legacy", + file, + jobId, + "the legacy unroutable runs-on sentinel shape is accepted during migration; migrate to the hosted failureSentinel label with the declared failureSentinelMarker step", + ), + ); + } } const seedLocalPermissionFlow = !isWorkflowCallExclusive(workflow) || !localIncomingFiles.has(file); @@ -3597,7 +3640,7 @@ export async function auditRepository({ continue; } - if (target?.kind === "unroutable-failure" && target.approved) { + if (target?.kind === "selector-failure-sentinel" && target.approved) { continue; } if (target?.kind === "selector-output" && target.approved) { @@ -3655,7 +3698,7 @@ export async function auditRepository({ "selector-failure-sentinel-required", file, jobId, - `required no-default local runner calls using ${selectorId} require exactly one approved ${policy.governedReusableRunnerInput.failureSentinel} rejection job for the same selector in this workflow; found ${sentinelIds.length}`, + `required no-default local runner calls using ${selectorId} require exactly one approved selector failure sentinel for the same selector in this workflow; found ${sentinelIds.length}`, ), ); } diff --git a/docs/CATALOG.md b/docs/CATALOG.md index 27066d58e..9be200892 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -76,7 +76,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc - [`playbooks`](../plugins/playbooks) — Doctrine and knowledge playbooks as on-demand skills, plus a maintainer-facing update skill. boris — Boris Cherny's Claude Code workflow tips (howborisusesclaudecode.com); skill-authoring — Anthropic's internal skill-authoring playbook; fable-5 — Claude Fable 5's operating doctrine (self-authored, no upstream). The boris and skill-authoring packs vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs those baselines centrally (maintainers). - [`claude-config`](../plugins/claude-config) — Eight configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-permission-state (which settings scopes exist and what rules each one holds — managed policy, user-global, project, local, and the pre-v2.1.211 start-directory copy), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns). - [`claude-memory`](../plugins/claude-memory) — Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes. -- [`claude-ops`](../plugins/claude-ops) — Claude Code operations toolkit. Eight skills: audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json — full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action — an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. +- [`claude-ops`](../plugins/claude-ops) — Claude Code operations toolkit. Nine skills: inventory (read-only enumeration of the complete invocable surface — every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json — full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action — an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. - [`rate-limit-guard`](../plugins/rate-limit-guard) — Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume. - [`context-guard`](../plugins/context-guard) — Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels — the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker. - [`plugin-quality`](../plugins/plugin-quality) — Post-use behavioral audit of Claude Code plugin components: a six-step audit workflow (evidence capture, grounded mapping in a fresh subagent, blindspot pass, interactive contract lock, presence-gated review seams, work-item emit with draft+confirm) over any skill, agent, hook, command, or config you have actually used — zone-informed by context-guard snapshots when present, conservative when not. diff --git a/docs/SKILL-CHEAT-SHEET.md b/docs/SKILL-CHEAT-SHEET.md index 1322d34ac..bf316757b 100644 --- a/docs/SKILL-CHEAT-SHEET.md +++ b/docs/SKILL-CHEAT-SHEET.md @@ -211,6 +211,7 @@ owned by [docs/CATALOG-TAXONOMY.md](CATALOG-TAXONOMY.md). | Skill | Plugin | Cadence | What it does | | --- | --- | --- | --- | | [`/claude-ops:audit-install-state`](../plugins/claude-ops/skills/audit-install-state/SKILL.md) | `claude-ops` | weekly | Audit a Claude Code install directory — what is there, what the product manages, what is stale | +| [`/claude-ops:inventory`](../plugins/claude-ops/skills/inventory/SKILL.md) | `claude-ops` | weekly | Enumerate every command, skill, agent, and plugin component this machine can invoke | | [`/claude-ops:lanes`](../plugins/claude-ops/skills/lanes/SKILL.md) | `claude-ops` | daily | Start, restart, stop, and check loop lanes as named background sessions | | [`/claude-ops:morning-brief`](../plugins/claude-ops/skills/morning-brief/SKILL.md) | `claude-ops` | daily | Print the operator's read-only morning view — queues, merge-ready PRs, parked decisions | | [`/claude-ops:observability`](../plugins/claude-ops/skills/observability/SKILL.md) | `claude-ops` | weekly | Report on locally captured telemetry — token burn, cost, hook latency, trends | diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 3c245e562..1744561cc 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.29.3", - "description": "Claude Code operations toolkit. Eight skills: audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", + "version": "0.30.0", + "description": "Claude Code operations toolkit. Nine skills: inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 76d476da8..854117d38 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.30.0] + +### Added + +- **`skills/inventory` — read-only enumeration of the complete invocable surface.** + Answers "what can this machine actually invoke, and where did each thing come from" in one + report: built-in CLI commands with aliases and hidden/gated markers, bundled skills, and every + component of every installed plugin across all marketplaces. Built-in and bundled surfaces are + read from the shipped binary because upstream publishes no built-in command list — + `docs/en/slash-commands` and `docs/en/skills` return byte-identical markdown since commands were + merged into skills — so no documentation source is complete for them. Filters accept either a + flag (`--builtin`, `--plugins`, `--marketplace `, `--agents`, `--hooks`, `--diff`) or the + equivalent sentence; one extraction feeds every view. + + The extraction survives ordinary releases by resolving at runtime what changes between them: + registrar names come from the bundle's export maps (`registerBundledSkill:()=>xu`) rather than a + hardcoded minified identifier, the bundle is located by export-name anchor rather than section + layout, and each command's fields are read by brace depth rather than a text window — adjacent + minified literals otherwise bleed into one another. `scripts/inventory.py` needs only Python + 3.11+; no `strings`, `jq`, or PowerShell, so it behaves the same on all three platforms. + + Every run carries an integrity verdict (`ok` / `degraded` / `broken`) because the failure that + matters is not a crash but a clean-looking short list. Canary commands, a minimum resolved-to- + registration-token ratio, a sweep for unrecognised registrar-shaped exports, and the + resolved-versus-seen gap on bundled skills each convert a quiet shortfall into a stated one; a + `degraded` run reports counts as floors rather than totals. `--self-check` prints one verdict + line and exits 0/1/2 for use as a CI gate or scheduled drift check, with `/claude-ops:changelog` + as the natural trigger. `VALIDATED_AGAINST` records the last human-verified build, so a consumer + running an older plugin against a newer CLI is told its counts are believed rather than verified + instead of being handed a wrong answer. + ## [0.29.3] ### Fixed diff --git a/plugins/claude-ops/skills/inventory/SKILL.md b/plugins/claude-ops/skills/inventory/SKILL.md new file mode 100644 index 000000000..2999afd7c --- /dev/null +++ b/plugins/claude-ops/skills/inventory/SKILL.md @@ -0,0 +1,209 @@ +--- +description: "Enumerate the complete Claude Code ECOSYSTEM this machine can invoke — every built-in CLI command with its aliases and hidden/gated status, every bundled skill, and every component of every installed plugin (skills, agents, commands, hooks, MCP servers, LSP servers, workflows, output styles, themes, monitors, bin) across all marketplaces. Reads the shipped binary because the official docs publish no built-in command list. Reports; never changes anything. Use when: 'what slash commands do I have', 'list all my skills', 'what agents are available', 'show me every plugin component', 'what does Claude Code ship built-in', 'is /foo a real command', 'what changed after the update', 'show me only the plugin ones', 'what does marketplace X give me'. Not for: auditing the install DIRECTORY on disk (use /claude-ops:audit-install-state), updating or converging the plugin fleet (use /claude-ops:plugins), or checking settings and permission drift (use /claude-config:audit)." +argument-hint: "[--builtin|--plugins|--bundled|--agents|--hooks] [--marketplace ] [--diff ] — or just ask in words" +user-invocable: true +disable-model-invocation: false +shell: bash +metadata: + workflow-stage: operator + summary: Enumerate every command, skill, agent, and plugin component this machine can invoke + cadence: weekly +--- + +## Purpose + +Answers one question completely: **what can this machine actually invoke, and where did each thing come from?** + +That question has no single documented answer. Claude Code's own documentation does not publish a +list of its built-in slash commands — `docs/en/slash-commands` now serves the skills page, because +commands were merged into skills — so the only complete source for the built-in surface is the +shipped executable. Plugin components, by contrast, are on disk and enumerable directly. This skill +reads both and keeps them clearly separated, because they are evidence of different quality. + +The report is an inventory, not a judgement. Nothing here says a component is stale, misconfigured, +or wrong; the neighbours below own those verdicts. + +## Scope boundary + +| Question | Owner | +|---|---| +| What can I invoke, and where did it come from? | **this skill** | +| Is my install directory healthy — what is stale, what does the product manage? | `/claude-ops:audit-install-state` | +| Is the plugin fleet current, and at what scope? | `/claude-ops:plugins audit` | +| Are settings, hooks, permissions, and MCP config correct? | `/claude-config:audit` | +| Which permission scopes hold which rules? | `/claude-config:audit-permission-state` | + +The distinction that matters most: `audit-install-state` inventories **files on disk**, this skill +inventories **capabilities that resolve**. A plugin can be present on disk and contribute nothing +because it is disabled, and a built-in command can be fully live while existing nowhere on disk. + +## Run it + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/skills/inventory/scripts/inventory.py" --out ./claude-inventory.json +``` + +Python 3.11+ is the only requirement — no `strings`, no `jq`, no PowerShell, no third-party +packages. The run takes a few seconds, dominated by reading the executable once. + +Useful flags: `--binary ` (else auto-detected) · `--config-dir ` (else +`$CLAUDE_CONFIG_DIR`, else `~/.claude`) · `--binary-only` / `--disk-only` to skip a source. + +**Extract once, filter at presentation.** The script always emits the whole inventory; the user's +filter selects what you *show*. Filtering in the script would mean a different extraction per +question and a cache that disagrees with itself. One JSON, many views. + +## Resolving what the user asked for + +Both spellings reach the same place — treat a flag and its sentence as identical requests. + +| Flag | Sentences that mean it | Show | +|---|---|---| +| `--builtin` | "built-in commands", "what ships with Claude Code", "is /foo real" | `builtin_commands` | +| `--bundled` | "bundled skills", "Anthropic's skills" | `bundled_skills` | +| `--plugins` | "only the plugin ones", "what did my plugins add" | `disk.marketplaces` | +| `--marketplace ` | "what does melodic-software give me" | that marketplace only | +| `--agents` | "what agents do I have" | agents across every source | +| `--hooks` | "what hooks are wired", "what's hooked" | hooks across every source | +| `--diff ` | "what changed after the update" | this run against a saved one | + +With no argument, report every section at summary depth and offer to expand one. A full unfiltered +listing runs to several hundred rows, which buries the answer to whatever prompted the question. + +Two habits worth keeping. When a name the user asked about does not appear, say which sources were +searched — absence from a filtered view is not absence from the machine. And when they ask about one +name, answer it directly first, then offer the surrounding list. + +## Report structure + +Lead with the shape of the answer, then the rows. + +``` +# Claude Code inventory — , + +## Summary + built-in commands · bundled skills · plugins across marketplaces · enabled + +## Built-in CLI commands () +One per line, alphabetical, with aliases and hidden/gated markers. + +## Bundled skills () +One per line, alphabetical. + +## Plugin components +Grouped by marketplace, then plugin, with a component-type breakdown. + +## Provenance +Which source produced which section, and anything the run could not resolve. +``` + +One per line, alphabetical, is the default for every list. The point of this report is scanning for +a name; a prose paragraph or a multi-column grid defeats that. + +Never merge the sections into one list. A built-in command, a bundled skill, and a plugin skill +behave differently — different namespacing, different update path, different removal story — and a +merged list is unusable for deciding what to do about any of them. + +## Reading the evidence honestly + +**Extraction proves a command exists in the build, not that you can type it.** `hidden` and `gated` +mean a runtime predicate decides visibility from plan, platform, session type, or config. Report the +marker; never promote "present in the binary" to "available to you". `/skills` and `/help` are the +authority on what resolves right now. + +**`enabledPlugins: false` does not settle enablement.** It spans several scopes, and a plugin's +hooks live in its own manifest. Report the map as read and route the verdict to +`/claude-ops:plugins audit`. + +**Counts that disagree are data, not noise.** `bundled_skill_notes` carries `registrations_seen` +alongside `resolved`. When they differ, some registration used a dynamically computed name; say so +rather than reporting the smaller number as complete. + +**A marketplace checkout is not an installation.** Plugins under a cached marketplace are a catalog +of what is *available*. Only `enabledPlugins` says what loads. + +## How the binary read works + +The extraction is in `scripts/inventory.py`, and [reference/extraction.md](reference/extraction.md) +explains every choice in it — read that before changing the script or when a run reports a layout +error. The one thing worth knowing at the call site: the script resolves minified registrar names, +the bundle location, and each command's field boundaries **at runtime**, because all three change +between releases. Two earlier regex-only passes over this bundle produced lists that were wrong in +different ways, which is what the runtime resolution and the integrity block exist to prevent. + +The script opens the binary read-only. It never writes to it and never executes it. + +## Verifying an upstream claim + +Any claim about what Claude Code itself ships must come from the raw markdown endpoint — `curl -sSL` +`https://code.claude.com/docs/en/plugins-reference.md` to a file, then read the file. A summarizing +fetch returns a small model's answer *about* the page, so absence from that answer is not evidence +of absence. + +Two upstream facts this skill depends on, each with the trigger that obliges re-deriving it: + +| Claim | Basis | Recheck trigger | Verified | +|---|---|---|---| +| The docs publish no built-in slash-command list | `docs/en/slash-commands.md` and `docs/en/skills.md` return byte-identical content | Those two stop being identical, or either grows a command table | 2026-08-11 | +| The plugin component set is skills, commands, agents, workflows, output-styles, themes, monitors, hooks, bin, settings.json, .mcp.json, .lsp.json, dependencies | `docs/en/plugins-reference.md` manifest schema and standard plugin layout | The manifest schema gains or drops a component key | 2026-08-11 | + +The changelog at `https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md` is the +fastest way to explain a diff between two runs — commands appear and disappear between releases. +Cite the version that introduced or removed a command rather than asserting it changed. + +## Staying current as Claude Code ships + +Claude Code updates constantly, and this skill reads its internals. The design assumption is not +that the build holds still — it is that **drift must never be silent**. + +**Read the integrity block before quoting any number.** Every run carries one, and it states whether +the counts are verified or merely believed: + +| Status | Means | Do | +|---|---|---| +| `ok` | Build matches the last validated release, every check passed | Report counts as totals | +| `degraded` | Extraction worked, but something is unaccounted for | Report counts as **floors**, and say what is unaccounted for | +| `broken` | A canary command is missing or nothing resolved | Do not report counts at all; say the extractor needs updating | + +The distinction earns its keep because the dangerous failure is not a crash. A renamed export throws +and is obvious. A *new registration path* returns a clean, confident, short list — so the checks are +built to catch shortfall rather than error: canary commands that have shipped in every build, a +minimum ratio of resolved commands to registration tokens present, a sweep for registrar-shaped +exports the script does not know about, and the resolved-versus-seen gap on bundled skills. + +**Run the drift check on a schedule, not on incident:** + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/skills/inventory/scripts/inventory.py" --self-check +``` + +It prints one verdict line and exits `0` ok, `1` broken, `2` degraded — so it works as a CI gate, a +loop-lane step, or a post-update check without parsing JSON. The natural trigger is a CLI release: +`/claude-ops:changelog` already ingests those, and this is the check to run when it reports one. + +**What a maintainer actually updates.** Most releases need no change — registrar names are +discovered, not hardcoded, and the bundle is found by export name rather than layout. When a run +does go `degraded` or `broken`, each verdict maps to one edit in `scripts/inventory.py`; the verdict +table in [reference/extraction.md](reference/extraction.md) carries the mapping. After revalidating +against the new build, bump `VALIDATED_AGAINST` to that version — it is the one constant that turns +"believed" back into "verified". + +**For downstream consumers.** A consumer on an older plugin version against a newer CLI gets a +`degraded` or `broken` verdict rather than a wrong answer — the report tells on itself, which is the +property that makes shipping this safe. Fixes reach them the ordinary way: bump the plugin version, +and `/claude-ops:plugins sync` carries it. Never quietly widen a count to make a status look better; +the stale-but-honest report is the one a consumer can act on. + +## Gotchas + +- **A name in the build is not always a command.** Verified cases: `alias` is a sandboxed-shell + builtin beside `nohup` and `timeout`; `todos` is a session-cleanup hook. Both match a naive + `name:"…"` search. The brace-depth reader plus the `type:` requirement is what excludes them. +- **An alias is not a separate command.** `/cost` and `/stats` are aliases of `/usage`, not three + commands. Count commands once and list aliases beside them, or your total will drift from `/help`. +- **A bundled skill can also appear as a command object.** When a name registers as both, the skill + registration wins; the script drops the duplicate so one capability is not counted twice. +- **An npm install has no embedded bundle.** The launcher script is small and loads its bundle + elsewhere. The script reports this rather than parsing a shim, and the disk half still works. +- **The bundled-skills directory is a lazy cache.** Skills are extracted there on first use, so it + under-reports. It is never the source for the bundled-skill list. diff --git a/plugins/claude-ops/skills/inventory/evals/evals.json b/plugins/claude-ops/skills/inventory/evals/evals.json new file mode 100644 index 000000000..8b04e8bce --- /dev/null +++ b/plugins/claude-ops/skills/inventory/evals/evals.json @@ -0,0 +1,78 @@ +{ + "skill_name": "inventory", + "evals": [ + { + "id": 1, + "name": "full-inventory-separated-by-source", + "prompt": "What slash commands and skills do I actually have available? Give me the whole picture.", + "expected_output": "Runs scripts/inventory.py once, then reports the integrity status before any count. Built-in CLI commands, bundled skills, and plugin components appear as separate sections rather than one merged list, each one-per-line and alphabetical, with aliases and hidden/gated markers on the built-in rows. Closes with provenance: which source produced which section.", + "files": [], + "expectations": [ + "Output runs the bundled inventory.py engine rather than enumerating commands from memory or ad-hoc grep over the binary", + "Output keeps built-in commands, bundled skills, and plugin-provided components in separate sections instead of merging them into one list", + "Output lists names one per line in alphabetical order rather than as prose or a multi-column grid", + "Output states which source produced each section, distinguishing what was read from the binary from what was read from disk" + ] + }, + { + "id": 2, + "name": "degraded-counts-are-floors", + "prompt": "How many bundled skills does Claude Code ship? Just give me the number.", + "expected_output": "Reads the integrity block first. Because the current build reports degraded — some bundled-skill registrations use computed names — the count is reported as a floor with the shortfall named, not as a verified total. Does not round the unresolved registrations away or present the resolved number as complete.", + "files": [], + "expectations": [ + "Output reports the bundled-skill count as a floor or minimum rather than asserting it as an exact total", + "Output names the reason the count is not exact, referring to registrations whose names could not be resolved", + "Output does not silently drop the unresolved registrations from the explanation to present a cleaner number" + ] + }, + { + "id": 3, + "name": "alias-is-not-a-separate-command", + "prompt": "Is /cost still a thing? I thought it got removed and replaced by /usage.", + "expected_output": "Answers the specific name first: /cost resolves, as an alias of /usage alongside /stats — one command with three names, not a removal and not three commands. Distinguishes an alias from a separate registration, and notes that counting aliases separately would make any total disagree with /help.", + "files": [], + "expectations": [ + "Output states that /cost still resolves and identifies it as an alias of /usage rather than a removed or separate command", + "Output answers the named command directly before offering any surrounding list", + "Output does not count the alias as an additional command in any total it reports" + ] + }, + { + "id": 4, + "name": "hidden-is-not-available", + "prompt": "The report lists /heapdump and /sandbox. Walk me through using them.", + "expected_output": "Refuses the premise that presence in the extraction means availability. Both carry the hidden marker, meaning a runtime predicate decides visibility from plan, platform, session type, or config, so the extraction proves only that they exist in the build. Routes the question of what actually resolves right now to /help and /skills rather than asserting the commands are usable.", + "files": [], + "expectations": [ + "Output states that a hidden or gated marker means presence in the build, not availability to this user", + "Output declines to present the commands as usable on the strength of the extraction alone", + "Output routes the what-resolves-now question to /help or /skills as the live authority" + ] + }, + { + "id": 5, + "name": "plugin-filter-natural-language", + "prompt": "show me only the stuff my plugins added, not the built-in stuff", + "expected_output": "Treats the sentence as equivalent to the --plugins flag and reports only plugin-provided components, grouped by marketplace then plugin, with a component-type breakdown. Does not merge plugin components into the built-in sections, and notes that a marketplace checkout is a catalog of what is available while enabledPlugins governs what actually loads.", + "files": [], + "expectations": [ + "Output restricts the report to plugin-provided components rather than printing the full unfiltered inventory", + "Output groups plugin components by marketplace and plugin rather than flattening them into one list", + "Output distinguishes plugins present in a marketplace checkout from plugins actually enabled" + ] + }, + { + "id": 6, + "name": "routes-install-directory-question-away", + "prompt": "My ~/.claude directory is huge and I think there's stale junk in it. Can you inventory it and tell me what to clean up?", + "expected_output": "Recognises the object as the install directory on disk rather than the invocable surface, and routes to /claude-ops:audit-install-state for the staleness reading and /disk-hygiene:clean for any deletion. Offers what this skill can answer — which capabilities resolve and where each came from — without producing a staleness verdict or recommending deletions.", + "files": [], + "expectations": [ + "Output routes the install-directory staleness question to /claude-ops:audit-install-state rather than answering it", + "Output does not produce staleness verdicts or recommend deleting anything", + "Output explains the boundary between inventorying files on disk and inventorying capabilities that resolve" + ] + } + ] +} diff --git a/plugins/claude-ops/skills/inventory/reference/extraction.md b/plugins/claude-ops/skills/inventory/reference/extraction.md new file mode 100644 index 000000000..b3942c2d0 --- /dev/null +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -0,0 +1,135 @@ +# How the binary read works + +Background for maintaining `scripts/inventory.py`. Read this before changing the extraction or when +a run reports a layout error. The skill body carries what a caller needs; this carries what an +editor needs. + +## Why the binary is read at all + +Claude Code's documentation does not publish its built-in slash commands. `docs/en/slash-commands` +now serves the skills page — the two URLs return byte-identical markdown, because commands were +merged into skills — so no upstream page enumerates `/clear`, `/rewind`, `/artifacts`, or the rest. +Plugin components are on disk and need no such measure; the binary read exists only for the built-in +and bundled surfaces, which have no other complete source. + +Verify that premise rather than trusting this paragraph: + +```bash +curl -sSL -o /tmp/slash.md https://code.claude.com/docs/en/slash-commands.md +curl -sSL -o /tmp/skills.md https://code.claude.com/docs/en/skills.md +cmp /tmp/slash.md /tmp/skills.md && echo "still identical - binary read still required" +``` + +If those files ever differ and the slash-commands page grows a command table, prefer the +documentation and reduce the binary read to a cross-check. + +## Shape of the artifact + +The shipped executable is a Bun standalone build: a native container with the JavaScript bundle +appended. On the build observed while writing this (2.1.228, Windows, PE32+), the layout was: + +| Property | Value | +|---|---| +| Container | PE32+ x86-64, 12 sections | +| Payload section | `.bun`, ~212 MB, ~71% of the file | +| CLI bundle | ~25 MB of minified JS, header `// @bun @bytecode @bun-cjs` | +| Trailer | `\n---- Bun! ----\n` at EOF | + +None of those specifics are load-bearing in the script, and that is deliberate. Parsing the PE +section table would work on Windows and then need a Mach-O load-command reader for macOS and an ELF +section reader for Linux — three parsers to maintain against a packer that may rename its section +anyway. The script instead treats the file as bytes and finds the bundle by content. + +## The three extraction decisions + +### 1. Anchor on an export name, not the chunk header + +The obvious anchor is the `// @bun` header. It fails: the header appears in several small helper +chunks, and the *first* occurrence is a few hundred bytes of the wrong one. The first draft of this +script returned a 454-byte "bundle" and zero commands. + +The script anchors on `registerBundledSkill` — a string that occurs only in the CLI bundle — expands +to the surrounding printable run, and takes the largest candidate, rejecting anything under 1 MB. +Chunk headers remain as fallbacks in `BUNDLE_MARKERS` for a build that renames the export. + +### 2. Discover registrar names, never hardcode them + +Minified identifiers are regenerated on every build. The bundled-skill registrar is `xu` in 2.1.228 +and will be something else next release, so `re.findall(r'xu\(\{', src)` dates the script to a +single version. + +The bundle's module export maps keep the original names: + +```js +pt(QCd,{ ... registerBundledSkill:()=>xu, ... }) +``` + +`discover_registrar(src, "registerBundledSkill")` reads the minified name out of that map at +runtime. The readable half is what upstream maintains; the minified half is what changes. + +### 3. Resolve enclosing objects by brace depth, not by a text window + +Minified object literals sit flush against one another: + +```js +...,IWp=N7b});var $7b,DWp;var MWp=E(()=>{QH();$7b={type:"local-jsx",name:"artifacts",... +``` + +A fixed ±N-character window around `type:"local-jsx"` spans the neighbouring command and mixes its +`description` in. `build_brace_map` tokenizes the whole bundle once — tracking string, template, +regex, and comment states so a `{` inside a string is not counted — and records every matched pair. +Each command's fields are then read from its own literal. + +This is the single most important correctness property in the script. Two earlier regex-only passes +over this bundle produced lists that were wrong in different ways: one missed `/artifacts` entirely, +the other invented `/alias` and `/todos` as commands. + +## The three registration paths + +| Path | Shape | Yields | +|---|---|---| +| Command objects | `{type:"local"\|"local-jsx"\|"prompt", name, description, aliases, isEnabled, isHidden}` | Built-in CLI commands | +| `registerBundledSkill` | `xu({name, aliases, menuDescription, isEnabled, requires})` | Bundled skills | +| Cloud registrar | a thin wrapper registering remote-backed commands | `ultraplan`, `ultrareview`, `teleport`, `remote-control`, `schedule`, `autofix-pr` | + +Names arrive two ways in the second path. Some are literals; others are hoisted constants +(`xu({name:gme,...})` where `gme="code-review"`), which is why `build_const_map` exists — a +literal-only scan silently drops roughly a third of the bundled skills, including `code-review`, +`simplify`, and the artifact family. + +## Known non-commands + +Strings that match a naive `name:"…"` search but are not slash commands. Each was verified by +reading its surrounding code: + +| String | What it actually is | +|---|---| +| `alias` | A sandboxed-shell builtin, beside `nohup`, `srun`, `timeout`, `sleep` | +| `todos` | A session-cleanup hook name | +| `mcp__` | An MCP tool-name prefix | +| `stub` | A disabled placeholder (`isEnabled:()=>!1`) | +| `workflow-launch-exec` | Internal handoff for server-launched workflows | + +The `type:` requirement plus brace-depth resolution excludes all of these. `INTERNAL_NAMES` marks +the remainder that are real registrations but never user-typed. + +## When a build changes + +Work the integrity block, not the symptom. `--self-check` names which check failed, and each maps to +one edit: + +| Verdict | Cause | Fix | +|---|---|---| +| `broken`: canary commands absent | Bundle found but parsing yields little | Confirm the bundle size looks right; if so the object shape changed — re-derive from a known command | +| `broken`: registrar lookup failed | Export renamed upstream | Update the name passed to `discover_registrar` | +| `broken`: no bundle found | Packer layout changed | Add the new anchor to `BUNDLE_MARKERS` | +| `degraded`: unrecognised registrar export | A new registration path may exist | Inspect it; add to `KNOWN_REGISTRAR_EXPORTS` if it funnels into the known registrar, otherwise extract it | +| `degraded`: computed names unresolved | Registration built its name dynamically | Usually acceptable — report as a floor. Extend `build_const_map` only if the count grows | + +After revalidating, bump `VALIDATED_AGAINST`. Leaving it stale is not a bug: every report then says +its counts are believed rather than verified, which is the honest state until someone checks. + +## Cost + +Reading the executable dominates. On the observed build: ~2.7 s wall clock, ~300 MB read once, +~25 MB tokenized into ~190,000 brace pairs. The file is opened read-only and never executed. diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py new file mode 100755 index 000000000..2a25d4fa1 --- /dev/null +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -0,0 +1,1019 @@ +#!/usr/bin/env python3 +"""Enumerate the Claude Code ecosystem on this machine. + +Emits one JSON document describing what this machine can actually invoke: +built-in CLI commands, bundled skills, and every installed plugin component. + +Two independent evidence sources, never conflated in the output: + + binary - the shipped Claude Code executable. The only complete source for + built-in commands and bundled skills, because the official docs do + not publish either list (docs/en/slash-commands now serves the + skills page). Read-only; the file is never modified. + disk - settings, marketplaces, and plugin trees under the config dir. + +Requires Python 3.11+ and nothing else - no strings(1), no jq, no shell. +Every path is built with pathlib so Windows, macOS, and Linux behave alike. +""" + +from __future__ import annotations + +import argparse +import bisect +import json +import os +import platform +import re +import shutil +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +MIN_PYTHON = (3, 11) + +# The CLI release this extractor was last verified against by a human running +# the skill's evals. Drift from it is not an error - the extraction is designed +# to survive ordinary releases - but it downgrades every count from "verified" +# to "believed", which the report has to say out loud. +VALIDATED_AGAINST = "2.1.228" + +# Commands that have shipped in every build observed. Their absence means the +# extraction broke, not that Anthropic deleted /help. This is the cheapest +# guard against the failure mode that matters: a layout change that yields a +# clean-looking but badly incomplete list instead of an error. +CANARY_COMMANDS = ("help", "clear", "config", "resume", "status") + +# Registrar-shaped exports known to funnel into registerBundledSkill. A new +# name here is the signal that a fourth registration path appeared and this +# script may now be under-reporting silently. +KNOWN_REGISTRAR_EXPORTS = frozenset( + { + "registerBundledSkill", + "registerBundledSkillSessionReset", + "registerClaudeApiSkill", + "registerClaudeCodeSkill", + "registerCoworkSetupSkill", + "registerLoopSkill", + "registerRunSkill", + "registerRunSkillGeneratorSkill", + "registerScheduleRemoteAgentsSkill", + "registerAgentProxyEnvFn", + } +) + +# Below this ratio of extracted commands to `type:"local…"` tokens present, the +# brace reader is failing to resolve enclosing objects and the list is partial. +MIN_COMMAND_YIELD = 0.40 + +# The bundle is minified JS. These markers sit at the top of the embedded CLI +# chunk and are stable across the releases observed so far; each is tried in +# turn so one rename does not break discovery. +BUNDLE_MARKERS = (b"// @bun @bytecode @bun-cjs", b"// @bun @bun-cjs", b"// @bun") + +# Component types a plugin may ship, from the plugin manifest schema and the +# standard plugin layout. Directory is the default location; the manifest may +# redirect most of them, which is why the manifest is read before the tree. +PLUGIN_COMPONENTS: dict[str, dict[str, str]] = { + "skills": {"dir": "skills", "manifest": "skills", "kind": "dir-of-dirs"}, + "commands": {"dir": "commands", "manifest": "commands", "kind": "dir-of-files"}, + "agents": {"dir": "agents", "manifest": "agents", "kind": "dir-of-files"}, + "workflows": {"dir": "workflows", "manifest": "workflows", "kind": "dir-of-files"}, + "output-styles": {"dir": "output-styles", "manifest": "outputStyles", "kind": "dir-of-files"}, + "themes": {"dir": "themes", "manifest": "experimental.themes", "kind": "dir-of-files"}, + "monitors": {"dir": "monitors", "manifest": "experimental.monitors", "kind": "dir-of-files"}, + "hooks": {"dir": "hooks", "manifest": "hooks", "kind": "dir-of-files"}, + "bin": {"dir": "bin", "manifest": "", "kind": "dir-of-files"}, + "mcp-servers": {"dir": "", "manifest": "mcpServers", "kind": "file", "file": ".mcp.json"}, + "lsp-servers": {"dir": "", "manifest": "lspServers", "kind": "file", "file": ".lsp.json"}, + "settings": {"dir": "", "manifest": "", "kind": "file", "file": "settings.json"}, +} + + +# -------------------------------------------------------------------------- +# Locating the binary +# -------------------------------------------------------------------------- + + +def candidate_binaries() -> list[Path]: + """Ordered candidates for the Claude Code executable.""" + out: list[Path] = [] + + resolved = shutil.which("claude") + if resolved: + out.append(Path(resolved)) + + home = Path.home() + names = ("claude.exe", "claude") if os.name == "nt" else ("claude",) + roots = [ + home / ".local" / "bin", + home / ".claude" / "local", + Path("/usr/local/bin"), + Path("/opt/homebrew/bin"), + ] + for root in roots: + for name in names: + out.append(root / name) + + seen: set[str] = set() + uniq: list[Path] = [] + for p in out: + try: + key = str(p.resolve()) + except OSError: + key = str(p) + if key not in seen: + seen.add(key) + uniq.append(p) + return uniq + + +def pick_binary(explicit: str | None) -> tuple[Path | None, str]: + """Return the executable to read, plus a note on how it was chosen. + + A native build is a large single file. An npm install resolves to a small + launcher script instead; that is reported rather than parsed, because the + JS bundle it loads lives elsewhere and carries no embedded section. + """ + if explicit: + p = Path(explicit) + if not p.is_file(): + return None, f"--binary {explicit} is not a file" + return p, "explicit --binary" + + for p in candidate_binaries(): + try: + if not p.is_file(): + continue + size = p.stat().st_size + except OSError: + continue + if size > 20_000_000: + return p, "auto-detected native build" + # Keep looking; a shim may precede the real binary on PATH. + for p in candidate_binaries(): + try: + if p.is_file(): + return p, "auto-detected (small file - likely an npm launcher, not a native build)" + except OSError: + continue + return None, "no claude executable found on PATH or in the usual install roots" + + +def read_bundle(binary: Path) -> tuple[str | None, dict[str, Any]]: + """Pull the embedded JS bundle out of the executable. + + Deliberately format-agnostic. Parsing the PE section table (or Mach-O load + commands, or ELF section headers) would work but ties the script to each + container format and to the section name the packer happens to use. The + bundle announces itself with a marker comment, so the marker is located and + the surrounding printable run is taken. That behaves the same whether the + host is Windows, macOS, or Linux. + """ + meta: dict[str, Any] = {"path": str(binary), "size": binary.stat().st_size} + try: + data = binary.read_bytes() + except OSError as exc: + meta["error"] = f"cannot read binary: {exc}" + return None, meta + + meta["container"] = detect_container(data) + + printable = bytearray(256) + for c in b"\t\n\r": + printable[c] = 1 + for c in range(0x20, 0x7F): + printable[c] = 1 + + def run_bounds(pos: int) -> tuple[int, int]: + n = len(data) + end = pos + while end < n and printable[data[end]]: + end += 1 + begin = pos + while begin > 0 and printable[data[begin - 1]]: + begin -= 1 + return begin, end + + # Anchor on the export name the extraction needs rather than on the chunk + # header. The header appears several times (small helper chunks carry it + # too), so the first hit is routinely a few hundred bytes of the wrong + # chunk; the export name occurs only in the CLI bundle. Markers stay as a + # fallback for a build that renames the export. + anchors: list[bytes] = [b"registerBundledSkill", *BUNDLE_MARKERS] + + best: tuple[int, int] | None = None + best_anchor = "" + for anchor in anchors: + pos = data.find(anchor) + while pos >= 0: + begin, end = run_bounds(pos) + if best is None or (end - begin) > (best[1] - best[0]): + best = (begin, end) + best_anchor = anchor.decode("ascii", "replace") + pos = data.find(anchor, end if end > pos else pos + 1) + if best is not None and (best[1] - best[0]) > 1_000_000: + break + + if best is None: + meta["error"] = "no embedded JS bundle found - unsupported build layout" + return None, meta + + begin, end = best + if end - begin < 1_000_000: + meta["error"] = ( + f"largest candidate bundle is only {end - begin} bytes - " + "this build does not embed the CLI bundle where expected" + ) + return None, meta + + meta["anchor"] = best_anchor + meta["bundle_offset"] = begin + meta["bundle_bytes"] = end - begin + return data[begin:end].decode("latin1"), meta + + +def detect_container(data: bytes) -> str: + if data[:2] == b"MZ": + return "PE" + if data[:4] in (b"\x7fELF",): + return "ELF" + if data[:4] in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe", b"\xca\xfe\xba\xbe"): + return "Mach-O" + return "unknown" + + +# -------------------------------------------------------------------------- +# Minified-JS scanning +# -------------------------------------------------------------------------- + +_ID_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$") +_REGEX_PRECEDERS = set("(,=:[!&|?{};+-*%^~<>") | {"\n"} +_REGEX_KEYWORDS = { + "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", + "case", "do", "else", "yield", "await", +} + + +@dataclass +class BraceMap: + """Positions of every matched {...} pair in the source.""" + + pairs: dict[int, int] = field(default_factory=dict) + opens: list[int] = field(default_factory=list) + + def enclosing(self, pos: int) -> tuple[int, int] | None: + k = bisect.bisect_right(self.opens, pos) - 1 + while k >= 0: + o = self.opens[k] + if self.pairs[o] > pos: + return o, self.pairs[o] + k -= 1 + return None + + +def build_brace_map(s: str) -> BraceMap: + """Match braces while skipping strings, templates, regex literals, comments. + + Minified JS packs object literals against each other, so a fixed-width + window around a match routinely spans two neighbouring objects and mixes + their fields. Tracking real brace depth is what keeps each command's fields + attributed to that command. + """ + stack: list[int] = [] + pairs: dict[int, int] = {} + i, n = 0, len(s) + prev_sig = "\n" + prev_word = "" + + while i < n: + c = s[i] + if c in " \t\r\n": + i += 1 + continue + if c == "/" and i + 1 < n and s[i + 1] == "/": + j = s.find("\n", i) + i = n if j < 0 else j + 1 + continue + if c == "/" and i + 1 < n and s[i + 1] == "*": + j = s.find("*/", i + 2) + i = n if j < 0 else j + 2 + continue + if c in "\"'": + q = c + i += 1 + while i < n: + if s[i] == "\\": + i += 2 + continue + if s[i] == q: + i += 1 + break + i += 1 + prev_sig, prev_word = q, "" + continue + if c == "`": + i = _skip_template(s, i, n) + prev_sig, prev_word = "`", "" + continue + if c == "/": + if prev_word in _REGEX_KEYWORDS or prev_sig in _REGEX_PRECEDERS: + i = _skip_regex(s, i, n) + else: + i += 1 + prev_sig, prev_word = "/", "" + continue + if c in _ID_CHARS: + j = i + while j < n and s[j] in _ID_CHARS: + j += 1 + prev_word = s[i:j] + prev_sig = s[j - 1] + i = j + continue + if c == "{": + stack.append(i) + elif c == "}": + if stack: + pairs[stack.pop()] = i + prev_sig, prev_word = c, "" + i += 1 + + return BraceMap(pairs=pairs, opens=sorted(pairs)) + + +def _skip_template(s: str, i: int, n: int) -> int: + i += 1 + while i < n: + if s[i] == "\\": + i += 2 + continue + if s[i] == "`": + return i + 1 + if s[i] == "$" and i + 1 < n and s[i + 1] == "{": + depth = 1 + i += 2 + while i < n and depth: + if s[i] in "\"'`": + q = s[i] + i += 1 + while i < n: + if s[i] == "\\": + i += 2 + continue + if s[i] == q: + i += 1 + break + i += 1 + continue + if s[i] == "{": + depth += 1 + elif s[i] == "}": + depth -= 1 + i += 1 + continue + i += 1 + return i + + +def _skip_regex(s: str, i: int, n: int) -> int: + i += 1 + in_class = False + while i < n: + if s[i] == "\\": + i += 2 + continue + if s[i] == "[": + in_class = True + elif s[i] == "]": + in_class = False + elif s[i] == "/" and not in_class: + i += 1 + break + elif s[i] == "\n": + break + i += 1 + while i < n and s[i] in "gimsuyvd": + i += 1 + return i + + +_STR = r'"((?:[^"\\]|\\.)*)"' + + +def _unescape(raw: str) -> str: + try: + return json.loads('"' + raw + '"') + except Exception: + return raw + + +# -------------------------------------------------------------------------- +# Extracting the registries +# -------------------------------------------------------------------------- + +_TYPE_RE = re.compile(r'type:"(local|local-jsx|prompt)"') +_NAME_RE = re.compile(r'(?:^|[,{])name:' + _STR) +_UFN_RE = re.compile(r"userFacingName\(\)\{return" + _STR) +_DESC_RE = re.compile(r'(?:^|[,{])description:' + _STR) +_MENUDESC_RE = re.compile(r"(?:menuDescription|description):" + _STR) +_ALIAS_RE = re.compile(r"aliases:\[([^\]]*)\]") +_NAME_OK = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9:_-]{0,40}") + +# Names that exist in the build but are never typed by a user. +INTERNAL_NAMES = {"mcp__", "workflow-launch-exec", "pro-trial-expired", "rate-limit-options", "stub"} + + +def extract_builtin_commands(src: str, braces: BraceMap) -> dict[str, dict[str, Any]]: + """Built-in CLI commands, keyed by name. + + Each command is a small object literal carrying `type` and `name`. The + enclosing object is resolved by brace depth, then its own fields are read, + so an adjacent command's description cannot bleed in. + """ + out: dict[str, dict[str, Any]] = {} + for m in _TYPE_RE.finditer(src): + enc = braces.enclosing(m.start()) + if not enc: + continue + open_i, close_i = enc + if close_i - open_i > 4000: + # Too large to be a command literal - this is some enclosing scope. + continue + body = src[open_i : close_i + 1] + nm = _NAME_RE.search(body) or _UFN_RE.search(body) + if not nm: + continue + name = _unescape(nm.group(1)) + if not _NAME_OK.fullmatch(name or ""): + continue + desc = _DESC_RE.search(body) + aliases = _read_aliases(body) + rec = { + "name": name, + "source": "builtin", + "type": m.group(1), + "description": _unescape(desc.group(1)) if desc else "", + "aliases": aliases, + "hidden": "isHidden" in body, + "gated": "isEnabled" in body, + "internal": name in INTERNAL_NAMES, + } + prev = out.get(name) + if prev is None or (not prev["description"] and rec["description"]): + if prev is not None: + rec["aliases"] = sorted(set(prev["aliases"]) | set(aliases)) + out[name] = rec + elif aliases: + prev["aliases"] = sorted(set(prev["aliases"]) | set(aliases)) + return out + + +def _read_aliases(body: str) -> list[str]: + m = _ALIAS_RE.search(body) + if not m: + return [] + return re.findall(r'"([^"]+)"', m.group(1)) + + +def discover_registrar(src: str, export_name: str) -> str | None: + """Resolve a minified registrar function via its readable export name. + + Minified identifiers are regenerated every release, so hardcoding one dates + the script immediately. The bundle's export maps keep the original names + (`registerBundledSkill:()=>xu`), which makes them a stable way in. + """ + m = re.search(re.escape(export_name) + r":\(\)=>([A-Za-z_$][A-Za-z0-9_$]*)", src) + return m.group(1) if m else None + + +def build_const_map(src: str) -> dict[str, str]: + """Map single-valued identifiers to their kebab-case string literal. + + Several bundled skills are registered as `xu({name:gme,...})` where `gme` + is a hoisted constant, so a literal-only scan silently misses them. + """ + seen: dict[str, set[str]] = {} + for m in re.finditer( + r'\b([A-Za-z_$][A-Za-z0-9_$]{0,8})\s*=\s*"([a-z][a-z0-9]*(?:-[a-z0-9]+)*)"', src + ): + seen.setdefault(m.group(1), set()).add(m.group(2)) + return {k: next(iter(v)) for k, v in seen.items() if len(v) == 1} + + +def extract_bundled_skills(src: str) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]: + """Bundled skills, keyed by name, plus notes about resolution.""" + notes: dict[str, Any] = {} + fn = discover_registrar(src, "registerBundledSkill") + notes["registrar"] = fn + if not fn: + notes["error"] = "registerBundledSkill export not found - build layout changed" + return {}, notes + + consts = build_const_map(src) + out: dict[str, dict[str, Any]] = {} + unresolved: list[str] = [] + + for m in re.finditer(re.escape(fn) + r"\(\{", src): + window = src[m.start() : m.start() + 4000] + nm = re.search(r"name:(?:" + _STR + r"|([A-Za-z_$][A-Za-z0-9_$]{0,8}))", window) + if not nm: + continue + if nm.group(1) is not None: + name = _unescape(nm.group(1)) + else: + ident = nm.group(2) + if ident not in consts: + unresolved.append(ident) + continue + name = consts[ident] + desc = _MENUDESC_RE.search(window) + out[name] = { + "name": name, + "source": "bundled-skill", + "description": _unescape(desc.group(1)) if desc else "", + "aliases": _read_aliases(window[:1500]), + "gated": "isEnabled" in window[:1500], + "hidden": "isHidden" in window[:1500], + } + + notes["registrations_seen"] = len(re.findall(re.escape(fn) + r"\(\{", src)) + notes["resolved"] = len(out) + if unresolved: + notes["unresolved_dynamic_names"] = sorted(set(unresolved)) + return out, notes + + +def extract_plugin_backed(src: str) -> dict[str, str]: + """Commands the build registers as plugin-backed (`pluginName`).""" + out: dict[str, str] = {} + pattern = re.compile( + r'name:"([a-z0-9][a-z0-9:_-]{1,40})"' + r'((?:(?!name:")[\s\S]){0,900}?)' + r'pluginName:"([a-z0-9-]+)"' + ) + for m in pattern.finditer(src): + out[m.group(1)] = m.group(3) + return out + + +def detect_cli_version(src: str) -> str | None: + """Best-effort CLI version from the bundle. + + The build stamps its own version far more often than any dependency's, so + the most frequent version-shaped literal is a reliable read without having + to execute the binary. + """ + counts: dict[str, int] = {} + for m in re.finditer(r'"(\d+\.\d+\.\d+)"', src): + counts[m.group(1)] = counts.get(m.group(1), 0) + 1 + if not counts: + return None + best = max(counts.items(), key=lambda kv: kv[1]) + return best[0] if best[1] >= 20 else None + + +def check_integrity( + src: str, + commands: dict[str, Any], + skills: dict[str, Any], + skill_notes: dict[str, Any], +) -> dict[str, Any]: + """Decide whether this extraction can be trusted, and say why. + + A drifted build usually degrades quietly: the script still returns rows, + just fewer than exist. Every check here exists to convert that quiet + shortfall into a stated one, so a downstream reader is never handed a + confident short list. + """ + problems: list[str] = [] + advisories: list[str] = [] + + version = detect_cli_version(src) + if version is None and len(src) > 10_000: + advisories.append( + "cli version could not be detected; counts are believed, not verified against " + f"{VALIDATED_AGAINST}" + ) + elif version and version != VALIDATED_AGAINST: + advisories.append( + f"cli {version} differs from the last validated build {VALIDATED_AGAINST}; " + "counts are believed, not verified - re-run the skill's evals to revalidate" + ) + + missing = [c for c in CANARY_COMMANDS if c not in commands] + if missing: + problems.append( + f"canary commands absent: {', '.join(missing)} - extraction is broken, " + "not merely drifted" + ) + + type_tokens = len(_TYPE_RE.findall(src)) + yield_ratio = (len(commands) / type_tokens) if type_tokens else 0.0 + if type_tokens and yield_ratio < MIN_COMMAND_YIELD: + problems.append( + f"resolved {len(commands)} commands from {type_tokens} type tokens " + f"({yield_ratio:.0%}); the brace reader is not resolving enclosing objects" + ) + + found_registrars = set(re.findall(r"\b(register[A-Za-z]*)\s*:\(\)=>", src)) + registrar_like = { + r for r in found_registrars if re.search(r"(Skill|Command|Agent)", r) + } + unknown = sorted(registrar_like - KNOWN_REGISTRAR_EXPORTS) + if unknown: + advisories.append( + "unrecognised registrar-shaped exports: " + + ", ".join(unknown) + + " - a new registration path may exist and this run may under-report" + ) + + seen = skill_notes.get("registrations_seen") + resolved = skill_notes.get("resolved") + if isinstance(seen, int) and isinstance(resolved, int) and seen > resolved: + advisories.append( + f"{seen - resolved} bundled-skill registration(s) used a computed name and " + "were not resolved; the bundled-skill list is a floor, not a total" + ) + + if not skills: + problems.append("no bundled skills resolved - the registrar lookup failed") + + status = "broken" if problems else ("degraded" if advisories else "ok") + return { + "status": status, + "cli_version": version, + "validated_against": VALIDATED_AGAINST, + "command_yield": round(yield_ratio, 3), + "type_tokens": type_tokens, + "registrars_seen": sorted(registrar_like), + "problems": problems, + "advisories": advisories, + } + + +# -------------------------------------------------------------------------- +# Disk inventory +# -------------------------------------------------------------------------- + + +def config_dir() -> Path: + env = os.environ.get("CLAUDE_CONFIG_DIR") + return Path(env) if env else Path.home() / ".claude" + + +def _load_json(path: Path) -> Any | None: + try: + with path.open(encoding="utf-8") as fh: + return json.load(fh) + except (OSError, json.JSONDecodeError): + return None + + +def _merge_enabled_plugins(*maps: dict[str, Any] | None) -> dict[str, bool]: + """Merge enabledPlugins maps with later scopes overriding earlier ones.""" + merged: dict[str, bool] = {} + for m in maps: + if not isinstance(m, dict): + continue + for key, val in m.items(): + merged[str(key)] = bool(val) + return merged + + +def _scope_rank(scope: str) -> int: + return {"user": 1, "project": 2, "local": 3}.get(scope, 0) + + +def _pick_install_record( + records: list[dict[str, Any]], project_path: Path | None +) -> dict[str, Any] | None: + """Choose the install record for this project with local > project > user precedence.""" + project_norm = str(project_path).replace("\\", "/").rstrip("/") if project_path else "" + candidates: list[dict[str, Any]] = [] + for rec in records: + if not isinstance(rec, dict) or not rec.get("installPath"): + continue + rec_project = str(rec.get("projectPath") or "").replace("\\", "/").rstrip("/") + if rec_project: + if not project_norm: + continue + if rec_project != project_norm and not project_norm.startswith(rec_project + "/"): + continue + candidates.append(rec) + if not candidates: + return None + return max(candidates, key=lambda r: _scope_rank(str(r.get("scope") or "user"))) + + +def _manifest_component_path(root: Path, manifest: dict[str, Any], spec: dict[str, str]) -> Path | None: + """Resolve a component location from plugin.json, falling back to the default layout.""" + manifest_key = spec.get("manifest") or "" + declared = manifest + for part in manifest_key.split("."): + if not part: + continue + if not isinstance(declared, dict): + declared = {} + break + declared = declared.get(part) + if isinstance(declared, str) and declared.strip(): + rel = declared.strip().lstrip("./") + return root / rel + if spec["kind"] == "file": + return root / spec["file"] if spec.get("file") else None + return root / spec["dir"] if spec.get("dir") else None + + +def scan_disk(root: Path, project_root: Path | None = None) -> dict[str, Any]: + """Enumerate installed plugins, their components, and config-scope surfaces.""" + out: dict[str, Any] = {"config_dir": str(root), "exists": root.is_dir()} + + user_settings = _load_json(root / "settings.json") or {} + project_settings: dict[str, Any] = {} + local_settings: dict[str, Any] = {} + if project_root is not None: + project_claude = project_root / ".claude" + project_settings = _load_json(project_claude / "settings.json") or {} + local_settings = _load_json(project_claude / "settings.local.json") or {} + out["project_root"] = str(project_root) + + enabled_map = _merge_enabled_plugins( + user_settings.get("enabledPlugins") if isinstance(user_settings.get("enabledPlugins"), dict) else None, + project_settings.get("enabledPlugins") if isinstance(project_settings.get("enabledPlugins"), dict) else None, + local_settings.get("enabledPlugins") if isinstance(local_settings.get("enabledPlugins"), dict) else None, + ) + out["enabled_plugins"] = { + "total_entries": len(enabled_map), + "enabled": sorted(k for k, v in enabled_map.items() if v), + "disabled": sorted(k for k, v in enabled_map.items() if not v), + } + + installed = _load_json(root / "plugins" / "installed_plugins.json") or {} + plugin_index = installed.get("plugins") if isinstance(installed, dict) else {} + marketplaces: dict[str, Any] = {} + for key, enabled in sorted(enabled_map.items()): + if not enabled: + continue + records = plugin_index.get(key) if isinstance(plugin_index, dict) else None + if not isinstance(records, list): + records = [] + rec = _pick_install_record(records, project_root) + if rec is None: + market = key.split("@")[-1] if "@" in key else "unknown" + marketplaces.setdefault(market, {"plugins": {}})["plugins"][key] = { + "status": "unresolved", + "note": "no installPath in installed_plugins.json for this project", + } + continue + install_path = Path(str(rec["installPath"])) + market = key.split("@")[-1] if "@" in key else "unknown" + marketplaces.setdefault(market, {"plugins": {}})["plugins"][key] = scan_plugin(install_path) | { + "install_path": str(install_path), + "install_scope": rec.get("scope"), + "install_version": rec.get("version"), + } + + out["marketplaces"] = marketplaces + out["config_scope_components"] = scan_config_scope(root) + if project_root is not None: + out["project_scope_components"] = scan_project_scope(project_root) + return out + + +def scan_marketplace(loc: Path) -> dict[str, Any]: + """Every plugin a marketplace checkout offers, with its component counts.""" + found: dict[str, Any] = {} + if not loc.is_dir(): + return found + for parent in ("plugins", "external_plugins"): + base = loc / parent + if not base.is_dir(): + continue + for entry in sorted(base.iterdir()): + if entry.is_dir() and not entry.name.startswith("."): + found[entry.name] = scan_plugin(entry) | {"catalog_section": parent} + return found + + +def scan_plugin(root: Path) -> dict[str, Any]: + """Component inventory for one plugin directory.""" + manifest = _load_json(root / ".claude-plugin" / "plugin.json") or {} + info: dict[str, Any] = { + "version": manifest.get("version"), + "has_manifest": bool(manifest), + "components": {}, + } + if isinstance(manifest.get("dependencies"), list): + info["dependencies"] = manifest["dependencies"] + + for comp, spec in PLUGIN_COMPONENTS.items(): + names = _scan_component(root, spec, manifest) + if names: + info["components"][comp] = names + return info + + +def _scan_component(root: Path, spec: dict[str, str], manifest: dict[str, Any] | None = None) -> list[str]: + manifest = manifest or {} + kind = spec["kind"] + if kind == "file": + target = _manifest_component_path(root, manifest, spec) or (root / spec["file"]) + rel = target.relative_to(root).as_posix() if target.is_file() else spec["file"] + return [rel] if target.is_file() else [] + + directory = _manifest_component_path(root, manifest, spec) + if directory is None or not directory.is_dir(): + return [] + out: list[str] = [] + if kind == "dir-of-dirs": + for entry in sorted(directory.iterdir()): + if entry.is_dir() and (entry / "SKILL.md").is_file(): + out.append(entry.name) + else: + for entry in sorted(directory.iterdir()): + if entry.is_file() and not entry.name.startswith("."): + out.append(entry.name) + return out + + +def scan_project_scope(project_root: Path) -> dict[str, Any]: + """Project-scoped invocable surfaces outside the user config dir.""" + claude = project_root / ".claude" + out: dict[str, Any] = {} + skills = claude / "skills" + if skills.is_dir(): + out["skills"] = sorted( + e.name for e in skills.iterdir() if e.is_dir() and (e / "SKILL.md").is_file() + ) + for name, sub in (("agents", "agents"), ("commands", "commands")): + d = claude / sub + if d.is_dir(): + out[name] = sorted(e.name for e in d.iterdir() if e.is_file()) + for label, fname in (("project", "settings.json"), ("local", "settings.local.json")): + settings = _load_json(claude / fname) or {} + if isinstance(settings.get("hooks"), dict): + out.setdefault("hooks_events", {})[label] = sorted(settings["hooks"].keys()) + enabled = settings.get("enabledPlugins") + if isinstance(enabled, dict): + out.setdefault("enabled_plugins", {})[label] = { + "enabled": sorted(k for k, v in enabled.items() if v), + "disabled": sorted(k for k, v in enabled.items() if not v), + } + mcp = _load_json(project_root / ".mcp.json") + if isinstance(mcp, dict) and isinstance(mcp.get("mcpServers"), dict): + out["mcp_servers"] = sorted(mcp["mcpServers"].keys()) + return out + + +def project_root_from_env() -> Path | None: + env = os.environ.get("CLAUDE_PROJECT_DIR") + return Path(env) if env else None + + +def scan_config_scope(root: Path) -> dict[str, Any]: + """Components installed directly in the config dir, outside any plugin.""" + out: dict[str, Any] = {} + skills = root / "skills" + if skills.is_dir(): + out["skills"] = sorted( + e.name for e in skills.iterdir() if e.is_dir() and (e / "SKILL.md").is_file() + ) + for name, sub in (("agents", "agents"), ("commands", "commands")): + d = root / sub + if d.is_dir(): + out[name] = sorted(e.name for e in d.iterdir() if e.is_file()) + settings = _load_json(root / "settings.json") or {} + if isinstance(settings.get("hooks"), dict): + out["hooks_events"] = sorted(settings["hooks"].keys()) + mcp = _load_json(root / ".mcp.json") + if isinstance(mcp, dict) and isinstance(mcp.get("mcpServers"), dict): + out["mcp_servers"] = sorted(mcp["mcpServers"].keys()) + return out + + +# -------------------------------------------------------------------------- +# Assembly +# -------------------------------------------------------------------------- + + +def build_report(args: argparse.Namespace) -> dict[str, Any]: + report: dict[str, Any] = { + "schema": 1, + "host": { + "platform": platform.system(), + "python": platform.python_version(), + }, + "sources": {}, + } + + if not args.disk_only: + binary, how = pick_binary(args.binary) + if binary is None: + report["sources"]["binary"] = {"available": False, "reason": how} + else: + src, meta = read_bundle(binary) + meta["selected_by"] = how + if src is None: + report["sources"]["binary"] = {"available": False, **meta} + else: + braces = build_brace_map(src) + meta["brace_pairs"] = len(braces.pairs) + commands = extract_builtin_commands(src, braces) + skills, skill_notes = extract_bundled_skills(src) + plugin_backed = extract_plugin_backed(src) + + for name, plugin in plugin_backed.items(): + if name in commands: + commands[name]["plugin_name"] = plugin + commands[name]["source"] = "plugin-backed-builtin" + elif name in skills: + skills[name]["plugin_name"] = plugin + skills[name]["source"] = "plugin-backed-builtin" + + # A name registered as a bundled skill is a skill, not a command. + for name in list(commands): + if name in skills: + del commands[name] + + report["sources"]["binary"] = {"available": True, **meta} + report["builtin_commands"] = commands + report["bundled_skills"] = skills + report["bundled_skill_notes"] = skill_notes + report["plugin_backed"] = plugin_backed + report["integrity"] = check_integrity(src, commands, skills, skill_notes) + + if not args.binary_only: + report["sources"]["disk"] = {"available": True} + project = Path(args.project_dir) if args.project_dir else project_root_from_env() + report["disk"] = scan_disk( + Path(args.config_dir) if args.config_dir else config_dir(), + project, + ) + + return report + + +def main(argv: list[str] | None = None) -> int: + if sys.version_info < MIN_PYTHON: + print( + f"python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ required, running " + f"{platform.python_version()}", + file=sys.stderr, + ) + return 2 + + ap = argparse.ArgumentParser( + description="Enumerate the Claude Code ecosystem on this machine (read-only)." + ) + ap.add_argument("--binary", help="path to the claude executable (default: auto-detect)") + ap.add_argument("--config-dir", help="config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)") + ap.add_argument("--project-dir", help="project root (default: $CLAUDE_PROJECT_DIR when set)") + ap.add_argument("--binary-only", action="store_true", help="skip the disk scan") + ap.add_argument("--disk-only", action="store_true", help="skip reading the binary") + ap.add_argument("--out", help="write JSON here instead of stdout") + ap.add_argument( + "--self-check", + action="store_true", + help="print only the integrity verdict; exit 1 if extraction is broken, " + "2 if it is degraded. For CI and scheduled drift checks.", + ) + args = ap.parse_args(argv) + + if args.binary_only and args.disk_only: + print("--binary-only and --disk-only are mutually exclusive", file=sys.stderr) + return 2 + + if args.self_check: + args.disk_only = False + args.binary_only = True + + report = build_report(args) + + if args.self_check: + integrity = report.get("integrity") + if integrity is None: + reason = report.get("sources", {}).get("binary", {}).get( + "error", "binary source unavailable" + ) + print(f"BROKEN: {reason}") + return 1 + print(f"{integrity['status'].upper()}: cli {integrity['cli_version']}, " + f"validated against {integrity['validated_against']}") + for p in integrity["problems"]: + print(f" problem: {p}") + for a in integrity["advisories"]: + print(f" advisory: {a}") + return {"ok": 0, "broken": 1, "degraded": 2}[integrity["status"]] + + text = json.dumps(report, indent=1, sort_keys=True) + if args.out: + Path(args.out).write_text(text, encoding="utf-8") + print(f"wrote {args.out}", file=sys.stderr) + else: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py new file mode 100755 index 000000000..70d227593 --- /dev/null +++ b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Deterministic tests for the inventory extractor. + +Every case runs against a synthetic minified fragment rather than a real +Claude Code build, so the suite is fast, hermetic, and does not change its +verdict when the installed CLI updates. The fragments reproduce the shapes +observed in a real bundle, including the ones that broke earlier drafts. + +Run: python3 test_inventory.py +""" + +from __future__ import annotations + +import unittest + +import inventory as inv + + +class TestBraceMap(unittest.TestCase): + def test_matches_simple_pairs(self) -> None: + bm = inv.build_brace_map("a={b:{c:1}}") + self.assertEqual(len(bm.pairs), 2) + + def test_ignores_braces_inside_strings(self) -> None: + bm = inv.build_brace_map('x={a:"}{"}') + self.assertEqual(len(bm.pairs), 1) + + def test_ignores_braces_inside_regex(self) -> None: + # A regex literal containing an unbalanced brace would desync a naive + # counter and misattribute every object after it. + bm = inv.build_brace_map("x={a:/[{]/g}") + self.assertEqual(len(bm.pairs), 1) + + def test_ignores_braces_inside_template(self) -> None: + bm = inv.build_brace_map("x={a:`v${b}`}") + self.assertEqual(len(bm.pairs), 1) + + def test_division_is_not_a_regex(self) -> None: + # `a/b` followed by `{` must not swallow the object as a regex body. + bm = inv.build_brace_map("y=a/b;x={c:1}") + self.assertEqual(len(bm.pairs), 1) + + def test_enclosing_finds_innermost(self) -> None: + src = "x={outer:{inner:1}}" + bm = inv.build_brace_map(src) + pos = src.index("inner") + enc = bm.enclosing(pos) + self.assertIsNotNone(enc) + assert enc is not None + self.assertEqual(src[enc[0] : enc[1] + 1], "{inner:1}") + + +class TestCommandExtraction(unittest.TestCase): + # Two command literals packed flush together, as the real bundle packs + # them. A fixed-width text window around the first would capture the + # second's description; brace depth is what keeps them apart. + ADJACENT = ( + 'var a={type:"local-jsx",name:"artifacts",aliases:[],' + 'description:"Browse your published and shared artifacts",isEnabled:()=>Z9()},' + 'b={type:"local-jsx",name:"btw",description:"Ask a quick side question"};' + ) + + def _extract(self, src: str) -> dict: + return inv.extract_builtin_commands(src, inv.build_brace_map(src)) + + def test_adjacent_literals_do_not_bleed(self) -> None: + got = self._extract(self.ADJACENT) + self.assertEqual( + got["artifacts"]["description"], "Browse your published and shared artifacts" + ) + self.assertEqual(got["btw"]["description"], "Ask a quick side question") + + def test_gated_and_hidden_flags(self) -> None: + got = self._extract(self.ADJACENT) + self.assertTrue(got["artifacts"]["gated"]) + self.assertFalse(got["artifacts"]["hidden"]) + src = 'x={type:"local",name:"heapdump",description:"d",get isHidden(){return!0}};' + self.assertTrue(self._extract(src)["heapdump"]["hidden"]) + + def test_aliases_are_collected(self) -> None: + src = ( + 'x={type:"local-jsx",name:"usage",aliases:["cost","stats"],' + 'description:"Show session cost"};' + ) + self.assertEqual(self._extract(src)["usage"]["aliases"], ["cost", "stats"]) + + def test_shell_builtin_is_not_a_command(self) -> None: + # `alias` and `todos` carry a name but no `type:` - both were wrongly + # reported as slash commands by an earlier regex-only pass. + src = ( + '{name:"alias",description:"Create or list command aliases",' + 'args:{name:"definition"}}' + ) + self.assertEqual(self._extract(src), {}) + + def test_userfacingname_is_a_fallback(self) -> None: + src = 'x={type:"local-jsx",userFacingName(){return"autofix-pr"},description:"d"};' + self.assertIn("autofix-pr", self._extract(src)) + + def test_internal_names_are_marked(self) -> None: + src = 'x={type:"prompt",name:"mcp__",description:"d"};' + self.assertTrue(self._extract(src)["mcp__"]["internal"]) + + +class TestBundledSkills(unittest.TestCase): + BUNDLE = ( + 'pt(Q,{registerBundledSkill:()=>xu,getBundledSkills:()=>dF});' + 'var gme="code-review",dvz="dataviz";' + 'xu({name:gme,aliases:["review"],menuDescription:"Review the current diff"});' + 'xu({name:dvz,menuDescription:"Chart and dashboard design guidance"});' + 'xu({name:"doctor",aliases:["checkup"],menuDescription:"Health-check your setup"});' + ) + + def test_registrar_is_discovered_not_hardcoded(self) -> None: + self.assertEqual(inv.discover_registrar(self.BUNDLE, "registerBundledSkill"), "xu") + + def test_missing_export_returns_none(self) -> None: + self.assertIsNone(inv.discover_registrar("var x=1;", "registerBundledSkill")) + + def test_constant_names_resolve(self) -> None: + # The failure this guards: a literal-only scan silently drops roughly a + # third of the bundled skills, including code-review and dataviz. + skills, notes = inv.extract_bundled_skills(self.BUNDLE) + self.assertEqual(notes["registrar"], "xu") + self.assertIn("code-review", skills) + self.assertIn("dataviz", skills) + self.assertIn("doctor", skills) + self.assertEqual(skills["code-review"]["aliases"], ["review"]) + + def test_ambiguous_constant_is_not_resolved(self) -> None: + # An identifier bound to two different strings cannot be resolved + # safely, so it must be reported rather than guessed. + src = self.BUNDLE + 'var zz="a-one";var zz="a-two";xu({name:zz});' + _, notes = inv.extract_bundled_skills(src) + self.assertGreater(notes["registrations_seen"], notes["resolved"]) + + +class TestIntegrity(unittest.TestCase): + def _src(self, extra: str = "") -> str: + return ( + 'pt(Q,{registerBundledSkill:()=>xu});' + + "".join(f'x{i}={{type:"local",name:"{n}",description:"d"}};' + for i, n in enumerate(inv.CANARY_COMMANDS)) + + extra + ) + + def _commands(self, src: str) -> dict: + return inv.extract_builtin_commands(src, inv.build_brace_map(src)) + + def test_ok_when_everything_resolves(self) -> None: + src = self._src() + got = inv.check_integrity( + src, self._commands(src), {"a": {}}, {"registrations_seen": 1, "resolved": 1} + ) + self.assertEqual(got["status"], "ok") + + def test_broken_when_canary_missing(self) -> None: + src = 'x={type:"local",name:"help",description:"d"};' + got = inv.check_integrity(src, self._commands(src), {"a": {}}, {}) + self.assertEqual(got["status"], "broken") + self.assertTrue(any("canary" in p for p in got["problems"])) + + def test_broken_when_no_skills_resolve(self) -> None: + src = self._src() + got = inv.check_integrity(src, self._commands(src), {}, {}) + self.assertEqual(got["status"], "broken") + + def test_degraded_on_unknown_registrar(self) -> None: + # The silent-drift signal: a registration path the script does not know. + src = self._src("pt(Q,{registerSomethingNewSkill:()=>zz});") + got = inv.check_integrity( + src, self._commands(src), {"a": {}}, {"registrations_seen": 1, "resolved": 1} + ) + self.assertEqual(got["status"], "degraded") + self.assertTrue(any("registerSomethingNewSkill" in a for a in got["advisories"])) + + def test_degraded_when_registrations_exceed_resolved(self) -> None: + src = self._src() + got = inv.check_integrity( + src, self._commands(src), {"a": {}}, {"registrations_seen": 5, "resolved": 3} + ) + self.assertEqual(got["status"], "degraded") + self.assertTrue(any("floor" in a for a in got["advisories"])) + + def test_low_yield_is_broken(self) -> None: + # Many registration tokens present but few commands resolved means the + # brace reader stopped working - the exact shape of a quiet shortfall. + src = self._src() + 'type:"local"' * 200 + got = inv.check_integrity( + src, self._commands(src), {"a": {}}, {"registrations_seen": 1, "resolved": 1} + ) + self.assertEqual(got["status"], "broken") + + +class TestContainerAndVersion(unittest.TestCase): + def test_detects_containers(self) -> None: + self.assertEqual(inv.detect_container(b"MZ\x90\x00"), "PE") + self.assertEqual(inv.detect_container(b"\x7fELF\x02"), "ELF") + self.assertEqual(inv.detect_container(b"\xcf\xfa\xed\xfe"), "Mach-O") + self.assertEqual(inv.detect_container(b"nope"), "unknown") + + def test_version_needs_repetition(self) -> None: + # One mention is a dependency's version, not the build's. + self.assertIsNone(inv.detect_cli_version('"9.9.9"')) + self.assertEqual(inv.detect_cli_version('"2.1.228"' * 30), "2.1.228") + + +class TestPluginBacked(unittest.TestCase): + def test_finds_plugin_name(self) -> None: + src = ( + 'x={name:"security-review",description:"Complete a security review",' + 'pluginName:"security-review",pluginCommand:"security-review"};' + ) + self.assertEqual(inv.extract_plugin_backed(src), {"security-review": "security-review"}) + + +if __name__ == "__main__": + unittest.main(verbosity=2)