Skip to content

Commit 2d19a8a

Browse files
anshulsaoclaude
andauthored
feat(profiles): add praxis profiles to list all profiles + login state (#16)
* feat(profiles): add `praxis profiles` to list all profiles + login state `praxis status` only ever showed the active profile, leaving users with multiple deployments (default/dev/root) no way to see every configured profile and its login state at a glance. `praxis profiles` lists each profile in ~/.praxis/credentials with URL, username, an active-profile marker (*), and login state. Local-only by default (a profile is "logged in" when it holds a token); --refresh adds a live /ai-api/auth/me check per logged-in profile, reported inline so one revoked token never aborts the listing. Supports --json (auto when stdout isn't a TTY) per the CLI's output convention; no-config lists empty with a login hint and exits 0. Built TDD-first: multi/single/no-config, active-marker, no-network default, --refresh success+failure, and table/empty rendering. Also adds `profiles` to the visible-surface contract test in root_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add merge → release → upgrade → test ship runbook to CLAUDE.md Document the tag-driven release flow end to end: squash-merge, tag a semver bump on main to fire release.yml/goreleaser, watch CI publish the GitHub Release + Homebrew cask, `brew upgrade --cask praxis`, then verify `praxis version` and exercise the change against the real binary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(profiles): fail-fast on credentials.Put setup errors (CodeRabbit nit) Replace discarded `_ = credentials.Put(...)` setup calls with a mustPut(t, …) helper so a store-write failure aborts at its true cause instead of surfacing later as a misleading assertion. 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 8fbdbc2 commit 2d19a8a

4 files changed

Lines changed: 494 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,36 @@ Releases binary download. `praxis update` self-updates against GitHub
104104
Releases. `install.askpraxis.ai` is a separate shell-script install
105105
path (not yet built).
106106

107+
## Shipping a change (merge → release → upgrade → test)
108+
109+
The end-to-end runbook for getting a merged change into the locally
110+
installed binary. Releases are **tag-driven**: pushing a `v*.*.*` tag
111+
fires `.github/workflows/release.yml`, which runs goreleaser to publish
112+
the GitHub Release and bump the Homebrew cask in `facets-cloud/tap`.
113+
There is no `make release` target.
114+
115+
1. **Wait for review + CI, then merge the PR.** Let CodeRabbit finish
116+
its pass and address its findings; the `build` and `goreleaser-check`
117+
checks must be green. Squash-merge to `main`.
118+
2. **Tag the new version on `main`:**
119+
```bash
120+
git checkout main && git pull
121+
git tag vX.Y.Z # minor bump for a feature, patch for a fix
122+
git push origin vX.Y.Z
123+
```
124+
(Current scheme: semver, e.g. `v0.12.0``v0.13.0` for a feature.)
125+
3. **Watch the release CI** (`gh run watch` / `gh run list --workflow
126+
release.yml`). goreleaser publishes the GitHub Release and pushes the
127+
updated cask to the tap. Needs the `HOMEBREW_TAP_TOKEN` secret.
128+
4. **Upgrade locally** once the cask lands:
129+
```bash
130+
brew update && brew upgrade --cask praxis
131+
```
132+
(Installed at `/opt/homebrew/bin/praxis` from cask `facets-cloud/tap`.)
133+
5. **Test in local** — run `praxis version` to confirm the new version,
134+
then exercise the shipped change against the real CLI (read-only
135+
commands are safe to run live).
136+
107137
## License
108138

109139
MIT. See [LICENSE](LICENSE).

cmd/profiles.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"text/tabwriter"
7+
8+
"github.com/Facets-cloud/praxis-cli/internal/credentials"
9+
"github.com/Facets-cloud/praxis-cli/internal/render"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var (
14+
profilesJSON bool
15+
profilesRefresh bool
16+
)
17+
18+
func init() {
19+
profilesCmd.Flags().BoolVar(&profilesJSON, "json", false, "JSON output")
20+
profilesCmd.Flags().BoolVar(&profilesRefresh, "refresh", false,
21+
"also call /ai-api/auth/me for each logged-in profile to verify its token")
22+
rootCmd.AddCommand(profilesCmd)
23+
}
24+
25+
// authCheckResult is the per-profile live-verification outcome, populated
26+
// only under --refresh. Omitted entirely for profiles with no token.
27+
type authCheckResult struct {
28+
OK bool `json:"ok"`
29+
Username string `json:"username,omitempty"`
30+
Error string `json:"error,omitempty"`
31+
}
32+
33+
// profileEntry is one row of the profiles listing.
34+
type profileEntry struct {
35+
Name string `json:"name"`
36+
URL string `json:"url"`
37+
Username string `json:"username"`
38+
Active bool `json:"active"`
39+
LoggedIn bool `json:"logged_in"`
40+
AuthCheck *authCheckResult `json:"auth_check,omitempty"`
41+
}
42+
43+
// profilesOutput is the top-level JSON shape so AI hosts can dispatch on
44+
// active_profile and iterate profiles without parsing English.
45+
type profilesOutput struct {
46+
ActiveProfile string `json:"active_profile"`
47+
Profiles []profileEntry `json:"profiles"`
48+
}
49+
50+
var profilesCmd = &cobra.Command{
51+
Use: "profiles",
52+
Short: "List all profiles and their login state",
53+
Long: `List every profile in ~/.praxis/credentials with its URL, username,
54+
active-profile marker, and login state.
55+
56+
By default this is a LOCAL-ONLY snapshot (no network calls): a profile is
57+
"logged in" when it has a stored token. Pass --refresh to additionally hit
58+
/ai-api/auth/me for each logged-in profile, catching expired/revoked tokens.
59+
A failing check on one profile is reported inline and does not abort the
60+
listing.
61+
62+
The active profile (the one used when no --profile/PRAXIS_PROFILE is given)
63+
is marked with "*" and reported as active_profile in JSON output.`,
64+
Args: cobra.NoArgs,
65+
RunE: func(cmd *cobra.Command, args []string) error {
66+
out := cmd.OutOrStdout()
67+
asJSON := render.UseJSON(profilesJSON, false, out)
68+
69+
// Resolve the active profile via the standard chain so the marker
70+
// matches what every other command would actually use.
71+
active, err := credentials.ResolveActive("")
72+
if err != nil {
73+
return err
74+
}
75+
store, err := credentials.Load()
76+
if err != nil {
77+
return err
78+
}
79+
names, err := credentials.List() // sorted: "default" first, then alpha
80+
if err != nil {
81+
return err
82+
}
83+
84+
entries := make([]profileEntry, 0, len(names))
85+
for _, name := range names {
86+
p := store[name]
87+
e := profileEntry{
88+
Name: name,
89+
URL: p.URL,
90+
Username: p.Username,
91+
Active: name == active.Name,
92+
LoggedIn: p.Token != "",
93+
}
94+
// --refresh: live-verify only profiles that actually hold a
95+
// token. A per-profile failure is recorded, never fatal — the
96+
// listing must stay complete even with one revoked token.
97+
if profilesRefresh && e.LoggedIn {
98+
if user, ferr := fetchAuthMe(p.URL, p.Token); ferr != nil {
99+
e.AuthCheck = &authCheckResult{OK: false, Error: ferr.Error()}
100+
} else {
101+
e.AuthCheck = &authCheckResult{OK: true, Username: user.Email}
102+
}
103+
}
104+
entries = append(entries, e)
105+
}
106+
107+
result := profilesOutput{ActiveProfile: active.Name, Profiles: entries}
108+
if asJSON {
109+
return render.JSON(out, result)
110+
}
111+
return renderProfilesText(out, result)
112+
},
113+
}
114+
115+
// renderProfilesText prints the human-readable table. Kept separate so the
116+
// RunE body stays focused on data assembly.
117+
func renderProfilesText(out io.Writer, result profilesOutput) error {
118+
if len(result.Profiles) == 0 {
119+
fmt.Fprintln(out, "No profiles configured.")
120+
fmt.Fprintln(out, "Run `praxis login` to create one.")
121+
return nil
122+
}
123+
124+
tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0)
125+
fmt.Fprintln(tw, "ACTIVE\tPROFILE\tURL\tUSERNAME\tLOGIN")
126+
for _, p := range result.Profiles {
127+
marker := ""
128+
if p.Active {
129+
marker = "*"
130+
}
131+
login := loginCell(p)
132+
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
133+
marker, p.Name, dashIfEmpty(p.URL), dashIfEmpty(p.Username), login)
134+
}
135+
return tw.Flush()
136+
}
137+
138+
// loginCell renders the LOGIN column, folding in the --refresh verdict when
139+
// present so a stored-but-revoked token reads as "no (revoked)" not "yes".
140+
func loginCell(p profileEntry) string {
141+
if !p.LoggedIn {
142+
return "no"
143+
}
144+
if p.AuthCheck != nil {
145+
if p.AuthCheck.OK {
146+
return "yes (verified)"
147+
}
148+
return "no (token invalid)"
149+
}
150+
return "yes"
151+
}
152+
153+
func dashIfEmpty(s string) string {
154+
if s == "" {
155+
return "-"
156+
}
157+
return s
158+
}

0 commit comments

Comments
 (0)