Conversation
Lets jr target multiple Atlassian Cloud sites (prod, sandbox, etc.) from one install, with `jr auth switch <profile>` to flip between them. Shares the classic API token across profiles (account-level credential authenticates the same user against any site) while keeping OAuth tokens per-profile (cloudId-scoped, not transferable). Design decisions validated against industry conventions via Perplexity: kubectl-style shared users + per-host caches, gh-style consolidated auth subcommand surface, pip/cargo-style versioned cache root for schema migration, AWS-style per-profile field duplication, lazy/ opportunistic keyring migration vs upfront TOML migration. Spec: docs/specs/multi-profile-auth.md
Perplexity review surfaced one real gap and three pre-existing limitations worth surfacing: - Profile names like CON, NUL, AUX, COM1-9, LPT1-9 pass the character regex but fail at OS level on Windows. Reject them explicitly on every platform so configs stay portable. - Concurrent jr invocations writing config.toml can race (last-writer-wins). Pre-existing; tracked as atomic-save follow-up. - Concurrent OAuth refresh against the same profile can race the same way. Pre-existing; the existing 401 retry path masks it. - Cross-machine portability: TOML config is portable, keyring secrets are not (by design). Document that re-login on a new machine is the expected flow.
16 TDD tasks plus final verification, derived from the spec at docs/specs/multi-profile-auth.md. Tasks ordered foundation-first (validation → types → resolution → migration) so each commit stays green: dual-shape GlobalConfig keeps legacy [instance] readable during transition, refactored call sites land per-file, cleanup of legacy serde fields ships last. Each task contains exact file paths, complete code blocks, exact commands, and TDD red-green-refactor steps sized for 2-5 minute increments.
Introduces ProfileConfig and the dual-shape GlobalConfig (default_profile + profiles map) as foundation for multi-profile auth. Legacy InstanceConfig/FieldsConfig remain in place to be read for migration in a later task. Update init.rs to spread GlobalConfig::default() so the new fields are populated when constructing the struct literal.
Add `resolve_active_profile_name` free function applying the precedence
chain (--profile flag > JR_PROFILE env > default_profile config field >
literal "default") and a new `Config::active_profile_name` field
populated by `Config::load`. Add `Config::active_profile()` (lookup with
empty default) and `Config::active_profile_or_err()` (strict variant
listing known profiles in the error message).
Update existing `Config { .. }` literal constructors in tests and
`cli::init` to set `active_profile_name: String::new()`. The CLI flag
override is read from the `JR_PROFILE_OVERRIDE` env var, which Task 9
will populate from the parsed `--profile` flag in main.
…ault] Adds `migrate_legacy_global` (pure copy from legacy `[instance]`+`[fields]` into a new `[profiles.default]`) and wires `Config::load` to run it once, persist via the new `save_global_to` helper, and emit a one-time stderr notice. Legacy fields are intentionally PRESERVED in the migrated shape so callers still reading `global.instance.*` / `global.fields.*` keep working until Tasks 7/8 migrate them to `active_profile()`. Task 16 will stop serializing the legacy fields, dropping them from disk on next save. Adds `Clone` to `GlobalConfig`, `InstanceConfig`, `FieldsConfig`, `DefaultsConfig` for the migration test fixtures and any future copy-on-modify shape. Refactors `Config::save_global` to delegate to `save_global_to`.
Cache is now keyed by profile name to support multi-profile auth. All public readers/writers take `profile: &str` as their first argument and store under `~/.cache/jr/v1/<profile>/`. Adds `clear_profile_cache` for profile-scoped removal. Production call sites pass `&config.active_profile_name` where Config is in scope, or the literal "default" as a stopgap until Task 7 threads the active profile through JiraClient. The integration-test team-cache fixture is updated to write under the new v1/<profile> layout.
When `jr init` runs against a config with existing profiles, prompt to add another rather than overwriting. New profile name is validated and exported via JR_PROFILE_OVERRIDE so the rest of init scopes its writes to the new profile. Adds: - tests/auth_profiles.rs — exit codes, fresh install, precedence chain (--profile > JR_PROFILE > default_profile) - tests/migration_legacy.rs — legacy [instance] -> [profiles.default] migration is correct and idempotent
Updates the user-facing README: - Commands table: add jr auth switch / list / logout / remove; document --profile and --url on jr auth login - Global flags table: document --profile precedence chain - Configuration section: replace [instance] example with new [profiles.default] + [profiles.sandbox] shape; document switching options (auth switch / --profile / JR_PROFILE); note migration of legacy configs is automatic - Cache path: ~/.cache/jr/v1/<profile>/teams.json (was flat) - Scripting note: --profile + JR_PROFILE for agent workflows Updates CLAUDE.md (developer-facing): - Architecture tree: --profile global flag, expanded auth.rs purpose, per-profile keychain layout, versioned per-profile cache root - Gotchas: multi-profile boundary rule (every cache call takes profile), per-profile vs shared OAuth keys, cache-root versioning escape hatch - AI Agent Notes: JR_PROFILE env var, --profile flag precedence, JR_RUN_KEYRING_TESTS=1 gate for opt-in keyring round-trip tests
There was a problem hiding this comment.
Pull request overview
Adds multi-profile authentication to jr, enabling multiple Atlassian Cloud sites per install with profile-aware config, keyring entries, and caches.
Changes:
- Introduces new config schema (
default_profile+[profiles.<name>]) with legacy[instance]/[fields]migration. - Namespaces OAuth tokens and caches per profile (
<profile>:oauth-*in keyring,~/.cache/jr/v1/<profile>/on disk). - Expands
jr authCLI surface (switch/list/logout/remove/status/refresh) and adds a global--profileflag.
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
src/config.rs |
Adds profile schema/types, migration, active-profile resolution, and validation helpers. |
src/api/auth.rs |
Namespaces OAuth tokens by profile; adds clear helpers and lazy legacy-key migration. |
src/cache.rs |
Moves caches to per-profile v1/<profile> directories; updates cache APIs. |
src/api/client.rs |
JiraClient now consumes the active profile for URL/auth decisions. |
src/cli/mod.rs |
Adds global --profile and expands auth subcommands/flags. |
src/main.rs |
Wires --profile into config loading via JR_PROFILE_OVERRIDE. |
src/cli/auth.rs |
Implements multi-profile auth workflows (login/list/switch/logout/remove/status/refresh). |
src/cli/init.rs |
Attempts to make jr init multi-profile aware and update cache warming. |
src/cli/team.rs |
Threads active profile into team cache and org-id persistence. |
src/cli/issue/* + src/api/assets/* + src/api/jsm/servicedesks.rs |
Updates some cache calls, but several still hard-code "default". |
tests/auth_profiles.rs |
New integration tests for profile workflows and precedence. |
tests/migration_legacy.rs |
New integration tests for legacy config migration. |
docs/specs/multi-profile-auth.md / docs/superpowers/plans/... |
Spec + implementation plan for the feature. |
src/output.rs |
Adds print_warning() helper. |
src/cli/snapshots/*.snap |
Snapshot for jr auth list table rendering. |
Comments suppressed due to low confidence (1)
src/cli/init.rs:87
- When
jr initis adding a new profile, it setsJR_PROFILE_OVERRIDEbut still callslogin_oauth("default", ...)/login_token("default", ...). Since the login flows now namespace OAuth tokens and persistauth_methodby the passed profile name, this will authenticate/update the wrong profile. Use the resolved active/override profile name (or threadprofile_namethrough) instead of the hard-coded "default".
if auth_choice == 0 {
crate::cli::auth::login_oauth("default", None, None, false).await?;
} else {
crate::cli::auth::login_token("default", None, None, false).await?;
let mut config = Config::load()?;
config.global.instance.auth_method = Some("api_token".into());
config.save_global()?;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Plumb active profile name through JiraClient (5 sites had hard-coded "default" stopgaps; cache isolation now correct for non-default profiles) - jr init: persist URL/cloud_id/org_id/auth_method into the active profile entry instead of legacy [instance] (which is now skip_serializing and would silently drop the writes) - Config::load migration trigger: check all 7 legacy fields, not just url + team_field_id, so configs with only cloud_id/auth_method/etc. also migrate - Config::load: validate the resolved active_profile_name against the regex so a malicious JR_PROFILE=foo:bar can't flow into cache paths or keyring keys - tests/migration_legacy.rs: serialize the two tests with a Mutex to avoid process-global XDG_CONFIG_HOME race under parallel execution - docs/specs: refresh is not OAuth-only; document both auth-method paths
Both helpers were still reading config.global.instance.{auth_method,
oauth_scopes}. Since Task 16 made [instance] skip_serializing, any
config saved post-migration would have those fields back at None on
the next load — breaking the user's auth method preference and any
custom OAuth scope override. Switch to active_profile() reads.
Test fixtures (config_with_auth_method, config_with_oauth_scopes)
updated to construct configs via the [profiles.default] shape.
Caught during PR #275 review by the fix subagent while addressing
Copilot's findings — flagged as out-of-scope for that batch and
fixed here in a focused follow-up.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Critical:
- jr auth refresh --profile X: chosen_flow now inspects the target
profile's auth_method, not the active profile's. Refreshing a
non-active profile no longer dispatches the wrong flow. Factored
out chosen_flow_for_profile(&ProfileConfig, bool) and have
refresh_credentials thread the resolved target profile in.
- jr auth refresh --profile X: only the target's credentials are
cleared. OAuth profiles use clear_profile_creds (per-profile keys
only); api_token profiles still use clear_all_credentials because
the API token IS the shared credential being refreshed. Refreshing
an OAuth profile no longer wipes the shared API token used by other
profiles.
Important:
- jr auth logout --profile X: errors with exit 64 when X doesn't
exist (was silently succeeding via NoEntry-treated-as-success).
Consistent with switch/remove.
- unsafe { std::env::set_var(...) } SAFETY comments updated in
main.rs, cli/init.rs, and tests/migration_legacy.rs to describe
the actual invariant (pre-runtime / serial-flow / mutex-guarded)
rather than the inaccurate "single-threaded" claim.
Minor:
- tests/auth_profiles.rs precedence test asserts exactly one active
profile before indexing, so a malformed JSON shape gives a clear
failure message.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…oauth Round-15 Copilot finding: login_token and login_oauth persisted the target profile's auth_method but never touched global.default_profile. handle_login goes through prepare_login_target (which promotes the target if default_profile is None), but refresh_credentials calls login_token/login_oauth directly. Result: `jr auth refresh --profile sandbox` on a fresh install (no profiles, no default_profile) created the sandbox profile but left default_profile = None. The next strict Config::load() then errored trying to resolve the literal "default" against a profiles map that only had sandbox. Apply the same default_profile fallback inside login_token and login_oauth so any caller (handle_login, refresh_credentials, future direct callers) leaves config in a coherent state.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- refresh_credentials now refuses early when flow == Token AND the target profile has no URL configured. Previously, refresh on a fresh install / hand-edited URL-less profile would re-prompt for email/token via the api_token flow and report success — leaving the profile unusable for any actual API call. The new error tells the user to run \`jr auth login --profile X --url <https://...>\` instead, citing the actual root cause. OAuth flow keeps writing its own URL via accessible-resources, so it doesn't have this gap. - Update auth_refresh_no_input_fails_with_clear_message to assert the new error wording (no URL configured + jr auth login --url recovery hint) instead of the pre-fix email-required wording. - Spec: drop the "Confirmation prompt unless --no-input" claim about --url overwrites on existing profiles. \`prepare_login_target\` has always overwritten unconditionally; passing --url is itself the user's explicit confirmation of intent. Adding a prompt mid-flow would hurt the agent-friendly invariant. Spec now reflects the actual code semantics.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Round-17 Copilot finding: Config::load ran the migration detection against an env-merged Figment view AND persisted the migrated value to disk via save_global_to. Any transient JR_* env vars set for the upgrade invocation (e.g., JR_DEFAULTS_OUTPUT=json) were silently baked into the saved config.toml, persisting across future invocations even after the env var was unset. Fix: when migration is needed, perform a SECOND file-only Figment load (no Env layer) to drive the save. The env-merged in-memory `global` still gets migrated so callers see the new [profiles.default] entry, but on-disk state reflects only what was in the file plus the structural migration — never transient env values. The narrower "two-phase load (file-only first, save, then env- merged)" approach Copilot suggested was tried and reverted because it broke 10 team-related tests: migration's intended in-memory contract preserves legacy [instance]/[fields] populated for read-fallback during the transition (Tasks 7/8), and a clean re-read post-save would lose those copies. The kept design splits in-memory (env-merged) from save-back (file-only) without disturbing the legacy-fields invariant.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Robustness improvements:
- main.rs: --profile validation now happens inside `run()` instead
of standalone in main(). This means a bad profile name flows
through the unified error-reporting block, so `jr --profile
bad:name --output json ...` callers get the structured
{"error":..,"code":..} payload instead of a plain stderr line.
- tests/auth_refresh.rs: env_remove the full set of JR_INSTANCE_*
vars (URL, AUTH_METHOD, CLOUD_ID, ORG_ID, OAUTH_SCOPES) plus
JR_PROFILE / JR_DEFAULT_PROFILE. Previously, only
JR_INSTANCE_AUTH_METHOD was cleared, so a parent shell with
JR_INSTANCE_URL set would make the empty-config test look
configured and assertions would fail on dev machines.
- tests/migration_legacy.rs: ENV_MUTEX.lock() now uses
unwrap_or_else(|p| p.into_inner()) so a panic in one test doesn't
poison the mutex and cascade-fail subsequent tests in the same
binary. Matches the pattern used in other env-mutex helpers.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Test env hygiene: - tests/auth_profiles.rs: jr() helper now scrubs the full set of JR_* env vars (JR_PROFILE/JR_DEFAULT_PROFILE/JR_INSTANCE_*/JR_FIELDS_*/ JR_DEFAULTS_*/JR_BASE_URL/JR_AUTH_HEADER/JR_EMAIL/JR_API_TOKEN/ JR_OAUTH_CLIENT_*) so direnv-set vars on dev machines can't make fresh-install tests look configured or trigger unintended legacy migration. - tests/migration_legacy.rs: XdgConfigGuard now scrubs JR_* env vars (with prior-value restore on Drop) for the same reason. Extends the RAII guarantee to every var Config::load reads, not just XDG_CONFIG_HOME. Spec drift: - jr auth refresh now correctly documented: clears keychain entries (shared for api_token, per-profile for oauth) and re-runs the FULL login flow (oauth = browser-based 3LO, not silent refresh_token grant). The interactivity is intentional — same #207 macOS keychain ACL rebind that the api_token flow needs. --oauth flag is the explicit override that forces the OAuth path. - Error table updated to reflect actual behavior: - login no-input requires URL is now broader ("target profile has no URL configured", not just "creating a new profile") - refresh on URL-less profile is the actual error condition, not the spec's previous "OAuth refresh not applicable" wording
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/cache.rs:27
- Cache corruption warnings emitted by
read_cacheonly mention the filename (e.g.,teams.json) but not the profile namespace. With per-profile caches, this can be ambiguous when the user has multiple profiles (it's not obvious which~/.cache/jr/v1/<profile>/...is affected). Consider including theprofile(or full path) in the warning message so users can locate and delete the right cache directory.
fn read_cache<T: DeserializeOwned + Expiring>(profile: &str, filename: &str) -> Result<Option<T>> {
let path = cache_dir(profile).join(filename);
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let cache: T = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
eprintln!("warning: cache file {filename} unreadable ({e}); will refetch");
return Ok(None);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- src/api/auth.rs: refresh_oauth_token doc comment now references [profiles.<name>].oauth_scopes (was [instance].oauth_scopes — old pre-multi-profile shape). - src/cli/auth.rs::status: 'No profiles configured' fast-path now fires only when profile_arg is None (i.e., status with no --profile flag on a fresh install). When the user explicitly passes --profile X against an empty profiles map, take the strict path and error with 'unknown profile: X' — silent success would hide a typo and contradict switch/remove/logout's strict behavior.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Config::save_global now reads file-only baseline from disk before saving, then overlays only the multi-profile fields (default_profile + profiles map) from in-memory `self.global`. Prevents transient JR_* env overrides from baking into config.toml when mutating commands run with env vars set (e.g., JR_DEFAULTS_OUTPUT=json jr auth switch sandbox would have baked output = "json" into the saved file). Same shape as round-17's migration-time leak, but in the save path. Other top-level fields like defaults.output now pass through from disk unchanged. - clear_all_credentials now only deletes the legacy flat OAuth keys (oauth-access-token / oauth-refresh-token) when "default" appears in the profiles list. Previously, jr auth refresh --profile sandbox in api_token flow would unconditionally wipe the default profile's intact-but-unmigrated OAuth tokens — destroying credentials for users mid-upgrade who hadn't yet triggered lazy migration.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
Lets
jrtarget multiple Atlassian Cloud sites from one install, withjr auth switch <profile>to flip between them. Shared classic API token across profiles (account-level credential authenticates the same user against any Atlassian site), per-profile OAuth tokens (cloudId-scoped, not transferable). Auto-migration of legacy single-instance configs; lazy keyring migration for OAuth tokens.Highlights:
jr auth login --profile <N> --url <U>,jr auth switch <N>,jr auth list,jr auth logout [--profile N],jr auth remove <N>,jr auth status [--profile N],jr auth refresh [--profile N]. New global--profile <N>flag.[profiles.<name>]table-of-tables +default_profilefield. Legacy[instance]/[fields]blocks read for migration only and no longer serialized.email/api-token/oauth_client_*keys + per-profile<profile>:oauth-access-token/<profile>:oauth-refresh-token. Lazy migration of pre-existing flat OAuth keys todefault:*on first read.~/.cache/jr/v1/<profile>/per-profile subdirectory; legacy flat files orphan via path versioning (no migration code, matches pip/cargo/npm convention).[A-Za-z0-9_-]{1,64}, plus rejects Windows reserved names (CON, NUL, AUX, PRN, COM1-9, LPT1-9) cross-platform for portable configs.Backward Compatibility
For typical CLI users: effectively non-breaking. Existing commands (
jr auth login,auth status,auth refresh, all flags) continue to work unchanged. The first run after upgrading auto-migrates the config (one stderr notice, idempotent). API tokens are preserved as-is; OAuth tokens lazy-migrate on first read.Structurally breaking for direct consumers of
jr's internal state:~/.config/jr/config.tomlshape changes from[instance]to[profiles.default]after first migrationoauth-access-tokentodefault:oauth-access-tokenafter lazy migration~/.cache/jr/*.jsonto~/.cache/jr/v1/<profile>/*.json(old files orphan harmlessly)Squash-merge guidance: add a
BREAKING CHANGE:footer to the merge commit so the change is captured in conventional-commits-aware tooling and changelog generation:We're at
0.5.0-dev.3(pre-1.0), so semver allows the change in a0.6.0minor bump.Implementation
Spec:
docs/specs/multi-profile-auth.mdPlan:
docs/superpowers/plans/2026-04-24-multi-profile-auth.md16 TDD tasks landed as 18 commits (foundation → keyring → cache → client → CLI surface → migration → cleanup → docs). Each commit is green and bisectable. Dual-shape transition kept legacy fields readable until Task 16 stopped serializing them.
Design decisions validated via Perplexity at every step: kubectl-style shared users + per-host caches, gh-style consolidated
authsubcommand surface, pip/cargo-style versioned cache root for migration, AWS-style per-profile field duplication, lazy/opportunistic keyring migration vs upfront TOML migration.Test Plan
cargo fmt --all -- --checkpassescargo clippy --all-targets -- -D warningspassescargo test— 957 tests pass (was 918 before this branch; 39 new)JR_RUN_KEYRING_TESTS=1 cargo test --lib api::auth -- --ignoredopt-in keyring round-trip + lazy migration tests gated for CI portabilityOut of Scope (tracked as follow-ups)
jr profilesubcommand tree (consolidated underjr authper gh precedent).jr.tomlprofile pinning (use direnv withJR_PROFILE)KeyringProvidertrait abstraction for testability/CI portabilityConfig::save_global(tempfile + rename)tests/migration_legacy.rsmutates process-globalXDG_CONFIG_HOME; passes individually but flakes under parallel test execution. Worth either gating withserial_testor scoping the env-var contract more tightly in a follow-up.