mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 08:18:04 +00:00
feat(targets): add multi-target CLI adapter system (Droid support)
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.
This commit is contained in:
1 parent
8ab78f039c
commit
7d7054e2c0
27 files changed
+3157
-766
No files matched your search
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Unit tests for Droid config manager
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
upsertCcsModel,
|
||||
removeCcsModel,
|
||||
listCcsModels,
|
||||
pruneOrphanedModels,
|
||||
} from '../../../src/targets/droid-config-manager';
|
||||
|
||||
describe('droid-config-manager', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('upsertCcsModel', () => {
|
||||
it('should create settings.json with customModels', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('CCS gemini');
|
||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317');
|
||||
});
|
||||
|
||||
it('should update existing entry on second upsert', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'key-1',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8318',
|
||||
apiKey: 'key-2',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].apiKey).toBe('key-2');
|
||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
|
||||
});
|
||||
|
||||
it('should preserve user entries', async () => {
|
||||
// Create existing settings with user's own custom model
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
displayName: 'My GPT',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
apiKey: 'sk-xxx',
|
||||
provider: 'openai',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')
|
||||
);
|
||||
expect(settings.customModels).toHaveLength(2);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
expect(settings.customModels[1].displayName).toBe('CCS gemini');
|
||||
});
|
||||
|
||||
it('should write with restricted permissions', async () => {
|
||||
await upsertCcsModel('test', {
|
||||
model: 'test-model',
|
||||
displayName: 'CCS test',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'secret',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
const stat = fs.statSync(settingsPath);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const otherPerms = stat.mode & 0o077;
|
||||
expect(otherPerms).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCcsModel', () => {
|
||||
it('should remove a CCS entry', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
await removeCcsModel('gemini');
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not remove user entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
{ model: 'opus', displayName: 'CCS gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await removeCcsModel('gemini');
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCcsModels', () => {
|
||||
it('should list only CCS entries', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'opus',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
await upsertCcsModel('codex', {
|
||||
model: 'sonnet',
|
||||
displayName: 'CCS codex',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(2);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
expect(models.has('codex')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty map when no settings file', async () => {
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneOrphanedModels', () => {
|
||||
it('should remove orphaned CCS entries', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'opus',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'x',
|
||||
apiKey: 'y',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
await upsertCcsModel('codex', {
|
||||
model: 'sonnet',
|
||||
displayName: 'CCS codex',
|
||||
baseUrl: 'x',
|
||||
apiKey: 'y',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
// Only gemini is active — codex should be pruned
|
||||
const removed = await pruneOrphanedModels(['gemini']);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve user entries during prune', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
{ model: 'opus', displayName: 'CCS old-profile', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const removed = await pruneOrphanedModels([]);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent writes', () => {
|
||||
it('should handle concurrent upserts without data loss', async () => {
|
||||
// Write in batches of 3 to simulate realistic concurrency
|
||||
// (10 simultaneous locks exceeds retry budget)
|
||||
const profiles = Array.from({ length: 9 }, (_, i) => `profile-${i}`);
|
||||
|
||||
for (let i = 0; i < profiles.length; i += 3) {
|
||||
const batch = profiles.slice(i, i + 3);
|
||||
await Promise.all(
|
||||
batch.map((p) =>
|
||||
upsertCcsModel(p, {
|
||||
model: 'test-model',
|
||||
displayName: `CCS ${p}`,
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'key',
|
||||
provider: 'anthropic',
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(9);
|
||||
|
||||
for (const p of profiles) {
|
||||
expect(models.has(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Unit tests for target registry and adapters
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
registerTarget,
|
||||
getTarget,
|
||||
getDefaultTarget,
|
||||
hasTarget,
|
||||
getRegisteredTargets,
|
||||
ClaudeAdapter,
|
||||
DroidAdapter,
|
||||
} from '../../../src/targets';
|
||||
|
||||
describe('target-registry', () => {
|
||||
beforeEach(() => {
|
||||
// Re-register adapters (registry is module-scoped singleton)
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
});
|
||||
|
||||
it('should register and retrieve claude adapter', () => {
|
||||
const adapter = getTarget('claude');
|
||||
expect(adapter.type).toBe('claude');
|
||||
expect(adapter.displayName).toBe('Claude Code');
|
||||
});
|
||||
|
||||
it('should register and retrieve droid adapter', () => {
|
||||
const adapter = getTarget('droid');
|
||||
expect(adapter.type).toBe('droid');
|
||||
expect(adapter.displayName).toBe('Factory Droid');
|
||||
});
|
||||
|
||||
it('should return claude as default target', () => {
|
||||
const adapter = getDefaultTarget();
|
||||
expect(adapter.type).toBe('claude');
|
||||
});
|
||||
|
||||
it('should throw for unknown target', () => {
|
||||
expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/);
|
||||
});
|
||||
|
||||
it('should check target existence', () => {
|
||||
expect(hasTarget('claude')).toBe(true);
|
||||
expect(hasTarget('droid')).toBe(true);
|
||||
expect(hasTarget('unknown' as never)).toBe(false);
|
||||
});
|
||||
|
||||
it('should list registered targets', () => {
|
||||
const targets = getRegisteredTargets();
|
||||
expect(targets).toContain('claude');
|
||||
expect(targets).toContain('droid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClaudeAdapter', () => {
|
||||
const adapter = new ClaudeAdapter();
|
||||
|
||||
it('should have correct type and displayName', () => {
|
||||
expect(adapter.type).toBe('claude');
|
||||
expect(adapter.displayName).toBe('Claude Code');
|
||||
});
|
||||
|
||||
it('should support all profile types', () => {
|
||||
expect(adapter.supportsProfileType('account')).toBe(true);
|
||||
expect(adapter.supportsProfileType('settings')).toBe(true);
|
||||
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
|
||||
expect(adapter.supportsProfileType('default')).toBe(true);
|
||||
expect(adapter.supportsProfileType('copilot')).toBe(true);
|
||||
});
|
||||
|
||||
it('should build env with credentials', () => {
|
||||
const env = adapter.buildEnv(
|
||||
{
|
||||
profile: 'gemini',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-opus-4-6',
|
||||
},
|
||||
'settings'
|
||||
);
|
||||
|
||||
expect(env['ANTHROPIC_BASE_URL']).toBe('https://api.example.com');
|
||||
expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('test-key');
|
||||
expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('should pass through args unchanged', () => {
|
||||
const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']);
|
||||
expect(args).toEqual(['-p', 'hello', '--verbose']);
|
||||
});
|
||||
|
||||
it('prepareCredentials should be no-op', async () => {
|
||||
// Should not throw
|
||||
await adapter.prepareCredentials({
|
||||
profile: 'test',
|
||||
baseUrl: 'x',
|
||||
apiKey: 'y',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DroidAdapter', () => {
|
||||
const adapter = new DroidAdapter();
|
||||
|
||||
it('should have correct type and displayName', () => {
|
||||
expect(adapter.type).toBe('droid');
|
||||
expect(adapter.displayName).toBe('Factory Droid');
|
||||
});
|
||||
|
||||
it('should NOT support account profile type', () => {
|
||||
expect(adapter.supportsProfileType('account')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support non-account profile types', () => {
|
||||
expect(adapter.supportsProfileType('settings')).toBe(true);
|
||||
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
|
||||
expect(adapter.supportsProfileType('default')).toBe(true);
|
||||
expect(adapter.supportsProfileType('copilot')).toBe(true);
|
||||
});
|
||||
|
||||
it('should build args with -m custom:ccs- prefix', () => {
|
||||
const args = adapter.buildArgs('gemini', ['--verbose']);
|
||||
expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']);
|
||||
});
|
||||
|
||||
it('should build minimal env (no ANTHROPIC_ vars)', () => {
|
||||
const env = adapter.buildEnv(
|
||||
{
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
},
|
||||
'cliproxy'
|
||||
);
|
||||
|
||||
// Droid uses config file, not env vars
|
||||
expect(env['ANTHROPIC_BASE_URL']).toBeUndefined();
|
||||
expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Unit tests for target resolver
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target-resolver';
|
||||
|
||||
describe('resolveTargetType', () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
it('should return claude as default', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should detect --target flag', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'droid'])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect --target claude', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should use per-profile config when no flag', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([], { target: 'droid' })).toBe('droid');
|
||||
});
|
||||
|
||||
it('should prioritize --target flag over profile config', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude');
|
||||
});
|
||||
|
||||
it('should detect ccsd argv[0] (busybox pattern)', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .cmd extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.cmd'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .bat extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.bat'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should not match ccsd with .exe extension', () => {
|
||||
// .exe is not stripped — ccsd.exe won't match 'ccsd' in the map
|
||||
// This is intentional — npm creates .cmd shims, not .exe
|
||||
process.argv = ['node', 'ccsd.exe'];
|
||||
expect(resolveTargetType([])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should prioritize --target over argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should prioritize profile config over argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType([], { target: 'claude' })).toBe('claude');
|
||||
});
|
||||
|
||||
it('should throw for invalid --target value', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(() => resolveTargetType(['--target', 'invalid'])).toThrow(/Unknown target "invalid"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripTargetFlag', () => {
|
||||
it('should remove --target and its value', () => {
|
||||
expect(stripTargetFlag(['gemini', '--target', 'droid'])).toEqual(['gemini']);
|
||||
});
|
||||
|
||||
it('should handle --target at start', () => {
|
||||
expect(stripTargetFlag(['--target', 'droid', 'gemini'])).toEqual(['gemini']);
|
||||
});
|
||||
|
||||
it('should return args unchanged if no --target', () => {
|
||||
const args = ['gemini', '-p', 'hello'];
|
||||
expect(stripTargetFlag(args)).toEqual(['gemini', '-p', 'hello']);
|
||||
});
|
||||
|
||||
it('should not modify the original array', () => {
|
||||
const args = ['--target', 'droid', 'gemini'];
|
||||
stripTargetFlag(args);
|
||||
expect(args).toEqual(['--target', 'droid', 'gemini']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user