diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index db6ed8f..9fc57e9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,7 +12,7 @@ import { activateCommand } from "./utils/activateCommand"; import { readConfigFile } from "./utils"; import { version } from "../package.json"; import { spawn, exec } from "child_process"; -import {PID_FILE, readPresetFile, REFERENCE_COUNT_FILE} from "@CCR/shared"; +import {getPresetDir, loadConfigFromManifest, PID_FILE, readPresetFile, REFERENCE_COUNT_FILE} from "@CCR/shared"; import fs, { existsSync, readFileSync } from "fs"; import { join } from "path"; import { parseStatusLineData, StatusLineInput } from "./utils/statusline"; @@ -81,7 +81,7 @@ async function waitForService( const startTime = Date.now(); while (Date.now() - startTime < timeout) { - const isRunning = await isServiceRunning() + const isRunning = isServiceRunning() if (isRunning) { // Wait for an additional short period to ensure service is fully ready await new Promise((resolve) => setTimeout(resolve, 500)); @@ -93,43 +93,46 @@ async function waitForService( } async function main() { - const isRunning = await isServiceRunning() + const isRunning = isServiceRunning() // If command is not a known command, check if it's a preset if (command && !KNOWN_COMMANDS.includes(command)) { - const presetData: any = await readPresetFile(command); + const manifest = await readPresetFile(command); - if (presetData) { - // This is a preset, execute code command + if (manifest) { + // This is a preset, load its configuration + const presetDir = getPresetDir(command); + const config = loadConfigFromManifest(manifest, presetDir); + + // Execute code command const codeArgs = process.argv.slice(3); // Get remaining arguments // Check noServer configuration - const shouldStartServer = presetData.noServer !== true; + const shouldStartServer = config.noServer !== true; // Build environment variable overrides - let envOverrides: Record | undefined; + let envOverrides: Record = {}; // Handle provider configuration (supports both old and new formats) let provider: any = null; - // Old format: presetData.provider is the provider name - if (presetData.provider && typeof presetData.provider === 'string') { - const config = await readConfigFile(); - provider = config.Providers?.find((p: any) => p.name === presetData.provider); + // Old format: config.provider is the provider name + if (config.provider && typeof config.provider === 'string') { + const globalConfig = await readConfigFile(); + provider = globalConfig.Providers?.find((p: any) => p.name === config.provider); } - // New format: presetData.Providers is an array of providers - else if (presetData.Providers && presetData.Providers.length > 0) { - provider = presetData.Providers[0]; + // New format: config.Providers is an array of providers + else if (config.Providers && config.Providers.length > 0) { + provider = config.Providers[0]; } // If noServer is not true, use local server baseurl if (shouldStartServer) { - const config = await readConfigFile(); - const port = config.PORT || 3456; - const presetName = command; + const globalConfig = await readConfigFile(); + const port = globalConfig.PORT || 3456; envOverrides = { ...envOverrides, - ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/preset/${presetName}`, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/preset/${command}`, }; } else if (provider) { // Handle api_base_url, remove /v1/messages suffix @@ -157,10 +160,9 @@ async function main() { // Build PresetConfig const presetConfig: PresetConfig = { - noServer: presetData.noServer, - claudeCodeSettings: presetData.claudeCodeSettings, - provider: presetData.provider, - router: presetData.router, + noServer: config.noServer, + claudeCodeSettings: config.claudeCodeSettings, + StatusLine: config.StatusLine }; if (shouldStartServer && !isRunning) { @@ -179,7 +181,7 @@ async function main() { startProcess.unref(); if (await waitForService()) { - executeCodeCommand(codeArgs, presetConfig, envOverrides); + executeCodeCommand(codeArgs, presetConfig, envOverrides, command); } else { console.error( "Service startup timeout, please manually run `ccr start` to start the service" @@ -192,7 +194,7 @@ async function main() { console.error("Service is not running. Please start it first with `ccr start`"); process.exit(1); } - executeCodeCommand(codeArgs, presetConfig, envOverrides); + executeCodeCommand(codeArgs, presetConfig, envOverrides, command); } return; } else { @@ -245,7 +247,9 @@ async function main() { process.stdin.on("end", async () => { try { const input: StatusLineInput = JSON.parse(inputData); - const statusLine = await parseStatusLineData(input); + // Check if preset name is provided as argument + const presetName = process.argv[3]; + const statusLine = await parseStatusLineData(input, presetName); console.log(statusLine); } catch (error) { console.error("Error parsing status line data:", error); diff --git a/packages/cli/src/utils/codeCommand.ts b/packages/cli/src/utils/codeCommand.ts index 5041cce..1745a24 100644 --- a/packages/cli/src/utils/codeCommand.ts +++ b/packages/cli/src/utils/codeCommand.ts @@ -18,13 +18,15 @@ export interface PresetConfig { }; provider?: string; router?: Record; + StatusLine?: any; // Preset's StatusLine configuration [key: string]: any; } export async function executeCodeCommand( args: string[] = [], presetConfig?: PresetConfig | null, - envOverrides?: Record + envOverrides?: Record, + presetName?: string // Preset name for statusline command ) { // Set environment variables using shared function const config = await readConfigFile(); @@ -40,11 +42,19 @@ export async function executeCodeCommand( env: env as ClaudeSettingsFlag['env'] }; - // Add statusLine if StatusLine is configured - if (config?.StatusLine?.enabled) { + // Add statusLine configuration + // Priority: preset.StatusLine > global config.StatusLine + const statusLineConfig = presetConfig?.StatusLine || config?.StatusLine; + + if (statusLineConfig?.enabled) { + // If using preset, pass preset name to statusline command + const statuslineCommand = presetName + ? `ccr statusline ${presetName}` + : "ccr statusline"; + settingsFlag.statusLine = { type: "command", - command: "ccr statusline", + command: statuslineCommand, padding: 0, } } diff --git a/packages/cli/src/utils/statusline.ts b/packages/cli/src/utils/statusline.ts index 2567068..a410925 100644 --- a/packages/cli/src/utils/statusline.ts +++ b/packages/cli/src/utils/statusline.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { execSync } from "child_process"; import { tmpdir } from "node:os"; -import { CONFIG_FILE, HOME_DIR } from "@CCR/shared"; +import { CONFIG_FILE, HOME_DIR, readPresetFile, getPresetDir, loadConfigFromManifest } from "@CCR/shared"; import JSON5 from "json5"; export interface StatusLineModuleConfig { @@ -11,7 +11,8 @@ export interface StatusLineModuleConfig { text: string; color?: string; background?: string; - scriptPath?: string; // Path to the Node.js script file to execute for script-type modules + scriptPath?: string; + options?: Record; } export interface StatusLineThemeConfig { @@ -162,7 +163,7 @@ function replaceVariables(text: string, variables: Record): stri } // Execute script and get output -async function executeScript(scriptPath: string, variables: Record): Promise { +async function executeScript(scriptPath: string, variables: Record, options?: Record): Promise { try { // Check if file exists await fs.access(scriptPath); @@ -172,7 +173,7 @@ async function executeScript(scriptPath: string, variables: Record { + try { + // Read preset manifest + const manifest = await readPresetFile(presetName); + if (!manifest) { + return { theme: null, style: 'default' }; + } + + // Load preset configuration (applies userValues if present) + const presetDir = getPresetDir(presetName); + const config = loadConfigFromManifest(manifest, presetDir); + + // Check if there's StatusLine configuration in preset + if (config.StatusLine) { + // Get current style, default to 'default' + const currentStyle = config.StatusLine.currentStyle || 'default'; + + // Check if there's configuration for the corresponding style + if (config.StatusLine[currentStyle] && config.StatusLine[currentStyle].modules) { + return { theme: config.StatusLine[currentStyle], style: currentStyle }; + } + } + } catch (error) { + // Return null if reading fails + // console.error("Failed to read preset theme config:", error); + } + + return { theme: null, style: 'default' }; +} + // Check if simple theme should be used (fallback scheme) // When environment variable USE_SIMPLE_ICONS is set, or when a terminal that might not support Nerd Fonts is detected function shouldUseSimpleTheme(): boolean { @@ -585,36 +617,7 @@ function canDisplayNerdFonts(): boolean { return process.env.USE_SIMPLE_ICONS !== 'true'; } -// Check if specific Unicode characters can be displayed correctly -// This is a simple heuristic check -function canDisplayUnicodeCharacter(char: string): boolean { - // For Nerd Font icons, we assume UTF-8 terminals can display them - // But accurate detection is difficult, so we rely on environment variables and terminal type detection - try { - // Check if terminal supports UTF-8 - const lang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || ''; - if (lang.includes('UTF-8') || lang.includes('utf8') || lang.includes('UTF8')) { - return true; - } - - // Check LC_* environment variables - const lcVars = ['LC_ALL', 'LC_CTYPE', 'LANG']; - for (const lcVar of lcVars) { - const value = process.env[lcVar]; - if (value && (value.includes('UTF-8') || value.includes('utf8'))) { - return true; - } - } - } catch (e) { - // If check fails, default to true - return true; - } - - // By default, assume it can be displayed - return true; -} - -export async function parseStatusLineData(input: StatusLineInput): Promise { +export async function parseStatusLineData(input: StatusLineInput, presetName?: string): Promise { try { // Check if simple theme should be used const useSimpleTheme = shouldUseSimpleTheme(); @@ -625,8 +628,24 @@ export async function parseStatusLineData(input: StatusLineInput): Promise home directory config > default theme + let projectTheme: StatusLineThemeConfig | null = null; + let currentStyle = 'default'; + + if (presetName) { + // Try to get theme configuration from preset first + const presetConfig = await getPresetThemeConfig(presetName); + projectTheme = presetConfig.theme; + currentStyle = presetConfig.style; + } + + // If preset theme not found or no preset specified, try home directory config + if (!projectTheme) { + const homeConfig = await getProjectThemeConfig(); + projectTheme = homeConfig.theme; + currentStyle = homeConfig.style; + } + const theme = projectTheme || effectiveTheme; // Get current working directory and Git branch @@ -784,34 +803,6 @@ export async function parseStatusLineData(input: StatusLineInput): Promise { - try { - // Only use fixed configuration file in home directory - const configPath = CONFIG_FILE; - - // Check if configuration file exists - try { - await fs.access(configPath); - } catch { - return null; - } - - const configContent = await fs.readFile(configPath, "utf-8"); - const config = JSON5.parse(configContent); - - // Check if there's StatusLine configuration - if (config.StatusLine && config.StatusLine[style] && config.StatusLine[style].modules) { - return config.StatusLine[style]; - } - } catch (error) { - // Return null if reading fails - // console.error("Failed to read theme config:", error); - } - - return null; -} - // Render default style status line async function renderDefaultStyle( theme: StatusLineThemeConfig, @@ -831,7 +822,7 @@ async function renderDefaultStyle( // If script type, execute script to get text let text = ""; if (module.type === "script" && module.scriptPath) { - text = await executeScript(module.scriptPath, variables); + text = await executeScript(module.scriptPath, variables, module.options); } else { text = replaceVariables(module.text, variables); } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 1da5e26..ea4a2af 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -279,7 +279,7 @@ export const createServer = async (config: any): Promise => { // Return preset info, config uses the applied userValues configuration return { ...presetFile, - config: loadConfigFromManifest(manifest), + config: loadConfigFromManifest(manifest, presetDir), userValues: manifest.userValues || {}, }; } catch (error: any) { diff --git a/packages/shared/src/preset/install.ts b/packages/shared/src/preset/install.ts index ae72a3e..d1de1bf 100644 --- a/packages/shared/src/preset/install.ts +++ b/packages/shared/src/preset/install.ts @@ -450,7 +450,7 @@ export async function listPresets(): Promise { version: manifest.version, description: manifest.description, author: manifest.author, - config: loadConfigFromManifest(manifest), + config: loadConfigFromManifest(manifest, presetDir), }); } catch { // Ignore invalid preset directories (no manifest.json or read failed) diff --git a/packages/shared/src/preset/schema.ts b/packages/shared/src/preset/schema.ts index d80d416..cadc729 100644 --- a/packages/shared/src/preset/schema.ts +++ b/packages/shared/src/preset/schema.ts @@ -3,6 +3,7 @@ * Responsible for parsing and validating configuration schema, handling conditional logic and variable replacement */ +import path from 'path'; import { RequiredInput, InputType, @@ -180,8 +181,8 @@ export function getDynamicOptions( return []; } - // Parse provider reference (e.g. {{selectedProvider}}) - const providerId = String(providerField).replace(/^{{(.+)}}$/, '$1'); + // Parse provider reference (e.g. #{selectedProvider}) + const providerId = String(providerField).replace(/^#{(.+)}$/, '$1'); const selectedProvider = values[providerId]; if (!selectedProvider || !presetConfig.Providers) { @@ -242,7 +243,7 @@ export function resolveOptions( /** * Template variable replacement - * Supports {{variable}} syntax + * Supports #{variable} syntax (different from statusline's {{variable}} format) */ export function replaceTemplateVariables( template: any, @@ -254,7 +255,7 @@ export function replaceTemplateVariables( // Handle strings if (typeof template === 'string') { - return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + return template.replace(/#{(\w+)}/g, (_, key) => { return values[key] !== undefined ? String(values[key]) : ''; }); } @@ -295,9 +296,9 @@ export function applyConfigMappings( // Resolve value let value: any; - if (typeof mapping.value === 'string' && mapping.value.startsWith('{{')) { + if (typeof mapping.value === 'string' && mapping.value.startsWith('#')) { // Variable reference - const varName = mapping.value.replace(/^{{(.+)}}$/, '$1'); + const varName = mapping.value.replace(/^#{(.+)}$/, '$1'); value = values[varName]; } else { // Fixed value @@ -338,7 +339,7 @@ export function applyUserInputs( const schemaFields = getSchemaFields(presetFile.schema); // 1. First apply template (if exists) - // template completely defines configuration structure, using {{variable}} placeholders + // template completely defines configuration structure, using #{variable} placeholders if (presetFile.template) { config = replaceTemplateVariables(presetFile.template, values) as any; } else { @@ -347,7 +348,7 @@ export function applyUserInputs( // These fields will be updated or replaced in subsequent configMappings config = presetFile.config ? { ...presetFile.config } : {}; - // Replace placeholders in config (e.g. {{apiKey}} -> actual value) + // Replace placeholders in config (e.g. #{apiKey} -> actual value) config = replaceTemplateVariables(config, values) as any; // Finally, remove schema id fields (they should not appear in final configuration) @@ -557,7 +558,7 @@ export function buildDependencyGraph( if (field.options) { const options = field.options as any; if (options.type === 'models' && options.providerField) { - const providerId = String(options.providerField).replace(/^{{(.+)}}$/, '$1'); + const providerId = String(options.providerField).replace(/^#{(.+)}$/, '$1'); deps.add(providerId); } } @@ -588,14 +589,78 @@ export function getAffectedFields( return affected; } +/** + * Process StatusLine configuration, convert relative scriptPath to absolute path + * @param statusLineConfig StatusLine configuration + * @param presetDir Preset directory path + */ +function processStatusLineConfig(statusLineConfig: any, presetDir?: string): any { + if (!statusLineConfig || typeof statusLineConfig !== 'object') { + return statusLineConfig; + } + + const result = { ...statusLineConfig }; + + // Process each theme's modules + for (const themeKey of Object.keys(result)) { + const theme = result[themeKey]; + if (theme && typeof theme === 'object' && theme.modules) { + const modules = Array.isArray(theme.modules) ? theme.modules : []; + const processedModules = modules.map((module: any) => { + // If module has scriptPath and presetDir is provided, convert to absolute path + if (module.scriptPath && presetDir && !module.scriptPath.startsWith('/')) { + return { + ...module, + scriptPath: path.join(presetDir, module.scriptPath) + }; + } + return module; + }); + result[themeKey] = { + ...theme, + modules: processedModules + }; + } + } + + return result; +} + +/** + * Process transformers configuration, convert relative path to absolute path + * @param transformersConfig Transformers configuration array + * @param presetDir Preset directory path + */ +function processTransformersConfig(transformersConfig: any[], presetDir?: string): any[] { + if (!transformersConfig || !Array.isArray(transformersConfig)) { + return transformersConfig; + } + + if (!presetDir) { + return transformersConfig; + } + + return transformersConfig.map((transformer: any) => { + // If transformer has path and it's a relative path, convert to absolute path + if (transformer.path && !transformer.path.startsWith('/')) { + return { + ...transformer, + path: path.join(presetDir, transformer.path) + }; + } + return transformer; + }); +} + /** * Load configuration from Manifest and apply userValues * Used when reading installed presets, applying user configuration values at runtime * * @param manifest Manifest object (contains original configuration and userValues) + * @param presetDir Optional preset directory path (for resolving relative paths like scriptPath) * @returns Applied configuration object */ -export function loadConfigFromManifest(manifest: ManifestFile): PresetConfigSection { +export function loadConfigFromManifest(manifest: ManifestFile, presetDir?: string): PresetConfigSection { // Convert manifest to PresetFile format const presetFile: PresetFile = { metadata: { @@ -631,11 +696,25 @@ export function loadConfigFromManifest(manifest: ManifestFile): PresetConfigSect } } + let config: PresetConfigSection; + // If userValues exist, apply them if (manifest.userValues && Object.keys(manifest.userValues).length > 0) { - return applyUserInputs(presetFile, manifest.userValues); + config = applyUserInputs(presetFile, manifest.userValues); + } else { + // If no userValues, use original configuration directly + config = presetFile.config; } - // If no userValues, return original configuration directly - return presetFile.config; + // Process StatusLine configuration (convert relative scriptPath to absolute path) + if (config.StatusLine) { + config.StatusLine = processStatusLineConfig(config.StatusLine, presetDir); + } + + // Process transformers configuration (convert relative path to absolute path) + if (config.transformers) { + config.transformers = processTransformersConfig(config.transformers, presetDir); + } + + return config; } diff --git a/packages/shared/src/preset/types.ts b/packages/shared/src/preset/types.ts index 6ea4877..728e396 100644 --- a/packages/shared/src/preset/types.ts +++ b/packages/shared/src/preset/types.ts @@ -42,7 +42,7 @@ export interface DynamicOptions { // Automatically extract name and related configuration from preset's Providers // Used when type is 'models' - providerField?: string; // Point to provider selector field path (e.g. "{{selectedProvider}}") + providerField?: string; // Point to provider selector field path (e.g. "#{selectedProvider}") // Used when type is 'custom' (reserved) source?: string; // Custom data source @@ -152,8 +152,8 @@ export interface PresetConfigSection { // Template configuration (for dynamically generating configuration based on user input) export interface TemplateConfig { - // Template configuration using {{variable}} syntax - // Example: { "Providers": [{ "name": "{{providerName}}", "api_key": "{{apiKey}}" }] } + // Template configuration using #{variable} syntax (different from statusline's {{variable}} format) + // Example: { "Providers": [{ "name": "#{providerName}", "api_key": "#{apiKey}" }] } [key: string]: any; } @@ -163,7 +163,7 @@ export interface ConfigMapping { target: string; // Value source (references user input id, or uses fixed value) - value: string | any; // If string and starts with {{, treated as variable reference + value: string | any; // If string and starts with #, treated as variable reference (e.g. #{fieldId}) // Condition (optional, apply this mapping only when condition is met) when?: Condition | Condition[];