Implement target adapter pattern enabling CCS CLI to support multiple backend targets (Claude, Droid) via pluggable adapters. Core additions: - TargetAdapter interface for pluggable target implementations - ClaudeAdapter and DroidAdapter concrete implementations - Target registry (singleton Map-based storage) - Target resolver with precedence: --target flag > per-profile config > busybox detection - Droid config manager with atomic writes and file locking to ~/.factory/settings.json - Droid binary detector to validate runtime environment - Adapter dispatch integrated into ccs.ts main execution flow - ccsd busybox alias for seamless Droid invocation - --target flag documentation in help - Session tracking enriched with target metadata - Dashboard target badge for visual identification Testing: - 43 unit tests covering resolver, registry, config manager, and adapters - Full coverage of target detection logic and edge cases Documentation: - Refactored system-architecture.md into modular docs/system-architecture/ subdirectory - Updated code-standards.md with target adapter guidelines - Updated codebase-summary.md with architecture overview - Updated maintainability baseline (33.8% → 35.2%) This establishes extensible foundation for multi-target support without breaking existing Claude workflows. Droid adapter is production-ready but defaults to Claude for backward compatibility.
18 KiB
CCS Code Standards
Last Updated: 2026-02-04
Code standards, modularization patterns, and conventions for the CCS codebase.
Core Principles
YAGNI (You Aren't Gonna Need It)
- No features "just in case"
- Only implement what is currently needed
- Delete unused code rather than commenting it out
KISS (Keep It Simple, Stupid)
- Prefer simple solutions over clever ones
- Reduce complexity at every opportunity
- Use established patterns over custom implementations
DRY (Don't Repeat Yourself)
- One source of truth for configuration
- Extract common logic into shared utilities
- Use barrel exports to centralize imports
File Organization
Directory Structure Rules
- Domain-based organization: Group files by business domain, not by file type
- Barrel exports required: Every directory must have an
index.tsaggregating exports - Flat within depth: Keep nesting to 3 levels maximum
- Co-location: Keep related files together (component + hooks + utils)
File Naming Conventions
| Convention | Example | When to Use |
|---|---|---|
| kebab-case | cliproxy-executor.ts |
All TypeScript/TSX files |
| kebab-case | profile-detector.ts |
Multi-word file names |
| *-adapter.ts | claude-adapter.ts, droid-adapter.ts |
TargetAdapter implementations |
| *-detector.ts | droid-detector.ts |
Binary detection logic |
| *-manager.ts | droid-config-manager.ts |
Config/state management |
| PascalCase | BinaryManager |
Class exports only |
| camelCase | detectProfile |
Function exports |
File names should be descriptive: LLMs should understand the file's purpose from its name alone without reading content.
Correct Examples
src/cliproxy/binary-manager.ts # Binary management logic
src/commands/doctor-command.ts # Doctor CLI command handler
ui/src/components/cliproxy/provider-editor/index.tsx
Incorrect Examples
src/utils/helper.ts # Too vague
src/cliproxy/manager.ts # Which manager?
ui/src/components/Editor.tsx # Not kebab-case
File Size Limit: 200 Lines
Target: All code files should be under 200 lines.
Exceptions (with justification):
- Data files (model-pricing.ts, model-catalog.ts)
- Entry points with routing logic (ccs.ts)
- Complex transformation logic that cannot be meaningfully split
Why 200 Lines?
- Context efficiency: LLMs process smaller files faster
- Single responsibility: Forces focused, testable modules
- Navigation: Easier to scan and understand
- Maintainability: Reduces merge conflicts
When Files Exceed 200 Lines
If a file grows beyond 200 lines:
-
Identify extraction candidates:
- Helper functions that could be utilities
- Constants and type definitions
- Subcomponents within React components
- Related logic that forms a cohesive unit
-
Create subdirectory structure:
# Before provider-editor.tsx (921 lines) # After provider-editor/ ├── index.tsx # Main component (200 lines) ├── model-mapping-form.tsx ├── endpoint-config.tsx ├── auth-section.tsx ├── hooks.ts ├── types.ts └── utils.ts -
Preserve public API: Main export remains the same through barrel export
Barrel Export Pattern
What is a Barrel Export?
An index.ts file that aggregates and re-exports module contents:
// src/cliproxy/index.ts
// Types (with explicit type keyword)
export type { PlatformInfo, BinaryInfo } from './types';
// Functions
export { detectPlatform } from './platform-detector';
export { BinaryManager } from './binary-manager';
// From subdirectories
export * from './auth';
export * from './services';
Rules for Barrel Exports
- Every domain directory must have
index.ts - Export types with
export typefor tree-shaking - Re-export subdirectories for deep access
- Keep barrel exports flat - no logic, only exports
Import Patterns
// CORRECT: Import from domain barrel
import { execClaudeWithCLIProxy, CLIProxyProvider } from '../cliproxy';
import { Config, Settings } from '../types';
// INCORRECT: Import from specific file (bypasses barrel)
import { execClaudeWithCLIProxy } from '../cliproxy/cliproxy-executor';
Exception: Deep Imports
Allowed when:
- Importing private utilities not exposed in barrel
- Circular dependency avoidance
- Performance-critical tree-shaking
Target Adapter Pattern
The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, etc.) while preserving a unified profile system.
Pattern Overview
Each CLI target implements a TargetAdapter interface:
interface TargetAdapter {
readonly type: TargetType; // 'claude' | 'droid'
readonly displayName: string; // Human-readable name
detectBinary(): TargetBinaryInfo | null; // Find CLI on system
prepareCredentials(creds: TargetCredentials): Promise<void>; // Deliver credentials
buildArgs(profile: string, userArgs: string[]): string[]; // Build CLI args
buildEnv(creds: TargetCredentials, type: string): Env; // Build env vars
exec(args: string[], env: Env): void; // Spawn CLI process
supportsProfileType(type: string): boolean; // Validate profile
}
Key Differences Per Target
| Aspect | Claude | Droid |
|---|---|---|
| Credential delivery | Environment variables | Config file (~/.factory/settings.json) |
| Spawn args | claude <args> |
droid -m custom:ccs-<profile> <args> |
| Config write | None (uses env) | upsertCcsModel() writes to settings |
| Binary detection | detectClaudeCli() |
detectDroidCli() with version check |
Target Resolution Priority
Resolves which adapter to use via resolveTargetType():
1. --target <name> flag (highest priority)
↓
2. Profile config: profileConfig.target field
↓
3. argv[0] detection (busybox pattern):
- ccsd → droid
- ccs → default
↓
4. Fallback: 'claude' (lowest priority)
Registration Pattern
At startup, adapters self-register into the runtime registry:
// In ccs.ts or initialization
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
// Later, when executing
const targetType = resolveTargetType(args, profileConfig);
const adapter = getTarget(targetType);
await adapter.prepareCredentials(credentials);
const spawnArgs = adapter.buildArgs(profile, userArgs);
adapter.exec(spawnArgs, adapter.buildEnv(credentials, profileType));
Adding a New Target
To add support for a new CLI (e.g., newcli):
- Create
src/targets/newcli-adapter.tsimplementingTargetAdapter - Implement each required method (detection, credential delivery, spawning)
- Create
src/targets/newcli-detector.tsfor binary detection logic - Export from
src/targets/index.ts - Register in
ccs.ts:registerTarget(new NewCliAdapter()) - Update
TargetTypeunion to include'newcli'
Monster File Splitting Methodology
When splitting large files (500+ lines), follow this process:
Step 1: Analyze Structure
Identify logical boundaries:
- Render sections in React components
- Handler groups in route files
- Related utility functions
- Constants and types
Step 2: Extract Types First
// types.ts
export interface ProviderEditorProps {
providerId: string;
onSave: (config: ProviderConfig) => void;
}
export interface ModelMappingValues {
model: string;
endpoint: string;
}
Step 3: Extract Utilities
// utils.ts
export function validateEndpoint(url: string): boolean { ... }
export function formatModelName(name: string): string { ... }
Step 4: Extract Hooks
// hooks.ts
export function useProviderConfig(providerId: string) { ... }
export function useModelValidation() { ... }
Step 5: Extract Subcomponents
// model-mapping-form.tsx
export function ModelMappingForm({ values, onChange }: Props) { ... }
Step 6: Compose in Index
// index.tsx
import { ModelMappingForm } from './model-mapping-form';
import { useProviderConfig } from './hooks';
import type { ProviderEditorProps } from './types';
export function ProviderEditor({ providerId, onSave }: ProviderEditorProps) {
const config = useProviderConfig(providerId);
return (
<div>
<ModelMappingForm values={config.mapping} onChange={...} />
</div>
);
}
// Re-export types for consumers
export type { ProviderEditorProps, ModelMappingValues } from './types';
TypeScript Standards
Strict Mode Required
All projects use TypeScript strict mode:
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Type Annotations
// CORRECT: Explicit return types for public functions
export function detectProfile(args: string[]): DetectedProfile { ... }
// CORRECT: Inferred types for internal functions
const formatName = (name: string) => name.trim().toLowerCase();
// INCORRECT: any type
function processData(data: any) { ... } // Use unknown or proper type
Type Exports
// CORRECT: Use type keyword for type-only exports
export type { Config, Settings } from './config';
// CORRECT: Group type exports in barrel
export type {
PlatformInfo,
BinaryInfo,
DownloadProgress,
} from './types';
ESLint Rules (Enforced)
| Rule | Level | Notes |
|---|---|---|
@typescript-eslint/no-unused-vars |
error | Ignore _ prefix |
@typescript-eslint/no-explicit-any |
error | Use proper types |
@typescript-eslint/no-non-null-assertion |
error | No ! assertions |
prefer-const |
error | Immutable by default |
no-var |
error | Use const/let |
eqeqeq |
error | Strict equality |
react-hooks/* |
recommended | (UI only) |
Terminal Output Standards
ASCII Only
// CORRECT
console.log('[OK] Operation successful');
console.log('[!] Warning message');
console.log('[X] Error occurred');
console.log('[i] Information');
// INCORRECT - NO EMOJIS
console.log('Operation successful'); // NO
console.log('Warning message'); // NO
Color Handling
import { colors } from '../utils/ui';
// Colors are TTY-aware and respect NO_COLOR
console.log(colors.green('[OK]') + ' Operation successful');
Box Borders
Use ASCII box drawing for error displays:
+=====================================+
| [X] ERROR: Configuration failed |
| |
| Details: Unable to parse config |
+=====================================+
Cross-Platform Adapter Spawning
When implementing target adapters, handle platform differences for binary spawning:
// Window shell detection (.cmd, .bat, .ps1 require shell)
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath);
if (needsShell) {
// Escape arguments and use shell: true
const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' ');
spawn(cmdString, { shell: true, stdio: 'inherit' });
} else {
// Direct spawn (Unix-like, unshelled Windows executables)
spawn(binaryPath, args, { stdio: 'inherit' });
}
This pattern is used in both ClaudeAdapter and DroidAdapter to ensure cross-platform consistency.
React Component Standards (UI)
Component Structure
// component-name.tsx
// 1. Imports (grouped: react, external, internal, relative)
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useProfiles } from '@/hooks';
import { formatName } from './utils';
import type { ComponentProps } from './types';
// 2. Types (if not in separate file)
interface Props {
id: string;
onSave: () => void;
}
// 3. Component
export function ComponentName({ id, onSave }: Props) {
// Hooks first
const profiles = useProfiles();
const [state, setState] = useState(null);
// Handlers
const handleClick = () => { ... };
// Render
return ( ... );
}
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Component files | kebab-case.tsx | provider-editor.tsx |
| Component exports | PascalCase | ProviderEditor |
| Hook files | use-*.ts | use-profiles.ts |
| Hook exports | useCamelCase | useProfiles |
| Utility files | kebab-case.ts | path-utils.ts |
| Utility exports | camelCase | formatPath |
Input State Persistence Patterns
When building forms and editors that allow users to make changes, follow these patterns to prevent data loss.
Pattern 1: Key-Based Remounting
Use when: Component has complex local state that should reset on prop changes.
// Parent component
<ProfileEditor
key={profileId} // Forces remount when profile changes
profileId={profileId}
onSave={handleSave}
/>
Why: Without key, React reuses the component instance. Local useState values persist even when props change, causing stale data bugs.
Pattern 2: Unsaved Changes Confirmation
Use when: User might navigate away while editing.
// Parent tracks dirty state
const [editorHasChanges, setEditorHasChanges] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
// Child notifies parent of dirty state
useEffect(() => {
onHasChangesUpdate?.(computedHasChanges);
}, [computedHasChanges, onHasChangesUpdate]);
// Intercept navigation
const handleSelect = (id: string) => {
if (editorHasChanges && currentId !== id) {
setPendingSwitch(id); // Show confirmation dialog
} else {
setCurrentId(id);
}
};
Flow:
- Child computes
hasChangesfrom local state vs saved data - Child notifies parent via callback
- Parent intercepts navigation when dirty
- Show confirmation dialog: "Discard & Switch" or "Cancel"
- On confirm: reset dirty state, then switch
Pattern 3: Auto-Save with Visual Feedback
Use when: Simple inputs that should save immediately.
const [saved, setSaved] = useState(false);
const handleBlur = async () => {
if (value !== savedValue) {
await saveToBackend(value);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
};
return (
<div className="flex items-center gap-2">
<Input value={value} onChange={...} onBlur={handleBlur} />
{saved && (
<span className="text-green-600 text-xs flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Saved
</span>
)}
</div>
);
When to use which:
| Scenario | Pattern |
|---|---|
| Complex multi-field editor | Pattern 2 (confirmation dialog) |
| Simple single input | Pattern 3 (auto-save + feedback) |
| List item selection | Pattern 1 (key-based remount) + Pattern 2 |
Quality Gates
Pre-Commit Sequence
# Main project
bun run format
bun run lint:fix
bun run validate
# UI project (if changed)
cd ui
bun run format
bun run lint:fix
bun run validate
Validate Runs
| Project | Command | Checks |
|---|---|---|
| Main | bun run validate |
typecheck + lint + format:check + test |
| UI | bun run validate |
typecheck + lint + format:check |
Conventional Commits
All commits must follow conventional commit format:
<type>(<scope>): <description>
Types
| Type | When to Use | Version Bump |
|---|---|---|
feat |
New feature | MINOR |
fix |
Bug fix | PATCH |
perf |
Performance | PATCH |
docs |
Documentation | None |
style |
Formatting | None |
refactor |
Code restructure | None |
test |
Tests | None |
chore |
Maintenance | None |
Examples
# Correct
git commit -m "feat(cliproxy): add OAuth token refresh"
git commit -m "fix(doctor): handle missing config gracefully"
git commit -m "refactor(ui): split provider-editor into modules"
# Incorrect - REJECTED
git commit -m "added new feature"
git commit -m "Fixed bug"
Anti-Patterns to Avoid
1. God Files
// BAD: One file doing everything
// src/utils.ts (2000 lines with mixed concerns)
// GOOD: Split by domain
// src/utils/ui/colors.ts
// src/utils/ui/boxes.ts
// src/utils/shell-executor.ts
// src/utils/config-manager.ts
2. Barrel Import Bypass
// BAD: Direct import bypassing barrel
import { detectPlatform } from '../cliproxy/platform-detector';
// GOOD: Import from domain barrel
import { detectPlatform } from '../cliproxy';
3. Inline Everything
// BAD: Huge inline functions in components
function Component() {
const handleComplexOperation = () => {
// 100 lines of logic...
};
}
// GOOD: Extract to hooks or utilities
function Component() {
const { handleComplexOperation } = useComplexOperation();
}
4. Type Duplication
// BAD: Same types defined in multiple files
// file1.ts
interface Config { ... }
// file2.ts
interface Config { ... }
// GOOD: Single source of truth
// types/config.ts
export interface Config { ... }
5. Config Priority Pattern
When resolving configuration from multiple sources, follow this priority order:
// proxy-config-resolver.ts pattern
// Priority: CLI flags > Environment variables > config.yaml > defaults
const resolved = {
...DEFAULT_CONFIG, // 4. Defaults (lowest)
...yamlConfig, // 3. config.yaml
...envConfig, // 2. Environment variables
...cliFlags, // 1. CLI flags (highest)
};
This pattern is used in:
src/cliproxy/proxy-config-resolver.ts- Remote proxy configsrc/config/unified-config-loader.ts- Main config loading
Related Documentation
- Codebase Summary - Full directory structure
- System Architecture - Architecture diagrams
- CLAUDE.md - AI-facing development guidance