Skip to content

Commit 2b3aa53

Browse files
anshulsaoclaude
andauthored
feat(refresh-skills): default to user-level install, add --project scope (#23)
* feat(refresh-skills): default to user-level install, add --project scope `praxis refresh-skills` installed skills into the user-level home dirs (~/.claude/skills, ...), which apply globally across every repo. That over-applied org skills — e.g. praxis-k8s-operations + its MCPs nudging Claude away from a user's local kubectl in repos that don't want them. Keep user-level as the default (the main customer case) and add an opt-in `--project` flag that scopes the install to the current repo (<cwd>/.claude/skills, etc.) instead of the global home dir. - harness.Harness.ProjectScoped(dir): rebases SkillDir/AgentDir from the home dir onto a project dir, per-host (handles Codex's split ~/.agents/skills vs ~/.codex/agents). Non-home paths pass through. - runPostAuthSetup gains a projectScoped arg; refresh-skills wires it to --project and reports scope in text + JSON output. - Project scope SKIPS the receipt-based "wipe previous profile" steps (skills + agents) so a repo-local refresh can't delete the user's global install. The disk-scoped orphan sweep still runs (it only touches the project-scoped host dirs). TDD: harness unit tests + runPostAuthSetup project-scope tests (writes into project dir; does not wipe a pre-seeded user-level install) + refresh-skills --project RunE test. Full -race suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(refresh-skills): fall back to user scope correctly when getwd fails Addresses CodeRabbit review on #23. When `refresh-skills --project` is requested but the working directory can't be resolved, the install fell back to a user-level target but kept treating it as project-scoped — so the receipt-based user-level wipes were skipped and the reported scope lied about where files landed. - Track an `effectiveProjectScoped` in runPostAuthSetup that drops to false on getwd() failure; gate the three user-level wipe steps on it and surface it via postAuthState.projectScoped. - refresh-skills now reports the effective scope (state.projectScoped), not the requested flag, in both JSON and text output. - Regression tests: getwd-failure fallback runs the user-level wipe (login_setup_test) and reports user scope (skill_test). Nitpicks also addressed: - login_reuse_test stub now asserts login passes projectScoped=false. - TestProjectScoped_* consolidated into one table-driven test. - TestRefreshSkills_ProjectFlag_* drive --project/--json through Cobra flags to exercise flag wiring. - README: stop claiming refresh-skills doesn't exist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 37f2091 commit 2b3aa53

9 files changed

Lines changed: 529 additions & 27 deletions

File tree

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,11 @@ praxis login --url https://praxis.your-org.example
105105
Once saved, you don't need to pass `--url` again. Re-running
106106
`praxis login` reuses the URL stored in your credentials file.
107107

108-
Re-running `praxis login` is also how you **refresh** your skills and
109-
the MCP manifest. There's no separate `init`, `install-skill`, or
110-
`refresh-skills` command — login is the one mutator.
108+
Re-running `praxis login` is the canonical way to **refresh** your
109+
skills and the MCP manifest. If you're already logged in and just want
110+
the refresh without re-authenticating, `praxis refresh-skills` does the
111+
same thing minus the browser flow (pass `--project` to scope the install
112+
to the current repo instead of your user-level home dir).
111113

112114
That's it. Open Claude Code (or Codex, or Gemini CLI) and try:
113115

@@ -153,12 +155,16 @@ praxis agents [--json]
153155
agents from /ai-api/custom-agents, prefixed `praxis-`). Read-only,
154156
no network call.
155157
156-
praxis refresh-skills [--json]
158+
praxis refresh-skills [--project] [--json]
157159
Re-fetch this profile's catalog and rewrite skill files + MCP
158160
snapshot, without re-authenticating. Use when the org has
159161
published new skills or after `brew upgrade praxis`. Equivalent
160162
to `praxis login` minus the browser flow; requires existing
161163
valid credentials.
164+
Installs at USER level by default (~/.claude/skills, ...), so
165+
skills apply across every repo. Pass --project to scope the
166+
install to the current repo (<cwd>/.claude/skills, ...) instead —
167+
handy when you want Praxis skills active in one repository only.
162168
163169
praxis update [--yes] [--json]
164170
Self-update binary. --json implies --yes.

cmd/login.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ func persistAndSetup(out io.Writer, asJSON bool, profileName, baseURL, token, em
366366

367367
// Post-auth: install meta-skill, wipe previous org skills, install
368368
// this profile's catalog, refresh the MCP tools snapshot.
369-
state := postAuthSetup(out, asJSON, baseURL, token)
369+
state := postAuthSetup(out, asJSON, baseURL, token, false)
370370

371371
if asJSON {
372372
return render.JSON(out, map[string]any{

cmd/login_reuse_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ func stubPostAuth(t *testing.T) *bool {
4141
t.Helper()
4242
called := false
4343
orig := postAuthSetup
44-
postAuthSetup = func(out io.Writer, asJSON bool, baseURL, token string) postAuthState {
44+
postAuthSetup = func(out io.Writer, asJSON bool, baseURL, token string, projectScoped bool) postAuthState {
45+
// The login path is never project-scoped — only `refresh-skills
46+
// --project` is. Guard the compatibility contract so a future
47+
// signature change can't silently start passing true here.
48+
if projectScoped {
49+
t.Errorf("login should call postAuthSetup with projectScoped=false, got true")
50+
}
4551
called = true
4652
return postAuthState{}
4753
}

cmd/login_setup.go

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"fmt"
55
"io"
6+
"os"
67

78
"github.com/Facets-cloud/praxis-cli/internal/agentinstall"
89
"github.com/Facets-cloud/praxis-cli/internal/harness"
@@ -11,6 +12,10 @@ import (
1112
"github.com/Facets-cloud/praxis-cli/internal/skillinstall"
1213
)
1314

15+
// getwd is a seam for the current working directory so project-scoped
16+
// installs can be tested without chdir'ing the test process.
17+
var getwd = os.Getwd
18+
1419
// postAuthState captures what runPostAuthSetup did, for inclusion in
1520
// the JSON output of `praxis login --json`. AI hosts read this to know
1621
// exactly what changed on disk so they can decide whether to re-read
@@ -23,6 +28,11 @@ type postAuthState struct {
2328
removedAgents []agentInstallationLite
2429
snapshotPath string
2530
snapshotWarning string
31+
// projectScoped is the *effective* install scope after resolving the
32+
// working directory — not the requested flag. It's false when
33+
// --project was requested but getwd() failed and the install fell
34+
// back to user-level, so callers report where files actually landed.
35+
projectScoped bool
2636
}
2737

2838
// agentInstallationLite is the JSON shape used in login output. Mirrors
@@ -54,9 +64,44 @@ type agentInstallationLite struct {
5464
// Each step is best-effort: a failure logs a warning to `out` but does
5565
// not roll back the credentials save. The user can re-run login any
5666
// time to retry the steps that failed (login is idempotent).
57-
func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAuthState {
67+
// runPostAuthSetup installs the meta-skill + org catalog + agents into
68+
// the detected hosts and refreshes the MCP snapshot. When projectScoped
69+
// is true the install targets are rebased from the user-level home dirs
70+
// (~/.claude/skills, ...) onto the current working directory
71+
// (<repo>/.claude/skills, ...), so `praxis refresh-skills --project`
72+
// scopes skills to a single repo instead of applying them globally.
73+
//
74+
// Project scope also SKIPS the receipt-based "wipe previous profile"
75+
// step (UninstallByPrefix), which operates off the shared user-level
76+
// receipt and would otherwise delete the user's global install while
77+
// doing a repo-local refresh. The disk-scoped orphan sweep still runs —
78+
// it only touches the (project-scoped) host dirs, so it stays safe.
79+
func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string, projectScoped bool) postAuthState {
5880
state := postAuthState{}
5981
hosts := detectHarnesses()
82+
// effectiveProjectScoped tracks the scope we actually install at. It
83+
// starts from the requested flag but drops to false if getwd() fails,
84+
// because we then fall back to a genuine user-level install — which
85+
// must run the user-level wipes and be reported as user scope.
86+
effectiveProjectScoped := projectScoped
87+
if projectScoped && len(hosts) > 0 {
88+
dir, err := getwd()
89+
if err != nil {
90+
effectiveProjectScoped = false
91+
if !asJSON {
92+
fmt.Fprintf(out, "Warning: cannot resolve working directory for project-scoped install: %v\n", err)
93+
fmt.Fprintln(out, "Falling back to user-level install.")
94+
}
95+
} else {
96+
for i := range hosts {
97+
hosts[i] = hosts[i].ProjectScoped(dir)
98+
}
99+
if !asJSON {
100+
fmt.Fprintf(out, "Installing project-scoped under %s\n", dir)
101+
}
102+
}
103+
}
104+
state.projectScoped = effectiveProjectScoped
60105
noHosts := len(hosts) == 0
61106
if noHosts && !asJSON {
62107
fmt.Fprintln(out, "No supported AI hosts detected on this machine.")
@@ -103,17 +148,23 @@ func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAut
103148
}
104149
case len(skills) == 0:
105150
// Empty catalog is a definitive answer — wipe stale entries.
106-
removed := wipePrevProfileSkills(out, asJSON)
107-
state.removedSkills = liteResults(removed)
151+
// The receipt-based wipe is user-level only (see func doc).
152+
if !effectiveProjectScoped {
153+
removed := wipePrevProfileSkills(out, asJSON)
154+
state.removedSkills = liteResults(removed)
155+
}
108156
orphaned := removeOrphanedProfileSkills(out, asJSON, nil, hosts)
109157
state.removedSkills = append(state.removedSkills, liteResults(orphaned)...)
110158
if !asJSON {
111159
fmt.Fprintln(out, "\nCatalog is empty for this org — nothing to install.")
112160
}
113161
default:
114-
// Catalog in hand. Now wipe and install.
115-
removed := wipePrevProfileSkills(out, asJSON)
116-
state.removedSkills = liteResults(removed)
162+
// Catalog in hand. Now wipe and install. The receipt-based
163+
// wipe is user-level only (see func doc).
164+
if !effectiveProjectScoped {
165+
removed := wipePrevProfileSkills(out, asJSON)
166+
state.removedSkills = liteResults(removed)
167+
}
117168
orphaned := removeOrphanedProfileSkills(out, asJSON, skills, hosts)
118169
state.removedSkills = append(state.removedSkills, liteResults(orphaned)...)
119170
state.catalogSkills = installFetchedCatalog(out, asJSON, skills, hosts)
@@ -131,15 +182,19 @@ func runPostAuthSetup(out io.Writer, asJSON bool, baseURL, token string) postAut
131182
fmt.Fprintln(out, "Existing agents left in place. Re-run `praxis login` once the gateway is reachable.")
132183
}
133184
default:
134-
removed, err := uninstallAgentsByPrefix("praxis-")
135-
if err != nil {
136-
if !asJSON {
137-
fmt.Fprintf(out, "Warning: removing previous profile's agents failed: %v\n", err)
185+
// Receipt-based agent wipe is user-level only — same
186+
// reasoning as the skill wipe above (see func doc).
187+
if !effectiveProjectScoped {
188+
removed, err := uninstallAgentsByPrefix("praxis-")
189+
if err != nil {
190+
if !asJSON {
191+
fmt.Fprintf(out, "Warning: removing previous profile's agents failed: %v\n", err)
192+
}
193+
}
194+
state.removedAgents = agentLiteResults(removed)
195+
if !asJSON && len(removed) > 0 {
196+
fmt.Fprintf(out, "\nRemoved %d agent file(s) from previous profile.\n", len(removed))
138197
}
139-
}
140-
state.removedAgents = agentLiteResults(removed)
141-
if !asJSON && len(removed) > 0 {
142-
fmt.Fprintf(out, "\nRemoved %d agent file(s) from previous profile.\n", len(removed))
143198
}
144199

145200
if len(agents) == 0 {

0 commit comments

Comments
 (0)