Skip to content

Commit fe69644

Browse files
anshulsaoclaude
andcommitted
feat(profiles): kubectl-style multi-profile support
Replaces the JSON single-credential file with an INI multi-profile store matching ~/.facets/credentials format. Single-profile users see no behavior change — everything resolves to "default" silently. NEW package ─────────── internal/credentials INI parse/write (hand-rolled, no deps), profile-aware Load/Save/Put/Delete/List, ResolveActive() with the kubectl-style chain: --profile flag > PRAXIS_PROFILE env > ~/.praxis/config "default profile" > literal "default" section SetActive() / ClearActive() for `praxis use`. 78% covered. DELETED ─────── internal/config URL is now per-profile in credentials. NEW commands ──────────── praxis whoami [--profile X] live identity check via /auth/me praxis status [--profile X] read-only state (profile, URL, login, installed skills) — for AI hosts praxis init [--profile X] idempotent: install-skill + status praxis use <profile> set active profile (kubectl-style) UPDATED commands ──────────────── praxis login --profile X (target profile name; default "default") --url Y (saves URL into the named profile) --token Z (non-interactive) praxis logout --profile X (default: active profile) --all (wipe everything + active pointer) CREDENTIALS FILE ~/.praxis/credentials (INI, 0600) ──────────────────────────────────────────────────── [default] url = https://askpraxis.ai username = anshul@facets.cloud token = sk_live_… [acme] url = https://acme.console.facets.cloud username = support@acme.com token = sk_live_… ACTIVE PROFILE POINTER ~/.praxis/config (only created by `praxis use`) ──────────────────────────────────────────────────────────────────────── [default] profile = acme V0.3 → V0.4 MIGRATION ───────────────────── Clean slate. Old single-object credentials are not auto-migrated (only consumer was me on this machine, and re-running `praxis login` takes one click). Documented in CHANGELOG. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04ceaac commit fe69644

13 files changed

Lines changed: 1108 additions & 448 deletions

File tree

