Claude Code Plugin Shared Source Enable Disable
You installed a shared plugin source weeks ago, wired it into a teammate's workflow, and now half your claude sessions are loading commands you no longer want active while another machine is missing the very source you thought was global. Maybe the marketplace entry stayed enabled after you stopped trusting its author, or maybe a coworker disabled a source on a shared dotfiles repo and you cannot tell from the CLI output which state you are actually in. The frustrating part is that claude does not crash or warn — it silently skips disabled sources, silently loads enabled ones, and leaves you guessing why a command that worked yesterday now returns "unknown command" today.
This walkthrough fixes that ambiguity. By the end you will have a reproducible procedure for listing every shared plugin source Claude Code knows about, flipping individual sources between enabled and disabled without uninstalling them, and confirming the change took effect before you hand the machine back to your team. The working surface is the claude CLI itself plus the JSON state it writes under ~/.claude/ — no extra tooling, no Node or Python dependencies beyond what Claude Code already ships. We will read the [plugins.json](https://claudeplugins.nicedx.com/claude-code-bash-hook-posttooluse/) and settings.json files directly when the CLI output is not enough, and use jq for the one case where a structured query beats eyeballing nested keys.
This is written for developers who already have Claude Code installed and have added at least one shared plugin source from a marketplace or a Git URL, but who have never had to audit or toggle that source's state. When you are done you will be able to walk into any of your machines, answer "is this source enabled here?" in under thirty seconds, and reverse the answer just as fast.
Step 1: Modeling Shared Plugin Sources as Immutable Value Objects
Before a tool can toggle a shared plugin source on or off, the shared source itself needs a real type. Treating it as a loose dictionary leaks responsibilities everywhere — every call site re-checks the URL prefix, every persistence path re-implements the enabled flag, and every test has to invent its own shape. Step 1 closes that gap by introducing a PluginSource value object that owns its invariants and exposes enable/disable as pure transformations.
The design is deliberately small. We want a frozen dataclass, a discriminator for the source kind, location validation tuned to that kind, and three idempotent transition methods. Once this foundation exists, every later step — listing, enabling, disabling, verifying — operates on the same object instead of arguing about field names.
Setup
We start from an empty claudeplugins workspace and create four files inside the codebase/ half of the repository:
pyproject.toml— declares the package, points pytest at thetests/directory, and pinsrequires-python = ">=3.9".claude_plugin_sources/__init__.py— re-exportsPluginSourceandSourceKindso consumers canfrom claude_plugin_sources import PluginSource.claude_plugin_sources/source.py— the actual model.tests/test_source.py— eighteen pytest cases covering construction, validation, transitions, serialization, and immutability.
No third-party dependencies are needed. The standard library dataclasses and enum modules cover everything in this step, and pytest is the only dev tool. Keeping the dependency surface at zero now means the model can be lifted into any later runtime — a CLI, a hook, a server — without dragging a framework along.
Implementation
The discriminator comes first, because the location validator branches on it:
class SourceKind(str, Enum):
GIT = "git"
LOCAL = "local"
HTTP = "http"
_ALLOWED_SCHEMES = {
SourceKind.GIT: ("https://", "git@", "ssh://"),
SourceKind.HTTP: ("https://", "http://"),
SourceKind.LOCAL: ("/", "./", "~"),
}
SourceKind subclasses str, which means JSON serializers, log lines, and equality checks all treat the enum member as its string value without extra coercion. The _ALLOWED_SCHEMES table is the single source of truth for what counts as a legal location per kind — adding a new transport later is a one-line change here.
Validation is split into two private helpers so that __post_init__ reads as a sequence of intent, not a mass of conditions:
def _normalize_name(raw: str) -> str:
cleaned = raw.strip()
if not cleaned:
raise ValueError("plugin source name must not be blank")
if any(ch.isspace() for ch in cleaned):
raise ValueError("plugin source name must not contain whitespace")
return cleaned
def _validate_location(kind: SourceKind, location: str) -> str:
stripped = location.strip()
if not stripped:
raise ValueError("plugin source location must not be blank")
prefixes = _ALLOWED_SCHEMES[kind]
if not stripped.startswith(prefixes):
allowed = ", ".join(prefixes)
raise ValueError(
f"{kind.value} source location must start with one of: {allowed}"
)
return stripped
Each helper returns the cleaned value rather than mutating in place. That keeps the dataclass frozen contract intact: we compute the final value, then write it once through object.__setattr__ in __post_init__.
The model itself is a frozen dataclass that exposes transitions as methods that return new instances:
@dataclass(frozen=True)
class PluginSource:
name: str
location: str
kind: SourceKind = SourceKind.GIT
enabled: bool = False
description: Optional[str] = None
def __post_init__(self) -> None:
object.__setattr__(self, "name", _normalize_name(self.name))
object.__setattr__(self, "location", _validate_location(self.kind, self.location))
def enable(self) -> "PluginSource":
if self.enabled:
return self
return replace(self, enabled=True)
def disable(self) -> "PluginSource":
if not self.enabled:
return self
return replace(self, enabled=False)
def toggled(self) -> "PluginSource":
return replace(self, enabled=not self.enabled)
def state_label(self) -> str:
return "enabled" if self.enabled else "disabled"
def as_settings_entry(self) -> dict:
entry = {
"name": self.name,
"location": self.location,
"kind": self.kind.value,
"enabled": self.enabled,
}
if self.description is not None:
entry["description"] = self.description
return entry
Two design choices deserve a callout. First, enable() and disable() short-circuit to return self when already in the target state — the tests pin this with an identity assertion, which lets later steps cheaply detect "no-op" transitions without diffing fields. Second, as_settings_entry() omits description when it is None, so the serialized form stays minimal and round-trips cleanly through settings files that treat absent keys as defaults.
Verification
Run the test suite from inside codebase/:
python3 -m pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
rootdir: /codebase
configfile: pyproject.toml
testpaths: tests
collected 18 items
tests/test_source.py .................. [100%]
============================== 18 passed in 0.08s ==============================
All eighteen cases pass: construction defaults, blank/whitespace rejection, per-kind scheme rules, idempotent transitions, the settings-entry shape, and frozen-dataclass equality plus hashing.
What we built
We now have a PluginSource value object that knows what a legal shared source looks like and refuses to exist in an invalid state. Locations are checked against the scheme table for their kind, names are trimmed and forbidden from containing whitespace, and the dataclass is frozen so accidental mutation raises immediately.
State transitions are pure. enable(), disable(), and toggled() return new instances rather than mutating the receiver, and the two directional methods return self unchanged when already in the target state. That gives downstream code a cheap way to detect "nothing happened" without comparing fields.
Serialization is the bridge to the next step. as_settings_entry() produces a plain dict shaped for a settings file, omitting absent metadata. Step 2 will consume this exact shape to list currently configured sources alongside their enabled/disabled state, so locking it in here saves a refactor later.
The package surface is intentionally tiny: two public names, no dependencies, and a test suite that runs in under a tenth of a second. Every later step plugs into this foundation rather than reshaping it.
Repository
The state of the code after this step: 1beb06a
Step 2: Reading Configured Sources Into a Filterable Registry
Step 1 left us with a PluginSource value object that knows what a legal shared source looks like and exposes pure enable/disable transitions. That gets us a unit, but not a view — there is still no way to ask "what is currently configured?" without hand-rolling a loop at every call site. Before any later step can flip state, an operator needs to see what the existing state actually is.
Step 2 fills that gap with a PluginSourceRegistry collection type. It parses entries from a settings mapping, enforces a uniqueness invariant on names, exposes order-preserving views over the enabled and disabled subsets, and renders a plain-text table that an operator can scan at a glance. Locking the listing contract in now means the upcoming toggle commands have a single object to mutate against.
Setup
We stay inside the codebase/ half of the workspace and add two new files alongside the model from step 1:
claude_plugin_sources/registry.py— thePluginSourceRegistryclass plus theparse_source_entryhelper that turns raw settings dicts into validatedPluginSourceinstances.tests/test_registry.py— thirty pytest cases covering empty registries, insertion-order preservation, enabled/disabled filters, lookup by name, uniqueness, settings round-trip, table rendering, and equality plus hashing.
The package's __init__.py is extended to re-export PluginSourceRegistry, parse_source_entry, and the SETTINGS_KEY constant, so callers keep importing from the top-level package. No new third-party dependencies are introduced — the registry leans entirely on typing, dataclasses (transitively, via the model), and the standard collection protocols.
Implementation
The module starts with the public settings key and a single parser function. Splitting parsing out from the class keeps the registry constructor honest about taking real PluginSource instances:
SETTINGS_KEY = "pluginSources"
def parse_source_entry(entry: Mapping[str, object]) -> PluginSource:
required = {"name", "location", "kind"}
missing = sorted(required - set(entry))
if missing:
raise ValueError(
f"plugin source entry missing required keys: {missing}"
)
raw_kind = entry["kind"]
try:
kind = SourceKind(raw_kind)
except ValueError as exc:
raise ValueError(f"unknown plugin source kind: {raw_kind!r}") from exc
return PluginSource(
name=str(entry["name"]),
location=str(entry["location"]),
kind=kind,
enabled=bool(entry.get("enabled", False)),
description=_optional_description(entry.get("description")),
)
The required-key check uses set subtraction so the error message lists every missing field at once instead of failing on the first one. The SourceKind(raw_kind) call does double duty: it validates enum membership and converts the raw string in one step — anything outside git, local, or http raises with the offending value quoted, which is what the test for unknown kinds pins.
The registry itself is built on a frozen tuple of sources and uses __slots__ so it stays cheap to allocate and impossible to grow new attributes by accident:
class PluginSourceRegistry:
__slots__ = ("_sources",)
def __init__(self, sources: Iterable[PluginSource] = ()) -> None:
ordered = tuple(sources)
_ensure_unique_names(ordered)
self._sources = ordered
@classmethod
def from_settings(cls, settings: Mapping[str, object]) -> "PluginSourceRegistry":
raw = settings.get(SETTINGS_KEY, [])
if isinstance(raw, (str, bytes)) or not isinstance(raw, Sequence):
raise ValueError(
f"settings[{SETTINGS_KEY!r}] must be a list of entries"
)
parsed = [parse_source_entry(entry) for entry in raw]
return cls(parsed)
from_settings is the bridge from a settings file to a typed registry. The string-and-bytes guard before the Sequence check is deliberate: both satisfy the Sequence protocol, and accepting them silently would parse a stray string as a list of characters. Missing the top-level key is treated as "empty registry", which keeps the call site simple when the file has never been written.
The read-only views are one-liners over the tuple, but they encode the rules the listing has to obey:
def all(self) -> Tuple[PluginSource, ...]:
return self._sources
def enabled(self) -> Tuple[PluginSource, ...]:
return tuple(s for s in self._sources if s.enabled)
def disabled(self) -> Tuple[PluginSource, ...]:
return tuple(s for s in self._sources if not s.enabled)
def find(self, name: str) -> Optional[PluginSource]:
for source in self._sources:
if source.name == name:
return source
return None
Every accessor returns a tuple so callers cannot mutate the underlying storage by appending to a returned list. Iteration order is the insertion order from the settings file, and the enabled/disabled filters preserve relative order within each subset — that property is what the table renderer below relies on.
The table is plain text on purpose. There is no Rich, no Tabulate, just a width-padded ASCII layout that pipes cleanly into shell tooling:
def render_table(self) -> str:
if not self._sources:
return "(no plugin sources configured)"
headers = ("NAME", "KIND", "STATE", "LOCATION")
rows = [
(s.name, s.kind.value, s.state_label(), s.location)
for s in self._sources
]
widths = _column_widths(headers, rows)
lines = [_format_row(headers, widths)]
lines.extend(_format_row(row, widths) for row in rows)
return "\n".join(lines)
The empty case returns a parenthesized sentinel rather than a header row with no data, so an operator can tell at a glance the difference between "nothing configured" and "configured but all disabled". state_label() comes from the model and returns "enabled" or "disabled", which keeps the rendering vocabulary aligned with the toggle commands that will land in the next step.
Uniqueness is enforced once, at construction time, by a small helper:
def _ensure_unique_names(sources: Sequence[PluginSource]) -> None:
seen: set = set()
for source in sources:
if source.name in seen:
raise ValueError(f"duplicate plugin source name: {source.name!r}")
seen.add(source.name)
Putting this in the constructor (rather than at the parser layer) means the invariant holds for any registry — whether built from a settings file, from in-memory PluginSource instances, or from the output of a later toggle operation. The error message quotes the duplicated name so a stale settings file is easy to grep for.
Verification
Run the test suite from inside codebase/:
python3 -m pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
configfile: pyproject.toml
testpaths: tests
collected 48 items
tests/test_registry.py .............................. [ 62%]
tests/test_source.py .................. [100%]
============================== 48 passed in 0.10s ==============================
All forty-eight tests pass: the eighteen model cases from step 1 plus thirty new registry cases covering empty listings, insertion-order preservation, enabled/disabled filtering, name lookup, the contains protocol, uniqueness enforcement, settings round-trip, table rendering, and equality plus hashing.
What we built
We now have a PluginSourceRegistry that loads a list of shared sources from a settings mapping, refuses to construct itself with duplicate names, and exposes the configured set as immutable tuples. The enabled() and disabled() views are the read API the toggle commands in later steps will diff against — they preserve insertion order, so a user-facing listing matches the on-disk order of the settings file.
Settings round-trip is closed. parse_source_entry and from_settings consume the dict shape that PluginSource.as_settings_entry emits, and as_settings() serializes the registry back into the same shape. The dedicated round-trip test confirms that a registry survives the trip from object to dict and back without losing fields or reordering rows.
The render_table() method gives an operator a single command's worth of output that answers "what is currently configured, and which entries are on?" without needing any UI dependency. The empty-registry case prints an explicit placeholder, distinguishing "no sources" from "all sources disabled" — a distinction the enable command in the next step has to respect when it reports a no-op.
Equality, hashing, iteration, length, and the in operator are all wired through to the internal tuple, so a registry behaves like the collection a caller expects. That uniform surface is what lets step 3 hang the actual enable/disable commands off this object without re-inventing how a registry compares to itself or how it is sliced.
Repository
The state of the code after this step: e3a7b30
Step 3: Wiring the Enable Command Around a Three-Outcome Result
Step 2 left us with a PluginSourceRegistry that can load shared sources from a settings mapping, expose enabled and disabled views, and render the configured set as a table. What it still cannot do is change anything. An operator can see what is on or off, but there is no command that takes a name and flips the corresponding entry. Step 3 closes that loop without turning the registry into a mutable container.
The shape we want is a pure function — enable_source(registry, name) — that returns a result object describing what happened. We need three distinct outcomes: the named source does not exist, the source already has its enabled flag set, or the source was actually flipped from off to on. Each outcome carries enough context for a CLI to print an accurate message and for a hook to decide whether to persist a new settings file.
Setup
We stay inside the codebase/ half of the workspace and add one new module plus one new test file alongside the model and registry from earlier steps:
claude_plugin_sources/commands.py— declares theEnableOutcomeenum, theEnableResultdataclass, and theenable_sourcefunction. This is the only new public surface for step 3.tests/test_commands.py— thirty-six pytest cases organized into six classes, one per behavioral concern: registry replace, the not-found path, the already-enabled path, the freshly-enabled path, the result-shape contract, and an end-to-end settings round-trip.
Two existing files also pick up small additions. claude_plugin_sources/registry.py gains a replace method that returns a new registry with a single named source swapped out. claude_plugin_sources/__init__.py is extended to re-export EnableOutcome, EnableResult, and enable_source, so consumers can keep importing every public name from the top-level package. No new third-party dependencies are added — dataclasses, enum, and typing carry the entire step.
Implementation
The first new primitive sits on the registry: a replace method that swaps a single named entry for an updated PluginSource without disturbing surrounding order. Putting it on the registry, rather than inlining it into the command, means later commands — disable, toggle, rename — share the same lookup-and-rebuild path:
def replace(self, source: PluginSource) -> "PluginSourceRegistry":
if self.find(source.name) is None:
raise KeyError(f"no plugin source named {source.name!r}")
new_sources = tuple(
source if existing.name == source.name else existing
for existing in self._sources
)
return PluginSourceRegistry(new_sources)
The find guard fires before the tuple rebuild so an unknown name raises KeyError instead of silently appending. The new tuple keeps positional order intact, which the test_replace_preserves_order case in the test suite pins. Because the registry is constructed from the rebuilt tuple, the uniqueness invariant from step 2 is re-checked on the way out — there is no path where replace can leave the registry in an inconsistent state.
The outcome enum comes next. Making it str-backed mirrors the choice we made for SourceKind in step 1, which keeps log lines and JSON serializers readable without extra coercion:
class EnableOutcome(str, Enum):
NOT_FOUND = "not_found"
ALREADY_ENABLED = "already_enabled"
ENABLED = "enabled"
The three members are exhaustive for the enable operation. A disable command in a later step will define its own parallel enum rather than reuse this one, because the semantics of "already off" differ from "already on" in the messages we want to print.
The result type is a frozen dataclass that carries the outcome, the requested name, the resulting registry, and the affected source. Two derived properties — changed and found — give callers a boolean view without forcing them to import the enum:
@dataclass(frozen=True)
class EnableResult:
outcome: EnableOutcome
name: str
registry: PluginSourceRegistry
source: Optional[PluginSource]
@property
def changed(self) -> bool:
return self.outcome is EnableOutcome.ENABLED
@property
def found(self) -> bool:
return self.outcome is not EnableOutcome.NOT_FOUND
def message(self) -> str:
if self.outcome is EnableOutcome.NOT_FOUND:
return f"no plugin source named {self.name!r}"
if self.outcome is EnableOutcome.ALREADY_ENABLED:
return f"{self.name!r} is already enabled"
return f"enabled {self.name!r}"
message() is a thin formatter, not a translation layer — it produces the exact strings the tests assert on. Keeping the message format in the result, rather than in the calling CLI, means every consumer (a hook, a slash command, a future server endpoint) emits the same phrasing for the same outcome. The source field is Optional because the not-found branch genuinely has no source to return; leaning on None here is cleaner than inventing a sentinel value.
The command itself is short enough to fit on one screen, which is the point. It is a sequence of three guarded branches that map one-to-one onto the three enum members, with no nested conditionals:
def enable_source(registry: PluginSourceRegistry, name: str) -> EnableResult:
target = registry.find(name)
if target is None:
return EnableResult(EnableOutcome.NOT_FOUND, name, registry, None)
if target.enabled:
return EnableResult(EnableOutcome.ALREADY_ENABLED, name, registry, target)
updated = target.enable()
new_registry = registry.replace(updated)
return EnableResult(EnableOutcome.ENABLED, name, new_registry, updated)
Two design choices in this function deserve a callout. First, when the source already exists in the requested state, the returned registry field is the original instance — not a rebuilt copy — and the test suite pins this with an identity assertion. A caller can therefore detect "no work to do" by comparing the result registry to the input registry with is, without diffing fields. Second, the new registry is produced exclusively through registry.replace, so the uniqueness invariant and tuple ordering rules from step 2 apply unchanged.
Verification
Run the test suite from inside codebase/:
python3 -m pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
configfile: pyproject.toml
testpaths: tests
collected 84 items
tests/test_commands.py .................................... [ 42%]
tests/test_registry.py .............................. [ 78%]
tests/test_source.py .................. [100%]
============================== 84 passed in 0.16s ==============================
All eighty-four tests pass: the eighteen model cases from step 1, the thirty registry cases from step 2, and the thirty-six new command cases that cover registry replace, the three enable outcomes, the result-shape contract, and the end-to-end settings round-trip.
What we built
We now have an enable_source command that takes a registry and a name and returns an EnableResult describing exactly one of three outcomes. The function never mutates its arguments — when the source changes, the result carries a freshly built registry; when nothing changes, the result carries the original registry by identity. That immutability gives a CLI a cheap way to decide whether to write the settings file: if result.changed is true, persist; otherwise, skip the disk write entirely.
The registry picked up a single new method, replace, that swaps a named entry for an updated one without touching surrounding order or breaking the uniqueness invariant. Because the swap lives on the registry rather than inside the command, the disable command in a later step can reuse the same primitive instead of reimplementing the rebuild loop. The KeyError raised when the name is unknown is the safety net for any future caller that forgets to check existence first.
The EnableResult shape is the contract every later consumer will lean on. The outcome enum is exhaustive, the message() formatter produces operator-facing strings without pulling in a templating layer, and the derived changed and found properties let simple callers ignore the enum entirely. A round-trip test confirms that the new registry produced by an enable serializes through as_settings() and re-parses through from_settings() without losing the flipped flag.
What this unlocks is the matching disable command in the next step, which will mirror this exact shape: its own outcome enum, its own result type, and a function that reuses registry.replace. With both commands in place, an operator-facing CLI can finally close the loop — list, enable, disable — without any module needing to know how a PluginSource is constructed or how the settings file is laid out.
Repository
The state of the code after this step: 4baafa5
Step 4: Adding a Disable Command That Preserves the Source Entry
Step 3 closed the enable side of the loop: a pure enable_source function that takes a registry plus a name, returns a structured EnableResult describing one of three outcomes, and routes its only mutation through registry.replace. What is still missing is the symmetric operation. An operator who has just turned a shared plugin source on needs an equally cheap way to turn it back off — without losing the URL, the local path, or the description that originally described the entry.
Step 4 adds that mirror. The key constraint is that "disable" must NOT mean "delete". The entry stays in the registry, the location and kind survive untouched, and the enabled flag flips from true to false. That asymmetry between disabling and removing is the entire reason this article exists — and it is what makes a re-enable a one-word operation later, rather than a re-typed URL and a fresh configuration line.
Setup
We stay inside the codebase/ half of the workspace and extend the surface added in step 3 instead of introducing a new module. Two files change, and one new test file appears:
claude_plugin_sources/commands.py— add aDisableOutcomeenum, a frozenDisableResultdataclass, and thedisable_sourcefunction. The shape intentionally mirrorsEnableOutcome/EnableResult/enable_sourceso a reader who learned the enable side already knows the disable side.claude_plugin_sources/__init__.py— re-exportDisableOutcome,DisableResult, anddisable_sourceso consumers keep importing every public name from the top-level package.tests/test_disable.py— thirty-seven pytest cases organized into five classes: unknown name, already disabled, the freshly disabled path, the result-shape contract, and the configuration-preservation invariants (including round-trip throughas_settings()and an enable-then-disable identity check).
No new third-party dependencies are added. The registry.replace method introduced in step 3 carries the entire mutation path for this step, which is half the point of having built it on the registry rather than inside the enable command.
Implementation
The outcome enum comes first. Making it a sibling of EnableOutcome — not a reuse — keeps the operator-facing messages and the JSON serialization unambiguous. "Already off" and "already on" are different facts, and conflating them inside one enum would force every caller to re-derive the direction from context:
class DisableOutcome(str, Enum):
NOT_FOUND = "not_found"
ALREADY_DISABLED = "already_disabled"
DISABLED = "disabled"
The three members are exhaustive for the disable operation. The str-backed base class matches SourceKind and EnableOutcome, so log lines and settings serializers render the value without an explicit .value call.
The result dataclass mirrors EnableResult field-for-field, including the optional source slot for the not-found branch and the derived changed / found properties. Keeping the shape identical means a future CLI can dispatch on result.changed without first asking which command produced the result:
@dataclass(frozen=True)
class DisableResult:
outcome: DisableOutcome
name: str
registry: PluginSourceRegistry
source: Optional[PluginSource]
@property
def changed(self) -> bool:
return self.outcome is DisableOutcome.DISABLED
@property
def found(self) -> bool:
return self.outcome is not DisableOutcome.NOT_FOUND
def message(self) -> str:
if self.outcome is DisableOutcome.NOT_FOUND:
return f"no plugin source named {self.name!r}"
if self.outcome is DisableOutcome.ALREADY_DISABLED:
return f"{self.name!r} is already disabled"
return f"disabled {self.name!r}"
message() once again lives inside the result rather than the calling CLI. The exact phrasing — disabled 'alpha' versus 'alpha' is already disabled — is asserted by the test suite, so a hook, a slash command, and a future server endpoint all emit identical strings for identical outcomes. The Optional[PluginSource] field is None only for the not-found branch; the already-disabled branch returns the existing instance, which gives callers an is check for "no work was done".
The command itself is the smallest function in the module — three guarded branches that map one-to-one onto the three enum members, with no nested conditionals and no try/except:
def disable_source(registry: PluginSourceRegistry, name: str) -> DisableResult:
target = registry.find(name)
if target is None:
return DisableResult(DisableOutcome.NOT_FOUND, name, registry, None)
if not target.enabled:
return DisableResult(DisableOutcome.ALREADY_DISABLED, name, registry, target)
updated = target.disable()
new_registry = registry.replace(updated)
return DisableResult(DisableOutcome.DISABLED, name, new_registry, updated)
Two design choices are worth a callout. First, the already-disabled branch returns the original registry instance, not a rebuilt copy — test_returned_registry_is_original_instance pins this with an is assertion, which gives the CLI a constant-time "skip the disk write" signal. Second, the only mutation flows through target.disable() and registry.replace(updated), both introduced in earlier steps. The disable command itself owns zero rebuild logic, which is why it stays under ten lines.
The invariant the test suite leans on hardest is the one in the article's title: a disabled source still appears in result.registry.names() and still round-trips through as_settings() / from_settings() with its location, kind, and description intact. That is what separates disable from remove — and what makes the enable-then-disable-then-enable sequence in test_enable_then_disable_is_identity_on_full_list recover the exact original list ordering.
Verification
Run the full test suite from inside codebase/:
python3 -m pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
configfile: pyproject.toml
testpaths: tests
collected 121 items
tests/test_commands.py .................................... [ 29%]
tests/test_disable.py ..................................... [ 60%]
tests/test_registry.py .............................. [ 85%]
tests/test_source.py .................. [100%]
============================== 121 passed in 0.25s ==============================
All one hundred twenty-one tests pass: the eighteen model cases from step 1, the thirty registry cases from step 2, the thirty-six enable command cases from step 3, and the thirty-seven new disable cases that cover the unknown-name branch, the already-disabled branch, the freshly-disabled branch, the dataclass shape, and the round-trip preservation invariants.
What we built
We now have a disable_source command that mirrors enable_source exactly — same call signature, same three-arm result shape, same registry.replace mutation path. Operators can flip a shared plugin source off without losing the URL, the local path, the description, or the entry's position in the list. The disable is a structured event, not a side-effecting mutation: a DisableResult records which outcome occurred and carries the new registry alongside the source it touched.
The not-found branch returns the original registry instance by identity, and so does the already-disabled branch. A CLI consumer therefore decides "do I need to rewrite the settings file?" with a single result.changed boolean — there is no need to diff fields or compare registry contents. The DisableOutcome enum is exhaustive, the message() formatter produces the operator-facing strings the test suite asserts on, and the result is frozen so callers cannot quietly mutate it on the way to the next stage.
Because the disable command is a sibling of the enable command — not a special case of it — the two can be exercised independently and composed in either order. The round-trip test confirms that disabling and then enabling recovers the original state, and the identity test confirms that doing both in sequence on a multi-entry registry leaves names, kinds, and locations untouched.
What this unlocks is the slash-command and hook layer that the next step will wire on top. With both enable_source and disable_source behind the same result contract, a single CLI dispatcher can map enable <name> and disable <name> to two near-identical handlers — and the settings file is rewritten only when one of those handlers reports result.changed is True.
Repository
The state of the code after this step: f9ae3ff
Step 5: Verifying Registry State and Translating Command Outcomes into Actionable Issues
Step 4 finished the symmetry between enable_source and disable_source: two commands, three outcomes each, one shared registry.replace mutation path, and frozen result objects that tell callers whether to rewrite the settings file. That covers the happy paths. It does nothing for the operator who hand-edited settings.json, mistyped a name on the CLI, or pulled a configuration whose schema drifted from the loader's expectations.
Step 5 adds the missing observability layer on top of everything earlier steps have built. We introduce a verify module with one diagnostic vocabulary — Severity, IssueCode, StateIssue, DiagnoseReport — and three entry points that produce that vocabulary from three different inputs: a raw settings dictionary, a parsed registry, or a command result. The point is that every UI surface (slash command, hook, future API) renders the same record type, regardless of which inspection triggered it.
Setup
We stay inside codebase/ and add one new module plus one new test file. The fuzzy-matching helper comes from the standard library's difflib, so no third-party dependency is required:
claude_plugin_sources/verify.py— declaresSeverity,IssueCode,StateIssue,DiagnoseReport, the public entry pointsdiagnose_settings,verify_registry,explain_outcome, plusrender_reportfor terminal output and asuggest_namehelper that backs the did-you-mean behavior.claude_plugin_sources/__init__.py— re-exports every public name fromverifyso consumers continue to pull everything from the top-level package.tests/test_verify.py— thirty-six pytest cases organized into eight classes: healthy settings, shape errors, duplicate detection, the "nothing enabled" warning, fuzzy name suggestion, both directions ofexplain_outcome, registry round-trip verification, and the rendered text format.
The module sits beside commands.py rather than inside it. Diagnosis is read-only and never mutates a registry, so coupling it to the enable/disable surface would force every health check to import the mutation API just to ask "is this configuration valid?".
Implementation
The vocabulary comes first. Both enums are str-backed so they serialize cleanly into JSON for a future API and render readably in log lines without an explicit .value lookup. StateIssue is frozen and carries an optional name plus a tuple of suggestions, which lets the renderer point at one specific entry without re-parsing the message string:
class Severity(str, Enum):
ERROR = "error"
WARNING = "warning"
INFO = "info"
@dataclass(frozen=True)
class StateIssue:
severity: Severity
code: IssueCode
message: str
name: Optional[str] = None
suggestions: Tuple[str, ...] = ()
def is_error(self) -> bool:
return self.severity is Severity.ERROR
DiagnoseReport wraps a tuple of StateIssue records and a possibly-None registry. The registry slot is None only when settings were malformed enough that parsing could not finish — for example, when the top-level key was a string instead of a list. Every consumer follows the same shape: call report.ok(), branch on errors and warnings separately, then read the registry if it survived.
The shape scanner uses a private _IssueAccumulator so the descent through entries can collect multiple errors instead of raising on the first one. This is the behavior TestDiagnoseShapeErrors.test_multiple_errors_collected_not_raised pins — an operator who broke five entries in one edit sees all five in one pass rather than fixing them one-at-a-time:
def diagnose_settings(settings: Mapping[str, object]) -> DiagnoseReport:
bucket = _IssueAccumulator()
raw = settings.get(SETTINGS_KEY)
if raw is None:
bucket.add(
Severity.WARNING,
IssueCode.SETTINGS_KEY_MISSING,
f"settings has no {SETTINGS_KEY!r} key; treating as empty",
)
return DiagnoseReport(tuple(bucket.issues), PluginSourceRegistry())
if isinstance(raw, (str, bytes)) or not isinstance(raw, Sequence):
bucket.add(
Severity.ERROR,
IssueCode.SETTINGS_KEY_NOT_A_LIST,
f"settings[{SETTINGS_KEY!r}] must be a list of entries",
)
return DiagnoseReport(tuple(bucket.issues), None)
parsed = _parse_entries(raw, bucket)
if any(i.is_error() for i in bucket.issues):
return DiagnoseReport(tuple(bucket.issues), None)
registry = _build_registry(parsed, bucket)
_add_warnings(registry, bucket)
return DiagnoseReport(tuple(bucket.issues), registry)
The only try/except in the whole module lives inside _parse_one_entry. It catches the ValueError raised by parse_source_entry and routes the message through _classify_entry_error, which maps prefixes like "missing required keys" and "unknown plugin source kind" onto the corresponding IssueCode. Holding that classification table in one helper keeps every code addressable from tests — each branch backs an assertion of the form report.has_code(IssueCode.ENTRY_UNKNOWN_KIND). There is exactly one handler in the file and it has zero nested except blocks, which keeps us inside the codebase guide's rule.
Duplicate detection runs in two passes for clarity. Name collisions are errors because the registry's find lookup is ambiguous when two entries share a name. Location collisions are warnings because nothing intrinsically prevents two named entries from pointing at the same URL — it is unusual, but it is sometimes intentional during a migration, so the verifier surfaces it without blocking the rest of the pipeline:
def _check_duplicate_locations(registry, bucket):
locations: dict = {}
for source in registry:
locations.setdefault(source.location, []).append(source.name)
for location, names in locations.items():
if len(names) > 1:
bucket.add(
Severity.WARNING,
IssueCode.DUPLICATE_LOCATION,
f"location {location!r} is reused by: {', '.join(names)}",
suggestions=tuple(names),
)
_add_warnings also emits a NO_ENABLED_SOURCES warning when the registry is non-empty but every entry is disabled — the common state after an operator disables one source too many and never notices that the toolchain is now a no-op. The warning carries no name because it is a registry-wide condition, and TestNoEnabledWarning.test_empty_registry_does_not_emit_no_enabled confirms that an empty registry deliberately does NOT trigger it.
The second entry point, verify_registry, exists for the case where you already hold a parsed registry and want to confirm it survives a round-trip through as_settings() / from_settings(). Any drift here means a serialization bug, not an operator mistake — so the issue is an error with code ROUND_TRIP_DRIFT. The same _add_warnings helper runs after the round-trip check, which means dead configurations and duplicate locations are reported through both diagnostic paths with byte-identical text.
The third entry point, explain_outcome, is where the verify module pays off for the CLI layer. It accepts either an EnableResult or a DisableResult and returns a single StateIssue whose severity follows the outcome: errors for NOT_FOUND, infos for ALREADY_* and successful flips. The NOT_FOUND branch routes through _not_found_issue, which runs suggest_name over registry.names() — turning a typo like alpah into did you mean: alpha? instead of a flat "name not found":
def _not_found_issue(name, registry):
hints = suggest_name(name, registry.names())
base = f"no plugin source named {name!r}"
if hints:
message = f"{base}; did you mean: {', '.join(hints)}?"
else:
message = f"{base}; check `plugin sources list` for available names"
return StateIssue(
severity=Severity.ERROR,
code=IssueCode.NAME_NOT_FOUND,
message=message,
name=name,
suggestions=hints,
)
Because _not_found_issue is shared between the enable and disable explainers, the suggestion behavior cannot drift between the two commands. TestExplainEnableOutcome.test_not_found_includes_suggestion and TestExplainDisableOutcome.test_not_found_includes_suggestion both assert that "alpha" appears in issue.suggestions after typing alpah — one helper, two callers, one truth.
render_report closes the module with a flat string renderer for terminal output. It emits one line per issue, prefixed with the severity in upper case and the machine-readable code — easy to grep, easy to assert on in tests, and structured enough that the same stream can feed a log aggregator without reformatting. The renderer reads no other state, which means it composes cleanly with both diagnostic entry points.
Verification
Run the full test suite from inside codebase/:
python3 -m pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-8.4.2, pluggy-1.6.0
configfile: pyproject.toml
testpaths: tests
collected 157 items
tests/test_commands.py .................................... [ 22%]
tests/test_disable.py ..................................... [ 46%]
tests/test_registry.py .............................. [ 65%]
tests/test_source.py .................. [ 77%]
tests/test_verify.py .................................... [100%]
============================= 157 passed in 0.32s ==============================
All one hundred fifty-seven tests pass: the eighteen model cases from step 1, the thirty registry cases from step 2, the thirty-six enable cases from step 3, the thirty-seven disable cases from step 4, and the thirty-six new verify cases spanning healthy settings, shape errors, duplicate detection, the no-enabled warning, fuzzy name matching, both outcome explainers, registry round-trip drift, and the rendered output format.
What we built
We now have one diagnostic surface that turns three different inputs — a raw settings dictionary, a parsed registry, or a command result — into the same StateIssue record stream. Operators get errors, warnings, and infos with consistent codes, optional names, and structured suggestions. A future CLI or hook can route every one of these through render_report without per-input formatting logic.
The fuzzy did-you-mean behavior eliminates the most common operator mistake from steps 3 and 4: typing the wrong name and getting an opaque "not found". By running difflib.get_close_matches over registry.names(), the explainer turns a dead end into a one-line correction. The same helper feeds the human-readable message, the structured suggestions tuple, and the rendered output, so the three layers cannot disagree about what the close match was.
The module also enforces a serialization invariant that earlier steps only assumed: a registry must equal its own from_settings(as_settings(registry)) reconstruction. The ROUND_TRIP_DRIFT code makes that assertion an explicit, named issue rather than an obscure test failure, which gives future contributors an early warning when they add a field to PluginSource and forget to update the serializer.
What this unlocks is the operator-facing slash-command surface the next phase will reach for. With diagnose_settings, verify_registry, and explain_outcome all producing the same shape, a single dispatcher can fan every inspection out through one renderer — and Claude Code's notification stream gets a uniform issue feed regardless of whether the trigger was a file edit, a registry mutation, or a typed command.
Repository
The state of the code after this step: e529301
Repository
Full source at https://github.com/vytharion/claude-code-plugin-shared-source-enable-disable.
Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.