Skip to content

feat: add Copilot CLI Keychain token discovery and multi-account CLI support#84

Open
Shaglock wants to merge 3 commits intoopgginc:mainfrom
Shaglock:multi-account-and-copilot-cli
Open

feat: add Copilot CLI Keychain token discovery and multi-account CLI support#84
Shaglock wants to merge 3 commits intoopgginc:mainfrom
Shaglock:multi-account-and-copilot-cli

Conversation

@Shaglock
Copy link
Contributor

@Shaglock Shaglock commented Feb 17, 2026

Disclaimer

The initial issue (fix #73) I had was that my copilot sub was not detected through opencode auth token.
I have no Swift language knowledge, I tried to guide Claude Opus 4.6 to fix my problem. Then I went into a little rabbit hole trying to support two copilot subs at once. Obviously there is probably a lot of slop here, not sure if the keychain access is implemented correctly, and, but hopefully if it's not merged, at least it's a good starting point for someone.

Summary

  • Add macOS Keychain (copilot-cli service) as a new GitHub Copilot token discovery source
  • Rewrite CLI's CopilotCLIProvider to use TokenManager for multi-account token discovery, matching the GUI provider's logic
  • Fix CLI table output alignment when multi-account provider names exceed the column width
  • Add quota_snapshots API format support for Copilot plan info
  • Display GitHub login names instead of #1/#2 numbering in the menu
image
Detailed Changes

Token Discovery — Keychain Support (TokenManager.swift)

  • Add copilotCliKeychain case to CopilotAuthSource enum with CustomStringConvertible conformance
  • Implement readCopilotCliKeychainAccounts() using a two-step Keychain query:
    1. Query with kSecReturnAttributes + kSecMatchLimitAll to list all items
    2. Query each item individually with kSecReturnData + kSecMatchLimitOne to get the password
    • This two-step approach is required because kSecMatchLimitAll + kSecReturnData returns errSecParam (-50) on macOS
  • Parse username from the Keychain account field (format: https://github.com:username)
  • Integrate into getGitHubCopilotAccounts() between OpenCode auth and VS Code file discovery
  • Update dedupeCopilotAccounts() priority ordering:
    • OpenCode auth: 3 (highest)
    • Copilot CLI Keychain: 2
    • VS Code hosts.json: 1
    • VS Code apps.json: 0 (lowest)
  • Add diagnostic logging for Keychain discovery in the auth summary

Copilot API — quota_snapshots Format (TokenManager.swift)

  • Add parsing for the new quota_snapshots API response format alongside the legacy monthly_quotas / limited_user_quotas format
  • quota_snapshots contains per-type objects with entitlement, remaining, and unlimited fields
  • Priority: snapshots > monthly > legacy
  • Preserve existing unlimited plan handling (snapshotEntitlement = 0, snapshotRemaining = 0 placeholder)
  • Add error response body logging for failed Copilot API calls

GUI — Display Names (StatusBarController.swift, CopilotProvider.swift)

  • Show GitHub login names (e.g., GitHub Copilot (Shaglock)) instead of generic index numbers (GitHub Copilot #1)
  • Change auth source separator from parentheses to dash: GitHub Copilot (Shaglock) - OpenCode instead of GitHub Copilot #1 (OpenCode)
  • Update CopilotProvider.sourcePriority() to include copilotCliKeychain case with priority 2

CLI — CopilotCLIProvider Rewrite (CopilotCLIProvider.swift)

Complete rewrite from ~276 lines to ~544 lines. The old provider only supported browser cookies and returned a single ProviderResult. The new provider:

  • Uses TokenManager.shared.getGitHubCopilotAccounts() for token discovery (OpenCode auth, Keychain, VS Code files)
  • Falls back to browser cookies as an additional source (non-fatal if cookies unavailable)
  • Supports multi-account output via ProviderAccountResult array
  • Uses CandidateDedupe.merge() for cross-source deduplication
  • Fetches user login via GitHub /user API when not available from token metadata
  • Merges plan info from token API with usage data from cookie-based billing API
  • All cookie-based operations (fetchCustomerId, fetchUsageData) return optionals instead of throwing, enabling graceful fallback
  • Removes the local CopilotUsage struct (now uses the shared model from CopilotUsage.swift)

CLI — Table Formatter Alignment Fix (main.swift)

  • Replace hardcoded 20-char provider column width with dynamic computation
  • Add computeProviderWidth() that scans ALL row labels (regular providers, Gemini multi-account, generic multi-account) before rendering
  • Pass computed width to formatHeader(), formatSeparator(), formatRow(), formatGeminiAccountRow(), formatAccountRow()
  • Add shortenAuthSource() to display file paths as just the filename (e.g., /Users/x/.local/share/opencode/auth.json becomes auth.json)
  • Extract accountLabel() and geminiLabel() helpers shared between width computation and rendering
  • JSON formatter already supported multi-account output (added in earlier work)

Build Configuration (project.pbxproj)

  • Add CopilotUsage.swift to the CLI target's Sources build phase so the CLI can use the shared model

Files Changed

File Change
CopilotMonitor/CopilotMonitor/Services/TokenManager.swift Keychain reader, quota_snapshots parser, dedup priorities, diagnostics
CopilotMonitor/CopilotMonitor/Providers/CopilotProvider.swift Add copilotCliKeychain to sourcePriority()
CopilotMonitor/CopilotMonitor/App/StatusBarController.swift Display login names instead of indices
CopilotMonitor/CLI/Providers/CopilotCLIProvider.swift Full rewrite for multi-account TokenManager support
CopilotMonitor/CLI/main.swift Dynamic table column alignment, auth source shortening
CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj Add CopilotUsage.swift to CLI target

Testing

  • Both targets build successfully (BUILD SUCCEEDED)

  • CLI discovers accounts from both OpenCode auth and Keychain sources

  • Table output is correctly aligned with dynamic column widths:

    Example of new table render image
  • JSON output correctly serializes both accounts with all fields

  • GUI menu shows multiple accounts correctly

…support

Add macOS Keychain as a token source for GitHub Copilot, enabling
automatic discovery of credentials stored by `copilot-cli`. Rewrite the
CLI's CopilotCLIProvider to use TokenManager for multi-account support
(matching the GUI provider), and fix table column alignment for long
provider names.
@op-gg-ai-devops
Copy link
Contributor

Here is the summary of the changes in this PR:

This PR significantly enhances the CLI tool by adding multi-account support for Copilot (including discovery via macOS Keychain), aligns the CLI provider logic with the GUI version, and updates TokenManager to correctly parse the newer quota_snapshots API field.


Code Review Feedback

  • Keychain Integration: The readCopilotCliKeychainAccounts implementation using SecItemCopyMatching is clean and standard for retrieving generic passwords. This is a great addition for users who rely on the official GitHub CLI auth.
  • API Future-Proofing: Excellent catch on the quota_snapshots structure in TokenManager. Relying solely on monthly_quotas or limited_user_quotas was becoming risky as the API evolved, so aggregating entitlements from snapshots makes the logic much more robust.
  • CLI UX: The dynamic width calculation in TableFormatter is a thoughtful improvement; fixed-width tables almost always break when long email addresses are involved.
  • Maintainability: Since CopilotCLIProvider now mirrors the GUI CopilotProvider significantly (deduplication priority, fetching logic), consider extracting shared logic into a common service in a future refactor to prevent logic drift between the two interface modes.

This is just a quick preview.
For a detailed, line-by-line code review (including bug detection and security checks), please comment:
**@opgginc/op-gg-ai-devops** review this

@Shaglock Shaglock changed the title feat: add Copilot CLI Keychain token discovery and multi-account CLI support feat: add Copilot CLI Keychain token discovery and multi-account CLI support #73 Feb 17, 2026
@Shaglock Shaglock changed the title feat: add Copilot CLI Keychain token discovery and multi-account CLI support #73 feat: add Copilot CLI Keychain token discovery and multi-account CLI support Fix #73 Feb 17, 2026
@Shaglock Shaglock changed the title feat: add Copilot CLI Keychain token discovery and multi-account CLI support Fix #73 feat: add Copilot CLI Keychain token discovery and multi-account CLI support Feb 17, 2026
@kargnas
Copy link
Member

kargnas commented Feb 18, 2026

@opgginc/op-gg-ai-devops

@op-gg-ai-devops
Copy link
Contributor

op-gg-ai-devops bot commented Feb 18, 2026

✅ AI Code Review Completed

Review finished. Please address the feedback regarding usage logic, concurrency, and keychain access.

Review Plan

  • Read AGENTS.md to determine review language and project rules
  • Fetch and analyze git diff between BASE_SHA and HEAD_SHA
  • Check for documentation updates (README.md, AGENTS.md, .env.example)
  • Perform deep code review (logic, security, performance, style)
  • Draft and post review using gh api
  • Update status comment to Completed

Reviewed with google/gemini-3-pro-preview


📋 View Full Logs

Copy link
Contributor

@op-gg-ai-devops op-gg-ai-devops bot left a comment

Choose a reason for hiding this comment

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

🤖 OP.GG DevOps AI Code Review

This PR enhances the CopilotCLIProvider to support multi-account discovery via TokenManager, aligning the CLI behavior with the GUI application. It introduces Keychain access for token retrieval and improves output formatting for multiple accounts.

🔍 Key Findings

  1. Logic Change in Usage Calculation: The CLI now uses the shared CopilotUsage model, which calculates usedRequests differently than the previous CLI implementation. The old implementation included netQuantity (overage) and respected filteredUserPremiumRequestEntitlement. The shared model only uses discountQuantity and userPremiumRequestEntitlement. Please verify if this simplification is intentional or if the shared model needs updating.
  2. Concurrency Optimization: The token discovery process fetches plan info and user login sequentially for each account. For users with multiple accounts, this could be parallelized to improve performance.
  3. Keychain Access in CLI: The integration of TokenManager means the CLI will now attempt to access the macOS Keychain. Ensure this behaves correctly in headless environments (e.g., CI/CD) and that users are aware of potential permission prompts.

🛠️ Suggestions

I have included inline suggestions to improve performance, clean up unused code, and refine the table formatting logic.


private var cachedCustomerId: String?

private var cachedUserEmail: String?
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems cachedUserEmail is assigned but never read. If it's not needed for future logic, consider removing it to keep the state clean.

Suggested change
private var cachedUserEmail: String?
private var cachedCustomerId: String?

Comment on lines 151 to 169
private func fetchTokenInfos(_ accounts: [CopilotAuthAccount]) async -> [CopilotTokenInfo] {
var infos: [CopilotTokenInfo] = []

for account in accounts {
let planInfo = await TokenManager.shared.fetchCopilotPlanInfo(accessToken: account.accessToken)
var login = account.login

if login == nil {
login = await fetchCopilotUserLogin(accessToken: account.accessToken)
}

let accountId = account.accountId ?? planInfo?.userId ?? login
infos.append(
CopilotTokenInfo(
accountId: accountId,
login: login,
planInfo: planInfo,
authSource: account.authSource,
source: account.source
Copy link
Contributor

Choose a reason for hiding this comment

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

Fetching plan info and user login sequentially for each account might be slow if there are many accounts. Consider using TaskGroup to fetch these in parallel.

Suggested change
private func fetchTokenInfos(_ accounts: [CopilotAuthAccount]) async -> [CopilotTokenInfo] {
var infos: [CopilotTokenInfo] = []
for account in accounts {
let planInfo = await TokenManager.shared.fetchCopilotPlanInfo(accessToken: account.accessToken)
var login = account.login
if login == nil {
login = await fetchCopilotUserLogin(accessToken: account.accessToken)
}
let accountId = account.accountId ?? planInfo?.userId ?? login
infos.append(
CopilotTokenInfo(
accountId: accountId,
login: login,
planInfo: planInfo,
authSource: account.authSource,
source: account.source
await withTaskGroup(of: CopilotTokenInfo.self) { group in
for account in accounts {
group.addTask {
let planInfo = await TokenManager.shared.fetchCopilotPlanInfo(accessToken: account.accessToken)
var login = account.login
if login == nil {
login = await self.fetchCopilotUserLogin(accessToken: account.accessToken)
}
let accountId = account.accountId ?? planInfo?.userId ?? login
return CopilotTokenInfo(
accountId: accountId,
login: login,
planInfo: planInfo,
authSource: account.authSource,
source: account.source
)
}
}
for await info in group {
infos.append(info)
}
}

let totalWidth = columnWidths.provider + columnWidths.type + columnWidths.usage + 30 + 6

private static func formatSeparator(providerWidth: Int) -> String {
let totalWidth = providerWidth + typeWidth + usageWidth + 30 + 6
Copy link
Contributor

Choose a reason for hiding this comment

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

The hardcoded 30 for the metrics column width might cause the separator line to be too short if the metrics string is long (e.g., "$12.34 spent (resets Jan 1)"). Consider calculating the max width of the metrics column dynamically, similar to computeProviderWidth, or at least increasing this constant to cover typical output lengths.

…fetch, fix separator width, Add tests covering all three areas

Three changes from PR review feedback:

1. Remove unused `cachedUserEmail` (CopilotCLIProvider)
   The property was declared and assigned in the cookie branch of `fetch()`
   but never read. Remove the declaration and the assignment so the actor
   carries no stale mutable state.

2. Parallelise `fetchTokenInfos` (CopilotCLIProvider)
   The previous sequential `for` loop awaited each account's plan-info and
   user-login network requests one at a time. Replace it with
   `withTaskGroup` so every account fires its requests concurrently.
   The `dedupeTokenInfos` call at the end is preserved unchanged.
   For users with several Copilot accounts (e.g. personal + org), startup
   time for the Copilot row is now bounded by the slowest single request
   rather than the sum of all requests.

3. Dynamic metrics column width in TableFormatter (CLI)
   `formatSeparator` previously hardcoded a 30-character metrics column,
   which caused the separator line to be shorter than data rows whenever
   auth-source labels or long email addresses pushed the metrics string
   past 30 characters (e.g. "Browser Cookies (Chrome/Brave/Arc/Edge)").
   Add `computeMetricsWidth()`, which mirrors `computeProviderWidth()` and
   scans every rendered metrics string — including Gemini multi-account
   rows, generic multi-account rows with auth-source brackets, and
   single-account rows — to find the true maximum. Thread the computed
   value through `formatSeparator(providerWidth:metricsWidth:)` so the
   separator is always exactly as wide as the widest row.

Add tests covering all three areas:

- CLIFormatterTests: 8 new cases for TableFormatter
  · separator length equals header length for a single-account result
  · separator grows to accommodate long auth-source bracket labels
  · `accountLabel` uses `accountId` when present, falls back to `#N`
  · absolute-path auth sources are shortened to just the filename
  · tilde-path auth sources are shortened to just the filename
  · non-path auth sources (e.g. "Browser Cookies …") pass through verbatim
  · separator is ≥ every data row width for Gemini multi-account results

- CopilotProviderTests: 4 new cases for CopilotAuthSource / sourcePriority
  · all four enum cases exist with unique descriptions
  · each case's `description` string matches the expected value
    (including the newly-added `copilotCliKeychain` case)
  · relative priority ordering: opencodeAuth > copilotCliKeychain >
    vscodeHosts > vscodeApps
  · absolute priority values: 3, 2, 1, 0
@Shaglock
Copy link
Contributor Author

Tried to address the comments :)

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.

Support for Github copilot

2 participants