Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/agentcatalog/agentcatalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
28 changes: 25 additions & 3 deletions internal/agentcatalog/agentcatalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n> server-side installs as-is
// rather than becoming praxis-praxis-<n>. 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)
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions internal/skillcatalog/skillcatalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
31 changes: 28 additions & 3 deletions internal/skillcatalog/skillcatalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions internal/skillinstall/dummy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>`" + `. 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-<name>`" + `. 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
Expand Down
26 changes: 26 additions & 0 deletions internal/skillinstall/dummy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>",
"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)
}
}
}