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
27 changes: 15 additions & 12 deletions .github/workflows/test_ci.yaml
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
name: hello-world
name: test

on:
workflow_dispatch:
pull_request:
branches:
- master
- staging
- main
paths:
- README.md

branches: [main]
push:
branches: [main]
workflow_dispatch:

jobs:
my-job:
test:
runs-on: ubuntu-latest
steps:
- name: my-step
run: echo "Hello World!"
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run tests
run: yarn test
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.77

- Feat: multi-account Batch 2a + 2e — `codev account` CLI + configurable global-default
- `codev account list | add <label> [--dir D] | default <label> | remove <label> | regenerate | show | install | uninstall`
- One shared generator (`src/cli/account-manager.ts`) produces both `~/.config/codev/accounts.json` and `accounts.sh`, replacing Batch 1's hand-editing; run in dev via `yarn account <cmd>` (PATH install + Settings UI land in Batch 2b)
- Batch 2e (global-default): `codev account default <label>` + the shell dispatcher route bare `claude` to a chosen account; CodeV resume now uses `command claude` (bypasses the dispatcher) so a non-anchor global-default can't misroute an anchor-account resume
- Adds Vitest + a generator test suite, and a real CI workflow (replacing the placeholder)

## 1.0.76

- Feat: multi-account support (Batch 1) — run multiple Claude Code accounts (e.g. personal + work) from one machine
Expand Down
72 changes: 67 additions & 5 deletions docs/multi-account-support-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,35 @@ The registry records `configDirEnv` (null for default, the dir for extras) and
| Global `~/.claude/CLAUDE.md` (user instructions) | Config dir | No by default → **opt-in symlink** | `ln -s ~/.claude/CLAUDE.md ~/.claude-x/CLAUDE.md` |
| Global `skills/`, `commands/`, `plugins/` | Config dir | No by default → **opt-in symlink** | symlink the dir |
| `settings.json` (model, hooks, permissions, env) | Config dir | Optional symlink (careful) | Symlink ⇒ hooks/permissions shared; or install hook per-dir (§6.F) |
| Single `settings.json` keys (`statusLine`, `model`, `effortLevel`, `theme`) | Config dir | **Yes — per-key copy** | Copy the key into the other account's settings.json (statusLine done for `work` 2026-07; HOME-based script serves all accounts) |
| `.claude.json` (identity + per-project trust) | Config dir | **No — never symlink** | Holds `oauthAccount`; must stay per-account |
| Auto-memory (`projects/<path>/memory/`, `MEMORY.md`) | Config dir | No by default | Per-account, or advanced: symlink individual `memory/` subdirs |
| Session data (`history.jsonl`, `projects/*.jsonl`, `sessions/`) | Config dir | **No — aggregated for display, not shared** | CodeV scans all dirs (§6.E) |

**Verified 2026-07-09:** Claude Code follows symlinks for both `skills/<name>` entries
and the global `CLAUDE.md` — a work-account `-p` probe listed the symlinked
`fireflies-repos` skill and had the symlinked CLAUDE.md's rules in context. So the
symlink mechanism above is confirmed viable; the remaining Batch 3 work is UX
(share checkboxes / `codev account share`), not feasibility.

**Collision & ownership policy (the actual Batch 3 UX work):** sharing must never
silently overwrite. If the target already exists in the other account: back it up
(`*.codev-bak`, the convention session-status-hooks already uses) or skip and report —
never `ln -sf`. Link per **entry** (`skills/<name>`), not whole dirs, so an account
keeps private skills alongside shared ones. (Note: it's *hard* links that can't target
directories — a **symlink to a dir works fine** and new files *inside* a linked skill
are picked up automatically; only a brand-new skill needs a new per-entry link. If an
account needs no private skills, a whole-`skills/`-dir symlink is the zero-maintenance
alternative — current and future skills auto-shared, at the cost that anything either
account adds becomes shared.) A symlink is ONE file both ways — edits
from either account hit the same inode; offer **link (stay in sync) / copy (fork) /
skip** per item. `plugins/` is untested: the payload dir may symlink, but enablement
lives in each account's `settings.json` (`enabledPlugins`) and plugins may keep their
own state — needs its own experiment. Session data stays unshared by design (it's
live-written and identity-bound, and §6.E aggregation already provides the union view);
cross-account "resume" (§6.G/2c) should **copy-fork** a transcript into the target
account, never link it.

