From 5eac360f320c2ed656f44957bbd5ca38e1c3348f Mon Sep 17 00:00:00 2001 From: Anuj Hydrabadi Date: Tue, 4 Aug 2026 15:13:41 +0530 Subject: [PATCH] feat: login --dry-run + profiles rename/rm (issue #66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login was the only way to probe its own flags, and it is not a no-op: it mints a real API key, flips the active profile, and swaps the whole org-skill catalog (issue #66). Recovering from a throwaway profile then required hand-editing managed files or a second key-minting login. - `praxis login --dry-run`: reports resolved profile + URL, server reachability, browser-vs-token-reuse, and the exact skill effect, then exits. One read-only GET to /auth/me; no browser, no key, no credential or skill writes. Exit 0 = report complete, 5 = server unreachable. - `praxis profiles rename OLD NEW`: credentials-only section rename (keeps URL/username/token/raptor pairing); the global active-profile pointer follows. Stale --local project pointers stay inert by design. - `praxis profiles rm NAME`: delete a NON-active profile's credentials without the switch-logout-switch double skill-cycle. Refuses the active profile (that's `praxis logout`, which also cleans skills). - CLAUDE.md: codify the single-profile-first design principle — these are opt-in power-user tools; the default flow is unchanged. - Meta-skill documents `profiles` (previously missing entirely), the new subcommands, and --dry-run as the AI-safe login probe. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 16 ++ README.md | 45 +++++- cmd/login.go | 16 +- cmd/login_dryrun.go | 122 ++++++++++++++ cmd/login_dryrun_test.go | 196 +++++++++++++++++++++++ cmd/profiles_manage.go | 129 +++++++++++++++ cmd/profiles_manage_test.go | 127 +++++++++++++++ internal/credentials/credentials.go | 42 +++++ internal/credentials/credentials_test.go | 80 +++++++++ internal/skillinstall/dummy.go | 10 ++ internal/skillinstall/dummy_test.go | 17 ++ 11 files changed, 794 insertions(+), 6 deletions(-) create mode 100644 cmd/login_dryrun.go create mode 100644 cmd/login_dryrun_test.go create mode 100644 cmd/profiles_manage.go create mode 100644 cmd/profiles_manage_test.go diff --git a/CLAUDE.md b/CLAUDE.md index a88cf4e..816c9bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,22 @@ loop locally. Skills are sourced (fetched + nomenclature-translated) into the user's AI host; MCP tools execute server-side under org-managed credentials. See [README.md](README.md) for the user-facing story. +## Design principle — single-profile users first + +The typical praxis user has ONE profile (a customer on one control plane); +everything must resolve silently to "default" for them. Multi-profile users +(Facets engineers, support) are power users: their flows must work, but they +are strictly secondary and must never regress the single-profile flow. +Concretely: + +- New flags and subcommands are opt-in; the default flow never grows + required steps, prompts, or warnings a single-profile user would see. +- Power-user affordances (profile pins, raptor cross-checks, per-directory + profiles) live behind flags or status fields that stay inert when only + one profile exists. +- When a trade-off pits multi-profile ergonomics against single-profile + cleanliness, single-profile wins. + ## Testing — non-negotiable **Unit test coverage is required, not optional.** diff --git a/README.md b/README.md index e49d268..094df91 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,24 @@ praxis login [--profile X] [--url Y] [--token Z] [--local] [--raptor-profile R] --raptor-profile pairs this praxis profile with a raptor profile (~/.facets/credentials section). See "Pairing with raptor profiles" below. + --dry-run reports what login would do — resolved profile + URL, + server reachability, browser vs stored-token reuse, and what + happens to installed skills — then exits. No browser, no API key, + no credential or skill changes. Exit 0 = report complete; exit 5 = + server unreachable. + +praxis profiles [--refresh] [--json] + List every profile with URL, username, active marker, and login + state (never prints tokens). --refresh live-verifies each token. + +praxis profiles rename OLD NEW [--json] + Rename a credentials section in place, keeping URL/username/token/ + raptor pairing. The global active-profile pointer follows if it + named OLD. No browser, no new API key, no skill changes. + +praxis profiles rm NAME [--json] + Delete a NON-active profile's credentials. Refuses the active + profile (use `praxis logout` — it also cleans up installed skills). praxis logout [--all] Active profile: removes credentials, all org skills (praxis-*), @@ -368,16 +386,33 @@ Re-fetches your org's catalog and the MCP manifest snapshot. Idempotent. Run it whenever you suspect skill content has been updated server-side or you want to pick up new tools. +### Renaming a profile + +```bash +praxis profiles rename test-x astuto-cp +``` + +Credentials-only: the section keeps its URL, username, token, and +raptor pairing; the global active-profile pointer follows if it named +the old profile. No browser round-trip, no second API key, no skill +churn. (Directory trees pinned via `--local` reference profiles by +name — re-pin those with `praxis login --profile --local`; +until then they harmlessly fall back to the global profile.) + ### Removing a profile +For a **non-active** profile, delete just its credentials: + +```bash +praxis profiles rm test-x # credentials only; skills untouched +``` + `praxis logout` removes the **active** profile's credentials, org -skills, and manifest snapshot. To remove a non-active profile, switch -to it first: +skills, and manifest snapshot (it refuses nothing — it's the right +tool for the active profile precisely because it cleans up skills): ```bash -praxis login --profile acme # make acme active -praxis logout # remove acme -# default and bigcorp are untouched. +praxis logout # remove the active profile fully ``` To wipe every profile and every host: diff --git a/cmd/login.go b/cmd/login.go index a65ef43..f9c1ef5 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -44,6 +44,7 @@ var ( loginJSON bool loginTimeout time.Duration loginRaptorProfile string + loginDryRun bool ) // browserLoginFn and postAuthSetup are package-level seams so tests can @@ -73,6 +74,8 @@ func init() { // help table into `--raptor-profile praxis status`. loginCmd.Flags().StringVar(&loginRaptorProfile, "raptor-profile", "", "pair this praxis profile with a raptor profile (a ~/.facets/credentials section); 'praxis status' then reports raptor via that profile and AI hosts prefix raptor commands with FACETS_PROFILE=") + loginCmd.Flags().BoolVar(&loginDryRun, "dry-run", false, + "report what login would do (profile, URL reachability, browser-or-reuse, skill effect) and exit — no browser, no API key, no credential or skill changes") rootCmd.AddCommand(loginCmd) } @@ -102,7 +105,12 @@ Multiple deployments? Use --profile to keep them separate: Re-running login (with the same profile or a different one) is the canonical way to refresh skills + manifest snapshot. There is no -separate refresh command in v0.7.`, +separate refresh command in v0.7. + +Not sure what a login invocation will do? Add --dry-run: it reports the +resolved profile and URL, whether the server is reachable, whether the +browser would open or a stored token be reused, and what would happen to +installed skills — then exits without changing anything.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() @@ -120,6 +128,12 @@ separate refresh command in v0.7.`, return err } + // --dry-run: report the plan and exit before ANY side effect — + // no browser, no key minted, no credential write, no skill churn. + if loginDryRun { + return runLoginDryRun(out, asJSON, profileName, baseURL, loginLocal) + } + // --token is the explicit non-browser path: verify the supplied // key and persist it, unchanged by the reuse logic. if loginToken != "" { diff --git a/cmd/login_dryrun.go b/cmd/login_dryrun.go new file mode 100644 index 0000000..238f913 --- /dev/null +++ b/cmd/login_dryrun.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + + "github.com/Facets-cloud/praxis-cli/internal/credentials" + "github.com/Facets-cloud/praxis-cli/internal/exitcode" + "github.com/Facets-cloud/praxis-cli/internal/render" +) + +// runLoginDryRun reports what `praxis login` WOULD do with the given flags — +// without opening a browser, minting an API key, writing credentials, or +// touching installed skills (issue #66: login is not a safe probe). +// +// The only network traffic is a single read-only GET to /ai-api/auth/me: with +// a stored (or --token supplied) key it doubles as the token-reuse check; +// without one, an HTTP 401/403 answer still proves the deployment is +// reachable. Exit code 0 means the report is complete; exitcode.Network means +// the server could not be reached, so login's behavior can't be predicted. +func runLoginDryRun(out io.Writer, asJSON bool, profileName, baseURL string, local bool) error { + store, _ := credentials.Load() + prof, exists := store[profileName] + + // The profile whose org skills are on disk right now. Mirror login's + // scope semantics: a global login resolves globally (a project pointer + // can't redirect it); --local resolves against the full chain. + var active credentials.Active + if local { + active, _ = credentials.ResolveActive("") + } else { + active, _ = credentials.ResolveActiveGlobal() + } + + probeToken, tokenSource := "", "none" + switch { + case loginToken != "": + probeToken, tokenSource = loginToken, "supplied" + case exists && prof.Token != "" && prof.URL == baseURL: + probeToken, tokenSource = prof.Token, "stored" + } + + reachable := true + tokenStatus, action := tokenSource, "browser" + _, err := fetchAuthMe(baseURL, probeToken) + switch { + case err == nil: + switch tokenSource { + case "supplied": + tokenStatus, action = "supplied-valid", "save-token (no browser)" + case "stored": + if loginForce { + tokenStatus, action = "stored-valid", "browser (--force)" + } else { + tokenStatus, action = "stored-valid", "reuse-token (no browser)" + } + } + case errors.Is(err, errTokenRejected): + // The server answered — reachable. A 401 on an empty probe token is + // the expected "no credentials" response, not a token verdict. + switch tokenSource { + case "supplied": + tokenStatus, action = "supplied-invalid", "fail (supplied token rejected)" + case "stored": + tokenStatus, action = "stored-invalid", "browser" + } + default: + reachable = false + if tokenSource != "none" { + tokenStatus = tokenSource + "-unverified" + } + action = "unknown (server unreachable)" + } + + skillsEffect := fmt.Sprintf("org skills re-synced from %q's catalog (no profile switch)", profileName) + if active.Name != profileName { + skillsEffect = fmt.Sprintf("active profile switches %q → %q; %q's praxis-* org skills are wiped and %q's catalog installed", + active.Name, profileName, active.Name, profileName) + } + + if asJSON { + payload := map[string]any{ + "ok": reachable, + "dry_run": true, + "profile": profileName, + "profile_exists": exists, + "url": baseURL, + "scope": scopeLabel(local), + "active_profile": active.Name, + "reachable": reachable, + "token_status": tokenStatus, + "action": action, + "skills_effect": skillsEffect, + } + if rerr := render.JSON(out, payload); rerr != nil { + return rerr + } + } else { + fmt.Fprintln(out, "Dry run — nothing was changed (no browser, no API key, no skill churn).") + fmt.Fprintf(out, " profile: %s", profileName) + if !exists { + fmt.Fprint(out, " (new)") + } + fmt.Fprintln(out) + fmt.Fprintf(out, " url: %s\n", baseURL) + fmt.Fprintf(out, " scope: %s\n", scopeLabel(local)) + if reachable { + fmt.Fprintln(out, " server: reachable") + } else { + fmt.Fprintln(out, " server: UNREACHABLE — login behavior can't be predicted") + } + fmt.Fprintf(out, " token: %s\n", tokenStatus) + fmt.Fprintf(out, " action: %s\n", action) + fmt.Fprintf(out, " skills: %s\n", skillsEffect) + } + + if !reachable { + osExit(exitcode.Network) + } + return nil +} diff --git a/cmd/login_dryrun_test.go b/cmd/login_dryrun_test.go new file mode 100644 index 0000000..f17789e --- /dev/null +++ b/cmd/login_dryrun_test.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Facets-cloud/praxis-cli/internal/credentials" + "github.com/Facets-cloud/praxis-cli/internal/exitcode" +) + +// runDryRunJSON drives `praxis login --dry-run --json` end to end through +// RunE and decodes the report. +func runDryRunJSON(t *testing.T) map[string]any { + t.Helper() + loginDryRun, loginJSON = true, true + out, err := runLoginRunE(t) + if err != nil { + t.Fatalf("dry-run login err: %v", err) + } + var report map[string]any + if jerr := json.Unmarshal([]byte(out), &report); jerr != nil { + t.Fatalf("dry-run output not JSON: %v\n%s", jerr, out) + } + return report +} + +func TestLoginDryRun_StoredValidToken_ReportsReuse(t *testing.T) { + isolateHome(t) + resetLoginFlags(t) + t.Cleanup(func() { loginDryRun = false }) + seedProfile(t, "default", "https://stored.test", "tok") + browser := stubBrowserLogin(t) + setup := stubPostAuth(t) + stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + return &authMeResponse{Email: "u@x"}, nil + }) + + report := runDryRunJSON(t) + for k, want := range map[string]any{ + "ok": true, + "dry_run": true, + "profile": "default", + "reachable": true, + "token_status": "stored-valid", + "action": "reuse-token (no browser)", + } { + if got := report[k]; got != want { + t.Errorf("report[%q] = %v, want %v", k, got, want) + } + } + if *browser || *setup { + t.Error("dry-run must not run the browser flow or post-auth setup") + } +} + +func TestLoginDryRun_HasNoSideEffects(t *testing.T) { + isolateHome(t) + resetLoginFlags(t) + t.Cleanup(func() { loginDryRun = false }) + seedProfile(t, "default", "https://stored.test", "tok") + stubBrowserLogin(t) + stubPostAuth(t) + stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + return &authMeResponse{Email: "u@x"}, nil + }) + + credsPath := filepath.Join(os.Getenv("HOME"), ".praxis", "credentials") + before, err := os.ReadFile(credsPath) + if err != nil { + t.Fatal(err) + } + + // Aim at a DIFFERENT profile+URL — the most side-effect-prone shape. + loginProfile, loginURL = "probe", "https://probe.test" + runDryRunJSON(t) + + after, err := os.ReadFile(credsPath) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Errorf("dry-run modified the credentials file:\nbefore: %s\nafter: %s", before, after) + } + if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".praxis", "config.json")); !os.IsNotExist(err) { + t.Error("dry-run wrote the active-profile pointer") + } +} + +func TestLoginDryRun_TokenAndReachabilityMatrix(t *testing.T) { + tests := []struct { + name string + seedToken string // stored token for [default] at https://stored.test; "" = none + suppliedTok string // --token value + force bool + authErr error // fetchAuthMe result (nil = 200) + wantStatus string + wantAction string + wantOK bool + wantExit int // expected osExit code; -1 = not called + }{ + { + name: "no token, reachable server (401 on empty probe)", authErr: errTokenRejected, + wantStatus: "none", wantAction: "browser", wantOK: true, wantExit: -1, + }, + { + name: "stored token rejected falls back to browser", seedToken: "dead", authErr: errTokenRejected, + wantStatus: "stored-invalid", wantAction: "browser", wantOK: true, wantExit: -1, + }, + { + name: "stored token valid with --force still browsers", seedToken: "tok", force: true, + wantStatus: "stored-valid", wantAction: "browser (--force)", wantOK: true, wantExit: -1, + }, + { + name: "supplied token valid", suppliedTok: "sk_new", + wantStatus: "supplied-valid", wantAction: "save-token (no browser)", wantOK: true, wantExit: -1, + }, + { + name: "supplied token rejected", suppliedTok: "sk_bad", authErr: errTokenRejected, + wantStatus: "supplied-invalid", wantAction: "fail (supplied token rejected)", wantOK: true, wantExit: -1, + }, + { + name: "unreachable server", seedToken: "tok", authErr: context.DeadlineExceeded, + wantStatus: "stored-unverified", wantAction: "unknown (server unreachable)", wantOK: false, + wantExit: exitcode.Network, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolateHome(t) + resetLoginFlags(t) + t.Cleanup(func() { loginDryRun = false }) + if tt.seedToken != "" { + seedProfile(t, "default", "https://stored.test", tt.seedToken) + } else { + seedProfile(t, "default", "https://stored.test", "") + } + stubBrowserLogin(t) + stubPostAuth(t) + exit := stubOsExit(t) + stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + if tt.authErr != nil { + return nil, tt.authErr + } + return &authMeResponse{Email: "u@x"}, nil + }) + loginToken = tt.suppliedTok + loginForce = tt.force + + report := runDryRunJSON(t) + if got := report["token_status"]; got != tt.wantStatus { + t.Errorf("token_status = %v, want %v", got, tt.wantStatus) + } + if got := report["action"]; got != tt.wantAction { + t.Errorf("action = %v, want %v", got, tt.wantAction) + } + if got := report["ok"]; got != tt.wantOK { + t.Errorf("ok = %v, want %v", got, tt.wantOK) + } + if *exit != tt.wantExit { + t.Errorf("osExit code = %d, want %d", *exit, tt.wantExit) + } + }) + } +} + +func TestLoginDryRun_ProfileSwitchSkillsEffect(t *testing.T) { + isolateHome(t) + resetLoginFlags(t) + t.Cleanup(func() { loginDryRun = false }) + seedProfile(t, "default", "https://stored.test", "tok") + seedProfile(t, "acme", "https://acme.test", "tok2") + if err := credentials.SetActive("default"); err != nil { + t.Fatal(err) + } + stubBrowserLogin(t) + stubPostAuth(t) + stubAuthMe(t, func(_, _ string) (*authMeResponse, error) { + return &authMeResponse{Email: "u@x"}, nil + }) + + loginProfile = "acme" + report := runDryRunJSON(t) + effect, _ := report["skills_effect"].(string) + if effect == "" || report["active_profile"] != "default" { + t.Fatalf("unexpected report: %v", report) + } + for _, want := range []string{`"default"`, `"acme"`, "wiped"} { + if !strings.Contains(effect, want) { + t.Errorf("skills_effect %q missing %q", effect, want) + } + } +} diff --git a/cmd/profiles_manage.go b/cmd/profiles_manage.go new file mode 100644 index 0000000..1481309 --- /dev/null +++ b/cmd/profiles_manage.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "fmt" + + "github.com/Facets-cloud/praxis-cli/internal/credentials" + "github.com/Facets-cloud/praxis-cli/internal/exitcode" + "github.com/Facets-cloud/praxis-cli/internal/render" + "github.com/spf13/cobra" +) + +// Subcommands of `praxis profiles` for managing the credentials store +// without a login round-trip (issue #66: the only in-band alternatives were +// hand-editing ~/.praxis/credentials or minting another API key). Both are +// power-user tools: a single-profile user never needs them, and neither +// touches installed skills or opens a browser. + +var ( + profilesRenameJSON bool + profilesRmJSON bool +) + +func init() { + profilesRenameCmd.Flags().BoolVar(&profilesRenameJSON, "json", false, "JSON output") + profilesRmCmd.Flags().BoolVar(&profilesRmJSON, "json", false, "JSON output") + profilesCmd.AddCommand(profilesRenameCmd) + profilesCmd.AddCommand(profilesRmCmd) +} + +var profilesRenameCmd = &cobra.Command{ + Use: "rename OLD NEW", + Short: "Rename a profile (credentials only — no browser, no skill changes)", + Long: `Rename a profile section in ~/.praxis/credentials, keeping its URL, +username, token, and raptor pairing. If the global active-profile pointer +named OLD it follows to NEW automatically. + +Installed skills are not touched: they belong to the profile's org, not its +name. No browser opens and no API key is created — this fixes the "re-login +just to rename" workaround that minted an orphaned key each time. + +Directory trees pinned with 'praxis login --profile OLD --local' keep a +project pointer naming OLD; those trees silently fall back to the global +profile until re-pinned with 'praxis login --profile NEW --local'.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + asJSON := render.UseJSON(profilesRenameJSON, false, out) + oldName, newName := args[0], args[1] + + pointerUpdated, err := credentials.Rename(oldName, newName) + if err != nil { + render.PrintError(out, asJSON, err.Error(), + "run `praxis profiles` to see what exists", exitcode.Usage) + osExit(exitcode.Usage) + return err // reached only under test (osExit stubbed) + } + + if asJSON { + return render.JSON(out, map[string]any{ + "ok": true, + "renamed_from": oldName, + "renamed_to": newName, + "active_pointer_updated": pointerUpdated, + }) + } + fmt.Fprintf(out, "✓ Renamed profile %q → %q\n", oldName, newName) + if pointerUpdated { + fmt.Fprintf(out, " Active-profile pointer updated to %q.\n", newName) + } + return nil + }, +} + +var profilesRmCmd = &cobra.Command{ + Use: "rm NAME", + Short: "Remove a non-active profile's credentials (no skill changes)", + Long: `Delete one profile section from ~/.praxis/credentials. + +Only non-active profiles can be removed here: the active profile owns the +org skills installed on disk, so removing it goes through 'praxis logout' +(which also cleans those up). Removing a non-active profile touches +credentials only — installed skills and the active profile are unaffected, +so there is no double skill-cycle from switching just to delete.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + asJSON := render.UseJSON(profilesRmJSON, false, out) + name := args[0] + + // Resolve the active profile GLOBALLY, mirroring `praxis logout`: a + // stray project-local pointer must not decide what "active" means + // for a destructive credentials operation. + active, err := credentials.ResolveActiveGlobal() + if err != nil { + return err + } + if name == active.Name { + msg := fmt.Sprintf("%q is the active profile", name) + render.PrintError(out, asJSON, msg, + "use `praxis logout` to remove the active profile (it also removes its installed org skills)", + exitcode.Usage) + osExit(exitcode.Usage) + return fmt.Errorf("%s", msg) + } + store, err := credentials.Load() + if err != nil { + return err + } + if _, ok := store[name]; !ok { + msg := fmt.Sprintf("profile %q does not exist", name) + render.PrintError(out, asJSON, msg, + "run `praxis profiles` to see what exists", exitcode.Usage) + osExit(exitcode.Usage) + return fmt.Errorf("%s", msg) + } + if err := credentials.Delete(name); err != nil { + return err + } + + if asJSON { + return render.JSON(out, map[string]any{ + "ok": true, + "removed": name, + }) + } + fmt.Fprintf(out, "✓ Removed profile %q (credentials only — installed skills untouched)\n", name) + return nil + }, +} diff --git a/cmd/profiles_manage_test.go b/cmd/profiles_manage_test.go new file mode 100644 index 0000000..a1e2472 --- /dev/null +++ b/cmd/profiles_manage_test.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/Facets-cloud/praxis-cli/internal/credentials" + "github.com/Facets-cloud/praxis-cli/internal/exitcode" +) + +func resetProfilesManageFlags(t *testing.T) { + t.Helper() + profilesRenameJSON, profilesRmJSON = false, false + t.Cleanup(func() { profilesRenameJSON, profilesRmJSON = false, false }) +} + +func TestProfilesRename_HappyPath(t *testing.T) { + isolateHome(t) + resetProfilesManageFlags(t) + seedProfile(t, "test-x", "https://cp.test", "tok") + if err := credentials.SetActive("test-x"); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + profilesRenameCmd.SetOut(&buf) + profilesRenameJSON = true + if err := profilesRenameCmd.RunE(profilesRenameCmd, []string{"test-x", "astuto-cp"}); err != nil { + t.Fatalf("rename err: %v", err) + } + out := buf.String() + for _, want := range []string{`"ok": true`, `"renamed_to": "astuto-cp"`, `"active_pointer_updated": true`} { + if !strings.Contains(out, want) { + t.Errorf("rename output missing %q\nfull: %s", want, out) + } + } + store, _ := credentials.Load() + if _, ok := store["astuto-cp"]; !ok { + t.Error("renamed profile missing from store") + } + active, _ := credentials.ResolveActiveGlobal() + if active.Name != "astuto-cp" { + t.Errorf("active = %s, want astuto-cp", active.Name) + } +} + +func TestProfilesRename_MissingOldExitsUsage(t *testing.T) { + isolateHome(t) + resetProfilesManageFlags(t) + exit := stubOsExit(t) + + var buf bytes.Buffer + profilesRenameCmd.SetOut(&buf) + profilesRenameJSON = true + if err := profilesRenameCmd.RunE(profilesRenameCmd, []string{"ghost", "new"}); err == nil { + t.Fatal("rename of missing profile succeeded") + } + if *exit != exitcode.Usage { + t.Errorf("osExit code = %d, want %d (Usage)", *exit, exitcode.Usage) + } +} + +func TestProfilesRm_NonActive(t *testing.T) { + isolateHome(t) + resetProfilesManageFlags(t) + seedProfile(t, "default", "https://cp.test", "tok") + seedProfile(t, "stale", "https://old.test", "tok2") + if err := credentials.SetActive("default"); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + profilesRmCmd.SetOut(&buf) + profilesRmJSON = true + if err := profilesRmCmd.RunE(profilesRmCmd, []string{"stale"}); err != nil { + t.Fatalf("rm err: %v", err) + } + if !strings.Contains(buf.String(), `"removed": "stale"`) { + t.Errorf("rm output missing removed marker: %s", buf.String()) + } + store, _ := credentials.Load() + if _, ok := store["stale"]; ok { + t.Error("profile still in store after rm") + } + if _, ok := store["default"]; !ok { + t.Error("unrelated profile vanished") + } +} + +func TestProfilesRm_ActiveIsRefused(t *testing.T) { + isolateHome(t) + resetProfilesManageFlags(t) + seedProfile(t, "default", "https://cp.test", "tok") + exit := stubOsExit(t) + + var buf bytes.Buffer + profilesRmCmd.SetOut(&buf) + profilesRmJSON = true + if err := profilesRmCmd.RunE(profilesRmCmd, []string{"default"}); err == nil { + t.Fatal("rm of active profile succeeded") + } + if *exit != exitcode.Usage { + t.Errorf("osExit code = %d, want %d (Usage)", *exit, exitcode.Usage) + } + store, _ := credentials.Load() + if _, ok := store["default"]; !ok { + t.Error("active profile was deleted despite refusal") + } +} + +func TestProfilesRm_MissingExitsUsage(t *testing.T) { + isolateHome(t) + resetProfilesManageFlags(t) + seedProfile(t, "default", "https://cp.test", "tok") + exit := stubOsExit(t) + + var buf bytes.Buffer + profilesRmCmd.SetOut(&buf) + profilesRmJSON = true + if err := profilesRmCmd.RunE(profilesRmCmd, []string{"ghost"}); err == nil { + t.Fatal("rm of missing profile succeeded") + } + if *exit != exitcode.Usage { + t.Errorf("osExit code = %d, want %d (Usage)", *exit, exitcode.Usage) + } +} diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 5844873..dfec1fa 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -271,6 +271,48 @@ func Put(name string, p Profile) error { return Save(store) } +// Rename moves a profile to a new section name, keeping every field +// (URL, username, token, raptor_profile). If the GLOBAL active-profile +// pointer named the old profile it is updated to follow; project-local +// pointers can live in any directory tree and are NOT rewritten — a stale +// one is inert by design (LocalModeActive requires the pointer to name an +// existing profile, so it falls back to the global resolution). +// Returns whether the global pointer was updated. +func Rename(oldName, newName string) (pointerUpdated bool, err error) { + if err := validateProfileName(oldName); err != nil { + return false, err + } + if err := validateProfileName(newName); err != nil { + return false, err + } + if oldName == newName { + return false, fmt.Errorf("old and new profile names are both %q", oldName) + } + store, err := Load() + if err != nil { + return false, err + } + p, ok := store[oldName] + if !ok { + return false, fmt.Errorf("profile %q does not exist", oldName) + } + if _, exists := store[newName]; exists { + return false, fmt.Errorf("profile %q already exists", newName) + } + store[newName] = p + delete(store, oldName) + if err := Save(store); err != nil { + return false, err + } + if cfg, _ := loadConfig(); cfg.Profile == oldName { + if err := SetActive(newName); err != nil { + return false, fmt.Errorf("profile renamed, but updating the active-profile pointer failed: %w", err) + } + return true, nil + } + return false, nil +} + // Delete removes one profile. No-op if it didn't exist. func Delete(name string) error { if err := validateProfileName(name); err != nil { diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go index add8247..cb49bdc 100644 --- a/internal/credentials/credentials_test.go +++ b/internal/credentials/credentials_test.go @@ -515,3 +515,83 @@ func TestRaptorProfile_RoundTripAndValidation(t *testing.T) { } } } + +func TestRename(t *testing.T) { + seed := func(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + if err := Put("old", Profile{URL: "https://old.test", Username: "u@x", Token: "tok", RaptorProfile: "rp"}); err != nil { + t.Fatal(err) + } + if err := Put("other", Profile{URL: "https://other.test", Token: "tok2"}); err != nil { + t.Fatal(err) + } + } + + t.Run("moves every field and reports pointer untouched", func(t *testing.T) { + seed(t) + updated, err := Rename("old", "new") + if err != nil { + t.Fatal(err) + } + if updated { + t.Error("pointer reported updated but none was set") + } + store, _ := Load() + if _, ok := store["old"]; ok { + t.Error("old section still present") + } + got := store["new"] + want := Profile{URL: "https://old.test", Username: "u@x", Token: "tok", RaptorProfile: "rp"} + if got != want { + t.Errorf("renamed profile = %+v, want %+v", got, want) + } + }) + + t.Run("global pointer follows the rename", func(t *testing.T) { + seed(t) + if err := SetActive("old"); err != nil { + t.Fatal(err) + } + updated, err := Rename("old", "new") + if err != nil { + t.Fatal(err) + } + if !updated { + t.Error("pointer should have been reported updated") + } + active, _ := ResolveActiveGlobal() + if active.Name != "new" || active.Source != SourceConfig { + t.Errorf("active = %s (%s), want new (config)", active.Name, active.Source) + } + }) + + t.Run("pointer naming another profile is left alone", func(t *testing.T) { + seed(t) + if err := SetActive("other"); err != nil { + t.Fatal(err) + } + if _, err := Rename("old", "new"); err != nil { + t.Fatal(err) + } + active, _ := ResolveActiveGlobal() + if active.Name != "other" { + t.Errorf("active = %s, want other (untouched)", active.Name) + } + }) + + t.Run("errors", func(t *testing.T) { + seed(t) + for name, pair := range map[string][2]string{ + "missing old": {"ghost", "new"}, + "existing new": {"old", "other"}, + "same name": {"old", "old"}, + "invalid new name": {"old", "has space"}, + "invalid old name": {"[x]", "new"}, + } { + if _, err := Rename(pair[0], pair[1]); err == nil { + t.Errorf("%s: Rename(%q, %q) succeeded, want error", name, pair[0], pair[1]) + } + } + }) +} diff --git a/internal/skillinstall/dummy.go b/internal/skillinstall/dummy.go index 286306f..c4624be 100644 --- a/internal/skillinstall/dummy.go +++ b/internal/skillinstall/dummy.go @@ -158,6 +158,15 @@ AI-callable (always pass --json): when the org has published new skills or after ` + "`brew upgrade praxis`" + `. - ` + "`praxis logout`" + ` — drop creds + org skills for active profile. ` + "`--all`" + ` wipes everything except this meta-skill. + - ` + "`praxis profiles`" + ` — list every profile with URL, username, active + marker, and login state (no tokens printed). ` + "`--refresh`" + ` live-verifies + each stored token. + - ` + "`praxis profiles rename OLD NEW`" + ` / ` + "`praxis profiles rm NAME`" + ` — + credentials-only profile management; no browser, no skill changes. + ` + "`rm`" + ` refuses the active profile (that's ` + "`praxis logout`" + `). + - ` + "`praxis login --dry-run`" + ` — SAFE probe: reports what login would do + (profile, URL reachability, browser vs token reuse, skill effect) and + changes nothing. Use before any profile switch you're unsure about. - ` + "`praxis update`" + ` — self-update binary. ` + "`--json`" + ` implies ` + "`--yes`" + `. - ` + "`praxis version`" + ` — build metadata. @@ -166,6 +175,7 @@ Human-only (don't try to script these): - ` + "`praxis login`" + ` — opens the user's browser; you (the AI) RUN this on the user's behalf when status shows logged_out, but the user has to click "Create Key" once. Wait for exit 0 before retrying the task. + (` + "`--dry-run`" + ` is the exception — it's AI-safe, see above.) ## Facets control plane = the local raptor CLI diff --git a/internal/skillinstall/dummy_test.go b/internal/skillinstall/dummy_test.go index 159af18..6a411ea 100644 --- a/internal/skillinstall/dummy_test.go +++ b/internal/skillinstall/dummy_test.go @@ -87,3 +87,20 @@ func TestPraxisMetaSkill_ExplainsLocalMode(t *testing.T) { } } } + +func TestPraxisMetaSkill_ProfileManagementSurface(t *testing.T) { + body, err := ContentFor("praxis") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "praxis profiles", + "praxis profiles rename OLD NEW", + "praxis profiles rm NAME", + "praxis login --dry-run", + } { + if !strings.Contains(body, want) { + t.Errorf("praxis meta-skill missing profile-management surface %q", want) + } + } +}