Skip to content

feat(init): name the target profile, add --profile for new accounts - #7

Merged
piekstra merged 1 commit into
mainfrom
feat/init-names-target-profile
Aug 12, 2026
Merged

feat(init): name the target profile, add --profile for new accounts#7
piekstra merged 1 commit into
mainfrom
feat/init-names-target-profile

Conversation

@piekstra

Copy link
Copy Markdown
Contributor

Why

Pain point, experienced first-hand: gro init re-authenticated whatever the active ref happened to point at, without ever naming it. Someone running init intending 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:

Setting up profile: google-readonly/default (via config.yml credential_ref)
Currently holds:    user@example.com
To add a different account instead, use 'gro init --profile <name>'.

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:

    Profile google-readonly/work is authenticated but not active.
    Make it active:      gro profiles use work
    Use per invocation:  gro --ref google-readonly/work <command>
    

Identity recording: both verify paths (fresh OAuth flow and existing-token fast path) cache the confirmed email, so profiles list knows which account each profile holds from the first init.

Tests

Target announcement pinned (ref + source + cached email + token-saved naming); ConfirmReauth receives the named target; applyProfileFlag (valid/invalid/--ref-conflict); identity recording on both paths; --profile ending guidance. Prompter interface change updates the stub. make check green.

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 piekstra-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread initcmd/init_test.go
@@ -17,6 +17,7 @@ import (
"google.golang.org/api/googleapi"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread initcmd/init.go
func describeTarget() (ref, sourceLabel, cachedEmail string) {
st, err := keychain.OpenNoMigrate()
if err != nil {
cfg, cerr := config.LoadConfigForRuntime()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@piekstra
piekstra merged commit 06447ef into main Aug 12, 2026
4 checks passed
piekstra added a commit to open-cli-collective/google-readonly that referenced this pull request Aug 12, 2026
…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.
@piekstra
piekstra deleted the feat/init-names-target-profile branch August 12, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants