feat(init): name the target profile, add --profile for new accounts - #7
Conversation
gro init re-authenticated whatever the active ref happened to point at without ever saying which profile or account - so a user intending to ADD an account silently overwrote the active profile's token. Builds on the re-auth-instead-of-hard-fail flow (#1). - init announces its target up front: profile ref, how it was selected (config.yml/--ref/env/--profile), and the cached account email it currently holds - BEFORE any prompt, consent, or token write - plus a pointer to --profile for adding a different account. - The re-auth confirmation names what it is about to clear: 'Re-authenticate google-readonly/default (user@example.com)?' with an explicit 'other profiles are untouched'. - New --profile <name>: authenticate <service>/<name> via the same per-invocation override mechanism as --ref (migration auto-suppressed). Deliberately does NOT rebind config.yml - adding an account must not hijack the default - and ends with exactly how to reach the new profile (profiles use / --ref). Mutually exclusive with --ref. - Both verify paths record the confirmed email into the identity cache, so 'profiles list' knows which account each profile holds from day one. - Token-saved/expired messages name the ref.
piekstra-dev
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 7d5ddc058a86
Profile: reviewer - Posting as: piekstra-dev
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 2 |
| policies:conventions | 0 |
go:implementation-tests (2 findings)
Minor - initcmd/init.go:268
describeTarget's error-fallback branch (when keychain.OpenNoMigrate() fails) reads cfg.CredentialRef / cfg.CredentialRefSource() from the loaded config, which reflects the persisted default ref — it never consults the --profile/--ref per-invocation override that applyProfileFlag just set via keychain.SetCredentialRefOverride. The primary path (st.Ref() after a successful OpenNoMigrate) does honor the override per the comment on describeTarget ('per-invocation overrides included, since OpenNoMigrate applies them'), but the fallback silently reverts to naming the default/active profile instead of the one --profile pointed at. Since storeSetToken() makes its own, later, independent OpenNoMigrate() call, a transient keyring failure at announcement time (but not at token-write time) would produce an announcement/success message ('Setting up profile: ...', 'Token for %s saved to ...') that names the wrong profile during a --profile run — exactly the silent-overwrite-of-the-wrong-profile problem this PR sets out to prevent. Fix: have the fallback also check keychain.GetCredentialRefOverride() and prefer it over cfg.CredentialRef when set, so the announcement stays truthful even when the keyring can't be opened for the richer describeTarget() lookup.
Minor - initcmd/init_test.go:17
TestInitCommand's 'has expected flags' subtest only asserts the pre-existing flags (no-verify, no-browser, credentials-file) exist on the command; it wasn't updated to include the new --profile flag this PR adds (cmd.Flags().StringVar(&opts.profile, "profile", ...) in init.go). This is the one place that pins the command's registered flag surface, and it's cheap, high-value regression coverage for a user-facing flag — a future refactor that drops or renames --profile from NewCommand would pass every other test in this file (all of which exercise runWith/applyProfileFlag directly, bypassing NewCommand's flag registration) without being caught here. Add "profile" to the name list in that loop.
Reviewer Coverage
| Reviewer | Status | Inspected | Skipped | Constraints |
|---|---|---|---|---|
| go:implementation-tests | complete_broad | initcmd/init.go, initcmd/init_test.go | unavailable | Reviewed only the two assigned files (initcmd/init.go, initcmd/init_test.go) as checked out in the workbench; did not have direct git-diff tooling available, so old/new hunks were inferred by reading the full current file contents rather than a line-level diff. |
| policies:conventions | complete_broad | initcmd/init.go, initcmd/init_test.go | unavailable | No sibling ../cli-common/docs or ../.github convenience checkout was present in the workbench, so the review relied on repo-local precedent (rootutil.go, keychain/wire.go, profilescmd/profiles.go) rather than the canonical shared standards docs. |
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 3m 17s | $1.79 | claude-sonnet-5 | cr 0.10.268
| Field | Value |
|---|---|
| Model | claude-sonnet-5 |
| Reviewers | go:implementation-tests, policies:conventions |
| Engine | claude_cli · claude-sonnet-5 |
| Reviewed by | cr · piekstra-dev |
| Duration | 3m 17s wall · 4m 54s compute |
| Cost | $1.79 |
| Tokens | 78 in / 24.2k out |
Per-workstream usage
| Workstream | Model | In | Out | Cache read | Cache create | Cost | Duration |
|---|---|---|---|---|---|---|---|
| orchestrator-selection | claude-sonnet-5 | 6 | 1.7k | 59.2k | 13.2k | $0.12 | 20s |
| go:implementation-tests | claude-sonnet-5 | 20 | 13.6k | 341.2k | 64.8k | $0.70 | 2m 35s |
| policies:conventions | claude-sonnet-5 | 46 | 8.4k | 1.1M | 66.2k | $0.84 | 1m 47s |
| orchestrator-rollup | claude-sonnet-5 | 6 | 471 | 71.7k | 16.9k | $0.13 | 11s |
| @@ -17,6 +17,7 @@ import ( | |||
| "google.golang.org/api/googleapi" | |||
There was a problem hiding this comment.
File-level note: initcmd/init_test.go
TestInitCommand's 'has expected flags' subtest only asserts the pre-existing flags (no-verify, no-browser, credentials-file) exist on the command; it wasn't updated to include the new --profile flag this PR adds (cmd.Flags().StringVar(&opts.profile, "profile", ...) in init.go). This is the one place that pins the command's registered flag surface, and it's cheap, high-value regression coverage for a user-facing flag — a future refactor that drops or renames --profile from NewCommand would pass every other test in this file (all of which exercise runWith/applyProfileFlag directly, bypassing NewCommand's flag registration) without being caught here. Add "profile" to the name list in that loop.
Reply inline to this comment.
| func describeTarget() (ref, sourceLabel, cachedEmail string) { | ||
| st, err := keychain.OpenNoMigrate() | ||
| if err != nil { | ||
| cfg, cerr := config.LoadConfigForRuntime() |
There was a problem hiding this comment.
describeTarget's error-fallback branch (when keychain.OpenNoMigrate() fails) reads cfg.CredentialRef / cfg.CredentialRefSource() from the loaded config, which reflects the persisted default ref — it never consults the --profile/--ref per-invocation override that applyProfileFlag just set via keychain.SetCredentialRefOverride. The primary path (st.Ref() after a successful OpenNoMigrate) does honor the override per the comment on describeTarget ('per-invocation overrides included, since OpenNoMigrate applies them'), but the fallback silently reverts to naming the default/active profile instead of the one --profile pointed at. Since storeSetToken() makes its own, later, independent OpenNoMigrate() call, a transient keyring failure at announcement time (but not at token-write time) would produce an announcement/success message ('Setting up profile: ...', 'Token for %s saved to ...') that names the wrong profile during a --profile run — exactly the silent-overwrite-of-the-wrong-profile problem this PR sets out to prevent. Fix: have the fallback also check keychain.GetCredentialRefOverride() and prefer it over cfg.CredentialRef when set, so the announcement stays truthful even when the keyring can't be opened for the richer describeTarget() lookup.
Reply inline to this comment.
…mail list (#175) ## Why Delivers the credential/profile UX overhaul to gro by bumping **google-cli-common v0.2.0 → v0.3.0** ([#4](open-cli-collective/google-cli-common#4), [#5](open-cli-collective/google-cli-common#5), [#6](open-cli-collective/google-cli-common#6), [#7](open-cli-collective/google-cli-common#7)) and registering the new `profiles` command group. Driven by a first-hand incident: the active profile's token went stale, every command failed with a bare `oauth2: "invalid_grant"`, and it read as "gro is dead" — when other profiles were fine and there was no way to list them short of dumping the macOS keychain. ## What gro users get - **`gro profiles list [--check] [--json]`** — every stored profile, the account email it holds, an active marker with *where* the selection came from, and (with `--check`) per-profile live token health: `ok` / `expired or revoked` / `error`. - **`gro profiles use <profile>`** — deliberate, visible switching of the active binding. - **Attributed auth errors** — verified live against Google's token endpoint: ``` credential google-readonly/work (selected via config.yml credential_ref) can no longer authenticate: oauth2: "invalid_grant" "Bad Request"; other profiles may be unaffected - run 'gro profiles list' to check them, or 'gro init' to re-authenticate this one ``` - **`gro init`** announces which profile/account it will (re)authenticate before any prompt or write, and `gro init --profile <name>` adds a NEW account without touching the active profile's token. - **`gro mail list`** — the audit of item 6 found no `--max` divergence among siblings; the real gap was the missing `list` command (cobra's `unknown flag: --max` came from the parent command). - **`gro config show`** names the credential-ref source. ## Changes here - go.mod bump (also pulls cli-common v0.4.1 → v0.5.0 for `credstore.ListProfiles`) - `internal/cmd/root`: register `profilescmd.NewCommand()`; root test pins it - README: `gro mail list` examples + command reference A follow-up docs PR documents the profile model (`profiles` commands, `init --profile`, and the decision to keep the `default` profile). Full flow smoke-tested end-to-end with a hermetic HOME + file-backend keyring, including the real `invalid_grant` path. `make check` green.
Why
Pain point, experienced first-hand:
gro initre-authenticated whatever the active ref happened to point at, without ever naming it. Someone runninginitintending to ADD an account silently overwrote the active profile's token. Builds on #1's re-auth-instead-of-hard-fail (not a revert).What
init announces its target up front — before any prompt, consent, or token write:
The re-auth confirmation names what it clears:
Re-authenticate google-readonly/default (user@example.com)?with an explicit "other profiles are untouched". Same naming under--auth-code-stdin's skipped confirmation and in the expired-token message.New
--profile <name>— the obvious way to add an account:Authenticates
<service>/<name>via the same per-invocation override mechanism as the global--ref(flag precedence; one-time migration auto-suppressed, exactly as for--ref). Mutually exclusive with--ref.Deliberately does NOT rebind config.yml — adding an account must not hijack the default. Ends with exactly how to reach the new profile:
Identity recording: both verify paths (fresh OAuth flow and existing-token fast path) cache the confirmed email, so
profiles listknows which account each profile holds from the first init.Tests
Target announcement pinned (ref + source + cached email + token-saved naming);
ConfirmReauthreceives the named target;applyProfileFlag(valid/invalid/--ref-conflict); identity recording on both paths; --profile ending guidance. Prompter interface change updates the stub.make checkgreen.