- Upgrade React from 18.3.1 to 19.1.1 - Upgrade Ink from 5.2.1 to 6.2.3 - Fix Windows spawn EINVAL error by removing --tsconfig-raw - Fix top-level await issues in cli.tsx - Update build scripts for better Windows support - Version bump to 1.1.12
946 lines
27 KiB
TypeScript
946 lines
27 KiB
TypeScript
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
import { resolve, join } from 'path'
|
|
import { cloneDeep, memoize, pick } from 'lodash-es'
|
|
import { homedir } from 'os'
|
|
import { GLOBAL_CLAUDE_FILE } from './env'
|
|
import { getCwd } from './state'
|
|
import { randomBytes } from 'crypto'
|
|
import { safeParseJSON } from './json'
|
|
import { checkGate, logEvent } from '../services/statsig'
|
|
import { GATE_USE_EXTERNAL_UPDATER } from '../constants/betas'
|
|
import { ConfigParseError } from './errors'
|
|
import type { ThemeNames } from './theme'
|
|
import { debug as debugLogger } from './debugLogger'
|
|
import { getSessionState, setSessionState } from './sessionState'
|
|
|
|
export type McpStdioServerConfig = {
|
|
type?: 'stdio' // Optional for backwards compatibility
|
|
command: string
|
|
args: string[]
|
|
env?: Record<string, string>
|
|
}
|
|
|
|
export type McpSSEServerConfig = {
|
|
type: 'sse'
|
|
url: string
|
|
}
|
|
|
|
export type McpServerConfig = McpStdioServerConfig | McpSSEServerConfig
|
|
|
|
export type ProjectConfig = {
|
|
allowedTools: string[]
|
|
context: Record<string, string>
|
|
contextFiles?: string[]
|
|
history: string[]
|
|
dontCrawlDirectory?: boolean
|
|
enableArchitectTool?: boolean
|
|
mcpContextUris: string[]
|
|
mcpServers?: Record<string, McpServerConfig>
|
|
approvedMcprcServers?: string[]
|
|
rejectedMcprcServers?: string[]
|
|
lastAPIDuration?: number
|
|
lastCost?: number
|
|
lastDuration?: number
|
|
lastSessionId?: string
|
|
exampleFiles?: string[]
|
|
exampleFilesGeneratedAt?: number
|
|
hasTrustDialogAccepted?: boolean
|
|
hasCompletedProjectOnboarding?: boolean
|
|
}
|
|
|
|
const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
|
allowedTools: [],
|
|
context: {},
|
|
history: [],
|
|
dontCrawlDirectory: false,
|
|
enableArchitectTool: false,
|
|
mcpContextUris: [],
|
|
mcpServers: {},
|
|
approvedMcprcServers: [],
|
|
rejectedMcprcServers: [],
|
|
hasTrustDialogAccepted: false,
|
|
}
|
|
|
|
function defaultConfigForProject(projectPath: string): ProjectConfig {
|
|
const config = { ...DEFAULT_PROJECT_CONFIG }
|
|
if (projectPath === homedir()) {
|
|
config.dontCrawlDirectory = true
|
|
}
|
|
return config
|
|
}
|
|
|
|
export type AutoUpdaterStatus =
|
|
| 'disabled'
|
|
| 'enabled'
|
|
| 'no_permissions'
|
|
| 'not_configured'
|
|
|
|
export function isAutoUpdaterStatus(value: string): value is AutoUpdaterStatus {
|
|
return ['disabled', 'enabled', 'no_permissions', 'not_configured'].includes(
|
|
value as AutoUpdaterStatus,
|
|
)
|
|
}
|
|
|
|
export type NotificationChannel =
|
|
| 'iterm2'
|
|
| 'terminal_bell'
|
|
| 'iterm2_with_bell'
|
|
| 'notifications_disabled'
|
|
|
|
export type ProviderType =
|
|
| 'anthropic'
|
|
| 'openai'
|
|
| 'mistral'
|
|
| 'deepseek'
|
|
| 'kimi'
|
|
| 'qwen'
|
|
| 'glm'
|
|
| 'minimax'
|
|
| 'baidu-qianfan'
|
|
| 'siliconflow'
|
|
| 'bigdream'
|
|
| 'opendev'
|
|
| 'xai'
|
|
| 'groq'
|
|
| 'gemini'
|
|
| 'ollama'
|
|
| 'azure'
|
|
| 'custom'
|
|
| 'custom-openai'
|
|
|
|
// New model system types
|
|
export type ModelProfile = {
|
|
name: string // User-friendly name
|
|
provider: ProviderType // Provider type
|
|
modelName: string // Primary key - actual model identifier
|
|
baseURL?: string // Custom endpoint
|
|
apiKey: string
|
|
maxTokens: number // Output token limit (for GPT-5, this maps to max_completion_tokens)
|
|
contextLength: number // Context window size
|
|
reasoningEffort?: 'low' | 'medium' | 'high' | 'minimal' | 'medium'
|
|
isActive: boolean // Whether profile is enabled
|
|
createdAt: number // Creation timestamp
|
|
lastUsed?: number // Last usage timestamp
|
|
// 🔥 GPT-5 specific metadata
|
|
isGPT5?: boolean // Auto-detected GPT-5 model flag
|
|
validationStatus?: 'valid' | 'needs_repair' | 'auto_repaired' // Configuration status
|
|
lastValidation?: number // Last validation timestamp
|
|
}
|
|
|
|
export type ModelPointerType = 'main' | 'task' | 'reasoning' | 'quick'
|
|
|
|
export type ModelPointers = {
|
|
main: string // Main dialog model ID
|
|
task: string // Task tool model ID
|
|
reasoning: string // Reasoning model ID
|
|
quick: string // Quick model ID
|
|
}
|
|
|
|
export type AccountInfo = {
|
|
accountUuid: string
|
|
emailAddress: string
|
|
organizationUuid?: string
|
|
}
|
|
|
|
export type GlobalConfig = {
|
|
projects?: Record<string, ProjectConfig>
|
|
numStartups: number
|
|
autoUpdaterStatus?: AutoUpdaterStatus
|
|
userID?: string
|
|
theme: ThemeNames
|
|
hasCompletedOnboarding?: boolean
|
|
// Tracks the last version that reset onboarding, used with MIN_VERSION_REQUIRING_ONBOARDING_RESET
|
|
lastOnboardingVersion?: string
|
|
// Tracks the last version for which release notes were seen, used for managing release notes
|
|
lastReleaseNotesSeen?: string
|
|
mcpServers?: Record<string, McpServerConfig>
|
|
preferredNotifChannel: NotificationChannel
|
|
verbose: boolean
|
|
customApiKeyResponses?: {
|
|
approved?: string[]
|
|
rejected?: string[]
|
|
}
|
|
primaryProvider?: ProviderType
|
|
maxTokens?: number
|
|
hasAcknowledgedCostThreshold?: boolean
|
|
oauthAccount?: AccountInfo
|
|
iterm2KeyBindingInstalled?: boolean // Legacy - keeping for backward compatibility
|
|
shiftEnterKeyBindingInstalled?: boolean
|
|
proxy?: string
|
|
stream?: boolean
|
|
|
|
// New model system
|
|
modelProfiles?: ModelProfile[] // Model configuration list
|
|
modelPointers?: ModelPointers // Model pointer system
|
|
defaultModelName?: string // Default model
|
|
// Update notifications
|
|
lastDismissedUpdateVersion?: string
|
|
}
|
|
|
|
export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
|
|
numStartups: 0,
|
|
autoUpdaterStatus: 'not_configured',
|
|
theme: 'dark' as ThemeNames,
|
|
preferredNotifChannel: 'iterm2',
|
|
verbose: false,
|
|
primaryProvider: 'anthropic' as ProviderType,
|
|
customApiKeyResponses: {
|
|
approved: [],
|
|
rejected: [],
|
|
},
|
|
stream: true,
|
|
|
|
// New model system defaults
|
|
modelProfiles: [],
|
|
modelPointers: {
|
|
main: '',
|
|
task: '',
|
|
reasoning: '',
|
|
quick: '',
|
|
},
|
|
lastDismissedUpdateVersion: undefined,
|
|
}
|
|
|
|
export const GLOBAL_CONFIG_KEYS = [
|
|
'autoUpdaterStatus',
|
|
'theme',
|
|
'hasCompletedOnboarding',
|
|
'lastOnboardingVersion',
|
|
'lastReleaseNotesSeen',
|
|
'verbose',
|
|
'customApiKeyResponses',
|
|
'primaryProvider',
|
|
'preferredNotifChannel',
|
|
'shiftEnterKeyBindingInstalled',
|
|
'maxTokens',
|
|
] as const
|
|
|
|
export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number]
|
|
|
|
export function isGlobalConfigKey(key: string): key is GlobalConfigKey {
|
|
return GLOBAL_CONFIG_KEYS.includes(key as GlobalConfigKey)
|
|
}
|
|
|
|
export const PROJECT_CONFIG_KEYS = [
|
|
'dontCrawlDirectory',
|
|
'enableArchitectTool',
|
|
'hasTrustDialogAccepted',
|
|
'hasCompletedProjectOnboarding',
|
|
] as const
|
|
|
|
export type ProjectConfigKey = (typeof PROJECT_CONFIG_KEYS)[number]
|
|
|
|
export function checkHasTrustDialogAccepted(): boolean {
|
|
let currentPath = getCwd()
|
|
const config = getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
|
|
|
|
while (true) {
|
|
const projectConfig = config.projects?.[currentPath]
|
|
if (projectConfig?.hasTrustDialogAccepted) {
|
|
return true
|
|
}
|
|
const parentPath = resolve(currentPath, '..')
|
|
// Stop if we've reached the root (when parent is same as current)
|
|
if (parentPath === currentPath) {
|
|
break
|
|
}
|
|
currentPath = parentPath
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// We have to put this test code here because Jest doesn't support mocking ES modules :O
|
|
const TEST_GLOBAL_CONFIG_FOR_TESTING: GlobalConfig = {
|
|
...DEFAULT_GLOBAL_CONFIG,
|
|
autoUpdaterStatus: 'disabled',
|
|
}
|
|
const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = {
|
|
...DEFAULT_PROJECT_CONFIG,
|
|
}
|
|
|
|
export function isProjectConfigKey(key: string): key is ProjectConfigKey {
|
|
return PROJECT_CONFIG_KEYS.includes(key as ProjectConfigKey)
|
|
}
|
|
|
|
export function saveGlobalConfig(config: GlobalConfig): void {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
for (const key in config) {
|
|
TEST_GLOBAL_CONFIG_FOR_TESTING[key] = config[key]
|
|
}
|
|
return
|
|
}
|
|
|
|
// 直接保存配置(无需清除缓存,因为已移除缓存)
|
|
saveConfig(
|
|
GLOBAL_CLAUDE_FILE,
|
|
{
|
|
...config,
|
|
projects: getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG).projects,
|
|
},
|
|
DEFAULT_GLOBAL_CONFIG,
|
|
)
|
|
}
|
|
|
|
// 临时移除缓存,确保总是获取最新配置
|
|
export function getGlobalConfig(): GlobalConfig {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return TEST_GLOBAL_CONFIG_FOR_TESTING
|
|
}
|
|
const config = getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
|
|
return migrateModelProfilesRemoveId(config)
|
|
}
|
|
|
|
export function getAnthropicApiKey(): null | string {
|
|
return process.env.ANTHROPIC_API_KEY || null
|
|
}
|
|
|
|
export function normalizeApiKeyForConfig(apiKey: string): string {
|
|
return apiKey?.slice(-20) ?? ''
|
|
}
|
|
|
|
export function getCustomApiKeyStatus(
|
|
truncatedApiKey: string,
|
|
): 'approved' | 'rejected' | 'new' {
|
|
const config = getGlobalConfig()
|
|
if (config.customApiKeyResponses?.approved?.includes(truncatedApiKey)) {
|
|
return 'approved'
|
|
}
|
|
if (config.customApiKeyResponses?.rejected?.includes(truncatedApiKey)) {
|
|
return 'rejected'
|
|
}
|
|
return 'new'
|
|
}
|
|
|
|
function saveConfig<A extends object>(
|
|
file: string,
|
|
config: A,
|
|
defaultConfig: A,
|
|
): void {
|
|
// Filter out any values that match the defaults
|
|
const filteredConfig = Object.fromEntries(
|
|
Object.entries(config).filter(
|
|
([key, value]) =>
|
|
JSON.stringify(value) !== JSON.stringify(defaultConfig[key as keyof A]),
|
|
),
|
|
)
|
|
writeFileSync(file, JSON.stringify(filteredConfig, null, 2), 'utf-8')
|
|
}
|
|
|
|
// Flag to track if config reading is allowed
|
|
let configReadingAllowed = false
|
|
|
|
export function enableConfigs(): void {
|
|
// Any reads to configuration before this flag is set show an console warning
|
|
// to prevent us from adding config reading during module initialization
|
|
configReadingAllowed = true
|
|
// We only check the global config because currently all the configs share a file
|
|
getConfig(
|
|
GLOBAL_CLAUDE_FILE,
|
|
DEFAULT_GLOBAL_CONFIG,
|
|
true /* throw on invalid */,
|
|
)
|
|
}
|
|
|
|
function getConfig<A>(
|
|
file: string,
|
|
defaultConfig: A,
|
|
throwOnInvalid?: boolean,
|
|
): A {
|
|
// 简化配置访问逻辑,移除复杂的时序检查
|
|
|
|
debugLogger.state('CONFIG_LOAD_START', {
|
|
file,
|
|
fileExists: String(existsSync(file)),
|
|
throwOnInvalid: String(!!throwOnInvalid),
|
|
})
|
|
|
|
if (!existsSync(file)) {
|
|
debugLogger.state('CONFIG_LOAD_DEFAULT', {
|
|
file,
|
|
reason: 'file_not_exists',
|
|
defaultConfigKeys: Object.keys(defaultConfig as object).join(', '),
|
|
})
|
|
return cloneDeep(defaultConfig)
|
|
}
|
|
|
|
try {
|
|
const fileContent = readFileSync(file, 'utf-8')
|
|
debugLogger.state('CONFIG_FILE_READ', {
|
|
file,
|
|
contentLength: String(fileContent.length),
|
|
contentPreview:
|
|
fileContent.substring(0, 100) + (fileContent.length > 100 ? '...' : ''),
|
|
})
|
|
|
|
try {
|
|
const parsedConfig = JSON.parse(fileContent)
|
|
debugLogger.state('CONFIG_JSON_PARSED', {
|
|
file,
|
|
parsedKeys: Object.keys(parsedConfig).join(', '),
|
|
})
|
|
|
|
// Handle backward compatibility - remove logic for deleted fields
|
|
const finalConfig = {
|
|
...cloneDeep(defaultConfig),
|
|
...parsedConfig,
|
|
}
|
|
|
|
debugLogger.state('CONFIG_LOAD_SUCCESS', {
|
|
file,
|
|
finalConfigKeys: Object.keys(finalConfig as object).join(', '),
|
|
})
|
|
|
|
return finalConfig
|
|
} catch (error) {
|
|
// Throw a ConfigParseError with the file path and default config
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
debugLogger.error('CONFIG_JSON_PARSE_ERROR', {
|
|
file,
|
|
errorMessage,
|
|
errorType:
|
|
error instanceof Error ? error.constructor.name : typeof error,
|
|
contentLength: String(fileContent.length),
|
|
})
|
|
|
|
throw new ConfigParseError(errorMessage, file, defaultConfig)
|
|
}
|
|
} catch (error: unknown) {
|
|
// Re-throw ConfigParseError if throwOnInvalid is true
|
|
if (error instanceof ConfigParseError && throwOnInvalid) {
|
|
debugLogger.error('CONFIG_PARSE_ERROR_RETHROWN', {
|
|
file,
|
|
throwOnInvalid: String(throwOnInvalid),
|
|
errorMessage: error.message,
|
|
})
|
|
throw error
|
|
}
|
|
|
|
debugLogger.warn('CONFIG_FALLBACK_TO_DEFAULT', {
|
|
file,
|
|
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
action: 'using_default_config',
|
|
})
|
|
|
|
return cloneDeep(defaultConfig)
|
|
}
|
|
}
|
|
|
|
export function getCurrentProjectConfig(): ProjectConfig {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return TEST_PROJECT_CONFIG_FOR_TESTING
|
|
}
|
|
|
|
const absolutePath = resolve(getCwd())
|
|
const config = getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
|
|
|
|
if (!config.projects) {
|
|
return defaultConfigForProject(absolutePath)
|
|
}
|
|
|
|
const projectConfig =
|
|
config.projects[absolutePath] ?? defaultConfigForProject(absolutePath)
|
|
// Not sure how this became a string
|
|
// TODO: Fix upstream
|
|
if (typeof projectConfig.allowedTools === 'string') {
|
|
projectConfig.allowedTools =
|
|
(safeParseJSON(projectConfig.allowedTools) as string[]) ?? []
|
|
}
|
|
return projectConfig
|
|
}
|
|
|
|
export function saveCurrentProjectConfig(projectConfig: ProjectConfig): void {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
for (const key in projectConfig) {
|
|
TEST_PROJECT_CONFIG_FOR_TESTING[key] = projectConfig[key]
|
|
}
|
|
return
|
|
}
|
|
const config = getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
|
|
saveConfig(
|
|
GLOBAL_CLAUDE_FILE,
|
|
{
|
|
...config,
|
|
projects: {
|
|
...config.projects,
|
|
[resolve(getCwd())]: projectConfig,
|
|
},
|
|
},
|
|
DEFAULT_GLOBAL_CONFIG,
|
|
)
|
|
}
|
|
|
|
export async function isAutoUpdaterDisabled(): Promise<boolean> {
|
|
const useExternalUpdater = await checkGate(GATE_USE_EXTERNAL_UPDATER)
|
|
return (
|
|
useExternalUpdater || getGlobalConfig().autoUpdaterStatus === 'disabled'
|
|
)
|
|
}
|
|
|
|
export const TEST_MCPRC_CONFIG_FOR_TESTING: Record<string, McpServerConfig> = {}
|
|
|
|
export function clearMcprcConfigForTesting(): void {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
Object.keys(TEST_MCPRC_CONFIG_FOR_TESTING).forEach(key => {
|
|
delete TEST_MCPRC_CONFIG_FOR_TESTING[key]
|
|
})
|
|
}
|
|
}
|
|
|
|
export function addMcprcServerForTesting(
|
|
name: string,
|
|
server: McpServerConfig,
|
|
): void {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
TEST_MCPRC_CONFIG_FOR_TESTING[name] = server
|
|
}
|
|
}
|
|
|
|
export function removeMcprcServerForTesting(name: string): void {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
if (!TEST_MCPRC_CONFIG_FOR_TESTING[name]) {
|
|
throw new Error(`No MCP server found with name: ${name} in .mcprc`)
|
|
}
|
|
delete TEST_MCPRC_CONFIG_FOR_TESTING[name]
|
|
}
|
|
}
|
|
|
|
export const getMcprcConfig = memoize(
|
|
(): Record<string, McpServerConfig> => {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return TEST_MCPRC_CONFIG_FOR_TESTING
|
|
}
|
|
|
|
const mcprcPath = join(getCwd(), '.mcprc')
|
|
if (!existsSync(mcprcPath)) {
|
|
return {}
|
|
}
|
|
|
|
try {
|
|
const mcprcContent = readFileSync(mcprcPath, 'utf-8')
|
|
const config = safeParseJSON(mcprcContent)
|
|
if (config && typeof config === 'object') {
|
|
logEvent('tengu_mcprc_found', {
|
|
numServers: Object.keys(config).length.toString(),
|
|
})
|
|
return config as Record<string, McpServerConfig>
|
|
}
|
|
} catch {
|
|
// Ignore errors reading/parsing .mcprc (they're logged in safeParseJSON)
|
|
}
|
|
return {}
|
|
},
|
|
// This function returns the same value as long as the cwd and mcprc file content remain the same
|
|
() => {
|
|
const cwd = getCwd()
|
|
const mcprcPath = join(cwd, '.mcprc')
|
|
if (existsSync(mcprcPath)) {
|
|
try {
|
|
const stat = readFileSync(mcprcPath, 'utf-8')
|
|
return `${cwd}:${stat}`
|
|
} catch {
|
|
return cwd
|
|
}
|
|
}
|
|
return cwd
|
|
},
|
|
)
|
|
|
|
export function getOrCreateUserID(): string {
|
|
const config = getGlobalConfig()
|
|
if (config.userID) {
|
|
return config.userID
|
|
}
|
|
|
|
const userID = randomBytes(32).toString('hex')
|
|
saveGlobalConfig({ ...config, userID })
|
|
return userID
|
|
}
|
|
|
|
export function getConfigForCLI(key: string, global: boolean): unknown {
|
|
logEvent('tengu_config_get', {
|
|
key,
|
|
global: global?.toString() ?? 'false',
|
|
})
|
|
if (global) {
|
|
if (!isGlobalConfigKey(key)) {
|
|
console.error(
|
|
`Error: '${key}' is not a valid config key. Valid keys are: ${GLOBAL_CONFIG_KEYS.join(', ')}`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
return getGlobalConfig()[key]
|
|
} else {
|
|
if (!isProjectConfigKey(key)) {
|
|
console.error(
|
|
`Error: '${key}' is not a valid config key. Valid keys are: ${PROJECT_CONFIG_KEYS.join(', ')}`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
return getCurrentProjectConfig()[key]
|
|
}
|
|
}
|
|
|
|
export function setConfigForCLI(
|
|
key: string,
|
|
value: unknown,
|
|
global: boolean,
|
|
): void {
|
|
logEvent('tengu_config_set', {
|
|
key,
|
|
global: global?.toString() ?? 'false',
|
|
})
|
|
if (global) {
|
|
if (!isGlobalConfigKey(key)) {
|
|
console.error(
|
|
`Error: Cannot set '${key}'. Only these keys can be modified: ${GLOBAL_CONFIG_KEYS.join(', ')}`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (key === 'autoUpdaterStatus' && !isAutoUpdaterStatus(value as string)) {
|
|
console.error(
|
|
`Error: Invalid value for autoUpdaterStatus. Must be one of: disabled, enabled, no_permissions, not_configured`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
const currentConfig = getGlobalConfig()
|
|
saveGlobalConfig({
|
|
...currentConfig,
|
|
[key]: value,
|
|
})
|
|
} else {
|
|
if (!isProjectConfigKey(key)) {
|
|
console.error(
|
|
`Error: Cannot set '${key}'. Only these keys can be modified: ${PROJECT_CONFIG_KEYS.join(', ')}. Did you mean --global?`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
const currentConfig = getCurrentProjectConfig()
|
|
saveCurrentProjectConfig({
|
|
...currentConfig,
|
|
[key]: value,
|
|
})
|
|
}
|
|
// Wait for the output to be flushed, to avoid clearing the screen.
|
|
setTimeout(() => {
|
|
// Without this we hang indefinitely.
|
|
process.exit(0)
|
|
}, 100)
|
|
}
|
|
|
|
export function deleteConfigForCLI(key: string, global: boolean): void {
|
|
logEvent('tengu_config_delete', {
|
|
key,
|
|
global: global?.toString() ?? 'false',
|
|
})
|
|
if (global) {
|
|
if (!isGlobalConfigKey(key)) {
|
|
console.error(
|
|
`Error: Cannot delete '${key}'. Only these keys can be modified: ${GLOBAL_CONFIG_KEYS.join(', ')}`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
const currentConfig = getGlobalConfig()
|
|
delete currentConfig[key]
|
|
saveGlobalConfig(currentConfig)
|
|
} else {
|
|
if (!isProjectConfigKey(key)) {
|
|
console.error(
|
|
`Error: Cannot delete '${key}'. Only these keys can be modified: ${PROJECT_CONFIG_KEYS.join(', ')}. Did you mean --global?`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
const currentConfig = getCurrentProjectConfig()
|
|
delete currentConfig[key]
|
|
saveCurrentProjectConfig(currentConfig)
|
|
}
|
|
}
|
|
|
|
export function listConfigForCLI(global: true): GlobalConfig
|
|
export function listConfigForCLI(global: false): ProjectConfig
|
|
export function listConfigForCLI(global: boolean): object {
|
|
logEvent('tengu_config_list', {
|
|
global: global?.toString() ?? 'false',
|
|
})
|
|
if (global) {
|
|
const currentConfig = pick(getGlobalConfig(), GLOBAL_CONFIG_KEYS)
|
|
return currentConfig
|
|
} else {
|
|
return pick(getCurrentProjectConfig(), PROJECT_CONFIG_KEYS)
|
|
}
|
|
}
|
|
|
|
export function getOpenAIApiKey(): string | undefined {
|
|
return process.env.OPENAI_API_KEY
|
|
}
|
|
|
|
// Configuration migration utility functions
|
|
function migrateModelProfilesRemoveId(config: GlobalConfig): GlobalConfig {
|
|
if (!config.modelProfiles) return config
|
|
|
|
// 1. Remove id field from ModelProfile objects and build ID to modelName mapping
|
|
const idToModelNameMap = new Map<string, string>()
|
|
const migratedProfiles = config.modelProfiles.map(profile => {
|
|
// Build mapping before removing id field
|
|
if ((profile as any).id && profile.modelName) {
|
|
idToModelNameMap.set((profile as any).id, profile.modelName)
|
|
}
|
|
|
|
// Remove id field, keep everything else
|
|
const { id, ...profileWithoutId } = profile as any
|
|
return profileWithoutId as ModelProfile
|
|
})
|
|
|
|
// 2. Migrate ModelPointers from IDs to modelNames
|
|
const migratedPointers: ModelPointers = {
|
|
main: '',
|
|
task: '',
|
|
reasoning: '',
|
|
quick: '',
|
|
}
|
|
|
|
if (config.modelPointers) {
|
|
Object.entries(config.modelPointers).forEach(([pointer, value]) => {
|
|
if (value) {
|
|
// If value looks like an old ID (model_xxx), map it to modelName
|
|
const modelName = idToModelNameMap.get(value) || value
|
|
migratedPointers[pointer as ModelPointerType] = modelName
|
|
}
|
|
})
|
|
}
|
|
|
|
// 3. Migrate legacy config fields
|
|
let defaultModelName: string | undefined
|
|
if ((config as any).defaultModelId) {
|
|
defaultModelName =
|
|
idToModelNameMap.get((config as any).defaultModelId) ||
|
|
(config as any).defaultModelId
|
|
} else if ((config as any).defaultModelName) {
|
|
defaultModelName = (config as any).defaultModelName
|
|
}
|
|
|
|
// 4. Remove legacy fields and return migrated config
|
|
const migratedConfig = { ...config }
|
|
delete (migratedConfig as any).defaultModelId
|
|
delete (migratedConfig as any).currentSelectedModelId
|
|
delete (migratedConfig as any).mainAgentModelId
|
|
delete (migratedConfig as any).taskToolModelId
|
|
|
|
return {
|
|
...migratedConfig,
|
|
modelProfiles: migratedProfiles,
|
|
modelPointers: migratedPointers,
|
|
defaultModelName,
|
|
}
|
|
}
|
|
|
|
// New model system utility functions
|
|
|
|
export function setAllPointersToModel(modelName: string): void {
|
|
const config = getGlobalConfig()
|
|
const updatedConfig = {
|
|
...config,
|
|
modelPointers: {
|
|
main: modelName,
|
|
task: modelName,
|
|
reasoning: modelName,
|
|
quick: modelName,
|
|
},
|
|
defaultModelName: modelName,
|
|
}
|
|
saveGlobalConfig(updatedConfig)
|
|
}
|
|
|
|
export function setModelPointer(
|
|
pointer: ModelPointerType,
|
|
modelName: string,
|
|
): void {
|
|
const config = getGlobalConfig()
|
|
const updatedConfig = {
|
|
...config,
|
|
modelPointers: {
|
|
...config.modelPointers,
|
|
[pointer]: modelName,
|
|
},
|
|
}
|
|
saveGlobalConfig(updatedConfig)
|
|
|
|
// 🔧 Fix: Force ModelManager reload after config change
|
|
// Import here to avoid circular dependency
|
|
import('./model').then(({ reloadModelManager }) => {
|
|
reloadModelManager()
|
|
})
|
|
}
|
|
|
|
// 🔥 GPT-5 Configuration Validation and Auto-Repair Functions
|
|
|
|
/**
|
|
* Check if a model name represents a GPT-5 model
|
|
*/
|
|
export function isGPT5ModelName(modelName: string): boolean {
|
|
if (!modelName || typeof modelName !== 'string') return false
|
|
const lowerName = modelName.toLowerCase()
|
|
return lowerName.startsWith('gpt-5') || lowerName.includes('gpt-5')
|
|
}
|
|
|
|
/**
|
|
* Validate and auto-repair GPT-5 model configuration
|
|
*/
|
|
export function validateAndRepairGPT5Profile(profile: ModelProfile): ModelProfile {
|
|
const isGPT5 = isGPT5ModelName(profile.modelName)
|
|
const now = Date.now()
|
|
|
|
// Create a working copy
|
|
const repairedProfile: ModelProfile = { ...profile }
|
|
let wasRepaired = false
|
|
|
|
// 🔧 Set GPT-5 detection flag
|
|
if (isGPT5 !== profile.isGPT5) {
|
|
repairedProfile.isGPT5 = isGPT5
|
|
wasRepaired = true
|
|
}
|
|
|
|
if (isGPT5) {
|
|
// 🔧 GPT-5 Parameter Validation and Repair
|
|
|
|
// 1. Reasoning effort validation
|
|
const validReasoningEfforts = ['minimal', 'low', 'medium', 'high']
|
|
if (!profile.reasoningEffort || !validReasoningEfforts.includes(profile.reasoningEffort)) {
|
|
repairedProfile.reasoningEffort = 'medium' // Default for coding tasks
|
|
wasRepaired = true
|
|
console.log(`🔧 GPT-5 Config: Set reasoning effort to 'medium' for ${profile.modelName}`)
|
|
}
|
|
|
|
// 2. Context length validation (GPT-5 models typically have 128k context)
|
|
if (profile.contextLength < 128000) {
|
|
repairedProfile.contextLength = 128000
|
|
wasRepaired = true
|
|
console.log(`🔧 GPT-5 Config: Updated context length to 128k for ${profile.modelName}`)
|
|
}
|
|
|
|
// 3. Output tokens validation (reasonable defaults for GPT-5)
|
|
if (profile.maxTokens < 4000) {
|
|
repairedProfile.maxTokens = 8192 // Good default for coding tasks
|
|
wasRepaired = true
|
|
console.log(`🔧 GPT-5 Config: Updated max tokens to 8192 for ${profile.modelName}`)
|
|
}
|
|
|
|
// 4. Provider validation
|
|
if (profile.provider !== 'openai' && profile.provider !== 'custom-openai' && profile.provider !== 'azure') {
|
|
console.warn(`⚠️ GPT-5 Config: Unexpected provider '${profile.provider}' for GPT-5 model ${profile.modelName}. Consider using 'openai' or 'custom-openai'.`)
|
|
}
|
|
|
|
// 5. Base URL validation for official models
|
|
if (profile.modelName.includes('gpt-5') && !profile.baseURL) {
|
|
repairedProfile.baseURL = 'https://api.openai.com/v1'
|
|
wasRepaired = true
|
|
console.log(`🔧 GPT-5 Config: Set default base URL for ${profile.modelName}`)
|
|
}
|
|
}
|
|
|
|
// Update validation metadata
|
|
repairedProfile.validationStatus = wasRepaired ? 'auto_repaired' : 'valid'
|
|
repairedProfile.lastValidation = now
|
|
|
|
if (wasRepaired) {
|
|
console.log(`✅ GPT-5 Config: Auto-repaired configuration for ${profile.modelName}`)
|
|
}
|
|
|
|
return repairedProfile
|
|
}
|
|
|
|
/**
|
|
* Validate and repair all GPT-5 profiles in the global configuration
|
|
*/
|
|
export function validateAndRepairAllGPT5Profiles(): { repaired: number; total: number } {
|
|
const config = getGlobalConfig()
|
|
if (!config.modelProfiles) {
|
|
return { repaired: 0, total: 0 }
|
|
}
|
|
|
|
let repairCount = 0
|
|
const repairedProfiles = config.modelProfiles.map(profile => {
|
|
const repairedProfile = validateAndRepairGPT5Profile(profile)
|
|
if (repairedProfile.validationStatus === 'auto_repaired') {
|
|
repairCount++
|
|
}
|
|
return repairedProfile
|
|
})
|
|
|
|
// Save the repaired configuration
|
|
if (repairCount > 0) {
|
|
const updatedConfig = {
|
|
...config,
|
|
modelProfiles: repairedProfiles,
|
|
}
|
|
saveGlobalConfig(updatedConfig)
|
|
console.log(`🔧 GPT-5 Config: Auto-repaired ${repairCount} model profiles`)
|
|
}
|
|
|
|
return { repaired: repairCount, total: config.modelProfiles.length }
|
|
}
|
|
|
|
/**
|
|
* Get GPT-5 configuration recommendations for a specific model
|
|
*/
|
|
export function getGPT5ConfigRecommendations(modelName: string): Partial<ModelProfile> {
|
|
if (!isGPT5ModelName(modelName)) {
|
|
return {}
|
|
}
|
|
|
|
const recommendations: Partial<ModelProfile> = {
|
|
contextLength: 128000, // GPT-5 standard context length
|
|
maxTokens: 8192, // Good default for coding tasks
|
|
reasoningEffort: 'medium', // Balanced for most coding tasks
|
|
isGPT5: true,
|
|
}
|
|
|
|
// Model-specific optimizations
|
|
if (modelName.includes('gpt-5-mini')) {
|
|
recommendations.maxTokens = 4096 // Smaller default for mini
|
|
recommendations.reasoningEffort = 'low' // Faster for simple tasks
|
|
} else if (modelName.includes('gpt-5-nano')) {
|
|
recommendations.maxTokens = 2048 // Even smaller for nano
|
|
recommendations.reasoningEffort = 'minimal' // Fastest option
|
|
}
|
|
|
|
return recommendations
|
|
}
|
|
|
|
/**
|
|
* Create a properly configured GPT-5 model profile
|
|
*/
|
|
export function createGPT5ModelProfile(
|
|
name: string,
|
|
modelName: string,
|
|
apiKey: string,
|
|
baseURL?: string,
|
|
provider: ProviderType = 'openai'
|
|
): ModelProfile {
|
|
const recommendations = getGPT5ConfigRecommendations(modelName)
|
|
|
|
const profile: ModelProfile = {
|
|
name,
|
|
provider,
|
|
modelName,
|
|
baseURL: baseURL || 'https://api.openai.com/v1',
|
|
apiKey,
|
|
maxTokens: recommendations.maxTokens || 8192,
|
|
contextLength: recommendations.contextLength || 128000,
|
|
reasoningEffort: recommendations.reasoningEffort || 'medium',
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
isGPT5: true,
|
|
validationStatus: 'valid',
|
|
lastValidation: Date.now(),
|
|
}
|
|
|
|
console.log(`✅ Created GPT-5 model profile: ${name} (${modelName})`)
|
|
return profile
|
|
}
|