Skip to content

Classify plugin commands as CLI surface or internal mechanism - #847

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-838
Aug 19, 2026
Merged

Classify plugin commands as CLI surface or internal mechanism#847
philcunliffe merged 4 commits into
masterfrom
fix/issue-838

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What this is

A plugin's contributes.commands array was doing two jobs at once: it lists
what the plugin registers, and it is the advertisement hyp --help renders
before boot (LLP 0009). Nothing separated them, so every command a plugin
registered became a public CLI promise the moment its plugin was
config-active.

claude-account credential is the case that shows what that costs. Its caller
is the no-arg wrapper Claude Desktop execs, and its entire contract is that
stdout is a live credential and nothing else (LLP 0116#helper-contract). It
sat in hyp claude-account --help beside login and status, reading as a
third thing a person might try, and the thing they get for trying is a token
in their scrollback.

Approach

LLP 0009 said hidden commands stay out of help "by being omitted from the
manifest". Omission also deletes the only pre-boot record that the command
exists, and two things read that record: the dispatch-miss path that turns
"unknown command" into "unavailable, enable @hypaware/x" (LLP 0153), and the
manifest/registration parity tests that catch summary drift. An internal
command is the most likely to be typed by someone who does not know which
plugin owns it, so it is the worst one to make unattributable.

So this takes the route LLP 0202 already set for picker rows: hiding is a
display filter, never a catalog deletion. The declaration stays and is marked.

  • PluginCommandManifest gains hidden?: boolean. contributes.commands is
    now validated (validateCommandContributions) instead of passed through
    opaquely, so a manifest spelling it "true" is rejected rather than
    silently advertising an internal mechanism.
  • collectPluginHelpCommands skips hidden entries. The registry-side filters
    (renderHelp, listGroupChildren) already existed and are unchanged, which
    is why both sides must be set: the manifest flag governs pre-boot top-level
    help, the registration flag governs group help after activation.
  • claude-account credential is hidden on both sides, and still dispatches.
  • claude-desktop status and claude-account status gain the long help a
    visible diagnostic owes its reader, both stating they print no secret and
    where sign-in state actually lives.

What is deliberately not hidden

The issue proposed claude-desktop profile, install-helper, and status as
internal on the grounds that install drives all three. It does, but that is
not the whole audience: LLP 0139#macos-only (Accepted) already settled that
these three stay runnable off a Mac because rendering the MDM payload or
staging the helper "is legitimately useful on a non-Mac admin box preparing a
fleet push". A command an Accepted LLP keeps working for an audience is not
one to make undiscoverable for them. They are classified as compatibility
surface
and stay visible; status gets the diagnostic long help instead.

LLP

llp/0268-plugin-commands-classified-as-surface-or-mechanism.decision.md
(Decision, Systems: CLI, Plugins) records the four-way classification (public
workflow / public diagnostic / compatibility surface / internal mechanism) for
every manifest-declared first-party command, and the two-sided hidden rule.
LLP 0009 gets an Extended by forward-ref; nothing it settled is edited.

Regression test

test/core/plugin-command-visibility.test.js (7 tests). Against master 5 of
the 7 fail; all 7 pass on this branch:

  • manifest hidden commands stay out of pre-boot top-level help - before, a
    staged plugin declaring { name: 'demo plumbing', hidden: true } rendered
    demo Subcommands: plumbing, run; now it renders Subcommands: run.
  • manifest command hidden must be a boolean - the validator did not look at
    contributes.commands at all.
  • the credential helper contract is an internal mechanism in manifest and registry alike, group help hides the credential helper but keeps the public claude-account surfaces - credential was a visible row in
    hyp claude-account --help.
  • the hidden credential helper still dispatches - proves hiding is a help
    filter, not a deregistration: signed out, the body reports it (exit 1), not
    the dispatcher's unknown/unavailable exit (2).
  • every claude-desktop and claude-account command declares the same visibility in manifest and registry - holds the two hidden sides together.

Local: npm test 4264 pass / 0 fail, npm run typecheck clean.

Fixes#838

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review round: findings (fixed in 5d671f8e)

Reviewed head 5189050a. Mechanism is sound and the two deviations from issue #838 hold up (details at the bottom). One medium finding: the classification the PR is built on is incomplete, and the LLP asserts the incompleteness as a fact. Fixed on the branch. npm test 4265 pass / 0 fail, npm run typecheck clean, smoke claude_attach_detach and smoke cli_bundled_plugins_activated green after the fix.

What the mechanism does, verified

hidden survives registry.register by reference; src/core/cli/group_help.js:40 and src/core/cli/dispatch.js:749 already filtered it; the new dispatch.js:834 filter covers the pre-boot manifest path. findInactivePluginForCommand (dispatch.js:1159) and activateSeamCommandPlugins (dispatch.js:939) deliberately do not filter it, so the miss path and the seam still see the declaration. hyp claude-account credential still dispatches; --help is intercepted at dispatch.js:483 before run, so hyp claude-account credential --help prints help rather than a live token. All 22 bundled manifests pass the new validateCommandContributions. No em dashes, no semicolons, no @typedef, no inline import() types. PluginCommandManifest is declared in exactly one place (hypaware-plugin-kernel-types.d.ts), which package.json#files publishes directly, so the declaration build stays coherent. The LLP 0009 edit is an appended Extended by forward-ref only; nothing it settled is touched, which CLAUDE.md explicitly permits.

Findings

1 - medium - llp/0268-...decision.md:67,130 (FIXED). "Exactly one command is an internal mechanism" and "claude-account credential is hidden on both sides. Nothing else is." are both false. Three commands already register hidden: true:

  • hypaware-core/plugins-workspace/claude/src/index.js:294 - claude-hook session-context
  • hypaware-core/plugins-workspace/claude/src/index.js:305 - claude-hook classify-cwd
  • hypaware-core/plugins-workspace/codex/src/index.js:225 - codex-hook classify-cwd

None of them appeared in its plugin's manifest at all (claude and codex both had contributes.commands === undefined). That is precisely the hide-by-omission pattern this PR's own #not-deletion section argues a manifest-first CLI cannot afford - and it costs exactly what that section says it costs. These three are written into Claude Code's settings.json and Codex's config as hyp claude-hook ... lines (claude/src/settings.js:740), so they keep firing after the plugin leaves the active config. Demonstrated on a config without @hypaware/claude:

before: hyp: unknown command 'claude-hook session-context'
after: hyp: 'claude-hook' is provided by @hypaware/claude, which is not in the active config
repair: add {"name": "@hypaware/claude"} to plugins[] in .../hypaware-config.json

Fixed: declared all three in the claude / codex manifests marked hidden: true; added them to the LLP's classification table; corrected the two false claims. hyp --help still renders no hook row (checked directly), and nothing about how they run changed.

2 - low - test/core/plugin-command-visibility.test.js:164 (FIXED). The parity test the decision calls "what holds the two together" only walked manifest.contributes.commands -> registry, so it structurally could not see finding 1's drift class (registered, declared nowhere). Added the reverse direction (registered.size vs declared count) and a test pinning the three hooks as declared-and-marked. Verified non-vacuous: reverting only the claude manifest turns the new test red.

3 - low - hypaware-core/plugins-workspace/claude-desktop/src/index.js:109 (FIXED). The new long help ended "Exits nonzero when the credential wrapper is missing", but runStatus (:243) also returns 1, printing none of the promised inputs, when resolveInputs throws - which assertNotEphemeral (profile.js:71) does for any :0 gateway listen. That is a plausible state on exactly the non-Mac admin box the help invites the reader to use. Added the clause.

4 - low - src/core/manifest.js:211 (OPEN, needs a design call).validateCommandContributions promotes the whole contributes.commands block from opaque to hard-validated, and a failure fails validateManifest, which drops the plugin into failed[] (runtime/installed.js:56) with no user-facing boot message - only a span attribute (runtime/boot.js:94). An already-installed third-party plugin whose manifest has "commands": ["graph project"] or summary: null previously loaded fine (the malformed rows were skipped by collectPluginHelpCommands's own typeof cmd.name !== 'string' guard) and now stops activating entirely, so its sources quietly stop capturing. Only the hidden boolean check is needed for this PR's purpose; the shape / summary / usage rejections are a silent compat break with no version gate. Left alone because the LLP's Consequences section explicitly decided to validate the array - narrowing it is the author's call.

5 - low - LLP 0268 #field (OPEN, needs a design call). Nothing in core enforces the two-sided rule; it rests entirely on a test that (even after fix 2) covers four bundled plugins. A future or third-party plugin can set manifest-hidden without registration-hidden and land half-hidden, which the decision itself calls "worse than either consistent answer". Core has the manifest at activation time and could default the registration flag from it, or the parity walk could be generalized over every bundled plugin. Worth a follow-up.

The two deviations from issue #838

Both are justified, and neither is really a refusal.

Not hiding claude-desktop profile / install-helper / status. I read LLP 0139 independently. #macos-only (llp/0139-desktop-picker-consent.decision.md:150) is Accepted and does say, verbatim, that these three "also stay ungated" because "rendering the MDM payload or staging the helper touches no macOS surface and is legitimately useful on a non-Mac admin box preparing a fleet push". The quotation in LLP 0268 is accurate. One honest qualification: 0139 settles runnability off a Mac, not discoverability, so "an audience that cannot discover the command cannot use it" is the author's inference, not 0139's holding. LLP 0268 states it that way rather than overclaiming, which is the right handling. And the issue did not mandate hiding them: its acceptance criteria ask to classify every command into four classes and to "keep public diagnostics visible and give them useful long help" - which is what the PR did, using the issue's own vocabulary. Sound.

Adding hidden rather than omitting from the manifest. LLP 0009's discovery section does literally prescribe omission, but issue #838's own acceptance criteria anticipate the change: "decide whether plugin manifest command metadata needs an explicit hidden field and record that design in a new LLP". The argument holds on the merits too - LLP 0202 set the display-filter-not-catalog-deletion precedent for the same reason, and finding 1 is the live proof that omission does break LLP 0153 attribution in practice. One precision point I corrected in the LLP: the miss path matches head tokens (dispatch.js:1159), so omission only blinds it when the whole head token goes undeclared. That is the hook case, not the credential case - claude-account credential shares its head token with login/logout/status, so there omission costs the parity tests instead. The conclusion is unaffected; the record is now exact about which reader pays. Nothing LLP 0009 or LLP 0139 settled was edited; 0009 only gained an appended forward-ref.

Classification claim.claude-account credential was not the only genuinely internal command - see finding 1. Hiding it, though, breaks no machine caller: the only consumer is the generated wrapper, which is built from the helperCommandArgs capability (claude-account/src/index.js:38, claude-desktop/src/profile.js:118) and invokes the argv path directly. Nothing reads it out of help or out of the manifest.

philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
…th hidden sides for the client hooks
Two findings from the review of #847.
1. `validateCommandContributions` made every field of a command entry
fatal to `loadManifest`. That turns a cosmetic help-metadata mistake
into a whole-plugin outage (its sources, sinks and datasets go with
it) and it points the reader somewhere else: `hyp status` reports
`plugin_unknown` and never names the field. It also made the
`contributes_malformed` rows the doctor already has for `commands`
(`src/core/plugin_doctor/diagnose.js`) unreachable, because
`diagnosePlugin` returns at `manifest_invalid` before its shape
checks run - contradicting the doctor table in
`docs/PLUGIN_AUTHORING.md`. Every sibling contribution category
stays opaque to `validateManifest` for exactly that reason. Narrowed
to the one field LLP 0268 needs to be trustworthy: `hidden`, which is
not help metadata but the flag deciding whether a command is CLI
surface at all. LLP 0268's Consequences bullet says so now.
2. LLP 0268 says the parity test is what holds the manifest and
registration `hidden` flags together, but the walk covered only
`claude-account` and `claude-desktop`. For the three commands that
motivated the decision (`claude-hook session-context`,
`claude-hook classify-cwd`, `codex-hook classify-cwd`) it read the
manifest alone, so dropping `hidden: true` from a registration left
the suite green while `hyp claude-hook --help` started listing an
internal hook - the half-hidden state #field calls worse than either
consistent answer. The hook test now activates both client adapters
and asserts the registry side too; removing either flag fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review: findings (3), 2 fixed and pushed

Reviewed 92c0943c with /code-review high plus a manual pass over the
help/miss/seam paths in src/core/cli/dispatch.js, src/core/cli/group_help.js,
src/core/manifest.js, src/core/plugin_doctor/diagnose.js and both Desktop
plugins. Baseline at the reviewed head was green: npm test 4493 pass / 0 fail,
npm run typecheck clean.

Verdict: the mechanism is right.findInactivePluginForCommand
(dispatch.js:1157) and activateSeamCommandPlugins (dispatch.js:939) read
manifests unfiltered, so a hidden command keeps its attribution on the miss
path; only collectPluginHelpCommands filters, mirroring the pre-existing
registry-side filters at dispatch.js:749 and group_help.js:40. I confirmed
the three declared hook names match the registrations exactly
(claude/src/index.js:426,437, codex/src/index.js:222), that their manifest
summaries match the registered ones, and that all 22 in-repo plugin manifests
still validate under the new check.

1. src/core/manifest.js:210 - medium - fixed

validateCommandContributions promoted contributes.commands from opaque to
fatally validated in every field. Three costs, none of which the decision
needs:

  • A cosmetic help-metadata mistake becomes a total plugin outage. Setting
    summary: null on one @hypaware/gascity command entry makes loadManifest
    reject the whole manifest, so the plugin's sources, sinks and datasets go with
    it. The user-visible diagnostic points elsewhere: hyp status reports
    [ERROR] config_invalid: [plugin_unknown] plugin '@hypaware/gascity' is not a known first-party plugin and is not installed, never naming the field.
  • It silently kills the doctor path that already covered this. diagnosePlugin
    (src/core/plugin_doctor/diagnose.js:59) returns at the manifest_invalid
    branch, so checkContributesShape's commands rows (diagnose.js:176-203),
    which carry a location of /contributes/commands/<i> and a repair line, are
    now unreachable. That contradicts the doctor table this PR did not touch,
    docs/PLUGIN_AUTHORING.md:354 (contributes_malformed / "Give every entry a
    name") - which was finding 3 of the review, and is resolved by this same fix.
  • It is asymmetric: every sibling category (sources, sinks, datasets,
    skills, agents, init_presets, config_sections) stays opaque to
    validateManifest and is checked by hyp plugin doctor instead.

Fixed by narrowing the validator to the one field LLP 0268 actually needs to be
trustworthy, hidden - which is not help metadata but the flag deciding whether
a command is CLI surface at all, so a manifest spelling it "true" is still a
manifest rejection. name/summary/usage go back to opaque; both read sites
already coerce them defensively (dispatch.js:836, renderHelp). LLP 0268's
Consequences bullet is updated to describe what the code does. A new assertion in
test/core/plugin-command-visibility.test.js pins the loosening: a summary: 42
entry and a non-object row must leave validateManifest ok.

2. test/core/plugin-command-visibility.test.js:224 - low - fixed

LLP 0268 #field states "the parity test in
test/core/plugin-command-visibility.test.js is what holds the two together",
but the manifest-to-registry walk covered only claude-account and
claude-desktop. For the three commands that motivated the decision it read the
manifest alone. Verified by mutation: deleting hidden: true from
hypaware-core/plugins-workspace/claude/src/index.js:429 left the whole suite
green while hyp claude-hook --help would start listing an internal hook (top
level help still hides it via the manifest flag) - exactly the half-hidden state
#field calls worse than either consistent answer.

Fixed: the hook test now activates both client adapters against a widened stub
context and asserts the registry side too. Re-running the same mutation now
fails with claude-hook classify-cwd: hidden in the manifest but visible in group help.

3. docs/PLUGIN_AUTHORING.md:354 - low - resolved by fix 1

The doctor table promised contributes_malformed with repair "Give every entry
a name" for a commands entry missing its name; the fatal validator made that
row unreachable. Narrowing the validator restores it, so the doc is accurate
again and needs no edit.

Left open deliberately

  • test/plugins/claude-desktop-install.test.js:596 and the new
    every claude-desktop and claude-account command declares the same visibility
    test are near-duplicate manifest-to-registry walks over the same two plugins,
    one comparing summary and the other hidden. Merging them is a tidy-up with
    no behavioural payoff and would churn a test this PR is not otherwise touching.
  • The claude-hook / codex-hook registrations omit plugin: PLUGIN_NAME,
    unlike every other first-party command registration. Pre-existing, unrelated
    to visibility (hidden commands never reach a rendered row), and out of scope.
  • The parity walk still does not cover session *, graph *, vector *,
    gascity * or enrich *. A generic walk over all first-party plugins, or a
    core-side check at register time, would hold the invariant everywhere; that
    is a larger change than this PR should carry.

Checks

In a worktree at the pushed head: npm test 4491 pass / 0 fail / 1 skipped,
npm run typecheck clean, and the PR's own test file 8/8. No check was failing
at the reviewed head.

Pushed as bd608a48.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage at head bd608a48: the three findings left open by the last review
round are all non-blocking (a core-enforcement design call for the two-sided
hidden rule, a test consolidation, and a missing plugin: field on hook
registrations that nothing reads today). None can cause a production defect,
so they are deferred to #890 and this PR is clear to merge on its own merits.
Verified in a clean worktree at this head: the PR's own test file passes 8/8,
and no prior follow-up issue existed for this PR.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 19, 2026
philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
0268 is already claimed by fix/issue-838 (PR #847), which is older, so
this branch yields the number. 0279 is free across master and every
open branch.
Mechanical renumber only: no content change (LLP 0156).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
testand others added 4 commits August 19, 2026 12:41
Every command a plugin declared in `contributes.commands` became public CLI
surface the moment its plugin was config-active, because that array is both
the registration list and the pre-boot help advertisement. Nothing separated
them. `claude-account credential` is the case that shows the cost: its caller
is the no-arg wrapper Claude Desktop execs, its entire stdout is a live
credential (LLP 0116#helper-contract), and it sat in group help beside
`login` and `status` as a third thing a person might try.
LLP 0009 said hidden commands stay out of help by being omitted from the
manifest. Omission also deletes the only pre-boot record the command exists,
which is what the dispatch-miss path reads to say "unavailable, enable
@hypaware/x" instead of "unknown" (LLP 0153), and what the manifest/
registration parity tests compare. So this takes the picker's route instead
(LLP 0202): hiding is a display filter, never a catalog deletion.
- `PluginCommandManifest` gains `hidden?: boolean`, and `contributes.commands`
is now validated rather than passed through opaquely, so a manifest
spelling it `"true"` is rejected instead of silently advertising an
internal mechanism.
- `collectPluginHelpCommands` skips hidden entries; the registry-side filters
already existed.
- `claude-account credential` is hidden on both sides and still dispatches.
Nothing else is hidden: LLP 0139#macos-only already settled that
`claude-desktop profile`/`install-helper`/`status` serve a fleet admin
directly, so they are compatibility surface, not plumbing.
- `claude-desktop status` and `claude-account status` gain the long help a
visible diagnostic owes its reader, both stating they print no secret.
LLP 0268 records the four-way classification (public workflow, public
diagnostic, compatibility surface, internal mechanism) for every first-party
command and the two-sided `hidden` rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LLP 0268 claimed `claude-account credential` was the only internal
mechanism and that "nothing else is" hidden. Three commands already
registered `hidden: true`: `claude-hook session-context`, `claude-hook
classify-cwd`, and `codex-hook classify-cwd`. None appeared in its
plugin's manifest at all, which is exactly the hide-by-omission the
decision's #not-deletion section rejects.
It costs what that section says it costs. The hook lines live in Claude
Code's settings.json and Codex's config and keep firing after the plugin
leaves the active config; with `claude-hook` declared nowhere, the miss
path had no head token to match, so:
hyp: unknown command 'claude-hook session-context'
is now:
hyp: 'claude-hook' is provided by @hypaware/claude, which is not in
the active config
- Declare the three in the `claude` / `codex` manifests, marked
`hidden: true`. They stay out of help (verified: `hyp --help` renders
no hook row) and run exactly as before.
- LLP 0268: add them to the classification table, correct "exactly one"
and "nothing else is", and say which reader omission actually costs
for which command. The miss path matches head tokens, so it is only
blinded when the whole token goes undeclared, which is the hook case,
not the credential case; there omission costs the parity tests.
- The parity test walked manifest to registry only, so it could not see
a registration declared nowhere. Walk both directions, and pin the
three hooks.
- `claude-desktop status` long help promised nonzero only for a missing
wrapper; `runStatus` also exits 1 when `resolveInputs` throws, which
an ephemeral `:0` gateway listen does on precisely the non-Mac admin
box the help invites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…th hidden sides for the client hooks
Two findings from the review of #847.
1. `validateCommandContributions` made every field of a command entry
fatal to `loadManifest`. That turns a cosmetic help-metadata mistake
into a whole-plugin outage (its sources, sinks and datasets go with
it) and it points the reader somewhere else: `hyp status` reports
`plugin_unknown` and never names the field. It also made the
`contributes_malformed` rows the doctor already has for `commands`
(`src/core/plugin_doctor/diagnose.js`) unreachable, because
`diagnosePlugin` returns at `manifest_invalid` before its shape
checks run - contradicting the doctor table in
`docs/PLUGIN_AUTHORING.md`. Every sibling contribution category
stays opaque to `validateManifest` for exactly that reason. Narrowed
to the one field LLP 0268 needs to be trustworthy: `hidden`, which is
not help metadata but the flag deciding whether a command is CLI
surface at all. LLP 0268's Consequences bullet says so now.
2. LLP 0268 says the parity test is what holds the manifest and
registration `hidden` flags together, but the walk covered only
`claude-account` and `claude-desktop`. For the three commands that
motivated the decision (`claude-hook session-context`,
`claude-hook classify-cwd`, `codex-hook classify-cwd`) it read the
manifest alone, so dropping `hidden: true` from a registration left
the suite green while `hyp claude-hook --help` started listing an
internal hook - the half-hidden state #field calls worse than either
consistent answer. The hook test now activates both client adapters
and asserts the registry side too; removing either flag fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe
philcunliffe merged commit 6696ce2 into masterAug 19, 2026
10 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-838 branch August 19, 2026 19:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Classify plugin commands as public or internal CLI surface

1 participant

@philcunliffe