From 5094579869fca9295399626106d4d2c30cf9e0d6 Mon Sep 17 00:00:00 2001 From: Anshul Sao Date: Tue, 4 Aug 2026 00:24:01 +0530 Subject: [PATCH 1/2] fix(catalog): don't double-prefix skills/agents already named praxis-* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrefixedName() unconditionally concatenated "praxis-" onto the catalog name. Two agent-factory seed skills are authored as "praxis-dag" and "praxis-dag-runner", so they landed on disk as praxis-praxis-dag and praxis-praxis-dag-runner. That is user-visible, not just cosmetic: the Praxis UI generates pickup prompts that say "Use the praxis-dag skill" (ui/src/components/dag/ pickupPrompt.ts), so the name it hands the user never matched the folder the CLI installed. Collapse when the catalog name already carries the prefix. This is safe: - the reserved-namespace invariant the cleanup globs rely on is only that an installed folder LIVES IN the praxis- namespace, which holds either way (asserted by a new test); - nothing reverse-maps a folder name back to its catalog name by stripping exactly one prefix (verified: no TrimPrefix on the prefix constants anywhere in the tree); - praxis-praxis-dag is receipt-tracked and is not a meta-skill, so the wipe-and-reinstall in runPostAuthSetup removes the stale dir before the corrected name is installed. Both `praxis login` and `praxis refresh-skills` go through that path, so either heals an existing install — no manual cleanup needed. Only an exact prefix match collapses — "my-praxis-dag" and "praxisdag" are still prefixed. Applied to agentcatalog too, which had the identical concatenation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpVxm5k6cuVWnz5pKsFUdT --- internal/agentcatalog/agentcatalog.go | 8 ++++++ internal/agentcatalog/agentcatalog_test.go | 28 ++++++++++++++++--- internal/skillcatalog/skillcatalog.go | 11 ++++++++ internal/skillcatalog/skillcatalog_test.go | 31 +++++++++++++++++++--- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/internal/agentcatalog/agentcatalog.go b/internal/agentcatalog/agentcatalog.go index ad92d93..2c7e083 100644 --- a/internal/agentcatalog/agentcatalog.go +++ b/internal/agentcatalog/agentcatalog.go @@ -58,7 +58,15 @@ type Agent struct { } // PrefixedName is the on-disk file basename for this agent. +// +// A catalog name that already starts with the prefix is returned unchanged, +// so a server-side "praxis-dag" installs as praxis-dag rather than +// praxis-praxis-dag. The reserved-namespace invariant that the cleanup globs +// depend on holds either way. Mirrors skillcatalog.Skill.PrefixedName. func (a Agent) PrefixedName() string { + if strings.HasPrefix(a.Name, PrefixAgent) { + return a.Name + } return PrefixAgent + a.Name } diff --git a/internal/agentcatalog/agentcatalog_test.go b/internal/agentcatalog/agentcatalog_test.go index 568bfbe..e9fcfe4 100644 --- a/internal/agentcatalog/agentcatalog_test.go +++ b/internal/agentcatalog/agentcatalog_test.go @@ -11,9 +11,31 @@ import ( ) func TestAgentPrefixedName(t *testing.T) { - a := Agent{Name: "foo", Kind: KindAgent} - if got := a.PrefixedName(); got != "praxis-foo" { - t.Errorf("PrefixedName() = %q, want praxis-foo", got) + cases := []struct { + in, want string + }{ + {"foo", "praxis-foo"}, + + // An agent already authored as praxis- server-side installs as-is + // rather than becoming praxis-praxis-. Same rule as skillcatalog's + // PrefixedName — see the comment there. + {"praxis-dag", "praxis-dag"}, + + // Only an exact prefix match collapses. + {"my-praxis-dag", "praxis-my-praxis-dag"}, + {"praxisdag", "praxis-praxisdag"}, + {"praxis-", "praxis-"}, + } + for _, tc := range cases { + a := Agent{Name: tc.in, Kind: KindAgent} + got := a.PrefixedName() + if got != tc.want { + t.Errorf("PrefixedName(%q) = %q, want %q", tc.in, got, tc.want) + } + if !strings.HasPrefix(got, PrefixAgent) { + t.Errorf("PrefixedName(%q) = %q; must stay inside the %q namespace", + tc.in, got, PrefixAgent) + } } } diff --git a/internal/skillcatalog/skillcatalog.go b/internal/skillcatalog/skillcatalog.go index 02b11a7..01a1e91 100644 --- a/internal/skillcatalog/skillcatalog.go +++ b/internal/skillcatalog/skillcatalog.go @@ -64,7 +64,18 @@ func (s Skill) IsMultiFile() bool { // PrefixedName is the on-disk skill folder name (e.g. praxis-incident-investigator). // Skills the CLI installs from the catalog always carry this prefix so they // can't collide with user-authored or third-party skills. +// +// A catalog name that already starts with the prefix is returned unchanged. +// Some server-side skills are authored as "praxis-dag" / "praxis-dag-runner"; +// blindly concatenating produced "praxis-praxis-dag" on disk. Collapsing is +// safe because the invariant the cleanup globs depend on is only that the +// installed folder LIVES IN the praxis- namespace — which holds either way — +// and nothing reverse-maps a folder name back to its catalog name by +// stripping exactly one prefix. func (s Skill) PrefixedName() string { + if strings.HasPrefix(s.Name, PraxisPrefix) { + return s.Name + } return PraxisPrefix + s.Name } diff --git a/internal/skillcatalog/skillcatalog_test.go b/internal/skillcatalog/skillcatalog_test.go index 4cfa9bc..57d51c8 100644 --- a/internal/skillcatalog/skillcatalog_test.go +++ b/internal/skillcatalog/skillcatalog_test.go @@ -253,9 +253,24 @@ func TestPrefixedName_AlwaysPrefixed(t *testing.T) { in, want string }{ {"k8s-ops", "praxis-k8s-ops"}, - {"already-praxis-prefix-but-still-prefixed-once-more", "praxis-already-praxis-prefix-but-still-prefixed-once-more"}, - // We do NOT collapse double-prefixes — keeps the rule "if it starts - // with praxis-, the CLI installed it" mechanical. + + // Server-side catalog names that ALREADY carry the praxis- prefix are + // installed as-is rather than double-prefixed. The namespace invariant + // the cleanup globs rely on — "an on-disk skill dir starting with + // praxis- was installed by this CLI" — is satisfied either way, so + // re-prefixing bought nothing and produced praxis-praxis-dag / + // praxis-praxis-dag-runner on real installs. + {"praxis-dag", "praxis-dag"}, + {"praxis-dag-runner", "praxis-dag-runner"}, + + // Only an exact prefix match collapses. A name that merely *contains* + // the token, or starts with a lookalike, is still prefixed. + {"my-praxis-dag", "praxis-my-praxis-dag"}, + {"praxisdag", "praxis-praxisdag"}, + + // Degenerate: the bare prefix is not a usable name; leave it alone + // rather than emitting "praxis-praxis-". + {"praxis-", "praxis-"}, } for _, tc := range cases { s := Skill{Name: tc.in} @@ -265,6 +280,16 @@ func TestPrefixedName_AlwaysPrefixed(t *testing.T) { } } +// Whatever PrefixedName returns must keep the reserved-namespace invariant +// that UninstallByPrefix / RemoveOrphanedByPrefix glob on. +func TestPrefixedName_AlwaysInReservedNamespace(t *testing.T) { + for _, in := range []string{"k8s-ops", "praxis-dag", "my-praxis-dag", "praxisdag", "praxis-"} { + if got := (Skill{Name: in}).PrefixedName(); !strings.HasPrefix(got, PraxisPrefix) { + t.Errorf("PrefixedName(%q) = %q; must stay inside the %q namespace", in, got, PraxisPrefix) + } + } +} + func TestIsMultiFile(t *testing.T) { if (Skill{}).IsMultiFile() { t.Error("skill with no files should not be multi-file") From aeb0e3fdd8de4de183211bab117e364a96f2566c Mon Sep 17 00:00:00 2001 From: Anshul Sao Date: Tue, 4 Aug 2026 10:25:46 +0530 Subject: [PATCH 2/2] docs(meta-skill): teach that `praxis-` is a reserved namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog skills are installed as praxis-, but their bodies cross-reference each other by the BARE name. 17 such references exist across 5 installed skills, and none of them resolve: praxis-build-facets-module → /design-facets-module ×5, /modules-repo-workflow ×3 praxis-design-facets-module → /build-facets-module ×3, /modules-repo-workflow ×1 praxis-facets-module-testing → /modules-repo-workflow ×1 praxis-modules-repo-workflow → /build-facets-module ×1, /facets-module-testing ×1 praxis-memory → /memory ×2 These are load-bearing, not incidental. praxis-build-facets-module opens with "STOP — Have you completed the /design-facets-module skill?" and praxis-design-facets-module ends with "Proceed to /build-facets-module", so the design → build → test → publish handoff chain dead-ends at every hop. praxis-memory shows the same bug reaches binary-embedded content, so fixing only the server-side seeds would not have covered it. Rather than rewriting every reference at render time — which would have to re-run on content the CLI does not own and would silently rot as new skills add new cross-refs — state the resolution rule once in the meta-skill the host loads first: the prefix is provenance, skills name each other bare, resolve /X to praxis-X. Guarded by TestPraxisMetaSkill_TeachesPrefixResolutionRule. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpVxm5k6cuVWnz5pKsFUdT --- internal/skillinstall/dummy.go | 19 +++++++++++++++++++ internal/skillinstall/dummy_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/skillinstall/dummy.go b/internal/skillinstall/dummy.go index 555427a..fd48fad 100644 --- a/internal/skillinstall/dummy.go +++ b/internal/skillinstall/dummy.go @@ -96,6 +96,25 @@ praxis login --profile bigcorp # wipes acme skills, installs bigcorp This meta-skill survives every switch. Only the catalog skills cycle. +## Skill naming: ` + "`praxis-`" + ` is a reserved namespace + +Every skill this CLI installs lands under ` + "`praxis-`" + `. That prefix is +provenance, not part of the skill's identity — it marks "the Praxis CLI put +this here" so login/logout can clean up its own files without touching +user-authored skills. + +**Skills therefore refer to each other without the prefix.** When a Praxis +skill body says ` + "`/design-facets-module`" + `, ` + "`/modules-repo-workflow`" + `, or +` + "`/memory`" + `, it means the installed ` + "`/praxis-`" + `. Resolve a bare +` + "`/X`" + ` to ` + "`praxis-X`" + ` and invoke that. Do not report the bare name as a +missing skill, and do not go looking for an unprefixed copy — there isn't +one. + +This matters most for the module-authoring chain, which is a sequence of +handoffs: design → build → test → publish. Each step names the next one +bare. Run ` + "`praxis list-skills --json`" + ` if you need the exact installed +names. + ## Output convention Every AI-callable command supports ` + "`--json`" + ` and auto-emits JSON when diff --git a/internal/skillinstall/dummy_test.go b/internal/skillinstall/dummy_test.go index 9304a48..4e7ae63 100644 --- a/internal/skillinstall/dummy_test.go +++ b/internal/skillinstall/dummy_test.go @@ -49,3 +49,29 @@ func TestPraxisMetaSkill_RaptorIsLocalNotGateway(t *testing.T) { t.Error("meta-skill uses the wrong shape `raptor.stale`; tools is an array — find the raptor entry") } } + +// Catalog skills are installed under the reserved `praxis-` namespace, but +// their bodies cross-reference each other by the BARE name — e.g. +// praxis-build-facets-module says "Have you completed the +// /design-facets-module skill?" and praxis-design-facets-module ends with +// "Proceed to /build-facets-module". Those slash-names never exist on disk, +// so the handoff chain between the module-authoring skills dead-ends. +// +// Rather than rewriting every reference at render time, the meta-skill +// teaches the resolution rule once: a bare /X inside a Praxis skill means +// the installed praxis-X. Guard that the rule is actually stated. +func TestPraxisMetaSkill_TeachesPrefixResolutionRule(t *testing.T) { + body, err := ContentFor("praxis") + if err != nil { + t.Fatalf("ContentFor(praxis): %v", err) + } + for _, want := range []string{ + "reserved namespace", + "/praxis-", + "without the prefix", + } { + if !strings.Contains(body, want) { + t.Errorf("meta-skill missing prefix-resolution guidance %q — bare /X cross-references in catalog skills will dead-end", want) + } + } +}