chore: initialize recovered claude workspace

This commit is contained in:
2026-04-02 15:29:01 +08:00
commit a10efa3b4b
1940 changed files with 506426 additions and 0 deletions

View File

@@ -0,0 +1,467 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import {
type GlobalConfig,
getGlobalConfig,
getRemoteControlAtStartup,
saveGlobalConfig,
} from '../../utils/config.js'
import { errorMessage } from '../../utils/errors.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logError } from '../../utils/log.js'
import {
getInitialSettings,
updateSettingsForSource,
} from '../../utils/settings/settings.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { CONFIG_TOOL_NAME } from './constants.js'
import { DESCRIPTION, generatePrompt } from './prompt.js'
import {
getConfig,
getOptionsForSetting,
getPath,
isSupported,
} from './supportedSettings.js'
import {
renderToolResultMessage,
renderToolUseMessage,
renderToolUseRejectedMessage,
} from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
setting: z
.string()
.describe(
'The setting key (e.g., "theme", "model", "permissions.defaultMode")',
),
value: z
.union([z.string(), z.boolean(), z.number()])
.optional()
.describe('The new value. Omit to get current value.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
operation: z.enum(['get', 'set']).optional(),
setting: z.string().optional(),
value: z.unknown().optional(),
previousValue: z.unknown().optional(),
newValue: z.unknown().optional(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Input = z.infer<InputSchema>
export type Output = z.infer<OutputSchema>
export const ConfigTool = buildTool({
name: CONFIG_TOOL_NAME,
searchHint: 'get or set Claude Code settings (theme, model)',
maxResultSizeChars: 100_000,
async description() {
return DESCRIPTION
},
async prompt() {
return generatePrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Config'
},
shouldDefer: true,
isConcurrencySafe() {
return true
},
isReadOnly(input: Input) {
return input.value === undefined
},
toAutoClassifierInput(input) {
return input.value === undefined
? input.setting
: `${input.setting} = ${input.value}`
},
async checkPermissions(input: Input) {
// Auto-allow reading configs
if (input.value === undefined) {
return { behavior: 'allow' as const, updatedInput: input }
}
return {
behavior: 'ask' as const,
message: `Set ${input.setting} to ${jsonStringify(input.value)}`,
}
},
renderToolUseMessage,
renderToolResultMessage,
renderToolUseRejectedMessage,
async call({ setting, value }: Input, context): Promise<{ data: Output }> {
// 1. Check if setting is supported
// Voice settings are registered at build-time (feature('VOICE_MODE')), but
// must also be gated at runtime. When the kill-switch is on, treat
// voiceEnabled as an unknown setting so no voice-specific strings leak.
if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
const { isVoiceGrowthBookEnabled } = await import(
'../../voice/voiceModeEnabled.js'
)
if (!isVoiceGrowthBookEnabled()) {
return {
data: { success: false, error: `Unknown setting: "${setting}"` },
}
}
}
if (!isSupported(setting)) {
return {
data: { success: false, error: `Unknown setting: "${setting}"` },
}
}
const config = getConfig(setting)!
const path = getPath(setting)
// 2. GET operation
if (value === undefined) {
const currentValue = getValue(config.source, path)
const displayValue = config.formatOnRead
? config.formatOnRead(currentValue)
: currentValue
return {
data: { success: true, operation: 'get', setting, value: displayValue },
}
}
// 3. SET operation
// Handle "default" — unset the config key so it falls back to the
// platform-aware default (determined by the bridge feature gate).
if (
setting === 'remoteControlAtStartup' &&
typeof value === 'string' &&
value.toLowerCase().trim() === 'default'
) {
saveGlobalConfig(prev => {
if (prev.remoteControlAtStartup === undefined) return prev
const next = { ...prev }
delete next.remoteControlAtStartup
return next
})
const resolved = getRemoteControlAtStartup()
// Sync to AppState so useReplBridge reacts immediately
context.setAppState(prev => {
if (prev.replBridgeEnabled === resolved && !prev.replBridgeOutboundOnly)
return prev
return {
...prev,
replBridgeEnabled: resolved,
replBridgeOutboundOnly: false,
}
})
return {
data: {
success: true,
operation: 'set',
setting,
value: resolved,
},
}
}
let finalValue: unknown = value
// Coerce and validate boolean values
if (config.type === 'boolean') {
if (typeof value === 'string') {
const lower = value.toLowerCase().trim()
if (lower === 'true') finalValue = true
else if (lower === 'false') finalValue = false
}
if (typeof finalValue !== 'boolean') {
return {
data: {
success: false,
operation: 'set',
setting,
error: `${setting} requires true or false.`,
},
}
}
}
// Check options
const options = getOptionsForSetting(setting)
if (options && !options.includes(String(finalValue))) {
return {
data: {
success: false,
operation: 'set',
setting,
error: `Invalid value "${value}". Options: ${options.join(', ')}`,
},
}
}
// Async validation (e.g., model API check)
if (config.validateOnWrite) {
const result = await config.validateOnWrite(finalValue)
if (!result.valid) {
return {
data: {
success: false,
operation: 'set',
setting,
error: result.error,
},
}
}
}
// Pre-flight checks for voice mode
if (
feature('VOICE_MODE') &&
setting === 'voiceEnabled' &&
finalValue === true
) {
const { isVoiceModeEnabled } = await import(
'../../voice/voiceModeEnabled.js'
)
if (!isVoiceModeEnabled()) {
const { isAnthropicAuthEnabled } = await import('../../utils/auth.js')
return {
data: {
success: false,
error: !isAnthropicAuthEnabled()
? 'Voice mode requires a Claude.ai account. Please run /login to sign in.'
: 'Voice mode is not available.',
},
}
}
const { isVoiceStreamAvailable } = await import(
'../../services/voiceStreamSTT.js'
)
const {
checkRecordingAvailability,
checkVoiceDependencies,
requestMicrophonePermission,
} = await import('../../services/voice.js')
const recording = await checkRecordingAvailability()
if (!recording.available) {
return {
data: {
success: false,
error:
recording.reason ??
'Voice mode is not available in this environment.',
},
}
}
if (!isVoiceStreamAvailable()) {
return {
data: {
success: false,
error:
'Voice mode requires a Claude.ai account. Please run /login to sign in.',
},
}
}
const deps = await checkVoiceDependencies()
if (!deps.available) {
return {
data: {
success: false,
error:
'No audio recording tool found.' +
(deps.installCommand ? ` Run: ${deps.installCommand}` : ''),
},
}
}
if (!(await requestMicrophonePermission())) {
let guidance: string
if (process.platform === 'win32') {
guidance = 'Settings \u2192 Privacy \u2192 Microphone'
} else if (process.platform === 'linux') {
guidance = "your system's audio settings"
} else {
guidance =
'System Settings \u2192 Privacy & Security \u2192 Microphone'
}
return {
data: {
success: false,
error: `Microphone access is denied. To enable it, go to ${guidance}, then try again.`,
},
}
}
}
const previousValue = getValue(config.source, path)
// 4. Write to storage
try {
if (config.source === 'global') {
const key = path[0]
if (!key) {
return {
data: {
success: false,
operation: 'set',
setting,
error: 'Invalid setting path',
},
}
}
saveGlobalConfig(prev => {
if (prev[key as keyof GlobalConfig] === finalValue) return prev
return { ...prev, [key]: finalValue }
})
} else {
const update = buildNestedObject(path, finalValue)
const result = updateSettingsForSource('userSettings', update)
if (result.error) {
return {
data: {
success: false,
operation: 'set',
setting,
error: result.error.message,
},
}
}
}
// 5a. Voice needs notifyChange so applySettingsChange resyncs
// AppState.settings (useVoiceEnabled reads settings.voiceEnabled)
// and the settings cache resets for the next /voice read.
if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
const { settingsChangeDetector } = await import(
'../../utils/settings/changeDetector.js'
)
settingsChangeDetector.notifyChange('userSettings')
}
// 5b. Sync to AppState if needed for immediate UI effect
if (config.appStateKey) {
const appKey = config.appStateKey
context.setAppState(prev => {
if (prev[appKey] === finalValue) return prev
return { ...prev, [appKey]: finalValue }
})
}
// Sync remoteControlAtStartup to AppState so the bridge reacts
// immediately (the config key differs from the AppState field name,
// so the generic appStateKey mechanism can't handle this).
if (setting === 'remoteControlAtStartup') {
const resolved = getRemoteControlAtStartup()
context.setAppState(prev => {
if (
prev.replBridgeEnabled === resolved &&
!prev.replBridgeOutboundOnly
)
return prev
return {
...prev,
replBridgeEnabled: resolved,
replBridgeOutboundOnly: false,
}
})
}
logEvent('tengu_config_tool_changed', {
setting:
setting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
value: String(
finalValue,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return {
data: {
success: true,
operation: 'set',
setting,
previousValue,
newValue: finalValue,
},
}
} catch (error) {
logError(error)
return {
data: {
success: false,
operation: 'set',
setting,
error: errorMessage(error),
},
}
}
},
mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
if (content.success) {
if (content.operation === 'get') {
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `${content.setting} = ${jsonStringify(content.value)}`,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Set ${content.setting} to ${jsonStringify(content.newValue)}`,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Error: ${content.error}`,
is_error: true,
}
},
} satisfies ToolDef<InputSchema, Output>)
function getValue(source: 'global' | 'settings', path: string[]): unknown {
if (source === 'global') {
const config = getGlobalConfig()
const key = path[0]
if (!key) return undefined
return config[key as keyof GlobalConfig]
}
const settings = getInitialSettings()
let current: unknown = settings
for (const key of path) {
if (current && typeof current === 'object' && key in current) {
current = (current as Record<string, unknown>)[key]
} else {
return undefined
}
}
return current
}
function buildNestedObject(
path: string[],
value: unknown,
): Record<string, unknown> {
if (path.length === 0) {
return {}
}
const key = path[0]!
if (path.length === 1) {
return { [key]: value }
}
return { [key]: buildNestedObject(path.slice(1), value) }
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { MessageResponse } from '../../components/MessageResponse.js';
import { Text } from '../../ink.js';
import { jsonStringify } from '../../utils/slowOperations.js';
import type { Input, Output } from './ConfigTool.js';
export function renderToolUseMessage(input: Partial<Input>): React.ReactNode {
if (!input.setting) return null;
if (input.value === undefined) {
return <Text dimColor>Getting {input.setting}</Text>;
}
return <Text dimColor>
Setting {input.setting} to {jsonStringify(input.value)}
</Text>;
}
export function renderToolResultMessage(content: Output): React.ReactNode {
if (!content.success) {
return <MessageResponse>
<Text color="error">Failed: {content.error}</Text>
</MessageResponse>;
}
if (content.operation === 'get') {
return <MessageResponse>
<Text>
<Text bold>{content.setting}</Text> = {jsonStringify(content.value)}
</Text>
</MessageResponse>;
}
return <MessageResponse>
<Text>
Set <Text bold>{content.setting}</Text> to{' '}
<Text bold>{jsonStringify(content.newValue)}</Text>
</Text>
</MessageResponse>;
}
export function renderToolUseRejectedMessage(): React.ReactNode {
return <Text color="warning">Config change rejected</Text>;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIk1lc3NhZ2VSZXNwb25zZSIsIlRleHQiLCJqc29uU3RyaW5naWZ5IiwiSW5wdXQiLCJPdXRwdXQiLCJyZW5kZXJUb29sVXNlTWVzc2FnZSIsImlucHV0IiwiUGFydGlhbCIsIlJlYWN0Tm9kZSIsInNldHRpbmciLCJ2YWx1ZSIsInVuZGVmaW5lZCIsInJlbmRlclRvb2xSZXN1bHRNZXNzYWdlIiwiY29udGVudCIsInN1Y2Nlc3MiLCJlcnJvciIsIm9wZXJhdGlvbiIsIm5ld1ZhbHVlIiwicmVuZGVyVG9vbFVzZVJlamVjdGVkTWVzc2FnZSJdLCJzb3VyY2VzIjpbIlVJLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBNZXNzYWdlUmVzcG9uc2UgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL01lc3NhZ2VSZXNwb25zZS5qcydcbmltcG9ydCB7IFRleHQgfSBmcm9tICcuLi8uLi9pbmsuanMnXG5pbXBvcnQgeyBqc29uU3RyaW5naWZ5IH0gZnJvbSAnLi4vLi4vdXRpbHMvc2xvd09wZXJhdGlvbnMuanMnXG5pbXBvcnQgdHlwZSB7IElucHV0LCBPdXRwdXQgfSBmcm9tICcuL0NvbmZpZ1Rvb2wuanMnXG5cbmV4cG9ydCBmdW5jdGlvbiByZW5kZXJUb29sVXNlTWVzc2FnZShpbnB1dDogUGFydGlhbDxJbnB1dD4pOiBSZWFjdC5SZWFjdE5vZGUge1xuICBpZiAoIWlucHV0LnNldHRpbmcpIHJldHVybiBudWxsXG4gIGlmIChpbnB1dC52YWx1ZSA9PT0gdW5kZWZpbmVkKSB7XG4gICAgcmV0dXJuIDxUZXh0IGRpbUNvbG9yPkdldHRpbmcge2lucHV0LnNldHRpbmd9PC9UZXh0PlxuICB9XG4gIHJldHVybiAoXG4gICAgPFRleHQgZGltQ29sb3I+XG4gICAgICBTZXR0aW5nIHtpbnB1dC5zZXR0aW5nfSB0byB7anNvblN0cmluZ2lmeShpbnB1dC52YWx1ZSl9XG4gICAgPC9UZXh0PlxuICApXG59XG5cbmV4cG9ydCBmdW5jdGlvbiByZW5kZXJUb29sUmVzdWx0TWVzc2FnZShjb250ZW50OiBPdXRwdXQpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBpZiAoIWNvbnRlbnQuc3VjY2Vzcykge1xuICAgIHJldHVybiAoXG4gICAgICA8TWVzc2FnZVJlc3BvbnNlPlxuICAgICAgICA8VGV4dCBjb2xvcj1cImVycm9yXCI+RmFpbGVkOiB7Y29udGVudC5lcnJvcn08L1RleHQ+XG4gICAgICA8L01lc3NhZ2VSZXNwb25zZT5cbiAgICApXG4gIH1cbiAgaWYgKGNvbnRlbnQub3BlcmF0aW9uID09PSAnZ2V0Jykge1xuICAgIHJldHVybiAoXG4gICAgICA8TWVzc2FnZVJlc3BvbnNlPlxuICAgICAgICA8VGV4dD5cbiAgICAgICAgICA8VGV4dCBib2xkPntjb250ZW50LnNldHRpbmd9PC9UZXh0PiA9IHtqc29uU3RyaW5naWZ5KGNvbnRlbnQudmFsdWUpfVxuICAgICAgICA8L1RleHQ+XG4gICAgICA8L01lc3NhZ2VSZXNwb25zZT5cbiAgICApXG4gIH1cbiAgcmV0dXJuIChcbiAgICA8TWVzc2FnZVJlc3BvbnNlPlxuICAgICAgPFRleHQ+XG4gICAgICAgIFNldCA8VGV4dCBib2xkPntjb250ZW50LnNldHRpbmd9PC9UZXh0PiB0b3snICd9XG4gICAgICAgIDxUZXh0IGJvbGQ+e2pzb25TdHJpbmdpZnkoY29udGVudC5uZXdWYWx1ZSl9PC9UZXh0PlxuICAgICAgPC9UZXh0PlxuICAgIDwvTWVzc2FnZVJlc3BvbnNlPlxuICApXG59XG5cbmV4cG9ydCBmdW5jdGlvbiByZW5kZXJUb29sVXNlUmVqZWN0ZWRNZXNzYWdlKCk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIHJldHVybiA8VGV4dCBjb2xvcj1cIndhcm5pbmdcIj5Db25maWcgY2hhbmdlIHJlamVjdGVkPC9UZXh0PlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixTQUFTQyxlQUFlLFFBQVEscUNBQXFDO0FBQ3JFLFNBQVNDLElBQUksUUFBUSxjQUFjO0FBQ25DLFNBQVNDLGFBQWEsUUFBUSwrQkFBK0I7QUFDN0QsY0FBY0MsS0FBSyxFQUFFQyxNQUFNLFFBQVEsaUJBQWlCO0FBRXBELE9BQU8sU0FBU0Msb0JBQW9CQSxDQUFDQyxLQUFLLEVBQUVDLE9BQU8sQ0FBQ0osS0FBSyxDQUFDLENBQUMsRUFBRUosS0FBSyxDQUFDUyxTQUFTLENBQUM7RUFDM0UsSUFBSSxDQUFDRixLQUFLLENBQUNHLE9BQU8sRUFBRSxPQUFPLElBQUk7RUFDL0IsSUFBSUgsS0FBSyxDQUFDSSxLQUFLLEtBQUtDLFNBQVMsRUFBRTtJQUM3QixPQUFPLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBQyxRQUFRLENBQUNMLEtBQUssQ0FBQ0csT0FBTyxDQUFDLEVBQUUsSUFBSSxDQUFDO0VBQ3REO0VBQ0EsT0FDRSxDQUFDLElBQUksQ0FBQyxRQUFRO0FBQ2xCLGNBQWMsQ0FBQ0gsS0FBSyxDQUFDRyxPQUFPLENBQUMsSUFBSSxDQUFDUCxhQUFhLENBQUNJLEtBQUssQ0FBQ0ksS0FBSyxDQUFDO0FBQzVELElBQUksRUFBRSxJQUFJLENBQUM7QUFFWDtBQUVBLE9BQU8sU0FBU0UsdUJBQXVCQSxDQUFDQyxPQUFPLEVBQUVULE1BQU0sQ0FBQyxFQUFFTCxLQUFLLENBQUNTLFNBQVMsQ0FBQztFQUN4RSxJQUFJLENBQUNLLE9BQU8sQ0FBQ0MsT0FBTyxFQUFFO0lBQ3BCLE9BQ0UsQ0FBQyxlQUFlO0FBQ3RCLFFBQVEsQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxRQUFRLENBQUNELE9BQU8sQ0FBQ0UsS0FBSyxDQUFDLEVBQUUsSUFBSTtBQUN6RCxNQUFNLEVBQUUsZUFBZSxDQUFDO0VBRXRCO0VBQ0EsSUFBSUYsT0FBTyxDQUFDRyxTQUFTLEtBQUssS0FBSyxFQUFFO0lBQy9CLE9BQ0UsQ0FBQyxlQUFlO0FBQ3RCLFFBQVEsQ0FBQyxJQUFJO0FBQ2IsVUFBVSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQ0gsT0FBTyxDQUFDSixPQUFPLENBQUMsRUFBRSxJQUFJLENBQUMsR0FBRyxDQUFDUCxhQUFhLENBQUNXLE9BQU8sQ0FBQ0gsS0FBSyxDQUFDO0FBQzdFLFFBQVEsRUFBRSxJQUFJO0FBQ2QsTUFBTSxFQUFFLGVBQWUsQ0FBQztFQUV0QjtFQUNBLE9BQ0UsQ0FBQyxlQUFlO0FBQ3BCLE1BQU0sQ0FBQyxJQUFJO0FBQ1gsWUFBWSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQ0csT0FBTyxDQUFDSixPQUFPLENBQUMsRUFBRSxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUc7QUFDdEQsUUFBUSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQ1AsYUFBYSxDQUFDVyxPQUFPLENBQUNJLFFBQVEsQ0FBQyxDQUFDLEVBQUUsSUFBSTtBQUMxRCxNQUFNLEVBQUUsSUFBSTtBQUNaLElBQUksRUFBRSxlQUFlLENBQUM7QUFFdEI7QUFFQSxPQUFPLFNBQVNDLDRCQUE0QkEsQ0FBQSxDQUFFLEVBQUVuQixLQUFLLENBQUNTLFNBQVMsQ0FBQztFQUM5RCxPQUFPLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxTQUFTLENBQUMsc0JBQXNCLEVBQUUsSUFBSSxDQUFDO0FBQzVEIiwiaWdub3JlTGlzdCI6W119

View File

@@ -0,0 +1 @@
export const CONFIG_TOOL_NAME = 'Config'

View File

@@ -0,0 +1,93 @@
import { feature } from 'bun:bundle'
import { getModelOptions } from '../../utils/model/modelOptions.js'
import { isVoiceGrowthBookEnabled } from '../../voice/voiceModeEnabled.js'
import {
getOptionsForSetting,
SUPPORTED_SETTINGS,
} from './supportedSettings.js'
export const DESCRIPTION = 'Get or set Claude Code configuration settings.'
/**
* Generate the prompt documentation from the registry
*/
export function generatePrompt(): string {
const globalSettings: string[] = []
const projectSettings: string[] = []
for (const [key, config] of Object.entries(SUPPORTED_SETTINGS)) {
// Skip model - it gets its own section with dynamic options
if (key === 'model') continue
// Voice settings are registered at build-time but gated by GrowthBook
// at runtime. Hide from model prompt when the kill-switch is on.
if (
feature('VOICE_MODE') &&
key === 'voiceEnabled' &&
!isVoiceGrowthBookEnabled()
)
continue
const options = getOptionsForSetting(key)
let line = `- ${key}`
if (options) {
line += `: ${options.map(o => `"${o}"`).join(', ')}`
} else if (config.type === 'boolean') {
line += `: true/false`
}
line += ` - ${config.description}`
if (config.source === 'global') {
globalSettings.push(line)
} else {
projectSettings.push(line)
}
}
const modelSection = generateModelSection()
return `Get or set Claude Code configuration settings.
View or change Claude Code settings. Use when the user requests configuration changes, asks about current settings, or when adjusting a setting would benefit them.
## Usage
- **Get current value:** Omit the "value" parameter
- **Set new value:** Include the "value" parameter
## Configurable settings list
The following settings are available for you to change:
### Global Settings (stored in ~/.claude.json)
${globalSettings.join('\n')}
### Project Settings (stored in settings.json)
${projectSettings.join('\n')}
${modelSection}
## Examples
- Get theme: { "setting": "theme" }
- Set dark theme: { "setting": "theme", "value": "dark" }
- Enable vim mode: { "setting": "editorMode", "value": "vim" }
- Enable verbose: { "setting": "verbose", "value": true }
- Change model: { "setting": "model", "value": "opus" }
- Change permission mode: { "setting": "permissions.defaultMode", "value": "plan" }
`
}
function generateModelSection(): string {
try {
const options = getModelOptions()
const lines = options.map(o => {
const value = o.value === null ? 'null/"default"' : `"${o.value}"`
return ` - ${value}: ${o.descriptionForModel ?? o.description}`
})
return `## Model
- model - Override the default model. Available options:
${lines.join('\n')}`
} catch {
return `## Model
- model - Override the default model (sonnet, opus, haiku, best, or full model ID)`
}
}

View File

@@ -0,0 +1,211 @@
import { feature } from 'bun:bundle'
import { getRemoteControlAtStartup } from '../../utils/config.js'
import {
EDITOR_MODES,
NOTIFICATION_CHANNELS,
TEAMMATE_MODES,
} from '../../utils/configConstants.js'
import { getModelOptions } from '../../utils/model/modelOptions.js'
import { validateModel } from '../../utils/model/validateModel.js'
import { THEME_NAMES, THEME_SETTINGS } from '../../utils/theme.js'
/** AppState keys that can be synced for immediate UI effect */
type SyncableAppStateKey = 'verbose' | 'mainLoopModel' | 'thinkingEnabled'
type SettingConfig = {
source: 'global' | 'settings'
type: 'boolean' | 'string'
description: string
path?: string[]
options?: readonly string[]
getOptions?: () => string[]
appStateKey?: SyncableAppStateKey
/** Async validation called when writing/setting a value */
validateOnWrite?: (v: unknown) => Promise<{ valid: boolean; error?: string }>
/** Format value when reading/getting for display */
formatOnRead?: (v: unknown) => unknown
}
export const SUPPORTED_SETTINGS: Record<string, SettingConfig> = {
theme: {
source: 'global',
type: 'string',
description: 'Color theme for the UI',
options: feature('AUTO_THEME') ? THEME_SETTINGS : THEME_NAMES,
},
editorMode: {
source: 'global',
type: 'string',
description: 'Key binding mode',
options: EDITOR_MODES,
},
verbose: {
source: 'global',
type: 'boolean',
description: 'Show detailed debug output',
appStateKey: 'verbose',
},
preferredNotifChannel: {
source: 'global',
type: 'string',
description: 'Preferred notification channel',
options: NOTIFICATION_CHANNELS,
},
autoCompactEnabled: {
source: 'global',
type: 'boolean',
description: 'Auto-compact when context is full',
},
autoMemoryEnabled: {
source: 'settings',
type: 'boolean',
description: 'Enable auto-memory',
},
autoDreamEnabled: {
source: 'settings',
type: 'boolean',
description: 'Enable background memory consolidation',
},
fileCheckpointingEnabled: {
source: 'global',
type: 'boolean',
description: 'Enable file checkpointing for code rewind',
},
showTurnDuration: {
source: 'global',
type: 'boolean',
description:
'Show turn duration message after responses (e.g., "Cooked for 1m 6s")',
},
terminalProgressBarEnabled: {
source: 'global',
type: 'boolean',
description: 'Show OSC 9;4 progress indicator in supported terminals',
},
todoFeatureEnabled: {
source: 'global',
type: 'boolean',
description: 'Enable todo/task tracking',
},
model: {
source: 'settings',
type: 'string',
description: 'Override the default model',
appStateKey: 'mainLoopModel',
getOptions: () => {
try {
return getModelOptions()
.filter(o => o.value !== null)
.map(o => o.value as string)
} catch {
return ['sonnet', 'opus', 'haiku']
}
},
validateOnWrite: v => validateModel(String(v)),
formatOnRead: v => (v === null ? 'default' : v),
},
alwaysThinkingEnabled: {
source: 'settings',
type: 'boolean',
description: 'Enable extended thinking (false to disable)',
appStateKey: 'thinkingEnabled',
},
'permissions.defaultMode': {
source: 'settings',
type: 'string',
description: 'Default permission mode for tool usage',
options: feature('TRANSCRIPT_CLASSIFIER')
? ['default', 'plan', 'acceptEdits', 'dontAsk', 'auto']
: ['default', 'plan', 'acceptEdits', 'dontAsk'],
},
language: {
source: 'settings',
type: 'string',
description:
'Preferred language for Claude responses and voice dictation (e.g., "japanese", "spanish")',
},
teammateMode: {
source: 'global',
type: 'string',
description:
'How to spawn teammates: "tmux" for traditional tmux, "in-process" for same process, "auto" to choose automatically',
options: TEAMMATE_MODES,
},
...(process.env.USER_TYPE === 'ant'
? {
classifierPermissionsEnabled: {
source: 'settings' as const,
type: 'boolean' as const,
description:
'Enable AI-based classification for Bash(prompt:...) permission rules',
},
}
: {}),
...(feature('VOICE_MODE')
? {
voiceEnabled: {
source: 'settings' as const,
type: 'boolean' as const,
description: 'Enable voice dictation (hold-to-talk)',
},
}
: {}),
...(feature('BRIDGE_MODE')
? {
remoteControlAtStartup: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Enable Remote Control for all sessions (true | false | default)',
formatOnRead: () => getRemoteControlAtStartup(),
},
}
: {}),
...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION')
? {
taskCompleteNotifEnabled: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Push to your mobile device when idle after Claude finishes (requires Remote Control)',
},
inputNeededNotifEnabled: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Push to your mobile device when a permission prompt or question is waiting (requires Remote Control)',
},
agentPushNotifEnabled: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Allow Claude to push to your mobile device when it deems it appropriate (requires Remote Control)',
},
}
: {}),
}
export function isSupported(key: string): boolean {
return key in SUPPORTED_SETTINGS
}
export function getConfig(key: string): SettingConfig | undefined {
return SUPPORTED_SETTINGS[key]
}
export function getAllKeys(): string[] {
return Object.keys(SUPPORTED_SETTINGS)
}
export function getOptionsForSetting(key: string): string[] | undefined {
const config = SUPPORTED_SETTINGS[key]
if (!config) return undefined
if (config.options) return [...config.options]
if (config.getOptions) return config.getOptions()
return undefined
}
export function getPath(key: string): string[] {
const config = SUPPORTED_SETTINGS[key]
return config?.path ?? key.split('.')
}