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..169a805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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) + - zsh tab completion for `codev` (subcommands + account labels); MAS builds skip the `codev()` launcher (sandboxed + no `ELECTRON_RUN_AS_NODE`) + ## 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 5c230e6..a92f38f 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -325,6 +325,24 @@ 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. 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 OAuth is interactive (browser) — CodeV cannot automate it, only orchestrate it. @@ -436,7 +454,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..2d701cb 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,28 @@ 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). 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', + [ + 'src/cli/account-manager.ts', + 'src/cli/codev-account.ts', + '--outDir', + 'resources/cli', + '--module', + 'commonjs', + '--target', + 'es2020', + '--esModuleInterop', + '--skipLibCheck', + ], + { stdio: 'inherit' }, + ); }, }, packagerConfig: { @@ -37,6 +60,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..ea89425 100644 --- a/src/cli/account-manager.test.ts +++ b/src/cli/account-manager.test.ts @@ -125,6 +125,42 @@ 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('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 rm 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({ + 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 ac15c93..a335e7d 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -36,6 +36,8 @@ 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) + appExec?: string; // full app-binary path (survives .app renames) accounts: RegistryAccount[]; } @@ -285,10 +287,100 @@ 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)); + // 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( + '# 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 "${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 rm 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'); } +/** + * 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, appExec?: string): boolean { + if (!fs.existsSync(REGISTRY_PATH)) return false; + const reg = readRegistry(); + if (reg.appPath === appPath && reg.appExec === appExec) return false; + reg.appPath = appPath; + if (appExec) reg.appExec = appExec; + writeRegistry(reg); + 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/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..243216c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -630,6 +630,42 @@ 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) + ) { + 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()) { + // 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. + // 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 +693,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 +706,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 +717,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 +735,7 @@ ipcMain.handle( error: `Unknown shell-hook action "${String(action)}"`, }; } + if (action === 'install') syncAccountsAppPath(); const result = action === 'install' ? accountManager.installShellHook() @@ -1253,6 +1295,11 @@ 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 + // (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(() => { if (!ptyProcess) { diff --git a/src/popup.tsx b/src/popup.tsx index e2fa6cb..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 { @@ -160,7 +162,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 { @@ -763,6 +767,30 @@ const PopupDefaultExample = ({ {/* Accounts tab */} {settingsTab === 'accounts' && (
+
+ Claude Code (Anthropic) accounts — each launches claude with its + own config dir +
+ {/* Only after a successful load — avoids flashing while the IPC + call is in flight and contradicting an error message. */} + {accountsLoaded && !accountsError && 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) => (