cmd/init.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/Facets-cloud/praxis-cli/internal/credentials"
7+
"github.com/Facets-cloud/praxis-cli/internal/harness"
8+
"github.com/Facets-cloud/praxis-cli/internal/render"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var (
13+
initProfile string
14+
initJSON bool
15+
)
16+
17+
func init() {
18+
initCmd.Flags().StringVar(&initProfile, "profile", "", "use this profile (default: active)")
19+
initCmd.Flags().BoolVar(&initJSON, "json", false, "JSON output")
20+
rootCmd.AddCommand(initCmd)
21+
}
22+
23+
var initCmd = &cobra.Command{
24+
Use: "init",
25+
Short: "First-run bootstrap (install skill into all detected AI hosts + report state)",
26+
Long: `Idempotent setup. Installs the praxis skill into every detected
27+
AI host (so your AI knows how to use praxis) and prints the resulting
28+
state as JSON. Designed to be run by your AI host on first contact:
29+
30+
paste into your AI:
31+
"Run praxis init and tell me what's needed next."
32+
33+
The AI runs init, reads the JSON, and either reports "all set" or tells
34+
you to run praxis login (which the AI can also do directly).`,
35+
Args: cobra.NoArgs,
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
out := cmd.OutOrStdout()
38+
asJSON := render.UseJSON(initJSON, false, out)
39+
40+
// Step 1 — install-skill (idempotent; refresh if already installed).
41+
hosts := detectHarnesses()
42+
var skillResults []skillInstallationLite
43+
if len(hosts) > 0 {
44+
results, err := installSkill(skillName, hosts)
45+
if err != nil {
46+
return fmt.Errorf("install-skill: %w", err)
47+
}
48+
for _, r := range results {
49+
skillResults = append(skillResults, skillInstallationLite{
50+
Harness: r.Harness,
51+
Path: r.Path,
52+
})
53+
}
54+
}
55+
56+
// Step 2 — report state (URL + auth) so the caller can decide
57+
// whether to run `praxis login` next.
58+
active, _ := credentials.ResolveActive(initProfile)
59+
60+
var nextSteps []string
61+
if !active.Loaded || active.Profile.Token == "" {
62+
nextSteps = append(nextSteps, "run `praxis login` (browser will open; user clicks once)")
63+
}
64+
65+
state := map[string]any{
66+
"profile": active.Name,
67+
"profile_source": active.Source,
68+
"url": active.Profile.URL,
69+
"logged_in": active.Loaded && active.Profile.Token != "",
70+
"username": active.Profile.Username,
71+
"hosts_detected": harnessNames(hosts),
72+
"skills_installed": skillResults,
73+
"next_steps": nextSteps,
74+
}
75+
76+
if asJSON {
77+
return render.JSON(out, state)
78+
}
79+
80+
fmt.Fprintln(out, "Praxis init")
81+
fmt.Fprintf(out, " profile: %s (source: %s)\n", active.Name, active.Source)
82+
fmt.Fprintf(out, " url: %s\n", active.Profile.URL)
83+
if active.Loaded && active.Profile.Token != "" {
84+
fmt.Fprintf(out, " logged in: yes (%s)\n", active.Profile.Username)
85+
} else {
86+
fmt.Fprintln(out, " logged in: no")
87+
}
88+
fmt.Fprintf(out, " skills: %d installed\n", len(skillResults))
89+
for _, r := range skillResults {
90+
fmt.Fprintf(out, " ✓ %s @ %s\n", r.Harness, r.Path)
91+
}
92+
if len(nextSteps) > 0 {
93+
fmt.Fprintln(out, "\nNext:")
94+
for _, s := range nextSteps {
95+
fmt.Fprintf(out, " → %s\n", s)
96+
}
97+
}
98+
return nil
99+
},
100+
}
101+
102+
// skillInstallationLite is a trimmed JSON shape — drops InstalledAt because
103+
// init is run frequently and the timestamp churn would clutter output.
104+
type skillInstallationLite struct {
105+
Harness string `json:"harness"`
106+
Path string `json:"path"`
107+
}
108+
109+
func harnessNames(hosts []harness.Harness) []string {
110+
out := make([]string, 0, len(hosts))
111+
for _, h := range hosts {
112+
out = append(out, h.Name)
113+
}
114+
return out
115+
}

cmd/login.go

Lines changed: 52 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,26 @@ import (
1212
"net/url"
1313
"os"
1414
"os/exec"
15-
"path/filepath"
1615
"runtime"
1716
"time"
1817

19-
"github.com/Facets-cloud/praxis-cli/internal/config"
18+
"github.com/Facets-cloud/praxis-cli/internal/credentials"
2019
"github.com/Facets-cloud/praxis-cli/internal/exitcode"
21-
"github.com/Facets-cloud/praxis-cli/internal/paths"
2220
"github.com/Facets-cloud/praxis-cli/internal/render"
2321
"github.com/spf13/cobra"
2422
)
2523

2624
var (
25+
loginProfile string
2726
loginURL string
2827
loginToken string
2928
loginJSON bool
3029
loginTimeout time.Duration
3130
)
3231

3332
func init() {
34-
loginCmd.Flags().StringVar(&loginURL, "url", "", "Praxis deployment URL (overrides PRAXIS_URL/config)")
33+
loginCmd.Flags().StringVar(&loginProfile, "profile", "", "save under this profile name (default: \"default\")")
34+
loginCmd.Flags().StringVar(&loginURL, "url", "", "Praxis deployment URL (default: existing profile URL or "+credentials.DefaultURL+")")
3535
loginCmd.Flags().StringVar(&loginToken, "token", "", "skip browser flow; save and verify the given API key directly")
3636
loginCmd.Flags().BoolVar(&loginJSON, "json", false, "JSON output")
3737
loginCmd.Flags().DurationVar(&loginTimeout, "timeout", 90*time.Second, "max time to wait for browser callback")
@@ -46,30 +46,54 @@ key via a one-shot localhost listener. The user clicks "Create Key" once;
4646
this command handles the rest.
4747
4848
For non-interactive use (e.g. CI, or AI hosts that already have a token),
49-
pass --token sk_live_…`,
49+
pass --token sk_live_…
50+
51+
Multiple deployments? Use --profile to keep them separate:
52+
53+
praxis login → saves to "default"
54+
praxis login --profile acme --url https://... → saves to "acme"
55+
praxis login --profile vymo --url https://... → saves to "vymo"
56+
57+
Then switch contexts with:
58+
59+
praxis use acme (sets active profile)
60+
PRAXIS_PROFILE=acme praxis ... (one-shell override)
61+
praxis ... --profile acme (one-command override)`,
5062
Args: cobra.NoArgs,
5163
RunE: func(cmd *cobra.Command, args []string) error {
5264
out := cmd.OutOrStdout()
5365
asJSON := render.UseJSON(loginJSON, false, out)
5466

55-
resolved, err := config.ResolveURL(loginURL)
56-
if err != nil {
57-
return err
67+
profileName := loginProfile
68+
if profileName == "" {
69+
profileName = credentials.DefaultProfileName
5870
}
59-
baseURL := resolved.URL
71+
baseURL := resolveLoginURL(profileName, loginURL)
6072

6173
if loginToken != "" {
62-
return saveAndVerifyToken(out, asJSON, baseURL, loginToken)
74+
return saveAndVerifyToken(out, asJSON, profileName, baseURL, loginToken)
6375
}
64-
65-
return browserCallbackLogin(out, asJSON, baseURL, loginTimeout)
76+
return browserCallbackLogin(out, asJSON, profileName, baseURL, loginTimeout)
6677
},
6778
}
6879

80+
// resolveLoginURL resolves the URL for a NEW or EXISTING profile during
81+
// login: explicit --url > existing profile's saved URL > built-in default.
82+
func resolveLoginURL(profileName, flagURL string) string {
83+
if flagURL != "" {
84+
return flagURL
85+
}
86+
store, _ := credentials.Load()
87+
if p, ok := store[profileName]; ok && p.URL != "" {
88+
return p.URL
89+
}
90+
return credentials.DefaultURL
91+
}
92+
6993
// browserCallbackLogin opens the browser, waits up to timeout for the
7094
// browser to POST the new key to the localhost listener, then saves +
71-
// verifies the captured token.
72-
func browserCallbackLogin(out io.Writer, asJSON bool, baseURL string, timeout time.Duration) error {
95+
// verifies the captured token under profileName.
96+
func browserCallbackLogin(out io.Writer, asJSON bool, profileName, baseURL string, timeout time.Duration) error {
7397
sessionNonce := randomNonce()
7498

7599
listener, err := net.Listen("tcp", "127.0.0.1:0")
@@ -86,7 +110,6 @@ func browserCallbackLogin(out io.Writer, asJSON bool, baseURL string, timeout ti
86110

87111
mux := http.NewServeMux()
88112
mux.HandleFunc("/key", func(w http.ResponseWriter, r *http.Request) {
89-
// CORS for the cross-origin POST from baseURL.
90113
w.Header().Set("Access-Control-Allow-Origin", baseURL)
91114
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
92115
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
@@ -130,9 +153,6 @@ func browserCallbackLogin(out io.Writer, asJSON bool, baseURL string, timeout ti
130153
}()
131154

132155
openURL := buildLoginURL(baseURL, port, sessionNonce)
133-
// Print progress to stderr so even --json callers (and tests piping
134-
// stdout) can see what URL was opened. Stdout stays reserved for the
135-
// final structured result.
136156
fmt.Fprintln(os.Stderr, "Opening browser to create a Praxis API key…")
137157
fmt.Fprintln(os.Stderr, " ", openURL)
138158
fmt.Fprintf(os.Stderr, "Waiting for callback (timeout %s)…\n", timeout)
@@ -144,10 +164,10 @@ func browserCallbackLogin(out io.Writer, asJSON bool, baseURL string, timeout ti
144164
case res := <-resultCh:
145165
if res.err != nil {
146166
render.PrintError(out, asJSON, res.err.Error(),
147-
"the browser callback failed; check the page console", exitcode.Auth)
167+
"the browser callback failed", exitcode.Auth)
148168
os.Exit(exitcode.Auth)
149169
}
150-
return saveAndVerifyToken(out, asJSON, baseURL, res.key)
170+
return saveAndVerifyToken(out, asJSON, profileName, baseURL, res.key)
151171
case <-time.After(timeout):
152172
render.PrintError(out, asJSON, "login timed out",
153173
"finish the API key creation in the browser within the timeout", exitcode.Auth)
@@ -156,8 +176,6 @@ func browserCallbackLogin(out io.Writer, asJSON bool, baseURL string, timeout ti
156176
return nil
157177
}
158178

159-
// buildLoginURL composes the api-keys page URL with the cli-callback
160-
// query params the agent-factory ApiKeyCreateModal reads.
161179
func buildLoginURL(baseURL string, callbackPort int, sessionNonce string) string {
162180
u, _ := url.Parse(baseURL + "/ui/ai/settings/api-keys")
163181
q := u.Query()
@@ -168,15 +186,7 @@ func buildLoginURL(baseURL string, callbackPort int, sessionNonce string) string
168186
return u.String()
169187
}
170188

171-
// Credentials is the on-disk schema at ~/.praxis/credentials.
172-
type Credentials struct {
173-
URL string `json:"url"`
174-
AccessToken string `json:"access_token"`
175-
UserID string `json:"user_id,omitempty"`
176-
UserEmail string `json:"user_email,omitempty"`
177-
}
178-
179-
func saveAndVerifyToken(out io.Writer, asJSON bool, baseURL, token string) error {
189+
func saveAndVerifyToken(out io.Writer, asJSON bool, profileName, baseURL, token string) error {
180190
user, err := fetchAuthMe(baseURL, token)
181191
if err != nil {
182192
render.PrintError(out, asJSON,
@@ -186,33 +196,24 @@ func saveAndVerifyToken(out io.Writer, asJSON bool, baseURL, token string) error
186196
os.Exit(exitcode.Auth)
187197
}
188198

189-
credPath, err := paths.Credentials()
190-
if err != nil {
191-
return err
192-
}
193-
if err := os.MkdirAll(filepath.Dir(credPath), 0700); err != nil {
194-
return err
195-
}
196-
creds := Credentials{
197-
URL: baseURL,
198-
AccessToken: token,
199-
UserID: user.UserID,
200-
UserEmail: user.Email,
199+
prof := credentials.Profile{
200+
URL: baseURL,
201+
Username: user.Email,
202+
Token: token,
201203
}
202-
data, _ := json.MarshalIndent(creds, "", " ")
203-
if err := os.WriteFile(credPath, data, 0600); err != nil {
204-
return fmt.Errorf("write credentials: %w", err)
204+
if err := credentials.Put(profileName, prof); err != nil {
205+
return fmt.Errorf("save credentials: %w", err)
205206
}
206207

207208
if asJSON {
208209
return render.JSON(out, map[string]any{
209-
"ok": true,
210-
"user_email": user.Email,
211-
"user_id": user.UserID,
212-
"url": baseURL,
210+
"ok": true,
211+
"profile": profileName,
212+
"username": user.Email,
213+
"url": baseURL,
213214
})
214215
}
215-
fmt.Fprintf(out, "✓ Logged in as %s (%s)\n", user.Email, baseURL)
216+
fmt.Fprintf(out, "✓ Logged in as %s (profile: %s, url: %s)\n", user.Email, profileName, baseURL)
216217
return nil
217218
}
218219

@@ -251,8 +252,6 @@ func randomNonce() string {
251252
return hex.EncodeToString(b)
252253
}
253254

254-
// openBrowser launches the user's default browser. Caller doesn't wait
255-
// for it to exit — browser stays open as a child of init.
256255
var openBrowser = func(rawURL string) error {
257256
var cmd *exec.Cmd
258257
switch runtime.GOOS {

0 commit comments

Comments
 (0)