diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed630e..102a306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.0.78 + +- Feat: multi-account Batch 2b (part 1) — Accounts tab in Settings + - List accounts (identity, default marker), add (with log-in hint), remove, and set the global default — via IPC over the same shared generator the `codev account` CLI uses + - Shell-integration install/uninstall (`~/.zshrc` source block) from the UI + - Remaining 2b item: a real `codev` binary on PATH + ## 1.0.77 - Feat: multi-account Batch 2a + 2e — `codev account` CLI + configurable global-default diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index 7744f4a..5c230e6 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -433,8 +433,10 @@ and **(d)** for CodeV, backed by the same `projectMap` in the registry so both a - **Batch 2 — in progress (order 2a→2e):** - *2a — ✅ Done* (branch `feat/codev-account-cli`): `codev account` CLI generates the registry + `accounts.sh` from one shared generator (§6.A/B); Vitest + CI added. - - *2b:* manage-accounts Settings tab — list/add/rename/remove + shell-integration - toggle (§6.D), plus a real `codev` binary on PATH. + - *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. - *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/package.json b/package.json index 0e00ef2..6c513c8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.77", + "version": "1.0.78", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/cli/account-manager.ts b/src/cli/account-manager.ts index 1709006..ac15c93 100644 --- a/src/cli/account-manager.ts +++ b/src/cli/account-manager.ts @@ -319,6 +319,23 @@ export function addAccount( ); assertSafeDir(dir); const isDefault = isDefaultDir(dir); + // Fresh registry + adding an EXTRA account: seed the anchor (~/.claude) + // account first, so the machine's existing default install stays registered + // and keeps the global default (instead of it silently moving to a + // brand-new, not-yet-logged-in account). + if (reg.accounts.length === 0 && !isDefault) { + const anchorLabel = label === 'personal' ? 'default' : 'personal'; + const anchorIdentity = path.join(os.homedir(), '.claude.json'); + reg.accounts.push({ + label: anchorLabel, + dir: defaultDir(), + identityFile: anchorIdentity, + configDirEnv: null, + isDefault: true, + ...readIdentity(anchorIdentity), + }); + reg.defaultAccount = anchorLabel; + } const identityFile = isDefault ? path.join(os.homedir(), '.claude.json') : path.join(dir, '.claude.json'); diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index f021ec9..3fa2c34 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -2,9 +2,40 @@ type IpcCallback = (event: Electron.IpcRendererEvent, ...args: any[]) => void; +/** One configured Claude Code account (codev multi-account). */ +interface CodevAccountInfo { + label: string; + dir: string; + isDefault: boolean; // the anchor ~/.claude account + isCurrentDefault: boolean; // what bare `claude` resolves to + email?: string; + org?: string; + loggedIn?: boolean; +} + interface IElectronAPI { // App actions getHomeDir: () => Promise; + // codev multi-account (Settings → Accounts) + getAccounts: () => Promise<{ + ok: boolean; + error?: string; + accounts?: CodevAccountInfo[]; + shellInstalled?: boolean; + }>; + addAccount: (label: string) => Promise<{ + ok: boolean; + error?: string; + account?: CodevAccountInfo; + }>; + removeAccount: (label: string) => Promise<{ ok: boolean; error?: string }>; + setDefaultAccount: (label: string) => Promise<{ ok: boolean; error?: string }>; + setAccountsShellHook: (action: 'install' | 'uninstall') => Promise<{ + ok: boolean; + error?: string; + changed?: boolean; + path?: string; + }>; getBannerSeen: () => Promise; setBannerSeen: () => void; invokeVSCode: (path: string, option: string) => void; diff --git a/src/main.ts b/src/main.ts index 6238ae7..4985994 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,6 +43,8 @@ import { readVSCodeIndex, SessionStatus, } from './session-status-hooks'; +import * as accountManager from './cli/account-manager'; +import { invalidateAccountsCache } from './accounts'; import { deleteRecentProjectRecord, openVSCodeBasedIDE, @@ -625,6 +627,84 @@ ipcMain.handle('get-home-dir', () => { return require('os').homedir(); }); +// --- 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. +ipcMain.handle('accounts-get', () => { + try { + return { + ok: true, + accounts: accountManager.listAccounts(), + shellInstalled: accountManager.isShellHookInstalled(), + }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } +}); + +ipcMain.handle('accounts-add', (_event, label: string) => { + try { + const added = accountManager.addAccount(label); + invalidateAccountsCache(); + // Return the enriched (listed) shape so it matches CodevAccountInfo + // (isCurrentDefault etc.), not the raw registry entry. The fallback + // derives isCurrentDefault too, keeping the IPC contract intact. + const account = accountManager + .listAccounts() + .find((a) => a.label === added.label) || { + ...added, + isCurrentDefault: + accountManager.resolveDefaultLabel(accountManager.readRegistry()) === + added.label, + }; + return { ok: true, account }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } +}); + +ipcMain.handle('accounts-remove', (_event, label: string) => { + try { + accountManager.removeAccount(label); + invalidateAccountsCache(); + return { ok: true }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } +}); + +ipcMain.handle('accounts-set-default', (_event, label: string) => { + try { + accountManager.setDefault(label); + invalidateAccountsCache(); + return { ok: true }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } +}); + +ipcMain.handle( + 'accounts-shell-hook', + (_event, action: 'install' | 'uninstall') => { + try { + // Validate: a malformed payload must fail, not silently uninstall. + if (action !== 'install' && action !== 'uninstall') { + return { + ok: false, + error: `Unknown shell-hook action "${String(action)}"`, + }; + } + const result = + action === 'install' + ? accountManager.installShellHook() + : accountManager.uninstallShellHook(); + return { ok: true, changed: result.changed, path: result.path }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } + }, +); + ipcMain.on('hide-app', (event) => { hideSwitcherWindow(); }); diff --git a/src/popup.tsx b/src/popup.tsx index 825e16c..e2fa6cb 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -41,6 +41,17 @@ const labelStyle: React.CSSProperties = { color: THEME.text.primary, }; +const smallButtonStyle: React.CSSProperties = { + backgroundColor: '#333', + color: THEME.text.primary, + border: '1px solid #555', + borderRadius: '4px', + padding: '3px 10px', + fontSize: '12px', + cursor: 'pointer', + outline: 'none', +}; + const PopupDefaultExample = ({ workingFolderPath, saveCallback, @@ -53,7 +64,7 @@ const PopupDefaultExample = ({ saveCallback?: (key: string, value: string) => void; openCallback?: any; switcherMode?: string; - openToTab?: 'general' | 'sessions' | 'shortcuts' | null; + openToTab?: 'general' | 'sessions' | 'accounts' | 'shortcuts' | null; onOpenToTabConsumed?: () => void; }) => { const [isOpen, setIsOpen] = useState(false); @@ -69,7 +80,7 @@ const PopupDefaultExample = ({ const [isMASBuild, setIsMASBuild] = useState(false); const [sessionStatusHooks, setSessionStatusHooks] = useState(true); const [appModeState, setAppModeState] = useState('normal'); - const [settingsTab, setSettingsTab] = useState<'general' | 'sessions' | 'shortcuts'>('general'); + const [settingsTab, setSettingsTab] = useState<'general' | 'sessions' | 'accounts' | 'shortcuts'>('general'); const [ideDataAccessGranted, setIdeDataAccessGranted] = useState(false); const [shortcuts, setShortcuts] = useState({ quickSwitcher: 'Command+Control+R', @@ -83,6 +94,133 @@ const PopupDefaultExample = ({ const [updateReleaseName, setUpdateReleaseName] = useState(''); const [updateTimer, setUpdateTimer] = useState | null>(null); + // --- Accounts tab (codev multi-account, Batch 2b) --- + type AccountRow = { + label: string; + dir: string; + isDefault: boolean; + isCurrentDefault: boolean; + email?: string; + loggedIn?: boolean; + }; + const [accounts, setAccounts] = useState([]); + const [accountsShellInstalled, setAccountsShellInstalled] = useState(false); + const [accountsError, setAccountsError] = useState(''); + const [accountsNotice, setAccountsNotice] = useState(''); + const [newAccountLabel, setNewAccountLabel] = useState(''); + const [accountsBusy, setAccountsBusy] = useState(false); + + const refreshAccounts = async () => { + try { + const r = await window.electronAPI.getAccounts(); + if (r.ok) { + setAccounts((r.accounts as AccountRow[]) || []); + setAccountsShellInstalled(!!r.shellInstalled); + setAccountsError(''); + } else { + setAccountsNotice(''); + setAccountsError(r.error || 'Failed to load accounts'); + } + } catch (e) { + setAccountsNotice(''); + setAccountsError((e as Error).message || 'Failed to load accounts'); + } + }; + + useEffect(() => { + if (isOpen && settingsTab === 'accounts') { + // Fresh view: drop any stale notice/error from a previous visit. + setAccountsError(''); + setAccountsNotice(''); + refreshAccounts(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, settingsTab]); + + /** Serialize account mutations: busy-guard + shared error handling. */ + const runAccountOp = async (op: () => Promise) => { + if (accountsBusy) return; + setAccountsBusy(true); + setAccountsError(''); + try { + await op(); + } catch (e) { + setAccountsNotice(''); + setAccountsError((e as Error).message || 'Account operation failed'); + } finally { + setAccountsBusy(false); + } + }; + + const handleAddAccount = () => { + const label = newAccountLabel.trim(); + if (!label) return; + runAccountOp(async () => { + const r = await window.electronAPI.addAccount(label); + if (r.ok) { + setNewAccountLabel(''); + setAccountsNotice( + `Added "${label}". Log in with: claude ${label} — then open a new shell (or source ~/.zshrc).`, + ); + await refreshAccounts(); + } else { + setAccountsNotice(''); + setAccountsError(r.error || 'Failed to add account'); + } + }); + }; + + const handleRemoveAccount = (label: string) => { + runAccountOp(async () => { + const r = await window.electronAPI.removeAccount(label); + if (r.ok) { + setAccountsNotice(`Removed "${label}" (its folder stays on disk).`); + await refreshAccounts(); + } else { + setAccountsNotice(''); + setAccountsError(r.error || 'Failed to remove account'); + } + }); + }; + + const handleSetDefaultAccount = (label: string) => { + runAccountOp(async () => { + const r = await window.electronAPI.setDefaultAccount(label); + if (r.ok) { + // Without the ~/.zshrc shell integration, the saved default isn't + // live in shells yet — say so instead of overpromising. + setAccountsNotice( + accountsShellInstalled + ? `Bare claude now resolves to "${label}" — open a new shell (or source ~/.zshrc).` + : `Default saved as "${label}" — install Shell integration below so bare claude uses it.`, + ); + await refreshAccounts(); + } else { + setAccountsNotice(''); + setAccountsError(r.error || 'Failed to set default'); + } + }); + }; + + const handleAccountsShellToggle = () => { + runAccountOp(async () => { + const r = await window.electronAPI.setAccountsShellHook( + accountsShellInstalled ? 'uninstall' : 'install', + ); + if (r.ok) { + setAccountsNotice( + accountsShellInstalled + ? 'Removed the CodeV block from ~/.zshrc (registry and account folders kept).' + : 'Added the source block to ~/.zshrc — open a new shell to use claude