@brimveyn/aimux 1.22.7 → 1.22.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.7",
3
+ "version": "1.22.9",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,54 @@
1
+ import { runCli } from '../services/ai-usage/spawn'
2
+
3
+ export interface GhAccount {
4
+ host: string
5
+ user: string
6
+ active: boolean
7
+ }
8
+
9
+ /**
10
+ * `gh auth status` has no `--json`, so its human output is the API: one
11
+ * "Logged in to <host> account <user>" line per account, each followed by the
12
+ * "Active account:" line that belongs to it.
13
+ */
14
+ export function parseGhAuthStatus(output: string): GhAccount[] {
15
+ const accounts: GhAccount[] = []
16
+ for (const line of output.split('\n')) {
17
+ const login = /Logged in to (\S+) account (\S+)/.exec(line)
18
+ if (login !== null) {
19
+ accounts.push({ active: false, host: login[1] ?? '', user: login[2] ?? '' })
20
+ continue
21
+ }
22
+ const current = accounts.at(-1)
23
+ if (current !== undefined && /Active account:\s*true/i.test(line)) current.active = true
24
+ }
25
+ return accounts
26
+ }
27
+
28
+ export async function listGhAccounts(): Promise<GhAccount[]> {
29
+ const gh = Bun.which('gh')
30
+ if (gh === null) return []
31
+ // Non-zero exit when nothing is logged in, and the report has lived on both
32
+ // streams across gh versions — read both and let the parser decide.
33
+ const result = await runCli(gh, ['auth', 'status'], 10_000)
34
+ return parseGhAuthStatus(`${result.stdout}\n${result.stderr}`)
35
+ }
36
+
37
+ /** Where a "switch account" button goes next: the following account, cycling. */
38
+ export function nextGhAccount(accounts: readonly GhAccount[]): GhAccount | null {
39
+ if (accounts.length < 2) return null
40
+ const index = accounts.findIndex((account) => account.active)
41
+ return accounts[(index + 1) % accounts.length] ?? null
42
+ }
43
+
44
+ /** Returns an error message, or null when the switch landed. */
45
+ export async function switchGhAccount(account: GhAccount): Promise<string | null> {
46
+ const gh = Bun.which('gh')
47
+ if (gh === null) return 'gh CLI not found'
48
+ // --user is what keeps this non-interactive: with three accounts on a host,
49
+ // a bare `gh auth switch` opens a prompt no widget can answer.
50
+ const args = ['auth', 'switch', '--hostname', account.host, '--user', account.user]
51
+ const result = await runCli(gh, args, 15_000)
52
+ if (result.ok) return null
53
+ return result.stderr.trim().split('\n')[0] ?? 'gh auth switch failed'
54
+ }
@@ -231,6 +231,23 @@ export function clampPrBody(body: string, maxLines = 5, maxChars = 260): Clamped
231
231
  return { text, truncated: text.length < full.length }
232
232
  }
233
233
 
