supported_commands must be auto-derived, never hand-typed
Background
The proximate trigger
While debugging why https://staging.musehub.ai/domains showed zero
results (root-caused and fixed separately: list_repos_for_domain compared
the wrong column, and muse domains publish/update/delete never worked
against a local HTTPS musehub at all because the CLI's HTTP helpers didn't
use the shared mkcert-aware SSL context — both fixed), a third issue
surfaced: every published domain reports the identical
supported_commands: ["commit", "diff", "merge", "log", "status"],
regardless of domain.
Root cause — verified against source, not assumed
muse domains publish's auto-derive path (run_publish, used whenever
--capabilities is omitted) does this:
schema = plugin.schema()
capabilities = _Capabilities(
dimensions=[... for d in schema["dimensions"]], # REAL — from schema()
artifact_types=[],
merge_semantics=schema["merge_mode"], # REAL — from schema()
supported_commands=["commit", "diff", "merge", "log", "status"], # ← literal, ignores `schema` entirely
)
dimensions and merge_semantics are genuinely derived per-plugin
(verified: @gabriel/code, @gabriel/identity, and @gabriel/mist each
got different real dimensions when published). supported_commands is a
bare Python list baked into the CLI, consulted for zero plugins, ever.
There is currently no data source it could even be derived from —
DomainSchema (muse/core/schema.py) has exactly six fields:
domain, description, dimensions, top_level, merge_mode,
schema_version. No commands concept exists there.
The explicit---capabilities path (used when a dev passes JSON directly)
is honest by comparison — supported_commands there is exactly whatever
the dev typed, defaulting to [] if omitted. That's still not
future-proof: it depends on a human remembering to type an accurate list
and keep it in sync as the domain's real CLI surface grows, which this
issue's own trigger proves doesn't happen reliably.
The real source of truth already exists — it's just not exposed
muse code, muse mist, and muse social are each registered as a
top-level argparse namespace with a real, live subcommand tree — the
exact structure argparse itself uses to dispatch:
# app.py — muse code's ~50 subcommands, built inline
code_subs = code_parser.add_subparsers(dest="code_command", ...)
age_cmd.register(code_subs) # "age"
api_surface.register(code_subs) # "api-surface"
blast_risk.register(code_subs) # "blast-risk"
... # dozens more, one .register() call per command module
# mist.py::register() — muse mist's 10 subcommands
subs = parser.add_subparsers(dest="mist_subcommand", ...)
subs.add_parser("create", ...)
subs.add_parser("list", ...)
... # fork, push, embed, delete, update, forks, raw
code_subs.choices.keys() (and the equivalent for mist/social) is
already the authoritative, live list — the same object argparse
dispatches from. identity and scaffold have no dedicated top-level
namespace at all; for those, the honest value is [], not a borrowed
universal list.
Design — the shape to build toward
A tiny, dependency-free registry, populated as a side effect of argparse tree construction — never hand-typed anywhere.
muse/cli/domain_command_registry.py: a plain dict plus
register_namespace(name, iterable) / get_supported_commands(name).
Each domain-named command module (app.py inline for code,
mist.py::register(), social.py::register()) calls
register_namespace(name, subs.choices.keys()) immediately after
building its own subparser tree. This happens unconditionally on every
CLI invocation — main() must build the full parser tree to parse
sys.argv regardless of which command actually ran, so the registry is
always populated before any dispatch, including run_publish itself.
run_publish's auto-derive path calls get_supported_commands(active_domain_name)
instead of the literal. The explicit---capabilities-but-key-omitted case
in run_publish also falls back to this (publish's active-domain
context is trustworthy — it's literally what dimensions/merge_semantics
already assume). Explicit "supported_commands": [...] in --capabilities
still overrides, consistent with how viewer_type/merge_semantics
already work.
run_update is explicitly excluded from this auto-derive fallback.
Unlike publish, update carries no guarantee that the CLI's currently
active domain plugin has anything to do with the domain actually being
updated (you can update @aaronrene/knowtation while sitting in an
unrelated local repo with code active) — auto-deriving there would
silently attribute the wrong domain's commands. update's explicit
--capabilities path keeps its current []-on-omission behavior.
Goal — definition of done
supported_commandsis never a hand-typed literal inrun_publish's auto-derive path.- A domain with a dedicated
muse <name> <verb>namespace reports its real, current subcommand list at publish time. - A domain without one (
identity,scaffold) reports[]. - Adding a new
muse code <newverb>command tomorrow requires zero changes anywhere for the next publish of@gabriel/codeto pick it up correctly. - Explicit
--capabilitiesJSON with asupported_commandskey still overrides auto-derivation, in bothpublishandupdate. - Every deliverable is TDD'd: red test first, then green.
Phases
Ordered by load-bearing dependency — the registry must exist and be
correctly populated before domains.py can consume it.
Phase 1 — The registry module, in isolation
- [x]
SCR_01—muse/cli/domain_command_registry.py:register_namespace(name: str, commands: Iterable[str]) -> Noneandget_supported_commands(name: str) -> list[str]. Red test: querying an unregistered name returns[]; registering then querying returns the registered list. Done. - [x]
SCR_02— Idempotent re-registration: callingregister_namespacetwice for the same name replaces the stored list, never appends or duplicates (guards against double-registration ifmain()is ever called more than once in a process, e.g. tests). Done. - [x]
SCR_03— Deterministic, sorted output regardless of insertion order —get_supported_commandsmust not depend on dict/set iteration order being stable across Python versions. Done — verified across list, set, anddict.keys()input (the real shape_SubParsersAction.choiceswill pass in Phase 2).
Exit gate — met. tests/test_domain_command_registry.py: 8/8 new
tests pass (basic contract, idempotent re-registration ×2, deterministic
ordering ×3, copy-not-reference safety). mypy clean. Zero dependencies on
app.py or any command module — confirmed no circular-import risk is
even possible, since the module imports nothing from this codebase at
all beyond collections.abc.Iterable. Committed on
feat/supported-commands-registry, not yet merged to dev.
Phase 2 — Wire the real namespaces into the registry
- [x]
SCR_04—app.pycallsregister_namespace("code", code_subs.choices.keys())immediately after every.register(code_subs)call for thecodenamespace has run. Test: after any CLI invocation (even an unrelated one —main()always builds the full tree),get_supported_commands("code")contains stable, known-permanent anchors (e.g."grep","impact","symbols") and has more than 30 entries — asserting a floor and membership, not an exact list, so this test itself never becomes the new hardcoded array this issue exists to eliminate. Done. - [x]
SCR_05—mist.py::register()callsregister_namespace("mist", subs.choices.keys()). Test:get_supported_commands("mist")contains"create","fork","embed"and explicitly does not contain"commit"or"diff"(those are universal muse commands, not mist-specific — proves the registry captures the dedicated namespace, not everything). Done. - [x]
SCR_06— Same pattern forsocial.py::register(). Done. - [x]
SCR_07— Domains with no dedicated top-level namespace (identity,scaffold) return[]fromget_supported_commands— confirms the registry doesn't fabricate an entry for names it was never told about. Done.
Exit gate — met. tests/test_domain_command_registry_wiring.py:
10/10 new integration tests pass, invoking the real CLI via CliRunner
and asserting on muse code/muse mist/muse social's actual argparse
tree (membership + count floor, never an exact list). All 8 Phase 1
unit tests in tests/test_domain_command_registry.py still pass
unchanged — 18/18 total across both files. mypy: identical error count
on all three edited files before and after the change (app.py 165,
mist.py 23, social.py 11 — all pre-existing, unrelated to this
change; verified by diffing against each file's HEAD content). Wiring
added: one register_namespace(...) call at the end of the code
subparser block in app.py, and one each as the final line of
mist.py::register() and social.py::register(). Committed on
feat/supported-commands-registry, not yet merged to dev.
Phase 3 — Wire the registry into muse domains publish
- [x]
SCR_08— Red test proving the bug this issue exists to fix: publishing@gabriel/codevia the auto-derive path (no--capabilities) currently returns the hardcoded universal 5. Fix: replace the literal inrun_publishwithget_supported_commands(active_domain_name). Green: the same test now asserts the real code-domain list (membership + floor, not exact — same reasoning asSCR_04). Done. - [x]
SCR_09— Publishing@gabriel/identityvia the auto-derive path now correctly returns[], not the old fake universal list. Done. - [x]
SCR_10— Explicit--capabilities '{"supported_commands": [...]}'still overrides auto-derivation — regression guard for existing, correct behavior. Done — pinned by a new test; was already passing before the fix (proves the override path was never broken). - [x]
SCR_11— Explicit--capabilitieswithout asupported_commandskey, in thepublishpath specifically, also falls back toget_supported_commands(active_domain_name)rather than defaulting to[]— makes the field correct regardless of how a dev constructs their--capabilitiesJSON, as long as they're inside the domain's own repo (whichpublishalready assumes for dimensions/merge_semantics). Done. - [x]
SCR_12—run_updateis explicitly not changed — a test confirmsupdate's explicit---capabilities-with-omitted-key behavior is unchanged ([], not auto-derived), documenting the deliberate asymmetry from the Design section rather than leaving it to look like an oversight. Done —run_updatesource untouched; test pins the existing[]behavior as a regression guard.
Exit gate — met. New file tests/test_domains_publish_supported_commands.py:
5/5 new tests pass (SCR_08 code-domain real list, SCR_09 identity [],
SCR_10 explicit override, SCR_11 explicit-JSON-omitted-key fallback,
SCR_12 update stays []). Confirmed genuinely red before the fix
(SCR_08/09/11 failed against the old hardcoded literal; SCR_10/12
passed immediately since those paths were never broken — expected,
since they're regression guards, not new behavior). Zero regressions:
all pre-existing domains-publish suites still pass unchanged —
test_domains_publish.py (38), test_cmd_domains_hardening.py (189),
test_stress_domains_publish.py (40) — 267 pre-existing tests plus the
5 new ones, 272 total, all green. mypy: identical error count on
domains.py before and after (13, all pre-existing, unrelated to this
change; verified by diffing against HEAD content). Implementation:
active_domain_name resolution hoisted above the
capabilities_json is not None branch so both the explicit-JSON path
and the auto-derive path can consult it; both now call
get_supported_commands(active_domain_name) in place of the old
hardcoded literal, with the explicit-JSON path only falling back to it
when supported_commands is absent from the parsed JSON (an explicit
value still always wins). run_update left untouched by design.
Committed on feat/supported-commands-registry, not yet merged to
dev.
Exit gate: muse domains publish for code/mist/social domains
reports real commands; identity/scaffold report []; explicit overrides
still work; update's different (and correct) behavior is pinned by a
test, not just prose.
Phase 4 — Live re-verification
- [x]
SCR_13— Re-publish@gabriel/code,@gabriel/identity,@gabriel/miston local musehub first (per the debugging convention already in use for this investigation) using the fixed CLI, confirm realsupported_commandsin the live JSON response for each. Done — see note below on why disposable-scr13slugs were used instead of the canonical slugs. - [x]
SCR_14— Repeat against staging once local is confirmed correct. Done.
Exit gate — met. Live curl confirmation against both local
(https://localhost:1337) and staging (https://staging.musehub.ai):
@gabriel/code-scr13— 46 real commands (grep,impact,symbols,gravity,hotspots, ... ), confirmed!= ["commit", "diff", "merge", "log", "status"]on both hubs.@gabriel/identity-scr13—[]on both hubs (no dedicated namespace).@gabriel/mist-scr13—["create", "delete", "embed", "fork", "forks", "list", "push", "raw", "read", "update"]on both hubs; confirmed"commit"/"diff"absent.
Why -scr13 slugs, not the canonical @gabriel/code etc.: the
canonical domains were already registered from before this fix (with the
old hardcoded 5), and muse domains delete is a soft is_deprecated
flip, not a row delete — the server's (author_slug, slug) uniqueness
constraint holds even on deprecated rows, so re-publishing under the same
slug 409s. A first attempt at muse domains delete --slug code on
local musehub to free the slug hit exactly this: no CLI/API path
un-deprecates a domain, so @gabriel/code was stuck deprecated with no
clean way back. Caught it, stopped, asked gabriel before doing anything
further (per this workspace's data-loss/destructive-action rules) —
gabriel approved a direct fix to the local dev Postgres row
(UPDATE musehub_domains SET is_deprecated=false WHERE author_slug= 'gabriel' AND slug='code', musehub_postgres container on
127.0.0.1:5434), confirmed restored via GET /api/domains. Live
verification of the actual fix then proceeded using disposable
{code,identity,mist}-scr13 domains (published via the real, fixed CLI
in throwaway /tmp repos with the matching domain= in repo.json),
which avoided ever touching the canonical entries again. All three
-scr13 domains were deprecated on both hubs after verification —
is_deprecated: true is the correct terminal state for a throwaway
verification artifact, not a mistake to fix. The canonical
@gabriel/code/@gabriel/identity/@gabriel/mist entries on both
local and staging are untouched (still is_deprecated: false) and will
pick up real supported_commands the next time they're genuinely
re-published — out of scope for this ticket, which was about fixing the
mechanism, not re-publishing production catalog entries.
Committed on feat/supported-commands-registry, not yet merged to
dev. No source changes in this phase — verification only.
Exit gate: Live, in-browser/curl confirmation against both local and
staging — not just the test suite. @gabriel/code shows dozens of real
commands; @gabriel/identity shows []; no domain shows the old fake
universal 5 anymore.
Acceptance criteria (whole-issue gate)
- Zero hardcoded command literals remain in
run_publish's auto-derive path. code/mist/socialreport real, current, non-fabricated command lists;identity/scaffoldreport[].- Explicit
--capabilitiesoverrides still work in bothpublishandupdate. - Full TDD coverage: every phase's deliverables have a red-then-green test.
- Verified live on both local and staging, not just unit-tested.
Out of scope (explicit, for future issues)
muse domains update --capabilitiessilently wipingsupported_commands(anddimensions/artifact_types) back to empty when they're omitted from an update's JSON, becauseupdatereplaces the entire capabilities object rather than merging. Discovered while scoping this issue; real, but a distinct bug about update's replace-vs-merge semantics, not about command derivation. Tracked separately.- Giving
identityorscaffoldtheir own dedicatedmuse <name> <verb>CLI namespace — out of scope; this issue is about correctly reporting what already exists, not creating new CLI surface. - Any change to how
dimensions/merge_semantics/viewer_typeare derived — already correct, verified working throughout this investigation.