Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ src/build.json

# Claude Code worktrees
.claude/worktrees/
resources/cli
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 25 additions & 1 deletion docs/multi-account-support-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Comment thread
grimmerk marked this conversation as resolved.
'./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: {
Expand All @@ -37,6 +60,7 @@ const config: ForgeConfig = {
'../node_modules',
'images/MenuBar.png',
'images/MenuBar@2x.png',
'resources/cli',
],
extendInfo: {
LSUIElement: true,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
36 changes: 36 additions & 0 deletions src/cli/account-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
92 changes: 92 additions & 0 deletions src/cli/account-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand Down Expand Up @@ -285,10 +287,100 @@ export function generateAccountsSh(reg: Registry): string {
' echo " identity: run claude-whoami, or claude <acct> 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 });
Expand Down
6 changes: 5 additions & 1 deletion src/cli/codev-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>`; the dev form `yarn account <cmd>`
// passes <cmd> directly. Accept both.
if (argv[0] === 'account') argv.shift();
const [cmd, ...rest] = argv;
switch (cmd) {
case 'list':
case 'ls':
Expand Down
47 changes: 47 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
!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()) {
Comment thread
grimmerk marked this conversation as resolved.
// 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 {
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
Expand All @@ -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 };
Expand All @@ -694,6 +735,7 @@ ipcMain.handle(
error: `Unknown shell-hook action "${String(action)}"`,
};
}
if (action === 'install') syncAccountsAppPath();
const result =
action === 'install'
? accountManager.installShellHook()
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading