feat: clarify account history sync route (#1187)

* feat: clarify account history sync route

* fix: clarify shared context command examples

* fix: report missing bare profile settings
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-05 13:33:04 -04:00
committed by GitHub
parent 8df43585f4
commit be9effcce3
13 changed files with 767 additions and 17 deletions
+21 -2
View File
@@ -1,6 +1,6 @@
# Dashboard Authentication CLI
Last Updated: 2026-04-06
Last Updated: 2026-05-05
CLI commands for managing CCS dashboard authentication.
@@ -27,7 +27,16 @@ Dashboard auth and account context metadata are separate:
- `dashboard_auth`: protects dashboard access with username/password
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
Account context is isolation-first:
Account context is isolation-first. The recommended two-account route is:
```bash
ccs auth create work
ccs auth create personal
ccs work
ccs personal
```
Only enable history sync when both accounts should share local continuity while tokens stay separate:
| Mode | Default | Requirement |
|------|---------|-------------|
@@ -39,6 +48,16 @@ Shared continuity depth:
- `standard` (default): shares project workspace context only
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
`ccs auth show <profile>` reports credential isolation, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane.
Non-bare account profiles share the basic Claude `settings.json` with native Claude:
```text
~/.ccs/instances/<profile>/settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json
```
This keeps ordinary Claude settings in sync without copying account tokens. Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`.
`context_group` normalization and validation:
- trim + lowercase + collapse internal whitespace to `-`
+45 -3
View File
@@ -1,6 +1,6 @@
# Session Sharing Technical Analysis
Last Updated: 2026-04-04
Last Updated: 2026-05-05
## Summary
@@ -12,6 +12,38 @@ This is implemented as a context policy per account:
- `shared` + `standard` (default): account workspace context is linked to a shared context group
- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts
## Recommended Two-Account Route
Use `ccs auth` account profiles when you want two real Claude accounts and want to choose which one runs each session:
```bash
ccs auth create work
ccs auth create personal
ccs work
ccs personal
```
This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials.
Basic Claude settings are shared for non-bare account profiles:
```text
~/.ccs/instances/<account>/settings.json
-> ~/.ccs/shared/settings.json
-> ~/.claude/settings.json
```
This is for ordinary Claude Code settings, hooks, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show <account>` reports the current `Settings`, `History`, and `Plain ccs` lanes so users can see whether settings and resume history are aligned.
Only opt in to shared history when both accounts should see the same local continuity:
```bash
ccs auth create work2 --share-context --context-group daily --deeper-continuity
```
For existing accounts, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context.
## Why This Is Safe Enough
CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts.
@@ -93,14 +125,24 @@ continuity:
default: work
```
Example with an existing `ck` account:
```bash
ccs auth show ck
ccs auth backup default
ccs auth default ck
```
`ccs auth default ck` makes future plain `ccs` sessions use the `ck` account lane, so future `ccs` and `ccs ck` resume from the same local inventory. It does not automatically import old native `~/.claude/projects` history into `ck`; keep using `ccs -r` for the old native lane until you intentionally migrate that local history.
## User Workflows
### New account with shared context
```bash
ccs auth create work2 --share-context
ccs auth create backup --context-group sprint-a
ccs auth create backup2 --context-group sprint-a --deeper-continuity
ccs auth create backup --share-context --context-group sprint-a
ccs auth create backup2 --share-context --context-group sprint-a --deeper-continuity
```
### Existing account
+140
View File
@@ -0,0 +1,140 @@
import * as fs from 'fs';
import * as path from 'path';
import { DEFAULT_ACCOUNT_CONTEXT_GROUP, type AccountContextPolicy } from './account-context';
import { getCcsDir } from '../config/config-loader-facade';
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
export type SettingsSyncState = 'shared' | 'profile-local' | 'missing' | 'unknown';
export interface AccountSettingsSyncSummary {
state: SettingsSyncState;
profile_settings_path: string;
shared_settings_path: string;
root_settings_path: string;
description: string;
}
export interface AccountHistorySummary {
project_count: number;
session_count: number;
projects_path: string;
projects_shared: boolean;
deeper_artifacts_shared: boolean;
}
function safeRealpath(targetPath: string): string | null {
try {
return fs.realpathSync(targetPath);
} catch {
return null;
}
}
function countTopLevelDirectories(targetPath: string): number {
try {
return fs
.readdirSync(targetPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory()).length;
} catch {
return 0;
}
}
function countJsonFiles(targetPath: string): number {
try {
return fs.readdirSync(targetPath).filter((entry) => entry.endsWith('.json')).length;
} catch {
return 0;
}
}
function resolvesTo(targetPath: string, expectedPath: string): boolean {
const realTarget = safeRealpath(targetPath);
const realExpected = safeRealpath(expectedPath);
return !!realTarget && !!realExpected && realTarget === realExpected;
}
export function describeSettingsSync(
instancePath: string,
options: { bare?: boolean } = {}
): AccountSettingsSyncSummary {
const rootSettingsPath = path.join(getDefaultClaudeConfigDir(), 'settings.json');
const sharedSettingsPath = path.join(getCcsDir(), 'shared', 'settings.json');
const profileSettingsPath = path.join(instancePath, 'settings.json');
if (options.bare) {
if (!fs.existsSync(profileSettingsPath)) {
return {
state: 'missing',
profile_settings_path: profileSettingsPath,
shared_settings_path: sharedSettingsPath,
root_settings_path: rootSettingsPath,
description: 'missing (bare profile; no local settings.json yet)',
};
}
return {
state: 'profile-local',
profile_settings_path: profileSettingsPath,
shared_settings_path: sharedSettingsPath,
root_settings_path: rootSettingsPath,
description: 'profile-local (bare profile; not linked to ~/.claude/settings.json)',
};
}
if (!fs.existsSync(profileSettingsPath)) {
return {
state: 'missing',
profile_settings_path: profileSettingsPath,
shared_settings_path: sharedSettingsPath,
root_settings_path: rootSettingsPath,
description: 'missing (run or repair this profile to recreate settings link)',
};
}
if (resolvesTo(profileSettingsPath, rootSettingsPath)) {
return {
state: 'shared',
profile_settings_path: profileSettingsPath,
shared_settings_path: sharedSettingsPath,
root_settings_path: rootSettingsPath,
description: 'shared with ~/.claude/settings.json',
};
}
return {
state: 'unknown',
profile_settings_path: profileSettingsPath,
shared_settings_path: sharedSettingsPath,
root_settings_path: rootSettingsPath,
description: 'not linked to ~/.claude/settings.json',
};
}
export function summarizeAccountHistory(
instancePath: string,
policy: AccountContextPolicy
): AccountHistorySummary {
const projectsPath = path.join(instancePath, 'projects');
const sessionEnvPath = path.join(instancePath, 'session-env');
const group = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
const sharedGroupRoot = path.join(getCcsDir(), 'shared', 'context-groups', group);
const sharedProjectsPath = path.join(sharedGroupRoot, 'projects');
const deeperArtifacts = ['session-env', 'file-history', 'shell-snapshots', 'todos'];
return {
project_count: countTopLevelDirectories(projectsPath),
session_count: countJsonFiles(sessionEnvPath),
projects_path: projectsPath,
projects_shared: policy.mode === 'shared' && resolvesTo(projectsPath, sharedProjectsPath),
deeper_artifacts_shared:
policy.mode === 'shared' &&
policy.continuityMode === 'deeper' &&
deeperArtifacts.every((artifact) =>
resolvesTo(
path.join(instancePath, artifact),
path.join(sharedGroupRoot, 'continuity', artifact)
)
),
};
}
+23 -9
View File
@@ -81,18 +81,25 @@ class AuthCommands {
);
console.log('');
console.log(subheader('Examples'));
console.log(` ${dim('# Create & login to work profile')}`);
console.log(` ${dim('# Create two isolated accounts and choose one explicitly at runtime')}`);
console.log(` ${color('ccs auth create work', 'command')}`);
console.log(` ${color('ccs auth create personal', 'command')}`);
console.log(` ${color('ccs work "review code"', 'command')}`);
console.log(` ${color('ccs personal "write tests"', 'command')}`);
console.log('');
console.log(` ${dim('# Create account with shared project context (default group)')}`);
console.log(
` ${dim('# Optional: share local project history while credentials stay isolated')}`
);
console.log(` ${color('ccs auth create work2 --share-context', 'command')}`);
console.log('');
console.log(` ${dim('# Share context only within a specific group')}`);
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
console.log(
` ${color('ccs auth create backup --share-context --context-group sprint-a', 'command')}`
);
console.log('');
console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`);
console.log(
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
` ${color('ccs auth create backup --share-context --context-group sprint-a --deeper-continuity', 'command')}`
);
console.log('');
console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`);
@@ -113,9 +120,6 @@ class AuthCommands {
console.log(` ${dim('# List all profiles')}`);
console.log(` ${color('ccs auth list', 'command')}`);
console.log('');
console.log(` ${dim('# Use work profile')}`);
console.log(` ${color('ccs work "review code"', 'command')}`);
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--force', 'command')} Allow overwriting existing profile (create)`
@@ -147,14 +151,24 @@ class AuthCommands {
` By default, ${color('ccs', 'command')} uses Claude CLI defaults from ~/.claude/`
);
console.log(
` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.`
` Recommended two-account route: create ${color('work', 'command')} and ${color('personal', 'command')}, then run the profile you want.`
);
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.`
);
console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`);
console.log(
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
);
console.log(
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
);
console.log(
` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.`
);
console.log(
` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default <profile>', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.`
);
console.log(
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
);
+11 -1
View File
@@ -280,7 +280,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
`Profile: ${profileName}\n` +
`Instance: ${instancePath}\n` +
`Type: account\n` +
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
`Context: ${formatAccountContextPolicy(contextPolicy)}\n` +
`Tokens: isolated per account\n` +
`Settings: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude/settings.json'}` +
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
'Profile Created'
)
@@ -289,6 +291,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(header('Usage'));
console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`);
console.log('');
console.log(
'To keep two accounts separate, create another account and run either profile by name:'
);
console.log(` ${color('ccs auth create personal', 'command')}`);
console.log(
` ${color(`ccs ${profileName}`, 'command')} / ${color('ccs personal', 'command')}`
);
console.log('');
console.log(
warnBox(
`Running the command below will SWITCH your default\n` +
+44
View File
@@ -8,10 +8,18 @@ import * as fs from 'fs';
import * as path from 'path';
import { initUI, header, color, fail, table } from '../../utils/ui';
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics';
import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, ProfileOutput, parseArgs } from './types';
function formatHistorySummary(history: ReturnType<typeof summarizeAccountHistory>): string {
const scope = history.projects_shared ? 'shared projects' : 'profile-local projects';
const deeper = history.deeper_artifacts_shared ? ', deeper artifacts shared' : '';
return `${scope}: ${history.project_count} project(s), ${history.session_count} session env file(s)${deeper}`;
}
/**
* Handle the show command
*/
@@ -37,6 +45,11 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
const isDefault = profileName === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
const contextPolicy = resolveAccountContextPolicy(profile);
const settingsSync = describeSettingsSync(instancePath, { bare: profile.bare === true });
const historySummary = summarizeAccountHistory(instancePath, contextPolicy);
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane().catch(() => null);
const plainCcsUsesThisAccount =
!!plainCcsLane && path.resolve(plainCcsLane.configDir) === path.resolve(instancePath);
// Count sessions
let sessionCount = 0;
@@ -63,6 +76,24 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
session_count: sessionCount,
settings_sync: {
state: settingsSync.state,
profile_settings_path: settingsSync.profile_settings_path,
shared_settings_path: settingsSync.shared_settings_path,
root_settings_path: settingsSync.root_settings_path,
},
history: historySummary,
...(plainCcsLane
? {
plain_ccs_lane: {
kind: plainCcsLane.kind,
label: plainCcsLane.label,
config_dir: plainCcsLane.configDir,
project_count: plainCcsLane.projectCount,
uses_this_account: plainCcsUsesThisAccount,
},
}
: {}),
...(profile.bare ? { bare: true } : {}),
};
console.log(JSON.stringify(output, null, 2));
@@ -81,6 +112,19 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
['Created', new Date(profile.created).toLocaleString()],
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
['Context', formatAccountContextPolicy(contextPolicy)],
['Credentials', 'isolated per account'],
['Settings', settingsSync.description],
['History', formatHistorySummary(historySummary)],
...(plainCcsLane
? [
[
'Plain ccs',
plainCcsUsesThisAccount
? `uses this account lane (${plainCcsLane.projectCount} project(s))`
: `${plainCcsLane.label} (${plainCcsLane.projectCount} project(s))`,
],
]
: []),
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
['Sessions', `${sessionCount}`],
];
+20
View File
@@ -40,6 +40,26 @@ export interface ProfileOutput {
continuity_mode?: 'standard' | 'deeper' | null;
instance_path?: string;
session_count?: number;
settings_sync?: {
state: 'shared' | 'profile-local' | 'missing' | 'unknown';
profile_settings_path: string;
shared_settings_path: string;
root_settings_path: string;
};
history?: {
project_count: number;
session_count: number;
projects_path: string;
projects_shared: boolean;
deeper_artifacts_shared: boolean;
};
plain_ccs_lane?: {
kind: string;
label: string;
config_dir: string;
project_count: number;
uses_this_account: boolean;
};
bare?: boolean;
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
describe('auth help account route guidance', () => {
it('keeps the help copy explicit about account choice and credential isolation', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'auth', 'auth-commands.ts'),
'utf8'
);
expect(source).toContain('Create two isolated accounts and choose one explicitly at runtime');
expect(source).toContain('ccs auth create work');
expect(source).toContain('ccs auth create personal');
expect(source).toContain('Account logins, tokens, and .anthropic stay isolated');
expect(source).toContain('settings.json');
expect(source).toContain('ccs auth show <profile>');
expect(source).toContain('History sync is opt-in');
expect(source).toContain('ccs auth backup default');
});
});
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
describeSettingsSync,
summarizeAccountHistory,
} from '../../../src/auth/account-profile-diagnostics';
describe('account profile diagnostics', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-diagnostics-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
function seedSharedSettings(instancePath: string): void {
const claudeDir = path.join(tempHome, '.claude');
const sharedDir = path.join(tempHome, '.ccs', 'shared');
fs.mkdirSync(claudeDir, { recursive: true });
fs.mkdirSync(sharedDir, { recursive: true });
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}\n');
fs.symlinkSync(path.join(claudeDir, 'settings.json'), path.join(sharedDir, 'settings.json'));
fs.symlinkSync(path.join(sharedDir, 'settings.json'), path.join(instancePath, 'settings.json'));
}
it('reports non-bare account settings as shared with root Claude settings', () => {
const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck');
seedSharedSettings(instancePath);
const summary = describeSettingsSync(instancePath);
expect(summary.state).toBe('shared');
expect(summary.description).toBe('shared with ~/.claude/settings.json');
expect(summary.root_settings_path).toBe(path.join(tempHome, '.claude', 'settings.json'));
});
it('reports bare account settings as profile-local', () => {
const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(path.join(instancePath, 'settings.json'), '{}\n');
const summary = describeSettingsSync(instancePath, { bare: true });
expect(summary.state).toBe('profile-local');
expect(summary.description).toContain('bare profile');
});
it('reports bare account settings as missing when the local file is absent', () => {
const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare');
const summary = describeSettingsSync(instancePath, { bare: true });
expect(summary.state).toBe('missing');
expect(summary.description).toContain('bare profile');
expect(summary.profile_settings_path).toBe(path.join(instancePath, 'settings.json'));
});
it('summarizes shared project and deeper continuity lane state', () => {
const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck');
const groupRoot = path.join(tempHome, '.ccs', 'shared', 'context-groups', 'default');
const projectsPath = path.join(groupRoot, 'projects');
const sessionEnvPath = path.join(groupRoot, 'continuity', 'session-env');
fs.mkdirSync(projectsPath, { recursive: true });
fs.mkdirSync(sessionEnvPath, { recursive: true });
fs.mkdirSync(path.join(projectsPath, 'project-a'));
fs.mkdirSync(path.join(projectsPath, 'project-b'));
fs.writeFileSync(path.join(sessionEnvPath, 'session-a.json'), '{}\n');
fs.mkdirSync(instancePath, { recursive: true });
fs.symlinkSync(projectsPath, path.join(instancePath, 'projects'), 'dir');
for (const artifact of ['session-env', 'file-history', 'shell-snapshots', 'todos']) {
const sharedArtifactPath = path.join(groupRoot, 'continuity', artifact);
fs.mkdirSync(sharedArtifactPath, { recursive: true });
fs.symlinkSync(sharedArtifactPath, path.join(instancePath, artifact), 'dir');
}
const summary = summarizeAccountHistory(instancePath, {
mode: 'shared',
group: 'default',
continuityMode: 'deeper',
});
expect(summary.project_count).toBe(2);
expect(summary.session_count).toBe(1);
expect(summary.projects_shared).toBe(true);
expect(summary.deeper_artifacts_shared).toBe(true);
});
});
@@ -0,0 +1,126 @@
import { Link2, Settings2, ShieldCheck, Terminal, UserRoundCheck, Waves } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CopyButton } from '@/components/ui/copy-button';
interface AccountRouteGuideCardProps {
totalAccounts: number;
isolatedCount: number;
sharedPeerGroups: string[];
deeperReadyGroups: string[];
}
type RouteStatus = 'empty' | 'isolated' | 'shared' | 'deeper' | 'mixed';
export function AccountRouteGuideCard({
totalAccounts,
isolatedCount,
sharedPeerGroups,
deeperReadyGroups,
}: AccountRouteGuideCardProps) {
const { t } = useTranslation();
const recommendedGroup = deeperReadyGroups[0] || sharedPeerGroups[0] || 'daily';
const status: RouteStatus =
totalAccounts < 2
? 'empty'
: deeperReadyGroups.length > 0
? 'deeper'
: sharedPeerGroups.length > 0
? 'shared'
: totalAccounts >= 2 && isolatedCount === totalAccounts
? 'isolated'
: totalAccounts === 0
? 'empty'
: 'mixed';
const syncCommand = `ccs auth create work2 --share-context --context-group ${recommendedGroup} --deeper-continuity`;
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-lg">{t('accountRouteGuide.title')}</CardTitle>
<CardDescription>{t('accountRouteGuide.description')}</CardDescription>
</div>
<Badge variant={status === 'deeper' ? 'default' : 'secondary'}>
{t(`accountRouteGuide.status.${status}`)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<section className="rounded-md border bg-blue-50/50 p-3 text-sm dark:bg-blue-900/10">
<div className="flex items-center gap-2 font-semibold">
<ShieldCheck className="h-4 w-4 text-blue-600 dark:text-blue-400" />
{t('accountRouteGuide.cards.isolated.title')}
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
{t('accountRouteGuide.cards.isolated.desc')}
</p>
</section>
<section className="rounded-md border bg-emerald-50/50 p-3 text-sm dark:bg-emerald-900/10">
<div className="flex items-center gap-2 font-semibold">
<UserRoundCheck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
{t('accountRouteGuide.cards.select.title')}
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
{t('accountRouteGuide.cards.select.desc')}
</p>
</section>
<section className="rounded-md border bg-sky-50/50 p-3 text-sm dark:bg-sky-900/10">
<div className="flex items-center gap-2 font-semibold">
<Settings2 className="h-4 w-4 text-sky-600 dark:text-sky-400" />
{t('accountRouteGuide.cards.settings.title')}
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
{t('accountRouteGuide.cards.settings.desc')}
</p>
</section>
<section className="rounded-md border bg-indigo-50/50 p-3 text-sm dark:bg-indigo-900/10">
<div className="flex items-center gap-2 font-semibold">
<Link2 className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
{t('accountRouteGuide.cards.sync.title')}
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
{t('accountRouteGuide.cards.sync.desc')}
</p>
</section>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Terminal className="h-3.5 w-3.5" />
{t('accountRouteGuide.commands.isolated')}
</div>
{['ccs auth create work', 'ccs auth create personal', 'ccs work', 'ccs personal'].map(
(command) => (
<div
key={command}
className="mt-2 flex items-start gap-2 rounded-md bg-muted px-2 py-2 font-mono text-[11px]"
>
<span className="flex-1 break-all">{command}</span>
<CopyButton value={command} size="icon" />
</div>
)
)}
</div>
<div className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Waves className="h-3.5 w-3.5" />
{t('accountRouteGuide.commands.sync')}
</div>
<div className="flex items-start gap-2 rounded-md bg-muted px-2 py-2 font-mono text-[11px]">
<span className="flex-1 break-all">{syncCommand}</span>
<CopyButton value={syncCommand} size="icon" />
</div>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{t('accountRouteGuide.commands.syncDesc', { group: recommendedGroup })}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
+143
View File
@@ -253,6 +253,42 @@ const resources = {
copied: 'Copied!',
copyCommand: 'Copy Command',
},
accountRouteGuide: {
title: 'Recommended two-account route',
description:
'Use separate account profiles for token isolation. Turn on history sync only when both accounts should see the same local continuity.',
status: {
empty: 'create accounts',
isolated: 'isolated ready',
shared: 'shared project context',
deeper: 'deeper sync ready',
mixed: 'mixed setup',
},
cards: {
isolated: {
title: 'Tokens stay separate',
desc: 'Each ccs auth account owns its own Claude config, login, and credentials. Sync settings never copy tokens.',
},
select: {
title: 'You choose the account',
desc: 'Run ccs work or ccs personal for explicit usage. Set Default only when plain ccs should follow one account.',
},
settings: {
title: 'Settings follow root',
desc: 'Non-bare accounts link settings.json through CCS shared state to ~/.claude/settings.json. ccs auth show confirms it.',
},
sync: {
title: 'History sync is opt-in',
desc: 'Both accounts need Shared mode and the same group. Deeper sync adds extra local continuity artifacts.',
},
},
commands: {
isolated: 'Isolated account usage',
sync: 'Optional shared history',
syncDesc:
'Use the same group, such as "{{group}}", on both accounts when you want them to share local history.',
},
},
cliproxyModelCategory: {
google: 'Google (Gemini)',
openai: 'OpenAI (GPT)',
@@ -2819,6 +2855,41 @@ const resources = {
copied: '已复制!',
copyCommand: '复制命令',
},
accountRouteGuide: {
title: '推荐的双账号路线',
description:
'使用独立 account profile 保持 token 隔离。只有当两个账号应该看到同一份本地连续性时,才开启历史同步。',
status: {
empty: '创建账号',
isolated: '隔离已就绪',
shared: '共享项目上下文',
deeper: '更深同步已就绪',
mixed: '混合设置',
},
cards: {
isolated: {
title: 'Token 仍然隔离',
desc: '每个 ccs auth 账号都有自己的 Claude 配置、登录和凭据。同步设置不会复制 token。',
},
select: {
title: '你选择使用哪个账号',
desc: '运行 ccs work 或 ccs personal 来明确选择账号。只有当普通 ccs 应跟随某个账号时才设置默认。',
},
settings: {
title: '设置跟随根目录',
desc: '非 bare 账号会通过 CCS shared state 将 settings.json 链接到 ~/.claude/settings.json。ccs auth show 会确认状态。',
},
sync: {
title: '历史同步需要主动开启',
desc: '两个账号都需要使用 Shared 模式和同一分组。更深同步会加入额外的本地连续性文件。',
},
},
commands: {
isolated: '隔离账号用法',
sync: '可选共享历史',
syncDesc: '当希望两个账号共享本地历史时,在两个账号上使用同一分组,例如 "{{group}}"。',
},
},
cliproxyModelCategory: {
google: 'GoogleGemini',
openai: 'OpenAIGPT',
@@ -5282,6 +5353,42 @@ const resources = {
copied: 'Đã sao chép!',
copyCommand: 'Sao chép lệnh',
},
accountRouteGuide: {
title: 'Lộ trình hai tài khoản khuyến nghị',
description:
'Dùng account profile riêng để giữ token tách biệt. Chỉ bật history sync khi cả hai tài khoản cần thấy cùng continuity cục bộ.',
status: {
empty: 'tạo tài khoản',
isolated: 'đã sẵn sàng tách biệt',
shared: 'chia sẻ context project',
deeper: 'deeper sync sẵn sàng',
mixed: 'thiết lập hỗn hợp',
},
cards: {
isolated: {
title: 'Token vẫn tách biệt',
desc: 'Mỗi tài khoản ccs auth có Claude config, login và credential riêng. Cài đặt sync không bao giờ copy token.',
},
select: {
title: 'Bạn chọn tài khoản cần dùng',
desc: 'Chạy ccs work hoặc ccs personal để chọn rõ tài khoản. Chỉ đặt Default khi plain ccs nên đi theo một tài khoản.',
},
settings: {
title: 'Settings đi theo root',
desc: 'Tài khoản không bare link settings.json qua CCS shared state tới ~/.claude/settings.json. ccs auth show sẽ xác nhận.',
},
sync: {
title: 'History sync là tùy chọn',
desc: 'Cả hai tài khoản cần Shared mode và cùng group. Deeper sync thêm các artifact continuity cục bộ.',
},
},
commands: {
isolated: 'Dùng tài khoản tách biệt',
sync: 'Chia sẻ lịch sử tùy chọn',
syncDesc:
'Dùng cùng group, ví dụ "{{group}}", trên cả hai tài khoản khi muốn chia sẻ lịch sử cục bộ.',
},
},
cliproxyModelCategory: {
google: 'Google (Gemini)',
openai: 'OpenAI (GPT)',
@@ -7848,6 +7955,42 @@ const resources = {
copied: 'コピーしました!',
copyCommand: 'コマンドをコピー',
},
accountRouteGuide: {
title: '推奨される 2 アカウント構成',
description:
'トークンを分離するため、別々の account profile を使います。両方のアカウントで同じローカル継続性を見たい場合だけ履歴同期を有効にします。',
status: {
empty: 'アカウントを作成',
isolated: '分離準備済み',
shared: 'プロジェクト文脈を共有',
deeper: '拡張同期準備済み',
mixed: '混在設定',
},
cards: {
isolated: {
title: 'トークンは分離されたまま',
desc: '各 ccs auth アカウントは専用の Claude 設定、ログイン、認証情報を持ちます。同期設定でトークンはコピーされません。',
},
select: {
title: '使うアカウントを選ぶ',
desc: 'ccs work または ccs personal を実行して明示的に選びます。通常の ccs を特定アカウントに合わせたい場合だけ既定を設定します。',
},
settings: {
title: '設定はルートに追従',
desc: '非 bare アカウントは CCS shared state 経由で settings.json を ~/.claude/settings.json にリンクします。ccs auth show で確認できます。',
},
sync: {
title: '履歴同期は任意',
desc: '両方のアカウントで共有モードと同じグループが必要です。拡張同期では追加のローカル継続性ファイルも共有します。',
},
},
commands: {
isolated: '分離アカウントの使用',
sync: '任意の共有履歴',
syncDesc:
'ローカル履歴を共有したい場合は、両方のアカウントで "{{group}}" など同じグループを使ってください。',
},
},
cliproxyModelCategory: {
google: 'Google (Gemini)',
openai: 'OpenAI (GPT)',
+18 -2
View File
@@ -7,6 +7,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, Plus, Users, Zap } from 'lucide-react';
import { AccountsTable } from '@/components/account/accounts-table';
import { AccountRouteGuideCard } from '@/components/account/account-route-guide-card';
import { ContinuityOverview } from '@/components/account/continuity-overview';
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
import { CopyButton } from '@/components/ui/copy-button';
@@ -151,10 +152,11 @@ export function AccountsPage() {
<CardContent className="space-y-2">
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">
ccs auth create work --context-group sprint-a --deeper-continuity
ccs auth create work --share-context --context-group sprint-a
--deeper-continuity
</span>
<CopyButton
value="ccs auth create work --context-group sprint-a --deeper-continuity"
value="ccs auth create work --share-context --context-group sprint-a --deeper-continuity"
size="icon"
/>
</div>
@@ -205,6 +207,13 @@ export function AccountsPage() {
plainCcsLane={plainCcsLane}
/>
<AccountRouteGuideCard
totalAccounts={authAccounts.length}
isolatedCount={isolatedCount}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
/>
<Card className="flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-lg">{t('accountsPage.accountMatrix')}</CardTitle>
@@ -283,6 +292,13 @@ export function AccountsPage() {
plainCcsLane={plainCcsLane}
/>
<AccountRouteGuideCard
totalAccounts={authAccounts.length}
isolatedCount={isolatedCount}
sharedPeerGroups={sharedPeerGroups}
deeperReadyGroups={deeperReadyGroups}
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('accountsPage.accountMatrix')}</CardTitle>
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import i18n from '@/lib/i18n';
import { AccountRouteGuideCard } from '@/components/account/account-route-guide-card';
import { render, screen } from '@tests/setup/test-utils';
describe('AccountRouteGuideCard', () => {
it('explains explicit two-account usage while tokens stay isolated', async () => {
await i18n.changeLanguage('en');
render(
<AccountRouteGuideCard
totalAccounts={2}
isolatedCount={2}
sharedPeerGroups={[]}
deeperReadyGroups={[]}
/>
);
expect(screen.getByText('Recommended two-account route')).toBeInTheDocument();
expect(screen.getByText('isolated ready')).toBeInTheDocument();
expect(screen.getByText('Tokens stay separate')).toBeInTheDocument();
expect(screen.getByText('Settings follow root')).toBeInTheDocument();
expect(screen.getByText('ccs auth create work')).toBeInTheDocument();
expect(screen.getByText('ccs auth create personal')).toBeInTheDocument();
expect(screen.getByText('ccs work')).toBeInTheDocument();
expect(screen.getByText('ccs personal')).toBeInTheDocument();
});
it('uses the active deeper-ready group in optional sync guidance', async () => {
await i18n.changeLanguage('en');
render(
<AccountRouteGuideCard
totalAccounts={2}
isolatedCount={0}
sharedPeerGroups={['sprint-a']}
deeperReadyGroups={['sprint-a']}
/>
);
expect(screen.getByText('deeper sync ready')).toBeInTheDocument();
expect(
screen.getByText(
'ccs auth create work2 --share-context --context-group sprint-a --deeper-continuity'
)
).toBeInTheDocument();
expect(
screen.getByText(
'Use the same group, such as "sprint-a", on both accounts when you want them to share local history.'
)
).toBeInTheDocument();
});
});