Your guess was right: **per-project files are account-independent; only the global
files need help.** "Reusing" session data across accounts isn't actually desirable —
each account owns its sessions; what you want is CodeV *showing* them all, which §6.E
Expand All @@ -222,6 +247,14 @@ CodeV's "Add account" flow (§6.D) offers per-item checkboxes: *Share global CLA
/ skills / commands / plugins / settings with the default account?* → creates the
symlinks. `.claude.json` is never offered.

**Per-key copy instead of whole-file symlink (`settings.json`):** individual settings
keys can be shared by copying just that key into the other account's `settings.json` —
e.g. `statusLine` (done manually for `work`, 2026-07: same
`bash ~/.claude/statusline-command.sh` command works for every account since the script
path is HOME-based and Claude Code feeds it the session's own context via stdin).
Candidates for a future `codev account sync-settings <keys>`: `statusLine`, `model`,
`effortLevel`, `theme`. Hooks stay per-dir (§6.F installs them per account).

---

## 6. Sub-feature designs (approaches, pros/cons, recommendation)
Expand Down Expand Up @@ -249,6 +282,24 @@ lists all. These ship in Batch 1 (they make the imminent smoke-test legible and
first-arg would be swallowed by the dispatcher / passed to real `claude`; use distinct
function names. Batch 2 folds them into `codev account current | list | default`.

**Implemented (Batch 2a):** the `codev account` CLI (`src/cli/codev-account.ts`, run
via `yarn account <cmd>`) now generates **both** `accounts.json` and `accounts.sh` from
one shared generator (`src/cli/account-manager.ts`) — no more hand-editing. Commands:
`list`, `add <label> [--dir D]`, `default <label>`, `remove <label>`, `regenerate`,
`show` (dry-run), `install`/`uninstall` (the marker-guarded `~/.zshrc` source block).
A Vitest generator suite + CI guard the shell output. A real `codev` binary on PATH and
the Settings UI are Batch 2b.

**Caveat — the dispatcher leaks into Claude Code sessions (shell snapshots):** Claude
Code captures the interactive shell's functions into a snapshot
(`~/.claude/shell-snapshots/…`) and sources it for Bash-tool / `!` commands. So *inside
any session*, bare `claude …` resolves to the dispatcher function and routes to the
**global default** — NOT the session's own account. Consequences: (a) `!claude auth
status` is not a valid probe of the session's account (it reports the default; use
`command claude auth status` or `echo $CLAUDE_CONFIG_DIR` instead); (b) nested/scripted
`claude` invocations from within a session go to the default account — use
`command claude` or an explicit `claude <label>` when the account matters.

### 6.B Account registry (source of truth)

- **`~/.config/codev/accounts.json`** (plain JSON), owned/written by CodeV, readable
Expand Down Expand Up @@ -343,6 +394,11 @@ good. But the hook must be **registered** in each account's `settings.json`. Opt
- New-session launch (Projects `⌘+Enter`): allow an account override, e.g. extend the
planned `>` command mode (`> claude @work myrepo`) or an account segment in the
expanded project row.
- **Coupled with Batch 3 (§5).** Truly resuming/sharing a *transcript* across accounts
only works if the session data (and global config: CLAUDE.md/skills) is shareable
across config dirs — i.e. it depends on Batch 3's cross-account reuse. So design 2c
and Batch 3 **together**: without sharing, 2c is limited to "new session in this cwd
under account Y"; with sharing, genuine cross-account resume becomes possible.

### 6.H pyenv-style auto-switch by folder (requirement 6, nice-to-have, "reverse" priority)

Expand Down Expand Up @@ -372,11 +428,17 @@ and **(d)** for CodeV, backed by the same `projectMap` in the registry so both a
only) + account-aware resume/launch (§6.E); active-dot + title/branch enrichment per
account; same-cwd cross-ref per account; hooks registered per config dir (§6.F).
- *Deferred:* VS Code (`claude-vscode`) sessions can't be account-switched (URI-handler
launch) — tracked in grimmerk/codev#121. Setup UI/CLI is Batch 2 (registry +
`accounts.sh` are hand-created for now).
- **Batch 2 — after Batch 1, revisit:** account picker / per-launch override (§6.G, 4.2);
manage-accounts Settings tab — list/add/rename/remove + shell-integration toggle
(§6.D); pyenv-style folder auto-switch + terminal-side resume-to-right-account (§6.H).
launch) — tracked in grimmerk/codev#121. (Setup is no longer hand-made — see
Batch 2a below.)
- **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.
- *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
also needs the CLI/UI to manage.
- **Batch 3 — later:** cross-account symlink reuse of global files
(CLAUDE.md / skills / commands / plugins / settings, §5).

Expand Down
6 changes: 5 additions & 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.76",
"version": "1.0.77",
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
"repository": {
"type": "git",
Expand All @@ -12,6 +12,9 @@
"db": "prisma migrate dev",
"start": "cross-env NODE_ENV=development electron-forge start",
"dev": "npm run start",
"account": "ts-node src/cli/codev-account.ts",
"test": "vitest run",
"test:watch": "vitest",
"package": "electron-forge package",
"package_mas": "npm run prebuild && cross-env BUILD_TYPE=prod electron-forge package --platform=mas && npm run postbuild",
"prebuild": "sh prebuild.sh",
Expand Down Expand Up @@ -61,6 +64,7 @@
"ts-loader": "^9.5.4",
"ts-node": "^10.9.2",
"typescript": "~5.7.0",
"vitest": "^4.1.9",
"webpack-node-externals": "^3.0.0"
},
"dependencies": {
Expand Down
18 changes: 14 additions & 4 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,16 +1075,26 @@ const getResumeConfigDirEnv = (sessionId: string): string | null => {
};

/**
* Build `claude --resume <id>`, prefixed with CLAUDE_CONFIG_DIR when the session
* belongs to a non-default account so it resumes under the right account.
* Build `command claude --resume <id>`, prefixed with CLAUDE_CONFIG_DIR when the
* session belongs to a non-default account so it resumes under the right account.
*/
const buildResumeCommand = (sessionId: string): string => {
const configDir = getResumeConfigDirEnv(sessionId);
// Single-quote the value: some terminal injections (Ghostty `initial input`)
// don't escape the command, so a double-quoted prefix would break their
// AppleScript string. Single quotes are safe across all terminals + handle spaces.
const prefix = configDir ? `CLAUDE_CONFIG_DIR='${configDir}' ` : '';
return `${prefix}claude --resume ${sessionId}`;
// Escape single quotes (e.g. /Users/O'Brien/…) so the single-quoted prefix
// stays well-formed: ' becomes '\'' .
const prefix = configDir
? `CLAUDE_CONFIG_DIR='${configDir.replace(/'/g, "'\\''")}' `
: '';
// `command claude` bypasses the accounts.sh `claude` dispatcher. CodeV has
// already resolved the exact account (the prefix, or none for the anchor), so
// the shell must NOT re-route a bare `claude` to a configured non-default
// global-default (§2e) — that would resume an anchor-account session under the
// wrong account. Every terminal here runs the string through a shell, so the
// `command` builtin is available.
return `${prefix}command claude --resume ${sessionId}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
grimmerk marked this conversation as resolved.
};

/**
Expand Down
177 changes: 177 additions & 0 deletions src/cli/account-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { execFileSync } from 'child_process';
import { writeFileSync } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { describe, it, expect } from 'vitest';

import {
generateAccountsSh,
resolveDefaultLabel,
type Registry,
} from './account-manager';

const HOME = os.homedir();

/** A representative two-account registry (personal default + work extra). */
function reg(overrides: Partial<Registry> = {}): Registry {
return {
version: 1,
defaultAccount: 'personal',
accounts: [
{
label: 'personal',
dir: path.join(HOME, '.claude'),
identityFile: path.join(HOME, '.claude.json'),
configDirEnv: null,
isDefault: true,
},
{
label: 'work',
dir: path.join(HOME, '.claude-work'),
identityFile: path.join(HOME, '.claude-work', '.claude.json'),
configDirEnv: path.join(HOME, '.claude-work'),
isDefault: false,
},
],
...overrides,
};
}

describe('generateAccountsSh', () => {
it('launches the default account with CLAUDE_CONFIG_DIR unset', () => {
const sh = generateAccountsSh(reg());
expect(sh).toContain(
'claude-personal() { env -u CLAUDE_CONFIG_DIR claude "$@"; }',
);
});

it('launches an extra account with CLAUDE_CONFIG_DIR ($HOME-relative)', () => {
const sh = generateAccountsSh(reg());
expect(sh).toContain(
'claude-work() { env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude "$@"; }',
);
});

it('adds a dispatcher case per account', () => {
const sh = generateAccountsSh(reg());
expect(sh).toContain(
'personal) shift; env -u CLAUDE_CONFIG_DIR claude "$@" ;;',
);
expect(sh).toContain(
'work) shift; env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude "$@" ;;',
);
});

it('routes the dispatcher fallback (*) to the current defaultAccount', () => {
// defaultAccount = personal (the anchor) -> unset CLAUDE_CONFIG_DIR
expect(generateAccountsSh(reg())).toContain(
'*) env -u CLAUDE_CONFIG_DIR claude "$@" ;;',
);
// defaultAccount = work -> bare `claude` must set work's config dir
expect(generateAccountsSh(reg({ defaultAccount: 'work' }))).toContain(
'*) env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude "$@" ;;',
);
});

it('rejects an account dir with shell-unsafe characters', () => {
const bad = reg({
defaultAccount: 'evil',
accounts: [
{
label: 'evil',
dir: '/tmp/$(touch pwned)',
identityFile: '/tmp/x/.claude.json',
configDirEnv: '/tmp/$(touch pwned)',
isDefault: false,
},
],
});
expect(() => generateAccountsSh(bad)).toThrow(/Unsafe shell characters/);
});

it('reports the default account (not ambient env) in claude-whoami', () => {
// default = personal -> whoami reports personal, auth under the anchor
const sh = generateAccountsSh(reg());
expect(sh).toContain("bare 'claude' here -> personal");
expect(sh).toContain('env -u CLAUDE_CONFIG_DIR claude auth status');
// default = work -> whoami reports work + auth under work's env
const shWork = generateAccountsSh(reg({ defaultAccount: 'work' }));
expect(shWork).toContain("bare 'claude' here -> work");
expect(shWork).toContain(
'env CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth status',
);
});

it('emits syntactically valid shell (bash -n)', () => {
const sh = generateAccountsSh(reg());
const file = path.join(os.tmpdir(), `codev-accounts-${process.pid}.sh`);
writeFileSync(file, sh);
expect(() => execFileSync('bash', ['-n', file])).not.toThrow();
});

it('rejects an unsafe configDirEnv even when dir is clean', () => {
const bad = reg({
defaultAccount: 'evil',
accounts: [
{
label: 'evil',
dir: '/tmp/clean',
identityFile: '/tmp/clean/.claude.json',
configDirEnv: '/tmp/$(touch pwned)',
isDefault: false,
},
],
});
expect(() => generateAccountsSh(bad)).toThrow(/Unsafe shell characters/);
});

it('rejects a shell-unsafe label (hand-edited registry)', () => {
const bad = reg({
defaultAccount: 'x',
accounts: [
{
label: 'x; touch pwned',
dir: '/tmp/clean',
identityFile: '/tmp/clean/.claude.json',
configDirEnv: '/tmp/clean',
isDefault: false,
},
],
});
expect(() => generateAccountsSh(bad)).toThrow(/Invalid account label/);
});

it('rejects an empty configDirEnv (hand-edited registry)', () => {
const bad = reg({
defaultAccount: 'x',
accounts: [
{
label: 'x',
dir: '/tmp/x',
identityFile: '/tmp/x/.claude.json',
configDirEnv: '',
isDefault: false,
},
],
});
expect(() => generateAccountsSh(bad)).toThrow(/Invalid account path/);
});
});

describe('resolveDefaultLabel', () => {
it('returns defaultAccount when it names a real account', () => {
expect(resolveDefaultLabel(reg({ defaultAccount: 'work' }))).toBe('work');
});

it('falls back to the isDefault anchor when defaultAccount is missing', () => {
expect(resolveDefaultLabel(reg({ defaultAccount: undefined }))).toBe(
'personal',
);
});

it('falls back to the isDefault anchor when defaultAccount is unknown', () => {
expect(resolveDefaultLabel(reg({ defaultAccount: 'ghost' }))).toBe(
'personal',
);
});
});
Loading
Loading