From 234c137406f09c751b9e2a5ca0f18977bad2a2c9 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 00:45:06 +0800 Subject: [PATCH 01/10] =?UTF-8?q?docs(multi-account):=20supported=20shells?= =?UTF-8?q?/terminals=20note=20(=C2=A76.C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zsh only for the rc install (accounts.sh itself is bash-compatible, fish unsupported); account-aware resume covers all CodeV terminals; VS Code sessions can't switch accounts (#121). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- docs/multi-account-support-design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index 5c230e6..1cbd83b 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -325,6 +325,16 @@ detected rc, with **(c)** as manual fallback. Mirrors the existing Session-Statu toggle (write file with `0o755`, register a reference, idempotent, removable). Detect shell; warn if not zsh/bash. +**Supported environments (current implementation, Batch 2a/2b):** the Install button / +`codev account install` writes only `~/.zshrc` → **zsh** is the supported shell (the +macOS default). The generated `accounts.sh` is bash-compatible — bash users can +`source` it manually from `.bashrc` — while **fish is unsupported** (different function +syntax; the `env`-prefixed commands CodeV injects at resume do parse in fish ≥3.1, but +the dispatcher/whoami functions won't load there). Account-aware resume/launch covers +every terminal CodeV supports (iTerm2, Terminal.app, Ghostty, cmux, embedded Term tab); +VS Code sessions can't be account-switched (grimmerk/codev#121). rc-file shell +detection remains future work. + ### 6.D Add-account (login) bootstrap OAuth is interactive (browser) — CodeV cannot automate it, only orchestrate it. From a712a25fbfe680f26fb3bce2097757a2c6a42f93 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:00:46 +0800 Subject: [PATCH 02/10] feat(multi-account): codev command via bundled CLI (Batch 2b part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codev account ` now works in the terminal without a repo checkout: - accounts.sh gains a codev() launcher that runs the CLI bundled inside CodeV.app with ELECTRON_RUN_AS_NODE (app binary doubles as Node — no system Node, no sudo, no PATH edits; rides the existing shell integration) - the app records its real .app bundle root in the registry (appPath) on every launch and regenerates accounts.sh, so moving the app self-heals the path and generator updates propagate automatically (no-op for single-account users — no registry, no writes) - forge generateAssets tsc-compiles src/cli/*.ts into resources/cli (new extraResource); the CLI accepts the `account` prefix so both `codev account list` and dev `yarn account list` work Verified locally: simulated the packaged invocation via the dev Electron binary (ELECTRON_RUN_AS_NODE=1 Electron resources/cli/ codev-account.js account list) against the real registry. tsc 0; 15/15 tests (+2 for codev() emission). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- .gitignore | 1 + CHANGELOG.md | 7 ++++++ docs/multi-account-support-design.md | 8 ++++++- forge.config.ts | 22 +++++++++++++++++ package.json | 2 +- src/cli/account-manager.test.ts | 12 ++++++++++ src/cli/account-manager.ts | 35 ++++++++++++++++++++++++++++ src/cli/codev-account.ts | 6 ++++- src/main.ts | 18 ++++++++++++++ 9 files changed, 108 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index fc011be..356639e 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ src/build.json # Claude Code worktrees .claude/worktrees/ +resources/cli diff --git a/CHANGELOG.md b/CHANGELOG.md index 102a306..c680da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.0.79 + +- Feat: multi-account Batch 2b (part 2) — `codev` command in your shell + - `codev account list|add|default|remove|regenerate|show|install|uninstall` works in the terminal: `accounts.sh` gains a `codev()` launcher that runs the CLI bundled inside CodeV.app via `ELECTRON_RUN_AS_NODE` (no system Node required) + - CodeV records its real `.app` location on every launch and refreshes `accounts.sh` — moving the app self-heals, and generator updates propagate without manual `regenerate` + - The CLI is compiled into the app at build time (`resources/cli` extraResource) + ## 1.0.78 - Feat: multi-account Batch 2b (part 1) — Accounts tab in Settings diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index 1cbd83b..4588600 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -446,7 +446,13 @@ and **(d)** for CodeV, backed by the same `projectMap` in the registry so both a - *2b (UI ✅, branch `feat/accounts-settings-ui`):* Accounts tab in Settings — list/add/remove + set-default + shell-integration toggle (§6.D), as IPC wrappers over the shared manager (one generator with the CLI). Rename deferred (labels name - dirs; remove+add covers it). Remaining 2b item: a real `codev` binary on PATH. + dirs; remove+add covers it). + - *2b part 2 (✅, branch `feat/codev-path-binary`):* the `codev` command — a + `codev()` function in accounts.sh runs the CLI bundled inside CodeV.app via + `ELECTRON_RUN_AS_NODE` (no system Node; no sudo/PATH edits). The app records its + real `.app` location in the registry (`appPath`) on every launch and refreshes + accounts.sh, so app moves and generator updates self-heal. CLI is tsc-compiled + into `resources/cli` (extraResource) at build time. - *2c:* account picker / per-launch override (§6.G, 4.2). - *2d:* pyenv-style folder auto-switch + terminal-side resume-to-right-account (§6.H). - *2e:* configurable global-default (bare `claude` → chosen account) — last, since it diff --git a/forge.config.ts b/forge.config.ts index 622b25b..8f2af69 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -9,6 +9,7 @@ import { mainConfig } from './webpack.main.config'; import { preloadConfig } from './webpack.preload.config'; import { rendererConfig } from './webpack.renderer.config'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; // Ensure compatibility with Electron Forge v7 @@ -21,6 +22,26 @@ const config: ForgeConfig = { BUILD_TYPE: process.env.BUILD_TYPE, }), ); + // Compile the codev account CLI (plain fs/os/path TS — no bundling + // needed) into resources/cli. Shipped via extraResource below and run + // through the app binary with ELECTRON_RUN_AS_NODE (see accounts.sh + // codev() launcher). + execFileSync( + './node_modules/.bin/tsc', + [ + 'src/cli/account-manager.ts', + 'src/cli/codev-account.ts', + '--outDir', + 'resources/cli', + '--module', + 'commonjs', + '--target', + 'es2020', + '--esModuleInterop', + '--skipLibCheck', + ], + { stdio: 'inherit' }, + ); }, }, packagerConfig: { @@ -37,6 +58,7 @@ const config: ForgeConfig = { '../node_modules', 'images/MenuBar.png', 'images/MenuBar@2x.png', + 'resources/cli', ], extendInfo: { LSUIElement: true, diff --git a/package.json b/package.json index 6c513c8..d7b2758 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.78", + "version": "1.0.79", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/cli/account-manager.test.ts b/src/cli/account-manager.test.ts index 2b5c4e5..1c488cc 100644 --- a/src/cli/account-manager.test.ts +++ b/src/cli/account-manager.test.ts @@ -125,6 +125,18 @@ describe('generateAccountsSh', () => { expect(() => generateAccountsSh(bad)).toThrow(/Unsafe shell characters/); }); + it('emits a codev() launcher when appPath is recorded', () => { + const sh = generateAccountsSh(reg({ appPath: '/Applications/CodeV.app' })); + expect(sh).toContain('codev() {'); + expect(sh).toContain( + 'ELECTRON_RUN_AS_NODE=1 "/Applications/CodeV.app/Contents/MacOS/CodeV" "/Applications/CodeV.app/Contents/Resources/cli/codev-account.js" "$@"', + ); + }); + + it('omits the codev() launcher when no appPath is recorded', () => { + expect(generateAccountsSh(reg())).not.toContain('codev()'); + }); + it('rejects a shell-unsafe label (hand-edited registry)', () => { const bad = reg({ defaultAccount: 'x', diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index ac15c93..4145c97 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -36,6 +36,7 @@ export interface RegistryAccount { export interface Registry { version: number; defaultAccount?: string; // label bare `claude` resolves to + appPath?: string; // CodeV.app bundle root (recorded by the app on launch) accounts: RegistryAccount[]; } @@ -285,10 +286,44 @@ export function generateAccountsSh(reg: Registry): string { ' echo " identity: run claude-whoami, or claude auth status"', ); L.push('}'); + if (reg.appPath) { + assertSafeDir(expandHome(reg.appPath)); + const appPath = toShellPath(expandHome(reg.appPath)); + const exe = path.basename(expandHome(reg.appPath), '.app'); + L.push(''); + L.push('# codev CLI — the account manager bundled inside the CodeV app.'); + L.push( + '# ELECTRON_RUN_AS_NODE runs the app binary as plain Node (no system', + ); + L.push( + '# Node needed). CodeV refreshes this file on every launch, so a moved', + ); + L.push('# app self-heals the path below.'); + L.push('codev() {'); + L.push( + ` ELECTRON_RUN_AS_NODE=1 "${appPath}/Contents/MacOS/${exe}" "${appPath}/Contents/Resources/cli/codev-account.js" "$@"`, + ); + L.push('}'); + } L.push(''); return L.join('\n'); } +/** + * Record where the CodeV.app bundle lives (called by the app on launch, with + * the app's real location — wherever the user installed/moved it). No-op + * unless a registry already exists (single-account users never get one). + * Returns true when the stored path changed. + */ +export function setAppPath(appPath: string): boolean { + if (!fs.existsSync(REGISTRY_PATH)) return false; + const reg = readRegistry(); + if (reg.appPath === appPath) return false; + reg.appPath = appPath; + writeRegistry(reg); + return true; +} + export function regenerate(reg?: Registry): string { const r = reg || readRegistry(); fs.mkdirSync(CONFIG_DIR, { recursive: true }); diff --git a/src/cli/codev-account.ts b/src/cli/codev-account.ts index 9ab7aca..515d87c 100644 --- a/src/cli/codev-account.ts +++ b/src/cli/codev-account.ts @@ -65,7 +65,11 @@ function printList(): void { } function main(): number { - const [cmd, ...rest] = process.argv.slice(2); + const argv = process.argv.slice(2); + // Installed form is `codev account `; the dev form `yarn account ` + // passes directly. Accept both. + if (argv[0] === 'account') argv.shift(); + const [cmd, ...rest] = argv; switch (cmd) { case 'list': case 'ls': diff --git a/src/main.ts b/src/main.ts index 4985994..5cc7c86 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1253,6 +1253,24 @@ const trayToggleEvtHandler = async () => { // Initialize session status hooks (install if enabled, start watching) initSessionStatusHooks(); + // codev multi-account: keep accounts.sh in sync with this app install — + // record the real .app location (wherever the user put it, for the codev() + // CLI launcher) and refresh the generated file so generator updates and + // app moves self-heal. No-op for single-account users (no registry). + try { + if ( + app.isPackaged && + require('fs').existsSync(accountManager.REGISTRY_PATH) + ) { + // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root + const bundle = path.resolve(process.execPath, '..', '..', '..'); + accountManager.setAppPath(bundle); + accountManager.regenerate(); + } + } catch (e) { + console.error('[accounts] accounts.sh launch sync failed:', e); + } + // Pre-spawn terminal for faster first Terminal tab switch setTimeout(() => { if (!ptyProcess) { From aaf9b431d15b5ee0a67bfddfd04a7071bf847162 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:11:37 +0800 Subject: [PATCH 03/10] fix(multi-account): survive .app renames + label the Accounts tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry records appExec (the real binary path) alongside appPath; the codev() launcher prefers it — the executable inside Contents/MacOS keeps its name when the .app bundle is renamed, so deriving it from the bundle name broke on rename (user-spotted) - Accounts tab gains a header clarifying these are Claude Code (Anthropic) accounts, not CodeV accounts +1 test (16 total) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- src/cli/account-manager.test.ts | 12 ++++++++++++ src/cli/account-manager.ts | 20 ++++++++++++++++---- src/main.ts | 5 +++-- src/popup.tsx | 3 +++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/cli/account-manager.test.ts b/src/cli/account-manager.test.ts index 1c488cc..1beb52f 100644 --- a/src/cli/account-manager.test.ts +++ b/src/cli/account-manager.test.ts @@ -137,6 +137,18 @@ describe('generateAccountsSh', () => { expect(generateAccountsSh(reg())).not.toContain('codev()'); }); + it('uses the recorded appExec so a renamed .app bundle still works', () => { + const sh = generateAccountsSh( + reg({ + appPath: '/Applications/MyCodeV.app', + appExec: '/Applications/MyCodeV.app/Contents/MacOS/CodeV', + }), + ); + expect(sh).toContain( + 'ELECTRON_RUN_AS_NODE=1 "/Applications/MyCodeV.app/Contents/MacOS/CodeV" "/Applications/MyCodeV.app/Contents/Resources/cli/codev-account.js" "$@"', + ); + }); + it('rejects a shell-unsafe label (hand-edited registry)', () => { const bad = reg({ defaultAccount: 'x', diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index 4145c97..054af24 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -37,6 +37,7 @@ export interface Registry { version: number; defaultAccount?: string; // label bare `claude` resolves to appPath?: string; // CodeV.app bundle root (recorded by the app on launch) + appExec?: string; // full app-binary path (survives .app renames) accounts: RegistryAccount[]; } @@ -289,7 +290,17 @@ export function generateAccountsSh(reg: Registry): string { if (reg.appPath) { assertSafeDir(expandHome(reg.appPath)); const appPath = toShellPath(expandHome(reg.appPath)); - const exe = path.basename(expandHome(reg.appPath), '.app'); + // Prefer the recorded binary path: the executable inside Contents/MacOS + // keeps its original name ("CodeV") even if the user renames the .app + // bundle, so deriving it from the bundle name would break on rename. + let exec: string; + if (reg.appExec) { + assertSafeDir(expandHome(reg.appExec)); + exec = toShellPath(expandHome(reg.appExec)); + } else { + const exe = path.basename(expandHome(reg.appPath), '.app'); + exec = `${appPath}/Contents/MacOS/${exe}`; + } L.push(''); L.push('# codev CLI — the account manager bundled inside the CodeV app.'); L.push( @@ -301,7 +312,7 @@ export function generateAccountsSh(reg: Registry): string { L.push('# app self-heals the path below.'); L.push('codev() {'); L.push( - ` ELECTRON_RUN_AS_NODE=1 "${appPath}/Contents/MacOS/${exe}" "${appPath}/Contents/Resources/cli/codev-account.js" "$@"`, + ` ELECTRON_RUN_AS_NODE=1 "${exec}" "${appPath}/Contents/Resources/cli/codev-account.js" "$@"`, ); L.push('}'); } @@ -315,11 +326,12 @@ export function generateAccountsSh(reg: Registry): string { * unless a registry already exists (single-account users never get one). * Returns true when the stored path changed. */ -export function setAppPath(appPath: string): boolean { +export function setAppPath(appPath: string, appExec?: string): boolean { if (!fs.existsSync(REGISTRY_PATH)) return false; const reg = readRegistry(); - if (reg.appPath === appPath) return false; + if (reg.appPath === appPath && reg.appExec === appExec) return false; reg.appPath = appPath; + if (appExec) reg.appExec = appExec; writeRegistry(reg); return true; } diff --git a/src/main.ts b/src/main.ts index 5cc7c86..a606f09 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1262,9 +1262,10 @@ const trayToggleEvtHandler = async () => { app.isPackaged && require('fs').existsSync(accountManager.REGISTRY_PATH) ) { - // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root + // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root. + // Record the exec path too — it survives .app renames. const bundle = path.resolve(process.execPath, '..', '..', '..'); - accountManager.setAppPath(bundle); + accountManager.setAppPath(bundle, process.execPath); accountManager.regenerate(); } } catch (e) { diff --git a/src/popup.tsx b/src/popup.tsx index e2fa6cb..6b26661 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -763,6 +763,9 @@ const PopupDefaultExample = ({ {/* Accounts tab */} {settingsTab === 'accounts' && (
+
+ Claude Code (Anthropic) accounts — each launches claude with its own config dir +
{accounts.map((a) => (
From b4c82bdeb11ff64b07c97e84dfdb70a1594b9f37 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:14:19 +0800 Subject: [PATCH 04/10] fix(multi-account): sync appPath on account mutations + fresh-user UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncAccountsAppPath helper runs at launch AND after add/remove/ set-default/install — covers the fresh-install case where the registry is first created mid-run, so the very first accounts.sh already contains the codev() launcher (was: only after next launch) - add-notice tells fresh users to install Shell integration first when the ~/.zshrc block isn't present yet - empty Accounts list shows an explainer instead of a blank area Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- src/main.ts | 52 +++++++++++++++++++++++++++++++++------------------ src/popup.tsx | 10 +++++++++- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/main.ts b/src/main.ts index a606f09..9898b43 100644 --- a/src/main.ts +++ b/src/main.ts @@ -630,6 +630,30 @@ ipcMain.handle('get-home-dir', () => { // --- codev multi-account management (Settings → Accounts, Batch 2b) --- // Thin IPC wrappers over the shared manager (src/cli/account-manager.ts), // the same module the `codev account` CLI uses — one generator, no drift. + +/** + * Record this app install's real location in the registry (for the codev() + * launcher) and refresh accounts.sh. No-op without a registry (single-account + * users) or in dev. Called at launch AND after account mutations — the latter + * covers the fresh-install case where the registry is first created by an + * "Add account" while the app is already running. + */ +const syncAccountsAppPath = () => { + try { + if ( + app.isPackaged && + require('fs').existsSync(accountManager.REGISTRY_PATH) + ) { + // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root. + // Record the exec path too — it survives .app renames. + const bundle = path.resolve(process.execPath, '..', '..', '..'); + accountManager.setAppPath(bundle, process.execPath); + accountManager.regenerate(); + } + } catch (e) { + console.error('[accounts] accounts.sh app-path sync failed:', e); + } +}; ipcMain.handle('accounts-get', () => { try { return { @@ -657,6 +681,9 @@ ipcMain.handle('accounts-add', (_event, label: string) => { accountManager.resolveDefaultLabel(accountManager.readRegistry()) === added.label, }; + // First add creates the registry mid-run — record appPath now so the + // freshly generated accounts.sh already contains the codev() launcher. + syncAccountsAppPath(); return { ok: true, account }; } catch (error) { return { ok: false, error: (error as Error).message }; @@ -667,6 +694,7 @@ ipcMain.handle('accounts-remove', (_event, label: string) => { try { accountManager.removeAccount(label); invalidateAccountsCache(); + syncAccountsAppPath(); return { ok: true }; } catch (error) { return { ok: false, error: (error as Error).message }; @@ -677,6 +705,7 @@ ipcMain.handle('accounts-set-default', (_event, label: string) => { try { accountManager.setDefault(label); invalidateAccountsCache(); + syncAccountsAppPath(); return { ok: true }; } catch (error) { return { ok: false, error: (error as Error).message }; @@ -694,6 +723,7 @@ ipcMain.handle( error: `Unknown shell-hook action "${String(action)}"`, }; } + if (action === 'install') syncAccountsAppPath(); const result = action === 'install' ? accountManager.installShellHook() @@ -1253,24 +1283,10 @@ const trayToggleEvtHandler = async () => { // Initialize session status hooks (install if enabled, start watching) initSessionStatusHooks(); - // codev multi-account: keep accounts.sh in sync with this app install — - // record the real .app location (wherever the user put it, for the codev() - // CLI launcher) and refresh the generated file so generator updates and - // app moves self-heal. No-op for single-account users (no registry). - try { - if ( - app.isPackaged && - require('fs').existsSync(accountManager.REGISTRY_PATH) - ) { - // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root. - // Record the exec path too — it survives .app renames. - const bundle = path.resolve(process.execPath, '..', '..', '..'); - accountManager.setAppPath(bundle, process.execPath); - accountManager.regenerate(); - } - } catch (e) { - console.error('[accounts] accounts.sh launch sync failed:', e); - } + // codev multi-account: keep accounts.sh in sync with this app install + // (real .app location + latest generator template). Self-heals app + // moves/renames; no-op for single-account users. + syncAccountsAppPath(); // Pre-spawn terminal for faster first Terminal tab switch setTimeout(() => { diff --git a/src/popup.tsx b/src/popup.tsx index 6b26661..d3a8a00 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -160,7 +160,9 @@ const PopupDefaultExample = ({ if (r.ok) { setNewAccountLabel(''); setAccountsNotice( - `Added "${label}". Log in with: claude ${label} — then open a new shell (or source ~/.zshrc).`, + accountsShellInstalled + ? `Added "${label}". Log in with: claude ${label} — then open a new shell (or source ~/.zshrc).` + : `Added "${label}". Install Shell integration below, open a new shell, then log in with: claude ${label}.`, ); await refreshAccounts(); } else { @@ -766,6 +768,12 @@ const PopupDefaultExample = ({
Claude Code (Anthropic) accounts — each launches claude with its own config dir
+ {accounts.length === 0 && ( +
+ No accounts registered yet — your existing ~/.claude login stays + the default. Add a second account below to go multi-account. +
+ )} {accounts.map((a) => (
From 4b45f117964388f7c573b7b5e4369696d15ba2eb Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:18:12 +0800 Subject: [PATCH 05/10] fix(multi-account): clean resources/cli before compiling the CLI tsc never removes stale output, so a renamed source file would ship both old and new .js in the app bundle (cubic P3). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- forge.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/forge.config.ts b/forge.config.ts index 8f2af69..2d701cb 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -25,7 +25,9 @@ const config: ForgeConfig = { // Compile the codev account CLI (plain fs/os/path TS — no bundling // needed) into resources/cli. Shipped via extraResource below and run // through the app binary with ELECTRON_RUN_AS_NODE (see accounts.sh - // codev() launcher). + // codev() launcher). Clean first — tsc never removes stale output, so + // a renamed source would otherwise ship both old and new .js. + fs.rmSync('./resources/cli', { recursive: true, force: true }); execFileSync( './node_modules/.bin/tsc', [ From 73e9454f4852d237784737f1c5d1ea207db78ac9 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:26:16 +0800 Subject: [PATCH 06/10] fix(multi-account): don't advertise codev() on MAS builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAS builds are sandboxed and ELECTRON_RUN_AS_NODE is unsupported there, so a codev() launcher pointing at a MAS install could never run — skip recording appPath on MAS (same gating family as auto-update). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- src/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.ts b/src/main.ts index 9898b43..6b50343 100644 --- a/src/main.ts +++ b/src/main.ts @@ -640,8 +640,12 @@ ipcMain.handle('get-home-dir', () => { */ const syncAccountsAppPath = () => { try { + // Skip MAS builds: they're sandboxed and ELECTRON_RUN_AS_NODE is not + // supported there, so a codev() launcher pointing at a MAS install could + // never run — don't record appPath / advertise it. if ( app.isPackaged && + !isMAS() && require('fs').existsSync(accountManager.REGISTRY_PATH) ) { // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root. From e44910e8f064776d41095643caa9a8de8536f036 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:32:18 +0800 Subject: [PATCH 07/10] feat(multi-account): zsh tab completion for codev + MAS notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accounts.sh gains _codev completion: subcommands + account labels (baked in, refreshed on regenerate; remove offers only non-anchor labels). Registration guarded for zsh+compinit so the file stays bash-sourceable — verified with zsh -n AND bash -n - docs: MAS builds can't run the bundled CLI (sandbox + ELECTRON_RUN_AS_NODE unsupported) — appPath skipped there Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- CHANGELOG.md | 1 + docs/multi-account-support-design.md | 10 +++++++++- src/cli/account-manager.test.ts | 12 +++++++++++ src/cli/account-manager.ts | 30 ++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c680da5..169a805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `codev account list|add|default|remove|regenerate|show|install|uninstall` works in the terminal: `accounts.sh` gains a `codev()` launcher that runs the CLI bundled inside CodeV.app via `ELECTRON_RUN_AS_NODE` (no system Node required) - CodeV records its real `.app` location on every launch and refreshes `accounts.sh` — moving the app self-heals, and generator updates propagate without manual `regenerate` - The CLI is compiled into the app at build time (`resources/cli` extraResource) + - zsh tab completion for `codev` (subcommands + account labels); MAS builds skip the `codev()` launcher (sandboxed + no `ELECTRON_RUN_AS_NODE`) ## 1.0.78 diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index 4588600..a92f38f 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -333,7 +333,15 @@ syntax; the `env`-prefixed commands CodeV injects at resume do parse in fish ≥ the dispatcher/whoami functions won't load there). Account-aware resume/launch covers every terminal CodeV supports (iTerm2, Terminal.app, Ghostty, cmux, embedded Term tab); VS Code sessions can't be account-switched (grimmerk/codev#121). rc-file shell -detection remains future work. +detection remains future work. accounts.sh also ships zsh tab completion for `codev` +(subcommands + account labels, baked in and refreshed on regenerate; registration is +guarded so the file stays bash-sourceable). + +**MAS builds:** the Mac App Store variant is sandboxed and `ELECTRON_RUN_AS_NODE` is +unsupported there, so the bundled `codev` CLI can never run from a MAS install — CodeV +skips recording `appPath` on MAS (no broken `codev()` is advertised). The wider +`~/.claude`-reading feature set has the same sandbox constraint; academic today, since +no MAS build with the Claude-sessions feature set has shipped. ### 6.D Add-account (login) bootstrap diff --git a/src/cli/account-manager.test.ts b/src/cli/account-manager.test.ts index 1beb52f..0f2ac79 100644 --- a/src/cli/account-manager.test.ts +++ b/src/cli/account-manager.test.ts @@ -137,6 +137,18 @@ describe('generateAccountsSh', () => { expect(generateAccountsSh(reg())).not.toContain('codev()'); }); + it('emits zsh completion for codev with baked-in labels', () => { + const sh = generateAccountsSh(reg({ appPath: '/Applications/CodeV.app' })); + expect(sh).toContain('_codev() {'); + expect(sh).toContain('compdef _codev codev'); + expect(sh).toContain( + 'compadd list add default remove regenerate show install uninstall help', + ); + expect(sh).toContain('default) compadd personal work ;;'); + // anchor (personal) is not removable + expect(sh).toContain('remove|rm) compadd work ;;'); + }); + it('uses the recorded appExec so a renamed .app bundle still works', () => { const sh = generateAccountsSh( reg({ diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index 054af24..9765932 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -315,6 +315,36 @@ export function generateAccountsSh(reg: Registry): string { ` ELECTRON_RUN_AS_NODE=1 "${exec}" "${appPath}/Contents/Resources/cli/codev-account.js" "$@"`, ); L.push('}'); + // Tab completion (zsh). Labels are baked in and refresh on regenerate, + // like everything else in this file. The function body is only PARSED by + // bash (never registered/executed there), so the file stays + // bash-sourceable; registration is guarded for zsh + a loaded compinit. + const allLabels = accounts.map((a) => a.label).join(' '); + const removable = accounts + .filter((a) => !a.isDefault) + .map((a) => a.label) + .join(' '); + L.push(''); + L.push('# Tab completion for codev (zsh; needs compinit loaded).'); + L.push('_codev() {'); + L.push(' if (( CURRENT == 2 )); then'); + L.push(' compadd account'); + L.push(' elif (( CURRENT == 3 )) && [ "${words[2]}" = "account" ]; then'); + L.push( + ' compadd list add default remove regenerate show install uninstall help', + ); + L.push(' elif (( CURRENT == 4 )) && [ "${words[2]}" = "account" ]; then'); + L.push(' case "${words[3]}" in'); + if (allLabels) L.push(` default) compadd ${allLabels} ;;`); + if (removable) L.push(` remove|rm) compadd ${removable} ;;`); + L.push(' esac'); + L.push(' fi'); + L.push('}'); + L.push( + 'if [ -n "${ZSH_VERSION:-}" ] && (( ${+functions[compdef]} )); then', + ); + L.push(' compdef _codev codev'); + L.push('fi'); } L.push(''); return L.join('\n'); From 4614c2872b5a14ce5a133b63486cbe7d87218e78 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:35:03 +0800 Subject: [PATCH 08/10] style(multi-account): prettier-format the Accounts tab header JSX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflow the header + empty-state blocks per the review's ESLint output (changed lines only — the file as a whole predates the prettier rule). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- src/popup.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/popup.tsx b/src/popup.tsx index d3a8a00..4a61a37 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -765,11 +765,24 @@ const PopupDefaultExample = ({ {/* Accounts tab */} {settingsTab === 'accounts' && (
-
- Claude Code (Anthropic) accounts — each launches claude with its own config dir +
+ Claude Code (Anthropic) accounts — each launches claude with its + own config dir
{accounts.length === 0 && ( -
+
No accounts registered yet — your existing ~/.claude login stays the default. Add a second account below to go multi-account.
From d7700592d7462ef80b0f1cc020d7ce7b7a3a5aee Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 01:49:09 +0800 Subject: [PATCH 09/10] fix(multi-account): address cubic round 3 (Accounts tab + MAS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - empty-state renders only after a successful load and never alongside an error (accountsLoaded flag) — no flash while the IPC is in flight - MAS: clear stale appPath/appExec left by a previous direct-download install and regenerate, fully suppressing the codev() launcher - completion: add the rm alias to the subcommand compadd list Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- src/cli/account-manager.test.ts | 2 +- src/cli/account-manager.ts | 17 ++++++++++++++++- src/main.ts | 26 +++++++++++++++----------- src/popup.tsx | 6 +++++- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/cli/account-manager.test.ts b/src/cli/account-manager.test.ts index 0f2ac79..ea89425 100644 --- a/src/cli/account-manager.test.ts +++ b/src/cli/account-manager.test.ts @@ -142,7 +142,7 @@ describe('generateAccountsSh', () => { expect(sh).toContain('_codev() {'); expect(sh).toContain('compdef _codev codev'); expect(sh).toContain( - 'compadd list add default remove regenerate show install uninstall help', + 'compadd list add default remove rm regenerate show install uninstall help', ); expect(sh).toContain('default) compadd personal work ;;'); // anchor (personal) is not removable diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index 9765932..a335e7d 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -331,7 +331,7 @@ export function generateAccountsSh(reg: Registry): string { L.push(' compadd account'); L.push(' elif (( CURRENT == 3 )) && [ "${words[2]}" = "account" ]; then'); L.push( - ' compadd list add default remove regenerate show install uninstall help', + ' compadd list add default remove rm regenerate show install uninstall help', ); L.push(' elif (( CURRENT == 4 )) && [ "${words[2]}" = "account" ]; then'); L.push(' case "${words[3]}" in'); @@ -366,6 +366,21 @@ export function setAppPath(appPath: string, appExec?: string): boolean { return true; } +/** + * Remove recorded app metadata. MAS builds call this so a stale appPath from + * a previous direct-download install can't keep advertising a codev() + * launcher that can't run under the MAS sandbox. + */ +export function clearAppPath(): boolean { + if (!fs.existsSync(REGISTRY_PATH)) return false; + const reg = readRegistry(); + if (reg.appPath === undefined && reg.appExec === undefined) return false; + delete reg.appPath; + delete reg.appExec; + writeRegistry(reg); + return true; +} + export function regenerate(reg?: Registry): string { const r = reg || readRegistry(); fs.mkdirSync(CONFIG_DIR, { recursive: true }); diff --git a/src/main.ts b/src/main.ts index 6b50343..a1aa2ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -640,20 +640,24 @@ ipcMain.handle('get-home-dir', () => { */ const syncAccountsAppPath = () => { try { - // Skip MAS builds: they're sandboxed and ELECTRON_RUN_AS_NODE is not - // supported there, so a codev() launcher pointing at a MAS install could - // never run — don't record appPath / advertise it. if ( - app.isPackaged && - !isMAS() && - require('fs').existsSync(accountManager.REGISTRY_PATH) + !app.isPackaged || + !require('fs').existsSync(accountManager.REGISTRY_PATH) ) { - // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root. - // Record the exec path too — it survives .app renames. - const bundle = path.resolve(process.execPath, '..', '..', '..'); - accountManager.setAppPath(bundle, process.execPath); - accountManager.regenerate(); + return; + } + // MAS builds are sandboxed and ELECTRON_RUN_AS_NODE is unsupported there, + // so the codev() launcher can never run. Clear any appPath left over from + // a previous direct-download install so it stops being advertised. + if (isMAS()) { + if (accountManager.clearAppPath()) accountManager.regenerate(); + return; } + // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root. + // Record the exec path too — it survives .app renames. + const bundle = path.resolve(process.execPath, '..', '..', '..'); + accountManager.setAppPath(bundle, process.execPath); + accountManager.regenerate(); } catch (e) { console.error('[accounts] accounts.sh app-path sync failed:', e); } diff --git a/src/popup.tsx b/src/popup.tsx index 4a61a37..1cc7365 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -104,6 +104,7 @@ const PopupDefaultExample = ({ loggedIn?: boolean; }; const [accounts, setAccounts] = useState([]); + const [accountsLoaded, setAccountsLoaded] = useState(false); const [accountsShellInstalled, setAccountsShellInstalled] = useState(false); const [accountsError, setAccountsError] = useState(''); const [accountsNotice, setAccountsNotice] = useState(''); @@ -115,6 +116,7 @@ const PopupDefaultExample = ({ const r = await window.electronAPI.getAccounts(); if (r.ok) { setAccounts((r.accounts as AccountRow[]) || []); + setAccountsLoaded(true); setAccountsShellInstalled(!!r.shellInstalled); setAccountsError(''); } else { @@ -775,7 +777,9 @@ const PopupDefaultExample = ({ Claude Code (Anthropic) accounts — each launches claude with its own config dir
- {accounts.length === 0 && ( + {/* Only after a successful load — avoids flashing while the IPC + call is in flight and contradicting an error message. */} + {accountsLoaded && !accountsError && accounts.length === 0 && (
Date: Fri, 10 Jul 2026 02:03:26 +0800 Subject: [PATCH 10/10] fix(multi-account): MAS path regenerates unconditionally clearAppPath()-gated regeneration could leave a stale accounts.sh forever (registry already clean but a prior regenerate failed). Match the non-MAS branch: always regenerate when a registry exists. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- src/main.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index a1aa2ac..243216c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -650,7 +650,11 @@ const syncAccountsAppPath = () => { // so the codev() launcher can never run. Clear any appPath left over from // a previous direct-download install so it stops being advertised. if (isMAS()) { - if (accountManager.clearAppPath()) accountManager.regenerate(); + // Unconditional regenerate (like the non-MAS branch): even when the + // registry is already clean, accounts.sh itself may still be stale + // (e.g. a prior clear succeeded but its regenerate failed). + accountManager.clearAppPath(); + accountManager.regenerate(); return; } // process.execPath = …/CodeV.app/Contents/MacOS/CodeV → bundle root.