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") 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) + } + } +}