Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/infra/engines/core/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isEngineModule } from './base.js';
import codexEngine from '../providers/codex/index.js';
import claudeEngine from '../providers/claude/index.js';
import cursorEngine from '../providers/cursor/index.js';
import ccrEngine from '../providers/ccr/index.js';

/**
* Engine Registry - Singleton that manages all available engines
Expand All @@ -32,6 +33,7 @@ class EngineRegistry {
codexEngine,
claudeEngine,
cursorEngine,
ccrEngine,
// Add new engines here
];

Expand Down
1 change: 1 addition & 0 deletions src/infra/engines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './core/index.js';
// Export provider-specific items with namespace
export * as codex from './providers/codex/index.js';
export * as claude from './providers/claude/index.js';
export * as ccr from './providers/ccr/index.js';
165 changes: 165 additions & 0 deletions src/infra/engines/providers/ccr/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { stat, rm, writeFile, mkdir } from 'node:fs/promises';
import * as path from 'node:path';
import { homedir } from 'node:os';
import { execa } from 'execa';

import { expandHomeDir } from '../../../../shared/utils/index.js';
import { metadata } from './metadata.js';

/**
* Check if CLI is installed
*/
async function isCliInstalled(command: string): Promise<boolean> {
try {
const result = await execa(command, ['--version'], { timeout: 3000, reject: false });
if (typeof result.exitCode === 'number' && result.exitCode === 0) return true;
const out = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
if (/not recognized as an internal or external command/i.test(out)) return false;
if (/command not found/i.test(out)) return false;
if (/No such file or directory/i.test(out)) return false;
return false;
} catch {
return false;
}
}

export interface CcrAuthOptions {
ccrConfigDir?: string;
}

/**
* Resolves the CCR config directory (shared for authentication)
*/
export function resolveCcrConfigDir(options?: CcrAuthOptions): string {
if (options?.ccrConfigDir) {
return expandHomeDir(options.ccrConfigDir);
}

if (process.env.CCR_CONFIG_DIR) {
return expandHomeDir(process.env.CCR_CONFIG_DIR);
}

// Authentication is shared globally
return path.join(homedir(), '.codemachine', 'ccr');
}

/**
* Gets the path to the credentials file
* CCR stores it directly in CCR_CONFIG_DIR
*/
export function getCredentialsPath(configDir: string): string {
return path.join(configDir, '.credentials.json');
}

/**
* Gets paths to all CCR-related files that need to be cleaned up
*/
export function getCcrAuthPaths(configDir: string): string[] {
return [
getCredentialsPath(configDir), // .credentials.json
path.join(configDir, '.ccr.json'),
path.join(configDir, '.ccr.json.backup'),
];
}

/**
* Checks if CCR is authenticated
*/
export async function isAuthenticated(options?: CcrAuthOptions): Promise<boolean> {
// Check if token is set via environment variable
if (process.env.CCR_CODE_TOKEN) {
return true;
}

const configDir = resolveCcrConfigDir(options);
const credPath = getCredentialsPath(configDir);

try {
await stat(credPath);
return true;
} catch (_error) {
return false;
}
}

/**
* Ensures CCR is authenticated
* Unlike Claude, CCR doesn't require interactive setup - authentication is done via environment variable
*/
export async function ensureAuth(options?: CcrAuthOptions): Promise<boolean> {
// Check if token is already set via environment variable
if (process.env.CCR_CODE_TOKEN) {
return true;
}

const configDir = resolveCcrConfigDir(options);
const credPath = getCredentialsPath(configDir);

// If already authenticated, nothing to do
try {
await stat(credPath);
return true;
} catch {
// Credentials file doesn't exist
}

if (process.env.CODEMACHINE_SKIP_AUTH === '1') {
// Create a placeholder for testing/dry-run mode
const ccrDir = path.dirname(credPath);
await mkdir(ccrDir, { recursive: true });
await writeFile(credPath, '{}', { encoding: 'utf8' });
return true;
}

// Check if CLI is installed
const cliInstalled = await isCliInstalled(metadata.cliBinary);
if (!cliInstalled) {
console.error(`\n────────────────────────────────────────────────────────────`);
console.error(` ⚠️ ${metadata.name} CLI Not Installed`);
console.error(`────────────────────────────────────────────────────────────`);
console.error(`\nThe '${metadata.cliBinary}' command is not available.`);
console.error(`Please install ${metadata.name} CLI first:\n`);
console.error(` ${metadata.installCommand}\n`);
console.error(`────────────────────────────────────────────────────────────\n`);
throw new Error(`${metadata.name} CLI is not installed.`);
}

// For CCR, authentication is token-based via environment variable
console.error(`\n────────────────────────────────────────────────────────────`);
console.error(` ℹ️ CCR Authentication Notice`);
console.error(`────────────────────────────────────────────────────────────`);
console.error(`\nCCR uses token-based authentication.`);
console.error(`Please set your CCR token as an environment variable:\n`);
console.error(` export CCR_CODE_TOKEN=<your-token>\n`);
console.error(`For persistence, add this line to your shell configuration:`);
console.error(` ~/.bashrc (Bash) or ~/.zshrc (Zsh)\n`);
console.error(`────────────────────────────────────────────────────────────\n`);

throw new Error('Authentication incomplete. Please set CCR_CODE_TOKEN environment variable.');
}

/**
* Clears all CCR authentication data
*/
export async function clearAuth(options?: CcrAuthOptions): Promise<void> {
const configDir = resolveCcrConfigDir(options);
const authPaths = getCcrAuthPaths(configDir);

// Remove all auth-related files
await Promise.all(
authPaths.map(async (authPath) => {
try {
await rm(authPath, { force: true });
} catch (_error) {
// Ignore removal errors; treat as cleared
}
}),
);
}

/**
* Returns the next auth menu action based on current auth state
*/
export async function nextAuthMenuAction(options?: CcrAuthOptions): Promise<'login' | 'logout'> {
return (await isAuthenticated(options)) ? 'logout' : 'login';
}
72 changes: 72 additions & 0 deletions src/infra/engines/providers/ccr/execution/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export interface CcrCommandOptions {
workingDir: string;
prompt: string;
model?: string;
}

export interface CcrCommand {
command: string;
args: string[];
}

/**
* Model mapping from config models to CCR model names
* If model is not in this map, it will be passed as-is to CCR
*/
const MODEL_MAP: Record<string, string> = {
'gpt-5-codex': 'sonnet', // Map to Claude Sonnet equivalent
'gpt-4': 'sonnet',
'gpt-3.5-turbo': 'haiku',
};

/**
* Maps a model name from config to CCR's model naming convention
* Returns undefined if the model should use CCR's default
*/
function mapModel(model?: string): string | undefined {
if (!model) {
return undefined;
}

// If it's in our mapping, use the mapped value
if (model in MODEL_MAP) {
return MODEL_MAP[model];
}

// If it's already a Claude model name (which CCR uses), pass it through
if (model.startsWith('claude-') || model === 'sonnet' || model === 'opus' || model === 'haiku') {
return model;
}

// Otherwise, don't use a model flag and let CCR use its default
return undefined;
}

export function buildCcrExecCommand(options: CcrCommandOptions): CcrCommand {
const { model } = options;

// Base args: --print for non-interactive mode, similar to Claude but using ccr code
const args: string[] = [
'code',
'--print',
'--output-format',
'stream-json',
'--verbose',
'--dangerously-skip-permissions',
'--permission-mode',
'bypassPermissions',
];

// Add model if specified and valid
const mappedModel = mapModel(model);
if (mappedModel) {
args.push('--model', mappedModel);
}

// Prompt is now passed via stdin instead of as an argument
// Call ccr code - the runner passes cwd and prompt to spawnProcess
return {
command: 'ccr',
args,
};
}
105 changes: 105 additions & 0 deletions src/infra/engines/providers/ccr/execution/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as path from 'node:path';

import { runCcr } from './runner.js';
import { MemoryAdapter } from '../../../../fs/memory-adapter.js';
import { MemoryStore } from '../../../../../agents/index.js';

export interface RunAgentOptions {
abortSignal?: AbortSignal;
logger?: (chunk: string) => void;
stderrLogger?: (chunk: string) => void;
timeout?: number; // Timeout in milliseconds (default: 1800000ms = 30 minutes)
model?: string; // Model to use (e.g., 'sonnet', 'opus', 'haiku')
}

export function shouldSkipCcr(): boolean {
return process.env.CODEMACHINE_SKIP_CCR === '1';
}

export async function runCcrPrompt(options: {
agentId: string;
prompt: string;
cwd: string;
model?: string;
}): Promise<void> {
if (shouldSkipCcr()) {
console.log(`[dry-run] ${options.agentId}: ${options.prompt.slice(0, 80)}...`);
return;
}

await runCcr({
prompt: options.prompt,
workingDir: options.cwd,
model: options.model,
onData: (chunk) => {
try {
process.stdout.write(chunk);
} catch {
// Ignore stdout write errors
}
},
onErrorData: (chunk) => {
try {
process.stderr.write(chunk);
} catch {
// Ignore stderr write errors
}
},
});
}

export async function runAgent(
agentId: string,
prompt: string,
cwd: string,
options: RunAgentOptions = {},
): Promise<string> {
const logStdout: (chunk: string) => void = options.logger
?? ((chunk: string) => {
try {
process.stdout.write(chunk);
} catch {
// Ignore stdout write errors
}
});
const logStderr: (chunk: string) => void = options.stderrLogger
?? ((chunk: string) => {
try {
process.stderr.write(chunk);
} catch {
// Ignore stderr write errors
}
});

if (shouldSkipCcr()) {
logStdout(`[dry-run] ${agentId}: ${prompt.slice(0, 120)}...`);
return '';
}

let buffered = '';
const result = await runCcr({
prompt,
workingDir: cwd,
model: options.model,
abortSignal: options.abortSignal,
timeout: options.timeout,
onData: (chunk) => {
buffered += chunk;
logStdout(chunk);
},
onErrorData: (chunk) => {
logStderr(chunk);
},
});

const stdout = buffered || result.stdout || '';
try {
const memoryDir = path.resolve(cwd, '.codemachine', 'memory');
const adapter = new MemoryAdapter(memoryDir);
const store = new MemoryStore(adapter);
await store.append({ agentId, content: stdout, timestamp: new Date().toISOString() });
} catch {
// best-effort memory persistence
}
return stdout;
}
3 changes: 3 additions & 0 deletions src/infra/engines/providers/ccr/execution/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './commands.js';
export * from './executor.js';
export * from './runner.js';
Loading