Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-*),
Expand Down Expand Up @@ -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 <new> --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:
Expand Down
16 changes: 15 additions & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ var (
loginJSON bool
loginTimeout time.Duration
loginRaptorProfile string
loginDryRun bool
)

// browserLoginFn and postAuthSetup are package-level seams so tests can
Expand Down Expand Up @@ -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=<name>")
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)
}

Expand Down Expand Up @@ -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()
Expand All @@ -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 != "" {
Expand Down
122 changes: 122 additions & 0 deletions cmd/login_dryrun.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading