claude-code-router/packages/core/src/profiles/launch-core.ts
2026-07-05 19:57:39 +08:00

314 lines
10 KiB
TypeScript

import path from "node:path";
import type { AppConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app";
import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment";
import { resolveZcodeConfigFile } from "@ccr/core/agents/zcode/profile-config";
export type ProfileLaunchPlan = {
args: string[];
command: string;
env: Record<string, string>;
profile: ProfileConfig;
surface: ProfileOpenSurface;
};
export type ProfileLaunchSpawnCommand = {
args: string[];
command: string;
windowsVerbatimArguments?: boolean;
};
export function findProfileForOpen(config: Pick<AppConfig, "profile">, profileRef: string): ProfileConfig {
const needle = profileRef.trim();
if (!needle) {
throw new Error("Profile name is required.");
}
const profiles = config.profile.profiles.filter((profile) => profile.enabled);
const exactId = profiles.find((profile) => profile.id === needle);
if (exactId) {
return exactId;
}
const normalizedNeedle = normalizeLookupValue(needle);
const matches = profiles.filter((profile) =>
normalizeLookupValue(profile.name) === normalizedNeedle ||
normalizeLookupValue(profile.id) === normalizedNeedle ||
sanitizePathSegment(profile.name) === normalizedNeedle ||
sanitizePathSegment(profile.id) === normalizedNeedle
);
if (matches.length === 1) {
return matches[0];
}
if (matches.length > 1) {
throw new Error(`Profile "${needle}" is ambiguous. Use the profile ID instead.`);
}
throw new Error(`Profile "${needle}" was not found or is disabled.`);
}
export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[] {
if (profile.agent === "zcode") {
return ["app"];
}
const surface = normalizeProfileSurface(profile.surface);
if (surface === "cli") {
return ["cli"];
}
if (surface === "app") {
return ["app"];
}
return ["cli", "app"];
}
export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: ProfileOpenSurface): ProfileOpenSurface {
const surfaces = profileOpenSurfaces(profile);
if (surface) {
if (!surfaces.includes(surface)) {
throw new Error(`${profile.name || profile.id} does not support ${surface.toUpperCase()} opening.`);
}
return surface;
}
return surfaces[0];
}
export function defaultProfileOpenSurface(profile: Pick<ProfileConfig, "agent">): ProfileOpenSurface {
return profile.agent === "zcode" ? "app" : "cli";
}
export function profileOpenCommand(
profile: ProfileConfig,
surface: ProfileOpenSurface = defaultProfileOpenSurface(profile),
command = "ccr",
profileRef = profile.name?.trim() || profile.id
): string {
const quote = process.platform === "win32" ? windowsCommandQuote : shellQuote;
const parts = [quote(command), quote(profileRef)];
if (surface === "app") {
parts.push(surface);
}
return parts.join(" ");
}
export function buildProfileLaunchPlan(
configDir: string,
profile: ProfileConfig,
surface: ProfileOpenSurface,
extraArgs: string[] = []
): ProfileLaunchPlan {
const resolvedSurface = resolveProfileOpenSurface(profile, surface);
if (isCodexCompatibleAgent(profile.agent)) {
return buildCodexLaunchPlan(configDir, profile, resolvedSurface, extraArgs);
}
return buildClaudeCodeLaunchPlan(configDir, profile, resolvedSurface, extraArgs);
}
export function profileLaunchSpawnCommand(plan: Pick<ProfileLaunchPlan, "args" | "command">): ProfileLaunchSpawnCommand {
if (!isWindowsCommandScript(plan.command)) {
return {
args: plan.args,
command: plan.command
};
}
let cmdLine = `"${plan.command}"`;
if (plan.args && plan.args.length > 0) {
cmdLine += " " + plan.args.join(" ");
}
return {
args: ["/d", "/v:off", "/c", cmdLine],
command: process.env.ComSpec || process.env.COMSPEC || "cmd.exe",
windowsVerbatimArguments: true
};
}
export function ccrManagedProfileDir(configDir: string, profile: ProfileConfig): string {
const slug = sanitizePathSegment(profile.id || profile.name || profile.agent);
const baseDir = path.join(configDir, "profiles", slug || "profile");
return profile.scope === "custom" ? path.join(baseDir, "custom") : baseDir;
}
export function resolveClaudeCodeSettingsFile(configDir: string, profile: ProfileConfig): string {
if (isGeneratedProfileScope(profile.scope)) {
return path.join(ccrManagedProfileDir(configDir, profile), "claude", "settings.json");
}
return resolveUserPath(profile.settingsFile || "~/.claude/settings.json");
}
export function resolveCodexConfigFile(configDir: string, profile: ProfileConfig): string {
if (profile.agent === "zcode") {
return resolveZcodeConfigFile(profile);
}
if (isGeneratedProfileScope(profile.scope)) {
return path.join(ccrManagedProfileDir(configDir, profile), codexConfigSubdir(profile.agent), "config.toml");
}
const codexHome = profile.codexHome?.trim();
if (codexHome) {
return path.join(resolveUserPath(codexHome), "config.toml");
}
return resolveUserPath(profile.configFile || defaultCodexConfigFile(profile.agent));
}
function buildCodexLaunchPlan(
configDir: string,
profile: ProfileConfig,
surface: ProfileOpenSurface,
extraArgs: string[]
): ProfileLaunchPlan {
const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router";
const launcher = path.join(configDir, "bin", codexMiddlewareFilename(profile, providerId));
return {
args: surface === "app" && extraArgs.length === 0 ? ["app"] : extraArgs,
command: launcher,
env: {
CCR_PROFILE_SURFACE: surface
},
profile,
surface
};
}
function buildClaudeCodeLaunchPlan(
configDir: string,
profile: ProfileConfig,
surface: ProfileOpenSurface,
extraArgs: string[]
): ProfileLaunchPlan {
if (surface === "app") {
throw new Error("Claude App opening is available from the CCR desktop app.");
}
const settingsFile = resolveClaudeCodeSettingsFile(configDir, profile);
const launcher = path.join(configDir, "bin", claudeCodeWrapperFilename(profile));
return {
args: extraArgs,
command: launcher,
env: {
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
CCR_PROFILE_SURFACE: surface,
...claudeCodeModelEnv(profile),
...claudeCodeUtcTimezoneEnvOverride()
},
profile,
surface
};
}
function isCodexCompatibleAgent(agent: ProfileConfig["agent"]): boolean {
return agent === "codex" || agent === "zcode";
}
function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string {
return agent === "zcode" ? "~/.zcode/cli/config.json" : "~/.codex/config.toml";
}
function codexConfigSubdir(agent: ProfileConfig["agent"]): string {
return agent === "zcode" ? "zcode" : "codex";
}
function claudeCodeWrapperFilename(profile: ProfileConfig): string {
const slug = sanitizePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
return process.platform === "win32"
? `ccr-claude-code-wrapper-${slug}.cmd`
: `ccr-claude-code-wrapper-${slug}`;
}
function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): string {
const slug = sanitizeCodexProviderId(profile.id || profile.name || providerId) || "codex";
return process.platform === "win32"
? `ccr-codex-cli-stdio-${slug}.cmd`
: `ccr-codex-cli-stdio-${slug}`;
}
function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli" | "app" {
return value === "cli" || value === "app" ? value : "auto";
}
function claudeCodeModelEnv(profile: ProfileConfig): Record<string, string> {
const env: Record<string, string> = {};
const model = normalizeClientModel(profile.model);
if (model) {
env.ANTHROPIC_MODEL = model;
env.CCR_CLAUDE_CODE_MODEL = model;
env.CODEXL_CLAUDE_CODE_MODEL = model;
}
const smallFastModel = normalizeClientModel(profile.smallFastModel);
if (smallFastModel) {
env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel;
}
return env;
}
function normalizeClientModel(value: string | undefined): string {
const trimmed = value?.trim() || "";
if (!trimmed) {
return "";
}
const commaIndex = trimmed.indexOf(",");
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
const provider = trimmed.slice(0, commaIndex).trim();
const model = trimmed.slice(commaIndex + 1).trim();
return provider && model ? `${provider}/${model}` : "";
}
return trimmed;
}
function isGeneratedProfileScope(value: ProfileConfig["scope"]): boolean {
return value === "ccr" || value === "custom";
}
function resolveUserPath(value: string): string {
const trimmed = value.trim();
if (trimmed === "~") {
return homeDir();
}
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
return path.join(homeDir(), trimmed.slice(2));
}
return path.resolve(trimmed || ".");
}
function homeDir(): string {
return process.env.HOME || process.env.USERPROFILE || ".";
}
function sanitizeCodexProviderId(value: string): string {
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
}
function sanitizePathSegment(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
}
function normalizeLookupValue(value: string): string {
return value.trim().toLowerCase();
}
function shellQuote(value: string): string {
return /^[A-Za-z0-9_./:-]+$/.test(value)
? value
: `'${value.replace(/'/g, "'\\''")}'`;
}
function windowsCommandQuote(value: string): string {
const normalized = value.replace(/\r?\n/g, " ");
return /^[A-Za-z0-9_.:/\\-]+$/.test(normalized)
? normalized
: `"${normalized.replace(/"/g, '\\"')}"`;
}
function isWindowsCommandScript(command: string): boolean {
return process.platform === "win32" && /\.(?:bat|cmd)$/i.test(path.basename(command));
}
function windowsCommandScriptInvocation(command: string, args: string[]): string {
return [
"call",
windowsCommandInvocationArg(command),
...args.map(windowsCommandInvocationArg)
].join(" ");
}
function windowsCommandInvocationArg(value: string): string {
const normalized = value.replace(/\r?\n/g, " ");
if (!normalized) {
return "\"\"";
}
return `"${normalized.replace(/[\^"%&|<>()]/g, "^$&")}"`;
}