diff --git a/AGENTS.md b/AGENTS.md index ea5dab1..4492f83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # Repository Guidelines +**Generated:** 2026-01-04 | **Commit:** 649f42a | **Branch:** main + ## Project Structure ``` @@ -102,12 +104,12 @@ Location: `~/.local/bin/` ### Provider Auth Modes -| Provider | Auth Mode | Key Variable | -|----------|-----------|--------------| -| zai, minimax, custom | API Key | `ANTHROPIC_API_KEY` | -| openrouter | Auth Token | `ANTHROPIC_AUTH_TOKEN` | -| ccrouter | Optional | placeholder token | -| mirror | None | user authenticates normally | +| Provider | Auth Mode | Key Variable | +| -------------------- | ---------- | --------------------------- | +| zai, minimax, custom | API Key | `ANTHROPIC_API_KEY` | +| openrouter | Auth Token | `ANTHROPIC_AUTH_TOKEN` | +| ccrouter | Optional | placeholder token | +| mirror | None | user authenticates normally | ### Model Mapping (env vars) @@ -118,14 +120,18 @@ Location: `~/.local/bin/` ## Team Mode -Team mode patches `cli.js` to enable Task* tools for multi-agent collaboration. +Team mode patches `cli.js` to enable Task\* tools for multi-agent collaboration. ### How It Works ```javascript // Target function in cli.js -function sU() { return !1; } // disabled (default) -function sU() { return !0; } // enabled (patched) +function sU() { + return !1; +} // disabled (default) +function sU() { + return !0; +} // enabled (patched) ``` - Backup stored at `cli.js.backup` before patching @@ -140,10 +146,10 @@ function sU() { return !0; } // enabled (patched) ### Agent Identity Env Vars -| Variable | Purpose | -|----------|---------| -| `CLAUDE_CODE_TEAM_NAME` | Team namespace for task storage | -| `CLAUDE_CODE_AGENT_ID` | Unique identifier for this agent | +| Variable | Purpose | +| ------------------------ | ----------------------------------- | +| `CLAUDE_CODE_TEAM_NAME` | Team namespace for task storage | +| `CLAUDE_CODE_AGENT_ID` | Unique identifier for this agent | | `CLAUDE_CODE_AGENT_TYPE` | Agent role: `team-lead` or `worker` | ## Provider Blocked Tools @@ -151,20 +157,22 @@ function sU() { return !0; } // enabled (patched) Providers can block tools via TweakCC toolsets. Defined in `src/brands/*.ts`. **zai blocked tools:** + ```typescript export const ZAI_BLOCKED_TOOLS = [ - 'mcp__4_5v_mcp__analyze_image', // Server-injected + 'mcp__4_5v_mcp__analyze_image', // Server-injected 'mcp__milk_tea_server__claim_milk_tea_coupon', 'mcp__web_reader__webReader', - 'WebSearch', // Use zai-cli search - 'WebFetch', // Use zai-cli read + 'WebSearch', // Use zai-cli search + 'WebFetch', // Use zai-cli read ]; ``` **minimax blocked tools:** + ```typescript export const MINIMAX_BLOCKED_TOOLS = [ - 'WebSearch', // Use mcp__MiniMax__web_search + 'WebSearch', // Use mcp__MiniMax__web_search ]; ``` @@ -179,15 +187,15 @@ export const MINIMAX_BLOCKED_TOOLS = [ ## Common Development Tasks -| Task | Location | -|------|----------| -| Add/update provider | `src/providers/index.ts` | -| Add/update brand theme | `src/brands/*.ts` | -| Add blocked tools | `src/brands/zai.ts` or `minimax.ts` → `*_BLOCKED_TOOLS` | -| Modify prompt pack overlays | `src/core/prompt-pack/providers/*.ts` | -| Add build step | `src/core/variant-builder/steps/` | -| Add TUI screen | `src/tui/screens/` + `app.tsx` + `router/routes.ts` | -| Add team pack prompt | `src/team-pack/*.md` + `TEAM_PACK_FILES` in `index.ts` | +| Task | Location | +| --------------------------- | ------------------------------------------------------- | +| Add/update provider | `src/providers/index.ts` | +| Add/update brand theme | `src/brands/*.ts` | +| Add blocked tools | `src/brands/zai.ts` or `minimax.ts` → `*_BLOCKED_TOOLS` | +| Modify prompt pack overlays | `src/core/prompt-pack/providers/*.ts` | +| Add build step | `src/core/variant-builder/steps/` | +| Add TUI screen | `src/tui/screens/` + `app.tsx` + `router/routes.ts` | +| Add team pack prompt | `src/team-pack/*.md` + `TEAM_PACK_FILES` in `index.ts` | ## Debugging & Verification @@ -277,6 +285,7 @@ npm test -- --test-name-pattern="TUI" # TUI tests only ``` Key test files: + - `test/e2e/creation.test.ts` - Variant creation for all providers - `test/e2e/team-mode.test.ts` - Team mode + team pack - `test/e2e/blocked-tools.test.ts` - Provider blocked tools diff --git a/package-lock.json b/package-lock.json index 874903e..bb9cc91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-mirror", - "version": "1.0.2", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-mirror", - "version": "1.0.2", + "version": "1.1.5", "license": "MIT", "dependencies": { "ink": "^6.6.0", diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md new file mode 100644 index 0000000..c5ed99e --- /dev/null +++ b/src/core/AGENTS.md @@ -0,0 +1,41 @@ +# Core Module + +Public API for variant management. All CLI/TUI operations flow through `index.ts`. + +## Public API + +| Export | Purpose | +| ------------------------------------------ | -------------------------------------------------------- | +| `createVariant()` / `createVariantAsync()` | Build new variant (sync for CLI, async for TUI progress) | +| `updateVariant()` / `updateVariantAsync()` | Reinstall npm + reapply config | +| `removeVariant()` | Delete variant directory | +| `doctor()` | Health check all variants | +| `listVariants()` | Enumerate `~/.cc-mirror/` | +| `tweakVariant()` | Launch tweakcc UI | + +## Module Map + +| File | Role | +| ------------------ | -------------------------------------------------- | +| `constants.ts` | `DEFAULT_ROOT`, `DEFAULT_BIN_DIR`, `DEFAULT_NPM_*` | +| `paths.ts` | `expandTilde()` helper | +| `fs.ts` | `ensureDir()`, `writeJsonFile()` | +| `variants.ts` | Load/list variant metadata | +| `wrapper.ts` | Generate wrapper shell scripts | +| `tweakcc.ts` | TweakCC config management | +| `claude-config.ts` | `.claude.json` generation | +| `install.ts` | npm package installation | +| `prompt-pack.ts` | Provider prompt overlay resolution | +| `skills.ts` | Skill installation (dev-browser) | +| `shell-env.ts` | Write API keys to shell profile | + +## Subdirectories + +- `variant-builder/` - Step-based build orchestration (see its AGENTS.md) +- `prompt-pack/` - Per-provider system prompt overlays + +## Conventions + +- Sync functions for CLI, async variants for TUI (yields to event loop) +- All paths resolved via `expandTilde()` before use +- Errors thrown as `Error` with descriptive message diff --git a/src/core/index.ts b/src/core/index.ts index 0efefbd..2c689e1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -18,6 +18,8 @@ import type { export { DEFAULT_ROOT, DEFAULT_BIN_DIR, DEFAULT_NPM_PACKAGE, DEFAULT_NPM_VERSION }; export { expandTilde } from './paths.js'; +export { syncVariants, syncVariantsAsync, createConfigBackup, restoreConfigBackup } from './sync.js'; +export type { SyncItem, SyncOptions, SyncResult, SyncItemResult } from './sync.js'; export const createVariant = (params: CreateVariantParams): CreateVariantResult => { return new VariantBuilder(false).build(params); diff --git a/src/core/sync.ts b/src/core/sync.ts new file mode 100644 index 0000000..20ee395 --- /dev/null +++ b/src/core/sync.ts @@ -0,0 +1,382 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { ensureDir, readJson, writeJson } from './fs.js'; + +export type SyncItem = 'skills' | 'mcp-servers' | 'permissions' | 'claude-md'; + +export interface SyncOptions { + items: SyncItem[]; + createBackup: boolean; +} + +export interface SyncItemResult { + copied: number; + skipped: number; + errors: string[]; +} + +export interface SyncResult { + target: string; + success: boolean; + backupPath?: string; + itemResults: Partial>; +} + +type ClaudeConfig = { + mcpServers?: Record; + [key: string]: unknown; +}; + +type McpServerConfig = { + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: string[]; + transport?: string; +}; + +type SettingsFile = { + env?: Record; + permissions?: { + allow?: string[]; + ask?: string[]; + deny?: string[]; + }; + [key: string]: unknown; +}; + +const PROVIDER_ENV_PREFIXES = [ + 'ANTHROPIC_', + 'CC_MIRROR_', + 'TWEAKCC_', + 'CLAUDE_CODE_TEAM_', + 'CLAUDE_CODE_AGENT_', + 'Z_AI_', + 'MINIMAX_', + 'OPENROUTER_', +]; + +const BACKUP_DIR_NAME = 'config.backup'; +const CLAUDE_CONFIG_FILE = '.claude.json'; +const SETTINGS_FILE = 'settings.json'; +const SKILLS_DIR = 'skills'; +const CLAUDE_MD_FILE = 'CLAUDE.md'; + +export const createConfigBackup = (variantDir: string): string => { + const configDir = path.join(variantDir, 'config'); + const backupDir = path.join(variantDir, BACKUP_DIR_NAME); + + if (!fs.existsSync(configDir)) { + throw new Error(`Config directory not found: ${configDir}`); + } + + if (fs.existsSync(backupDir)) { + fs.rmSync(backupDir, { recursive: true, force: true }); + } + + fs.cpSync(configDir, backupDir, { recursive: true }); + + const metaPath = path.join(backupDir, '.backup-meta.json'); + writeJson(metaPath, { + createdAt: new Date().toISOString(), + source: 'sync', + }); + + return backupDir; +}; + +export const restoreConfigBackup = (variantDir: string): boolean => { + const configDir = path.join(variantDir, 'config'); + const backupDir = path.join(variantDir, BACKUP_DIR_NAME); + + if (!fs.existsSync(backupDir)) { + return false; + } + + if (fs.existsSync(configDir)) { + fs.rmSync(configDir, { recursive: true, force: true }); + } + + fs.cpSync(backupDir, configDir, { recursive: true }); + + const metaPath = path.join(configDir, '.backup-meta.json'); + if (fs.existsSync(metaPath)) { + fs.unlinkSync(metaPath); + } + + return true; +}; + +const isProviderEnvKey = (key: string): boolean => { + return PROVIDER_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)); +}; + +const syncSkills = (sourceConfigDir: string, targetConfigDir: string): SyncItemResult => { + const result: SyncItemResult = { copied: 0, skipped: 0, errors: [] }; + const sourceSkillsDir = path.join(sourceConfigDir, SKILLS_DIR); + const targetSkillsDir = path.join(targetConfigDir, SKILLS_DIR); + + if (!fs.existsSync(sourceSkillsDir)) { + result.skipped = 1; + return result; + } + + try { + ensureDir(targetSkillsDir); + + const skills = fs.readdirSync(sourceSkillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()); + + for (const skill of skills) { + const sourceSkillPath = path.join(sourceSkillsDir, skill.name); + const targetSkillPath = path.join(targetSkillsDir, skill.name); + + try { + if (fs.existsSync(targetSkillPath)) { + fs.rmSync(targetSkillPath, { recursive: true, force: true }); + } + fs.cpSync(sourceSkillPath, targetSkillPath, { recursive: true }); + result.copied++; + } catch (err) { + result.errors.push(`Failed to copy skill ${skill.name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } catch (err) { + result.errors.push(`Failed to sync skills: ${err instanceof Error ? err.message : String(err)}`); + } + + return result; +}; + +const syncMcpServers = (sourceConfigDir: string, targetConfigDir: string): SyncItemResult => { + const result: SyncItemResult = { copied: 0, skipped: 0, errors: [] }; + const sourceConfigPath = path.join(sourceConfigDir, CLAUDE_CONFIG_FILE); + const targetConfigPath = path.join(targetConfigDir, CLAUDE_CONFIG_FILE); + + const sourceConfig = readJson(sourceConfigPath); + if (!sourceConfig?.mcpServers || Object.keys(sourceConfig.mcpServers).length === 0) { + result.skipped = 1; + return result; + } + + try { + const targetConfig = readJson(targetConfigPath) || {}; + const existingServers = targetConfig.mcpServers || {}; + + const mergedServers = { ...existingServers }; + for (const [name, config] of Object.entries(sourceConfig.mcpServers)) { + mergedServers[name] = config; + result.copied++; + } + + const updatedConfig: ClaudeConfig = { + ...targetConfig, + mcpServers: mergedServers, + }; + + writeJson(targetConfigPath, updatedConfig); + } catch (err) { + result.errors.push(`Failed to sync MCP servers: ${err instanceof Error ? err.message : String(err)}`); + } + + return result; +}; + +const syncPermissions = (sourceConfigDir: string, targetConfigDir: string): SyncItemResult => { + const result: SyncItemResult = { copied: 0, skipped: 0, errors: [] }; + const sourceSettingsPath = path.join(sourceConfigDir, SETTINGS_FILE); + const targetSettingsPath = path.join(targetConfigDir, SETTINGS_FILE); + + const sourceSettings = readJson(sourceSettingsPath); + if (!sourceSettings?.permissions) { + result.skipped = 1; + return result; + } + + try { + const targetSettings = readJson(targetSettingsPath) || {}; + + const mergedPermissions = { + allow: sourceSettings.permissions.allow || [], + ask: sourceSettings.permissions.ask || [], + deny: sourceSettings.permissions.deny || [], + }; + + const targetEnv = targetSettings.env || {}; + const sourceEnv = sourceSettings.env || {}; + + const mergedEnv: Record = { ...targetEnv }; + for (const [key, value] of Object.entries(sourceEnv)) { + if (!isProviderEnvKey(key)) { + mergedEnv[key] = value; + result.copied++; + } + } + + const updatedSettings: SettingsFile = { + ...targetSettings, + env: mergedEnv, + permissions: mergedPermissions, + }; + + writeJson(targetSettingsPath, updatedSettings); + } catch (err) { + result.errors.push(`Failed to sync permissions: ${err instanceof Error ? err.message : String(err)}`); + } + + return result; +}; + +const syncClaudeMd = (sourceConfigDir: string, targetConfigDir: string): SyncItemResult => { + const result: SyncItemResult = { copied: 0, skipped: 0, errors: [] }; + const sourcePath = path.join(sourceConfigDir, CLAUDE_MD_FILE); + const targetPath = path.join(targetConfigDir, CLAUDE_MD_FILE); + + if (!fs.existsSync(sourcePath)) { + result.skipped = 1; + return result; + } + + try { + fs.copyFileSync(sourcePath, targetPath); + result.copied = 1; + } catch (err) { + result.errors.push(`Failed to sync CLAUDE.md: ${err instanceof Error ? err.message : String(err)}`); + } + + return result; +}; + +export const syncVariants = (sourceDir: string, targetDirs: string[], options: SyncOptions): SyncResult[] => { + const results: SyncResult[] = []; + const sourceConfigDir = path.join(sourceDir, 'config'); + + if (!fs.existsSync(sourceConfigDir)) { + throw new Error(`Source config directory not found: ${sourceConfigDir}`); + } + + for (const targetDir of targetDirs) { + const result: SyncResult = { + target: path.basename(targetDir), + success: true, + itemResults: {}, + }; + + const targetConfigDir = path.join(targetDir, 'config'); + + if (options.createBackup) { + try { + result.backupPath = createConfigBackup(targetDir); + } catch (err) { + result.success = false; + const backupErrorMessage = `Backup failed: ${err instanceof Error ? err.message : String(err)}`; + for (const item of options.items) { + result.itemResults[item] = { + copied: 0, + skipped: 0, + errors: [backupErrorMessage], + }; + } + results.push(result); + continue; + } + } + + for (const item of options.items) { + switch (item) { + case 'skills': + result.itemResults[item] = syncSkills(sourceConfigDir, targetConfigDir); + break; + case 'mcp-servers': + result.itemResults[item] = syncMcpServers(sourceConfigDir, targetConfigDir); + break; + case 'permissions': + result.itemResults[item] = syncPermissions(sourceConfigDir, targetConfigDir); + break; + case 'claude-md': + result.itemResults[item] = syncClaudeMd(sourceConfigDir, targetConfigDir); + break; + } + + const itemResult = result.itemResults[item]; + if (itemResult && itemResult.errors.length > 0) { + result.success = false; + } + } + + results.push(result); + } + + return results; +}; + +export const syncVariantsAsync = async ( + sourceDir: string, + targetDirs: string[], + options: SyncOptions, + onProgress?: (target: string, item: SyncItem) => void +): Promise => { + const results: SyncResult[] = []; + const sourceConfigDir = path.join(sourceDir, 'config'); + + if (!fs.existsSync(sourceConfigDir)) { + throw new Error(`Source config directory not found: ${sourceConfigDir}`); + } + + for (const targetDir of targetDirs) { + const result: SyncResult = { + target: path.basename(targetDir), + success: true, + itemResults: {}, + }; + + const targetConfigDir = path.join(targetDir, 'config'); + + if (options.createBackup) { + try { + result.backupPath = createConfigBackup(targetDir); + } catch (err) { + result.success = false; + const backupErrorMessage = `Backup failed: ${err instanceof Error ? err.message : String(err)}`; + for (const item of options.items) { + result.itemResults[item] = { + copied: 0, + skipped: 0, + errors: [backupErrorMessage], + }; + } + results.push(result); + continue; + } + } + + for (const item of options.items) { + onProgress?.(result.target, item); + await new Promise((resolve) => setImmediate(resolve)); + + switch (item) { + case 'skills': + result.itemResults[item] = syncSkills(sourceConfigDir, targetConfigDir); + break; + case 'mcp-servers': + result.itemResults[item] = syncMcpServers(sourceConfigDir, targetConfigDir); + break; + case 'permissions': + result.itemResults[item] = syncPermissions(sourceConfigDir, targetConfigDir); + break; + case 'claude-md': + result.itemResults[item] = syncClaudeMd(sourceConfigDir, targetConfigDir); + break; + } + + const itemResult = result.itemResults[item]; + if (itemResult && itemResult.errors.length > 0) { + result.success = false; + } + } + + results.push(result); + } + + return results; +}; diff --git a/src/core/variant-builder/AGENTS.md b/src/core/variant-builder/AGENTS.md new file mode 100644 index 0000000..eec919e --- /dev/null +++ b/src/core/variant-builder/AGENTS.md @@ -0,0 +1,64 @@ +# Variant Builder + +Step-based orchestration for variant create/update. Eliminates sync/async duplication. + +## Architecture + +``` +VariantBuilder + ├── initContext(params) → BuildContext + ├── build() → sync execution + └── buildAsync() → async execution with progress + +VariantUpdater (same pattern for updates) +``` + +## BuildStep Interface + +```typescript +interface BuildStep { + name: string; + execute(ctx: BuildContext): void; + executeAsync(ctx: BuildContext): Promise; +} +``` + +## Build Order (10 steps) + +| # | Step | Creates | +| --- | ---------------------- | ---------------------------------------------------------- | +| 1 | PrepareDirectoriesStep | `variantDir/`, `configDir/`, `tweakDir/`, `npmDir/` | +| 2 | InstallNpmStep | `npm/node_modules/@anthropic-ai/claude-code/` | +| 3 | WriteConfigStep | `config/settings.json`, `config/.claude.json` | +| 4 | BrandThemeStep | `tweakcc/config.json` (must precede TeamModeStep) | +| 5 | TeamModeStep | Patches `cli.js` for Task\* tools, configures team toolset | +| 6 | TweakccStep | Applies theme + prompt pack to `tweakcc/system-prompts/` | +| 7 | WrapperStep | `~/.local/bin/` wrapper script | +| 8 | ShellEnvStep | `~/.zshrc` or `~/.bashrc` (zai only) | +| 9 | SkillInstallStep | `config/skills/` (zai/minimax only) | +| 10 | FinalizeStep | `variant.json` metadata | + +## Adding a Step + +1. Create `steps/NewStep.ts` implementing `BuildStep` +2. Add to `this.steps` array in `VariantBuilder` constructor +3. Order matters - BrandTheme must precede TeamMode + +## BuildContext + +```typescript +interface BuildContext { + params: CreateVariantParams; + provider: ProviderTemplate; + paths: BuildPaths; // variantDir, configDir, tweakDir, etc. + prefs: BuildPreferences; // promptPackEnabled, skillInstallEnabled, etc. + state: BuildState; // binaryPath, notes, meta (accumulated) + report: ReportFn; // Progress callback + isAsync: boolean; +} +``` + +## Anti-Patterns + +- **Never skip BrandThemeStep before TeamModeStep** - team toolset needs `tweakcc/config.json` +- **Never modify `this.steps` at runtime** - order is fixed at construction diff --git a/src/tui/AGENTS.md b/src/tui/AGENTS.md new file mode 100644 index 0000000..b0903dd --- /dev/null +++ b/src/tui/AGENTS.md @@ -0,0 +1,68 @@ +# TUI Module + +Ink-based terminal wizard for variant management. + +## Entry Point + +`index.tsx` renders `` from `app.tsx`. + +## Structure + +| Directory | Purpose | +| ---------------- | --------------------------------------------------------- | +| `screens/` | One component per wizard screen (17 screens) | +| `components/ui/` | Reusable primitives (Frame, TextField, YesNoSelect, etc.) | +| `hooks/` | Business logic (useVariantCreate, useVariantUpdate, etc.) | +| `content/` | Static text, haikus, provider descriptions | +| `state/` | Type definitions for screen states | +| `router/` | Route metadata (parent relationships) | + +## Screen State Machine + +Routing via `useState('home')` in `app.tsx`. ESC navigation hardcoded in `useInput` handler. + +Key screens: + +- `home` - Main menu +- `quick-*` - Quick setup flow +- `create-*` - Full create wizard +- `manage-*` - Variant management +- `doctor` - Health check + +## Hooks Pattern + +Business logic extracted to hooks in `hooks/`: + +```typescript +useVariantCreate({ screen, params, core, setProgressLines, setScreen, onComplete }) +useVariantUpdate({ screen, selectedVariant, ... }) +useUpdateAll({ screen, rootDir, binDir, ... }) +``` + +Each hook watches `screen` state and triggers operations when appropriate screen is reached. + +## UI Components + +| Component | Usage | +| ------------------ | ---------------------------------- | +| `Frame` | Border wrapper with optional color | +| `Header` | Title + subtitle | +| `TextField` | Labeled text input | +| `YesNoSelect` | Boolean selection | +| `Menu` | General selection list | +| `HintBar` | Bottom help hints | +| `ProgressScreen` | Shows operation progress lines | +| `CompletionScreen` | Shows success summary | + +## Conventions + +- Screen names: `{flow}-{step}` (e.g., `create-api-key`, `manage-update`) +- Done screens: `*-done` suffix +- All screens return early with `if (screen === '...')` pattern +- Ink testing: use `ink-testing-library` with helpers from `test/helpers/ink-helpers.ts` + +## Known Technical Debt + +- `app.tsx` is 1269 lines (monolithic) +- `state/` module exists but App uses inline useState +- ESC navigation is hardcoded switch statement diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 6db2840..5a68d69 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -25,8 +25,10 @@ import { useUpdateAll, useModelConfig, useTeamModeToggle, + useSync, type CompletionResult, } from './hooks/index.js'; +import type { SyncItem } from '../core/sync.js'; // Import clean screen components import { @@ -46,6 +48,8 @@ import { AboutScreen, FeedbackScreen, TeamModeScreen, + SyncSourceScreen, + SyncTargetsScreen, } from './screens/index.js'; // Import UI components @@ -234,6 +238,9 @@ export const App: React.FC = ({ const [selectedVariant, setSelectedVariant] = useState<(VariantMeta & { wrapperPath: string }) | null>(null); const [doctorReport, setDoctorReport] = useState([]); const [apiKeyDetectedFrom, setApiKeyDetectedFrom] = useState(null); + const [syncSourceVariant, setSyncSourceVariant] = useState(''); + const [syncTargetVariants, setSyncTargetVariants] = useState([]); + const syncItems: SyncItem[] = ['skills', 'mcp-servers', 'permissions', 'claude-md']; // Include experimental providers to show "Coming Soon" in UI const providerList = useMemo(() => providers.listProviders(true), [providers]); @@ -330,12 +337,22 @@ export const App: React.FC = ({ case 'manage-models-done': setScreen('manage-actions'); break; + // Sync screens - back steps + case 'sync-source': + setScreen('home'); + break; + case 'sync-targets': + setScreen('sync-source'); + break; + case 'sync-running': + break; // Completion/done screens - back to home case 'create-done': case 'manage-update-done': case 'manage-tweak-done': case 'manage-remove-done': case 'updateAll-done': + case 'sync-done': setScreen('home'); break; // Doctor screen - home @@ -357,7 +374,7 @@ export const App: React.FC = ({ }); useEffect(() => { - if (screen === 'manage') { + if (screen === 'manage' || screen === 'sync-source') { setVariants(core.listVariants(rootDir)); } }, [screen, rootDir, core]); @@ -496,6 +513,18 @@ export const App: React.FC = ({ onComplete: handleOperationComplete, }); + // Sync variants operation + useSync({ + screen, + rootDir, + sourceVariant: syncSourceVariant, + targetVariants: syncTargetVariants, + syncItems, + setProgressLines, + setScreen, + onComplete: handleOperationComplete, + }); + const resetWizard = () => { setProviderKey(null); setBrandKey('auto'); @@ -547,6 +576,11 @@ export const App: React.FC = ({ setScreen('create-provider'); } if (value === 'manage') setScreen('manage'); + if (value === 'sync') { + setSyncSourceVariant(''); + setSyncTargetVariants([]); + setScreen('sync-source'); + } if (value === 'updateAll') setScreen('updateAll'); if (value === 'doctor') setScreen('doctor'); if (value === 'about') setScreen('about'); @@ -1248,6 +1282,61 @@ export const App: React.FC = ({ ); } + if (screen === 'sync-source') { + return ( + ({ + name: v.name, + provider: v.meta?.provider, + wrapperPath: path.join(binDir, v.name), + }))} + onSelect={(variantName) => { + setSyncSourceVariant(variantName); + setScreen('sync-targets'); + }} + onBack={() => setScreen('home')} + /> + ); + } + + if (screen === 'sync-targets') { + return ( + ({ + name: v.name, + provider: v.meta?.provider, + }))} + sourceVariant={syncSourceVariant} + onConfirm={(selected) => { + setSyncTargetVariants(selected); + setProgressLines([]); + setScreen('sync-running'); + }} + onBack={() => setScreen('sync-source')} + /> + ); + } + + if (screen === 'sync-running') { + return ; + } + + if (screen === 'sync-done') { + return ( + { + if (value === 'home') setScreen('home'); + else setScreen('exit'); + }} + /> + ); + } + if (screen === 'doctor') { return setScreen('home')} />; } diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts index ef9f53a..062e9a7 100644 --- a/src/tui/hooks/index.ts +++ b/src/tui/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useVariantUpdate.js'; export * from './useUpdateAll.js'; export * from './useModelConfig.js'; export * from './useTeamModeToggle.js'; +export * from './useSync.js'; diff --git a/src/tui/hooks/useSync.ts b/src/tui/hooks/useSync.ts new file mode 100644 index 0000000..a90e3f2 --- /dev/null +++ b/src/tui/hooks/useSync.ts @@ -0,0 +1,128 @@ +import { useEffect, useRef } from 'react'; +import path from 'node:path'; +import type { SyncItem, SyncOptions, SyncResult } from '../../core/sync.js'; +import { syncVariantsAsync } from '../../core/sync.js'; +import type { CompletionResult } from './types.js'; + +export interface UseSyncOptions { + screen: string; + rootDir: string; + sourceVariant: string; + targetVariants: string[]; + syncItems: SyncItem[]; + setProgressLines: (updater: (prev: string[]) => string[]) => void; + setScreen: (screen: string) => void; + onComplete: (result: CompletionResult) => void; +} + +const SYNC_ITEM_LABELS: Record = { + skills: 'Skills', + 'mcp-servers': 'MCP Servers', + permissions: 'Permissions', + 'claude-md': 'CLAUDE.md', +}; + +export function useSync(options: UseSyncOptions): void { + const { screen, rootDir, sourceVariant, targetVariants, syncItems, setProgressLines, setScreen, onComplete } = + options; + + const isRunningRef = useRef(false); + + useEffect(() => { + if (screen !== 'sync-running') return; + if (isRunningRef.current) return; + isRunningRef.current = true; + let cancelled = false; + + const runSync = async () => { + if (targetVariants.length === 0) { + onComplete({ + doneLines: ['No target variants selected.'], + summary: [], + nextSteps: [], + help: [], + }); + setScreen('sync-done'); + return; + } + + setProgressLines(() => [`Syncing from ${sourceVariant} to ${targetVariants.length} variant(s)...`, '']); + + const sourceDir = path.join(rootDir, sourceVariant); + const targetDirs = targetVariants.map((name) => path.join(rootDir, name)); + + const syncOptions: SyncOptions = { + items: syncItems, + createBackup: true, + }; + + try { + const results = await syncVariantsAsync(sourceDir, targetDirs, syncOptions, (target, item) => { + if (cancelled) return; + setProgressLines((prev) => [...prev, ` ${target}: syncing ${SYNC_ITEM_LABELS[item]}...`]); + }); + + if (cancelled) return; + + const successCount = results.filter((r) => r.success).length; + const failCount = results.length - successCount; + + const summary = buildSummary(results); + const doneLines = failCount === 0 ? ['Sync completed successfully.'] : ['Sync completed with errors.']; + + const nextSteps = + failCount > 0 ? ['Check errors above', 'Restore from backup if needed'] : ['Run any variant to verify']; + + onComplete({ + doneLines, + summary, + nextSteps, + help: [`Synced: ${successCount}`, `Failed: ${failCount}`], + }); + } catch (error) { + if (cancelled) return; + const message = error instanceof Error ? error.message : String(error); + onComplete({ + doneLines: [`Sync failed: ${message}`], + summary: [], + nextSteps: ['Check source variant exists', 'Verify file permissions'], + help: [], + }); + } + + if (!cancelled) { + isRunningRef.current = false; + setScreen('sync-done'); + } + }; + + runSync(); + return () => { + cancelled = true; + isRunningRef.current = false; + }; + }, [screen, rootDir, sourceVariant, targetVariants, syncItems, setProgressLines, setScreen, onComplete]); +} + +function buildSummary(results: SyncResult[]): string[] { + const lines: string[] = []; + + for (const result of results) { + const status = result.success ? '[OK]' : '[FAIL]'; + lines.push(`${status} ${result.target}`); + + for (const [item, itemResult] of Object.entries(result.itemResults)) { + if (!itemResult) continue; + const label = SYNC_ITEM_LABELS[item as SyncItem]; + if (itemResult.errors.length > 0) { + lines.push(` ${label}: ${itemResult.errors[0]}`); + } else if (itemResult.skipped > 0) { + lines.push(` ${label}: skipped (not present in source)`); + } else { + lines.push(` ${label}: ${itemResult.copied} copied`); + } + } + } + + return lines; +} diff --git a/src/tui/screens/HomeScreen.tsx b/src/tui/screens/HomeScreen.tsx index 10064f1..5bb58ab 100644 --- a/src/tui/screens/HomeScreen.tsx +++ b/src/tui/screens/HomeScreen.tsx @@ -64,7 +64,12 @@ export const HomeScreen: React.FC = ({ onSelect }) => { { value: 'quick', label: 'Quick Setup', description: 'Provider + API key → Ready in 30s', icon: 'star' }, { value: 'create', label: 'New Variant', description: 'Full configuration wizard' }, { value: 'manage', label: 'Manage Variants', description: 'Update, remove, or inspect' }, - { value: 'updateAll', label: 'Update All', description: 'Sync all variants to latest' }, + { + value: 'sync', + label: 'Sync Variants', + description: 'Copy skills, MCP servers, CLAUDE.md, and permissions between variants', + }, + { value: 'updateAll', label: 'Update All', description: 'Reinstall Claude Code on all variants' }, { value: 'doctor', label: 'Diagnostics', description: 'Health check all variants' }, { value: 'about', label: 'About', description: 'Learn how CC-MIRROR works' }, { value: 'feedback', label: 'Feedback', description: 'Links, issues, and contributions' }, diff --git a/src/tui/screens/SyncSourceScreen.tsx b/src/tui/screens/SyncSourceScreen.tsx new file mode 100644 index 0000000..d4f0874 --- /dev/null +++ b/src/tui/screens/SyncSourceScreen.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { ScreenLayout } from '../components/ui/ScreenLayout.js'; +import { VariantCard } from '../components/ui/Menu.js'; +import { EmptyVariantsArt } from '../components/ui/AsciiArt.js'; +import { colors, icons } from '../components/ui/theme.js'; + +interface Variant { + name: string; + provider?: string; + wrapperPath?: string; +} + +interface SyncSourceScreenProps { + variants: Variant[]; + onSelect: (name: string) => void; + onBack: () => void; +} + +export const SyncSourceScreen: React.FC = ({ variants, onSelect, onBack }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const totalItems = variants.length + 1; + + useInput((input, key) => { + if (key.upArrow) { + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); + } + if (key.downArrow) { + setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); + } + if (key.return) { + if (selectedIndex === variants.length) { + onBack(); + } else if (variants[selectedIndex]) { + onSelect(variants[selectedIndex].name); + } + } + if (key.escape) { + onBack(); + } + }); + + const isBackSelected = selectedIndex === variants.length; + + return ( + + + {variants.length === 0 ? ( + + ) : ( + variants.map((variant, idx) => ( + + )) + )} + + + + {isBackSelected ? icons.pointer : icons.pointerEmpty}{' '} + + + Back {icons.arrowLeft} + + + + + ); +}; diff --git a/src/tui/screens/SyncTargetsScreen.tsx b/src/tui/screens/SyncTargetsScreen.tsx new file mode 100644 index 0000000..1e6c213 --- /dev/null +++ b/src/tui/screens/SyncTargetsScreen.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { ScreenLayout } from '../components/ui/ScreenLayout.js'; +import { colors, icons } from '../components/ui/theme.js'; + +interface Variant { + name: string; + provider?: string; +} + +interface SyncTargetsScreenProps { + variants: Variant[]; + sourceVariant: string; + onConfirm: (selected: string[]) => void; + onBack: () => void; +} + +export const SyncTargetsScreen: React.FC = ({ variants, sourceVariant, onConfirm, onBack }) => { + const availableVariants = variants.filter((v) => v.name !== sourceVariant); + const [selectedIndex, setSelectedIndex] = useState(0); + const [selected, setSelected] = useState>(new Set()); + + const totalItems = availableVariants.length + 2; + const confirmIndex = availableVariants.length; + const backIndex = availableVariants.length + 1; + + useInput((input, key) => { + if (key.upArrow) { + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); + } + if (key.downArrow) { + setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); + } + if (input === ' ' && selectedIndex < availableVariants.length) { + const variantName = availableVariants[selectedIndex].name; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(variantName)) { + next.delete(variantName); + } else { + next.add(variantName); + } + return next; + }); + } + if (key.return) { + if (selectedIndex === confirmIndex) { + if (selected.size > 0) { + onConfirm(Array.from(selected)); + } + } else if (selectedIndex === backIndex) { + onBack(); + } else if (selectedIndex < availableVariants.length) { + const variantName = availableVariants[selectedIndex].name; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(variantName)) { + next.delete(variantName); + } else { + next.add(variantName); + } + return next; + }); + } + } + if (key.escape) { + onBack(); + } + }); + + const isConfirmSelected = selectedIndex === confirmIndex; + const isBackSelected = selectedIndex === backIndex; + + return ( + + + {availableVariants.length === 0 ? ( + No other variants available to sync to. + ) : ( + availableVariants.map((variant, idx) => { + const isSelected = idx === selectedIndex; + const isChecked = selected.has(variant.name); + return ( + + + {isSelected ? icons.pointer : icons.pointerEmpty}{' '} + + {isChecked ? '[x]' : '[ ]'} + + {variant.name} + + {variant.provider && ({variant.provider})} + + ); + }) + )} + + + + + {isConfirmSelected ? icons.pointer : icons.pointerEmpty}{' '} + + 0 ? (isConfirmSelected ? colors.success : colors.text) : colors.textMuted} + bold={isConfirmSelected} + > + Sync to {selected.size} variant{selected.size !== 1 ? 's' : ''} {icons.arrowRight} + + + + + {isBackSelected ? icons.pointer : icons.pointerEmpty}{' '} + + + Back {icons.arrowLeft} + + + + + + ); +}; diff --git a/src/tui/screens/index.ts b/src/tui/screens/index.ts index b348c88..a2c3e62 100644 --- a/src/tui/screens/index.ts +++ b/src/tui/screens/index.ts @@ -20,3 +20,5 @@ export { EnvEditorScreen } from './EnvEditorScreen.js'; export { AboutScreen } from './AboutScreen.js'; export { FeedbackScreen } from './FeedbackScreen.js'; export { TeamModeScreen } from './TeamModeScreen.js'; +export { SyncSourceScreen } from './SyncSourceScreen.js'; +export { SyncTargetsScreen } from './SyncTargetsScreen.js'; diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 0000000..76c8156 --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,82 @@ +# Test Suite + +Node.js built-in test runner with `tsx` for TypeScript. + +## Commands + +```bash +npm test # All tests +npm test -- --test-name-pattern="E2E" # E2E only +npm test -- --test-name-pattern="TUI" # TUI only +npm run test:coverage # With c8 coverage +``` + +## Structure + +| Directory | Tests | +| ---------- | -------------------------------------------------- | +| `e2e/` | Variant creation, team mode, blocked tools, doctor | +| `tui/` | Screen components, navigation, hooks | +| `cli/` | Argument parsing, doctor output | +| `core/` | Wrapper generation, tweakcc | +| `helpers/` | Test utilities (see below) | + +## Test Helpers + +Import from `test/helpers/index.js`: + +| Helper | Purpose | +| -------------------------------- | ----------------------------------- | +| `makeTempDir(prefix?)` | Create temp directory | +| `cleanup(dir)` | Recursive delete | +| `writeExecutable(path, content)` | Write with 0o755 | +| `makeCore()` | Mock core module with call tracking | +| `tick()` | Wait 30ms for Ink updates | +| `send(stdin, input)` | Send key input + tick | +| `waitFor(predicate)` | Poll until true (50 attempts) | +| `KEYS` | `{ up, down, enter, escape, tab }` | +| `withFakeNpm(fn)` | Run with mock npm in PATH | + +## Patterns + +### E2E Test with Cleanup + +```typescript +test('E2E: Feature', async (t) => { + const dirs: string[] = []; + t.after(() => dirs.forEach(cleanup)); + + await t.test('case', async () => { + const dir = makeTempDir(); + dirs.push(dir); + // test + }); +}); +``` + +### TUI Component Test + +```typescript +test('Screen', async () => { + const app = render(React.createElement(Screen, { onSelect })); + await send(app.stdin, KEYS.enter); + assert.ok(app.lastFrame()?.includes('expected')); + app.unmount(); +}); +``` + +### Simple Unit Test + +```typescript +test('function', () => { + const result = fn(input); + assert.equal(result, expected); +}); +``` + +## Conventions + +- File naming: `*.test.ts` or `*.test.tsx` +- Always use strict assertions: `import assert from 'node:assert/strict'` +- Cleanup: `try/finally` for simple tests, `t.after()` for suites +- No real npm downloads in tests - use `withFakeNpm()` diff --git a/test/tui.test.ts b/test/tui.test.ts index 35dca97..3b8c93d 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -135,6 +135,7 @@ test('TUI update all flow', async () => { await tick(); await send(app.stdin, down); // create await send(app.stdin, down); // manage + await send(app.stdin, down); // sync await send(app.stdin, down); // updateAll await send(app.stdin, enter); await tick(); @@ -160,6 +161,7 @@ test('TUI doctor flow', async () => { await tick(); await send(app.stdin, down); // create await send(app.stdin, down); // manage + await send(app.stdin, down); // sync await send(app.stdin, down); // updateAll await send(app.stdin, down); // doctor await send(app.stdin, enter);