Skip to content

Commit db10ce0

Browse files
authored
Merge pull request #14 from apraditya/ccr-support
feat: add CCR (Claude Code Router) engine support
2 parents b138f72 + ab8fe86 commit db10ce0

File tree

13 files changed

+1019
-0
lines changed

13 files changed

+1019
-0
lines changed

src/infra/engines/core/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { isEngineModule } from './base.js';
99
import codexEngine from '../providers/codex/index.js';
1010
import claudeEngine from '../providers/claude/index.js';
1111
import cursorEngine from '../providers/cursor/index.js';
12+
import ccrEngine from '../providers/ccr/index.js';
1213

1314
/**
1415
* Engine Registry - Singleton that manages all available engines
@@ -32,6 +33,7 @@ class EngineRegistry {
3233
codexEngine,
3334
claudeEngine,
3435
cursorEngine,
36+
ccrEngine,
3537
// Add new engines here
3638
];
3739

src/infra/engines/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './core/index.js';
44
// Export provider-specific items with namespace
55
export * as codex from './providers/codex/index.js';
66
export * as claude from './providers/claude/index.js';
7+
export * as ccr from './providers/ccr/index.js';
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { stat, rm, writeFile, mkdir } from 'node:fs/promises';
2+
import * as path from 'node:path';
3+
import { homedir } from 'node:os';
4+
import { execa } from 'execa';
5+
6+
import { expandHomeDir } from '../../../../shared/utils/index.js';
7+
import { metadata } from './metadata.js';
8+
9+
/**
10+
* Check if CLI is installed
11+
*/
12+
async function isCliInstalled(command: string): Promise<boolean> {
13+
try {
14+
const result = await execa(command, ['--version'], { timeout: 3000, reject: false });
15+
if (typeof result.exitCode === 'number' && result.exitCode === 0) return true;
16+
const out = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
17+
if (/not recognized as an internal or external command/i.test(out)) return false;
18+
if (/command not found/i.test(out)) return false;
19+
if (/No such file or directory/i.test(out)) return false;
20+
return false;
21+
} catch {
22+
return false;
23+
}
24+
}
25+
26+
export interface CcrAuthOptions {
27+
ccrConfigDir?: string;
28+
}
29+
30+
/**
31+
* Resolves the CCR config directory (shared for authentication)
32+
*/
33+
export function resolveCcrConfigDir(options?: CcrAuthOptions): string {
34+
if (options?.ccrConfigDir) {
35+
return expandHomeDir(options.ccrConfigDir);
36+
}
37+
38+
if (process.env.CCR_CONFIG_DIR) {
39+
return expandHomeDir(process.env.CCR_CONFIG_DIR);
40+
}
41+
42+
// Authentication is shared globally
43+
return path.join(homedir(), '.codemachine', 'ccr');
44+
}
45+
46+
/**
47+
* Gets the path to the credentials file
48+
* CCR stores it directly in CCR_CONFIG_DIR
49+
*/
50+
export function getCredentialsPath(configDir: string): string {
51+
return path.join(configDir, '.credentials.json');
52+
}
53+
54+
/**
55+
* Gets paths to all CCR-related files that need to be cleaned up
56+
*/
57+
export function getCcrAuthPaths(configDir: string): string[] {
58+
return [
59+
getCredentialsPath(configDir), // .credentials.json
60+
path.join(configDir, '.ccr.json'),
61+
path.join(configDir, '.ccr.json.backup'),
62+
];
63+
}
64+
65+
/**
66+
* Checks if CCR is authenticated
67+
*/
68+
export async function isAuthenticated(options?: CcrAuthOptions): Promise<boolean> {
69+
// Check if token is set via environment variable
70+
if (process.env.CCR_CODE_TOKEN) {
71+
return true;
72+
}
73+
74+
const configDir = resolveCcrConfigDir(options);
75+
const credPath = getCredentialsPath(configDir);
76+
77+
try {
78+
await stat(credPath);
79+
return true;
80+
} catch (_error) {
81+
return false;
82+
}
83+
}
84+
85+
/**
86+
* Ensures CCR is authenticated
87+
* Unlike Claude, CCR doesn't require interactive setup - authentication is done via environment variable
88+
*/
89+
export async function ensureAuth(options?: CcrAuthOptions): Promise<boolean> {
90+
// Check if token is already set via environment variable
91+
if (process.env.CCR_CODE_TOKEN) {
92+
return true;
93+
}
94+
95+
const configDir = resolveCcrConfigDir(options);
96+
const credPath = getCredentialsPath(configDir);
97+
98+
// If already authenticated, nothing to do
99+
try {
100+
await stat(credPath);
101+
return true;
102+
} catch {
103+
// Credentials file doesn't exist
104+
}
105+
106+
if (process.env.CODEMACHINE_SKIP_AUTH === '1') {
107+
// Create a placeholder for testing/dry-run mode
108+
const ccrDir = path.dirname(credPath);
109+
await mkdir(ccrDir, { recursive: true });
110+
await writeFile(credPath, '{}', { encoding: 'utf8' });
111+
return true;
112+
}
113+
114+
// Check if CLI is installed
115+
const cliInstalled = await isCliInstalled(metadata.cliBinary);
116+
if (!cliInstalled) {
117+
console.error(`\n────────────────────────────────────────────────────────────`);
118+
console.error(` ⚠️ ${metadata.name} CLI Not Installed`);
119+
console.error(`────────────────────────────────────────────────────────────`);
120+
console.error(`\nThe '${metadata.cliBinary}' command is not available.`);
121+
console.error(`Please install ${metadata.name} CLI first:\n`);
122+
console.error(` ${metadata.installCommand}\n`);
123+
console.error(`────────────────────────────────────────────────────────────\n`);
124+
throw new Error(`${metadata.name} CLI is not installed.`);
125+
}
126+
127+
// For CCR, authentication is token-based via environment variable
128+
console.error(`\n────────────────────────────────────────────────────────────`);
129+
console.error(` ℹ️ CCR Authentication Notice`);
130+
console.error(`────────────────────────────────────────────────────────────`);
131+
console.error(`\nCCR uses token-based authentication.`);
132+
console.error(`Please set your CCR token as an environment variable:\n`);
133+
console.error(` export CCR_CODE_TOKEN=<your-token>\n`);
134+
console.error(`For persistence, add this line to your shell configuration:`);
135+
console.error(` ~/.bashrc (Bash) or ~/.zshrc (Zsh)\n`);
136+
console.error(`────────────────────────────────────────────────────────────\n`);
137+
138+
throw new Error('Authentication incomplete. Please set CCR_CODE_TOKEN environment variable.');
139+
}
140+
141+
/**
142+
* Clears all CCR authentication data
143+
*/
144+
export async function clearAuth(options?: CcrAuthOptions): Promise<void> {
145+
const configDir = resolveCcrConfigDir(options);
146+
const authPaths = getCcrAuthPaths(configDir);
147+
148+
// Remove all auth-related files
149+
await Promise.all(
150+
authPaths.map(async (authPath) => {
151+
try {
152+
await rm(authPath, { force: true });
153+
} catch (_error) {
154+
// Ignore removal errors; treat as cleared
155+
}
156+
}),
157+
);
158+
}
159+
160+
/**
161+
* Returns the next auth menu action based on current auth state
162+
*/
163+
export async function nextAuthMenuAction(options?: CcrAuthOptions): Promise<'login' | 'logout'> {
164+
return (await isAuthenticated(options)) ? 'logout' : 'login';
165+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
export interface CcrCommandOptions {
2+
workingDir: string;
3+
prompt: string;
4+
model?: string;
5+
}
6+
7+
export interface CcrCommand {
8+
command: string;
9+
args: string[];
10+
}
11+
12+
/**
13+
* Model mapping from config models to CCR model names
14+
* If model is not in this map, it will be passed as-is to CCR
15+
*/
16+
const MODEL_MAP: Record<string, string> = {
17+
'gpt-5-codex': 'sonnet', // Map to Claude Sonnet equivalent
18+
'gpt-4': 'sonnet',
19+
'gpt-3.5-turbo': 'haiku',
20+
};
21+
22+
/**
23+
* Maps a model name from config to CCR's model naming convention
24+
* Returns undefined if the model should use CCR's default
25+
*/
26+
function mapModel(model?: string): string | undefined {
27+
if (!model) {
28+
return undefined;
29+
}
30+
31+
// If it's in our mapping, use the mapped value
32+
if (model in MODEL_MAP) {
33+
return MODEL_MAP[model];
34+
}
35+
36+
// If it's already a Claude model name (which CCR uses), pass it through
37+
if (model.startsWith('claude-') || model === 'sonnet' || model === 'opus' || model === 'haiku') {
38+
return model;
39+
}
40+
41+
// Otherwise, don't use a model flag and let CCR use its default
42+
return undefined;
43+
}
44+
45+
export function buildCcrExecCommand(options: CcrCommandOptions): CcrCommand {
46+
const { model } = options;
47+
48+
// Base args: --print for non-interactive mode, similar to Claude but using ccr code
49+
const args: string[] = [
50+
'code',
51+
'--print',
52+
'--output-format',
53+
'stream-json',
54+
'--verbose',
55+
'--dangerously-skip-permissions',
56+
'--permission-mode',
57+
'bypassPermissions',
58+
];
59+
60+
// Add model if specified and valid
61+
const mappedModel = mapModel(model);
62+
if (mappedModel) {
63+
args.push('--model', mappedModel);
64+
}
65+
66+
// Prompt is now passed via stdin instead of as an argument
67+
// Call ccr code - the runner passes cwd and prompt to spawnProcess
68+
return {
69+
command: 'ccr',
70+
args,
71+
};
72+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import * as path from 'node:path';
2+
3+
import { runCcr } from './runner.js';
4+
import { MemoryAdapter } from '../../../../fs/memory-adapter.js';
5+
import { MemoryStore } from '../../../../../agents/index.js';
6+
7+
export interface RunAgentOptions {
8+
abortSignal?: AbortSignal;
9+
logger?: (chunk: string) => void;
10+
stderrLogger?: (chunk: string) => void;
11+
timeout?: number; // Timeout in milliseconds (default: 1800000ms = 30 minutes)
12+
model?: string; // Model to use (e.g., 'sonnet', 'opus', 'haiku')
13+
}
14+
15+
export function shouldSkipCcr(): boolean {
16+
return process.env.CODEMACHINE_SKIP_CCR === '1';
17+
}
18+
19+
export async function runCcrPrompt(options: {
20+
agentId: string;
21+
prompt: string;
22+
cwd: string;
23+
model?: string;
24+
}): Promise<void> {
25+
if (shouldSkipCcr()) {
26+
console.log(`[dry-run] ${options.agentId}: ${options.prompt.slice(0, 80)}...`);
27+
return;
28+
}
29+
30+
await runCcr({
31+
prompt: options.prompt,
32+
workingDir: options.cwd,
33+
model: options.model,
34+
onData: (chunk) => {
35+
try {
36+
process.stdout.write(chunk);
37+
} catch {
38+
// Ignore stdout write errors
39+
}
40+
},
41+
onErrorData: (chunk) => {
42+
try {
43+
process.stderr.write(chunk);
44+
} catch {
45+
// Ignore stderr write errors
46+
}
47+
},
48+
});
49+
}
50+
51+
export async function runAgent(
52+
agentId: string,
53+
prompt: string,
54+
cwd: string,
55+
options: RunAgentOptions = {},
56+
): Promise<string> {
57+
const logStdout: (chunk: string) => void = options.logger
58+
?? ((chunk: string) => {
59+
try {
60+
process.stdout.write(chunk);
61+
} catch {
62+
// Ignore stdout write errors
63+
}
64+
});
65+
const logStderr: (chunk: string) => void = options.stderrLogger
66+
?? ((chunk: string) => {
67+
try {
68+
process.stderr.write(chunk);
69+
} catch {
70+
// Ignore stderr write errors
71+
}
72+
});
73+
74+
if (shouldSkipCcr()) {
75+
logStdout(`[dry-run] ${agentId}: ${prompt.slice(0, 120)}...`);
76+
return '';
77+
}
78+
79+
let buffered = '';
80+
const result = await runCcr({
81+
prompt,
82+
workingDir: cwd,
83+
model: options.model,
84+
abortSignal: options.abortSignal,
85+
timeout: options.timeout,
86+
onData: (chunk) => {
87+
buffered += chunk;
88+
logStdout(chunk);
89+
},
90+
onErrorData: (chunk) => {
91+
logStderr(chunk);
92+
},
93+
});
94+
95+
const stdout = buffered || result.stdout || '';
96+
try {
97+
const memoryDir = path.resolve(cwd, '.codemachine', 'memory');
98+
const adapter = new MemoryAdapter(memoryDir);
99+
const store = new MemoryStore(adapter);
100+
await store.append({ agentId, content: stdout, timestamp: new Date().toISOString() });
101+
} catch {
102+
// best-effort memory persistence
103+
}
104+
return stdout;
105+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './commands.js';
2+
export * from './executor.js';
3+
export * from './runner.js';

0 commit comments

Comments
 (0)