234
+ /**
235
+ * `gh`'s failure in one line. The raw spawn error is a binary path, an exit
236
+ * code and a GraphQL dump — accurate, unreadable, and far wider than the pane.
237
+ */
238
+ export function ghErrorMessage(detail: string): string {
239
+ const repo = /could not resolve to a repository with the name '([^']+)'/i.exec(detail)
240
+ if (repo !== null) return `No access to ${repo[1]}`
241
+ if (/gh auth login|not logged in|authentication failed|bad credentials/i.test(detail)) {
242
+ return 'Not logged in to GitHub'
243
+ }
244
+ const first = detail
245
+ .split('\n')
246
+ .map((line) => line.trim())
247
+ .find((line) => line !== '')
248
+ return (first ?? 'gh failed').replace(/^(error|gh):\s*/i, '').slice(0, 160)
249
+ }
250
+
234
251
  export async function collectPrStatus(cwd: string): Promise<PrStatusResult> {
235
252
  const gh = Bun.which('gh')
236
253
  if (gh === null) return { kind: 'no-gh' }
@@ -242,7 +259,9 @@ export async function collectPrStatus(cwd: string): Promise<PrStatusResult> {
242
259
  // at all (aimux projects can point anywhere). None of those is an error.
243
260
  const stderr = result.stderr.toLowerCase()
244
261
  if (NOT_AN_ERROR.some((needle) => stderr.includes(needle))) return { kind: 'no-pr' }
245
- return { kind: 'error', message: (result.error ?? 'gh failed').slice(0, 200) }
262
+ // A spawn that never started (missing cwd) reports on `error`, not stderr.
263
+ const detail = result.stderr.trim() !== '' ? result.stderr : (result.error ?? '')
264
+ return { kind: 'error', message: ghErrorMessage(detail) }
246
265
  }
247
266
 
248
267
  try {
@@ -1,5 +1,9 @@
1
1
  import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
2
 
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
3
7
  import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
4
8
 
5
9
  import { computePace, formatTimeRemaining } from '../pace'
@@ -11,7 +15,7 @@ interface ClaudeOAuthCreds {
11
15
  planTier: string | null
12
16
  }
13
17
 
14
- interface ClaudeKeychainPayload {
18
+ interface ClaudeCredentialsPayload {
15
19
  claudeAiOauth?: {
16
20
  accessToken?: string
17
21
  expiresAt?: number
@@ -59,7 +63,7 @@ function normalizePlanTier(raw: string | undefined | null): string | null {
59
63
  return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
60
64
  }
61
65
 
62
- function planTierFromPayload(payload: ClaudeKeychainPayload): string | null {
66
+ function planTierFromPayload(payload: ClaudeCredentialsPayload): string | null {
63
67
  const o = payload.claudeAiOauth
64
68
  if (!o) return null
65
69
  const raw = o.rateLimitTier ?? o.rate_limit_tier ?? o.subscriptionType
@@ -77,6 +81,33 @@ function planTierFromPayload(payload: ClaudeKeychainPayload): string | null {
77
81
  return null
78
82
  }
79
83
 
84
+ function claudeConfigDir(): string {
85
+ const env = process.env.CLAUDE_CONFIG_DIR?.trim()
86
+ if (env != null && env !== '') return env
87
+ return join(homedir(), '.claude')
88
+ }
89
+
90
+ async function readCredsFromFile(): Promise<string | null> {
91
+ try {
92
+ const raw = await readFile(join(claudeConfigDir(), '.credentials.json'), 'utf8')
93
+ return raw.trim() || null
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ async function readCredsFromKeychain(): Promise<string | null> {
100
+ if (process.platform !== 'darwin') return null
101
+ const result = await runCli('security', [
102
+ 'find-generic-password',
103
+ '-s',
104
+ 'Claude Code-credentials',
105
+ '-w',
106
+ ])
107
+ if (!result.ok) return null
108
+ return result.stdout.trim() || null
109
+ }
110
+
80
111
  async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
81
112
  const now = Date.now()
82
113
  if (
@@ -87,22 +118,27 @@ async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
87
118
  return cachedCreds
88
119
  }
89
120
 
90
- if (process.platform !== 'darwin') {
91
- throw new Error('claude usage requires macOS keychain (darwin only)')
121
+ // macOS keeps the OAuth blob in the keychain; Linux (incl. WSL) and Windows
122
+ // write the same JSON to ~/.claude/.credentials.json. Try the platform's
123
+ // primary store first, then the other one — a macOS install with the keychain
124
+ // unavailable falls back to the file too.
125
+ const raw =
126
+ process.platform === 'darwin'
127
+ ? ((await readCredsFromKeychain()) ?? (await readCredsFromFile()))
128
+ : ((await readCredsFromFile()) ?? (await readCredsFromKeychain()))
129
+ if (raw === null) {
130
+ throw new Error('no Claude credentials found — run `claude` to sign in')
92
131
  }
93
- const result = await runCli('security', [
94
- 'find-generic-password',
95
- '-s',
96
- 'Claude Code-credentials',
97
- '-w',
98
- ])
99
- if (!result.ok) {
100
- throw new Error(`keychain read failed — run \`claude\` to sign in`)
132
+
133
+ let parsed: ClaudeCredentialsPayload
134
+ try {
135
+ parsed = JSON.parse(raw) as ClaudeCredentialsPayload
136
+ } catch {
137
+ throw new Error('claude credentials unreadable — run `claude` to sign in')
101
138
  }
102
- const parsed = JSON.parse(result.stdout.trim()) as ClaudeKeychainPayload
103
139
  const access = parsed.claudeAiOauth?.accessToken
104
140
  if (!(access != null && access !== '')) {
105
- throw new Error('no accessToken in Claude Code keychain')
141
+ throw new Error('no accessToken in Claude credentials — run `claude` to sign in')
106
142
  }
107
143
  cachedCreds = {
108
144
  accessToken: access,
@@ -57,7 +57,7 @@ export const GitPaneWidget = memo(function GitPaneWidget({
57
57
  <box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
58
58
  <GitPaneHeader gitPanel={display} onTabChange={setTab} projectPath={projectPath} tab={tab} />
59
59
  {tab === 'github' ? (
60
- <PrChecksPanel contentWidth={contentWidth} />
60
+ <PrChecksPanel contentWidth={contentWidth} projectPath={projectPath} />
61
61
  ) : (
62
62
  <GitPanel
63
63
  collapsedFolders={gitMode.collapsedFolders}
@@ -1,13 +1,21 @@
1
- import { memo, useCallback, useState } from 'react'
1
+ import { memo, useCallback, useEffect, useState } from 'react'
2
2
 
3
+ import {
4
+ type GhAccount,
5
+ listGhAccounts,
6
+ nextGhAccount,
7
+ switchGhAccount,
8
+ } from '../../../../git/gh-auth'
3
9
  import {
4
10
  clampPrBody,
5
11
  type PrCheck,
6
12
  type PrCheckState,
7
13
  type PrStatusResult,
8
14
  } from '../../../../git/pr-status'
15
+ import { refreshPrStatus } from '../../../../git/pr-status-poller'
9
16
  import { openUrl } from '../../../../platform/open-url'
10
17
  import { usePrStatusStore } from '../../../../state/pr-status-store'
18
+ import { toast } from '../../../../state/toast-store'
11
19
  import { useBusySpinner } from '../../../hooks/use-busy-spinner'
12
20
  import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
13
21
 
@@ -47,10 +55,93 @@ function placeholder(
47
55
  if (result === null) return { color: t.textMuted, label: '…' }
48
56
  if (result.kind === 'no-gh') return { color: t.textMuted, label: 'gh CLI not found' }
49
57
  if (result.kind === 'no-pr') return { color: t.textMuted, label: 'No pull request' }
50
- if (result.kind === 'error') return { color: t.error, label: result.message }
51
58
  return null
52
59
  }
53
60
 
61
+ /**
62
+ * Nearly every `gh` failure the pane can hit is really "the active account
63
+ * can't see this repo", so the error state carries the one fix worth a click:
64
+ * cycle to the next authenticated account and refetch.
65
+ */
66
+ const GhErrorState = memo(function GhErrorState({
67
+ bg,
68
+ message,
69
+ projectPath,
70
+ }: {
71
+ bg: string | undefined
72
+ message: string
73
+ projectPath: string | undefined
74
+ }) {
75
+ const t = useTheme()
76
+ const [accounts, setAccounts] = useState<readonly GhAccount[]>([])
77
+ const [switching, setSwitching] = useState(false)
78
+ const spinner = useBusySpinner(switching)
79
+
80
+ useEffect(() => {
81
+ let cancelled = false
82
+ void (async () => {
83
+ const list = await listGhAccounts()
84
+ if (!cancelled) setAccounts(list)
85
+ })()
86
+ return () => {
87
+ cancelled = true
88
+ }
89
+ }, [])
90
+
91
+ const active = accounts.find((account) => account.active)
92
+ const next = nextGhAccount(accounts)
93
+
94
+ const onSwitch = useCallback(() => {
95
+ if (next === null || switching) return
96
+ setSwitching(true)
97
+ void (async () => {
98
+ const error = await switchGhAccount(next)
99
+ setSwitching(false)
100
+ if (error !== null) {
101
+ toast.error(error)
102
+ return
103
+ }
104
+ setAccounts(await listGhAccounts())
105
+ // The poller backs off to two minutes on errors; don't make the user wait
106
+ // it out to find out whether the account they picked was the right one.
107
+ if (projectPath != null && projectPath !== '') await refreshPrStatus(projectPath)
108
+ })()
109
+ }, [next, projectPath, switching])
110
+
111
+ return (
112
+ <box
113
+ flexGrow={1}
114
+ flexDirection="column"
115
+ alignItems="center"
116
+ gap={1}
117
+ backgroundColor={bg}
118
+ padding={1}
119
+ >
120
+ <box flexDirection="column" alignItems="center">
121
+ <text selectable={false} fg={t.error} bg={bg}>
122
+ ✗ {message}
123
+ </text>
124
+ {active !== undefined ? (
125
+ <text selectable={false} fg={t.textMuted} bg={bg}>
126
+ signed in as {active.user}
127
+ </text>
128
+ ) : null}
129
+ </box>
130
+ {next !== null ? (
131
+ <text
132
+ selectable={false}
133
+ fg={switching ? t.warning : t.primary}
134
+ bg={bg}
135
+ wrapMode="none"
136
+ onMouseDown={onSwitch}
137
+ >
138
+ {switching ? `${spinner} switching…` : `↺ Switch to ${next.user}`}
139
+ </text>
140
+ ) : null}
141
+ </box>
142
+ )
143
+ })
144
+
54
145
  const CheckRow = memo(function CheckRow({
55
146
  bg,
56
147
  check,
@@ -97,8 +188,10 @@ const CheckRow = memo(function CheckRow({
97
188
 
98
189
  export const PrChecksPanel = memo(function PrChecksPanel({
99
190
  contentWidth,
191
+ projectPath,
100
192
  }: {
101
193
  contentWidth: number
194
+ projectPath: string | undefined
102
195
  }) {
103
196
  const t = useTheme()
104
197
  // A tone apart from the PR state row above, so the two zones read as
@@ -113,6 +206,10 @@ export const PrChecksPanel = memo(function PrChecksPanel({
113
206
  const checks = result?.kind === 'ok' ? result.checks : []
114
207
  const spinner = useBusySpinner(checks.some((c) => c.state === 'pending'))
115
208
 
209
+ if (result?.kind === 'error') {
210
+ return <GhErrorState bg={bg} message={result.message} projectPath={projectPath} />
211
+ }
212
+
116
213
  const status = placeholder(result, t)
117
214
  if (status !== null || result?.kind !== 'ok') {
118
215
  return (