/ tools / ConfigTool / ConfigTool.ts
ConfigTool.ts
  1  import { feature } from 'bun:bundle'
  2  import { z } from 'zod/v4'
  3  import {
  4    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  5    logEvent,
  6  } from '../../services/analytics/index.js'
  7  import { buildTool, type ToolDef } from '../../Tool.js'
  8  import {
  9    type GlobalConfig,
 10    getGlobalConfig,
 11    getRemoteControlAtStartup,
 12    saveGlobalConfig,
 13  } from '../../utils/config.js'
 14  import { errorMessage } from '../../utils/errors.js'
 15  import { lazySchema } from '../../utils/lazySchema.js'
 16  import { logError } from '../../utils/log.js'
 17  import {
 18    getInitialSettings,
 19    updateSettingsForSource,
 20  } from '../../utils/settings/settings.js'
 21  import { jsonStringify } from '../../utils/slowOperations.js'
 22  import { CONFIG_TOOL_NAME } from './constants.js'
 23  import { DESCRIPTION, generatePrompt } from './prompt.js'
 24  import {
 25    getConfig,
 26    getOptionsForSetting,
 27    getPath,
 28    isSupported,
 29  } from './supportedSettings.js'
 30  import {
 31    renderToolResultMessage,
 32    renderToolUseMessage,
 33    renderToolUseRejectedMessage,
 34  } from './UI.js'
 35  
 36  const inputSchema = lazySchema(() =>
 37    z.strictObject({
 38      setting: z
 39        .string()
 40        .describe(
 41          'The setting key (e.g., "theme", "model", "permissions.defaultMode")',
 42        ),
 43      value: z
 44        .union([z.string(), z.boolean(), z.number()])
 45        .optional()
 46        .describe('The new value. Omit to get current value.'),
 47    }),
 48  )
 49  type InputSchema = ReturnType<typeof inputSchema>
 50  
 51  const outputSchema = lazySchema(() =>
 52    z.object({
 53      success: z.boolean(),
 54      operation: z.enum(['get', 'set']).optional(),
 55      setting: z.string().optional(),
 56      value: z.unknown().optional(),
 57      previousValue: z.unknown().optional(),
 58      newValue: z.unknown().optional(),
 59      error: z.string().optional(),
 60    }),
 61  )
 62  type OutputSchema = ReturnType<typeof outputSchema>
 63  
 64  export type Input = z.infer<InputSchema>
 65  export type Output = z.infer<OutputSchema>
 66  
 67  export const ConfigTool = buildTool({
 68    name: CONFIG_TOOL_NAME,
 69    searchHint: 'get or set Claude Code settings (theme, model)',
 70    maxResultSizeChars: 100_000,
 71    async description() {
 72      return DESCRIPTION
 73    },
 74    async prompt() {
 75      return generatePrompt()
 76    },
 77    get inputSchema(): InputSchema {
 78      return inputSchema()
 79    },
 80    get outputSchema(): OutputSchema {
 81      return outputSchema()
 82    },
 83    userFacingName() {
 84      return 'Config'
 85    },
 86    shouldDefer: true,
 87    isConcurrencySafe() {
 88      return true
 89    },
 90    isReadOnly(input: Input) {
 91      return input.value === undefined
 92    },
 93    toAutoClassifierInput(input) {
 94      return input.value === undefined
 95        ? input.setting
 96        : `${input.setting} = ${input.value}`
 97    },
 98    async checkPermissions(input: Input) {
 99      // Auto-allow reading configs
100      if (input.value === undefined) {
101        return { behavior: 'allow' as const, updatedInput: input }
102      }
103      return {
104        behavior: 'ask' as const,
105        message: `Set ${input.setting} to ${jsonStringify(input.value)}`,
106      }
107    },
108    renderToolUseMessage,
109    renderToolResultMessage,
110    renderToolUseRejectedMessage,
111    async call({ setting, value }: Input, context): Promise<{ data: Output }> {
112      // 1. Check if setting is supported
113      // Voice settings are registered at build-time (feature('VOICE_MODE')), but
114      // must also be gated at runtime. When the kill-switch is on, treat
115      // voiceEnabled as an unknown setting so no voice-specific strings leak.
116      if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
117        const { isVoiceGrowthBookEnabled } = await import(
118          '../../voice/voiceModeEnabled.js'
119        )
120        if (!isVoiceGrowthBookEnabled()) {
121          return {
122            data: { success: false, error: `Unknown setting: "${setting}"` },
123          }
124        }
125      }
126      if (!isSupported(setting)) {
127        return {
128          data: { success: false, error: `Unknown setting: "${setting}"` },
129        }
130      }
131  
132      const config = getConfig(setting)!
133      const path = getPath(setting)
134  
135      // 2. GET operation
136      if (value === undefined) {
137        const currentValue = getValue(config.source, path)
138        const displayValue = config.formatOnRead
139          ? config.formatOnRead(currentValue)
140          : currentValue
141        return {
142          data: { success: true, operation: 'get', setting, value: displayValue },
143        }
144      }
145  
146      // 3. SET operation
147  
148      // Handle "default" — unset the config key so it falls back to the
149      // platform-aware default (determined by the bridge feature gate).
150      if (
151        setting === 'remoteControlAtStartup' &&
152        typeof value === 'string' &&
153        value.toLowerCase().trim() === 'default'
154      ) {
155        saveGlobalConfig(prev => {
156          if (prev.remoteControlAtStartup === undefined) return prev
157          const next = { ...prev }
158          delete next.remoteControlAtStartup
159          return next
160        })
161        const resolved = getRemoteControlAtStartup()
162        // Sync to AppState so useReplBridge reacts immediately
163        context.setAppState(prev => {
164          if (prev.replBridgeEnabled === resolved && !prev.replBridgeOutboundOnly)
165            return prev
166          return {
167            ...prev,
168            replBridgeEnabled: resolved,
169            replBridgeOutboundOnly: false,
170          }
171        })
172        return {
173          data: {
174            success: true,
175            operation: 'set',
176            setting,
177            value: resolved,
178          },
179        }
180      }
181  
182      let finalValue: unknown = value
183  
184      // Coerce and validate boolean values
185      if (config.type === 'boolean') {
186        if (typeof value === 'string') {
187          const lower = value.toLowerCase().trim()
188          if (lower === 'true') finalValue = true
189          else if (lower === 'false') finalValue = false
190        }
191        if (typeof finalValue !== 'boolean') {
192          return {
193            data: {
194              success: false,
195              operation: 'set',
196              setting,
197              error: `${setting} requires true or false.`,
198            },
199          }
200        }
201      }
202  
203      // Check options
204      const options = getOptionsForSetting(setting)
205      if (options && !options.includes(String(finalValue))) {
206        return {
207          data: {
208            success: false,
209            operation: 'set',
210            setting,
211            error: `Invalid value "${value}". Options: ${options.join(', ')}`,
212          },
213        }
214      }
215  
216      // Async validation (e.g., model API check)
217      if (config.validateOnWrite) {
218        const result = await config.validateOnWrite(finalValue)
219        if (!result.valid) {
220          return {
221            data: {
222              success: false,
223              operation: 'set',
224              setting,
225              error: result.error,
226            },
227          }
228        }
229      }
230  
231      // Pre-flight checks for voice mode
232      if (
233        feature('VOICE_MODE') &&
234        setting === 'voiceEnabled' &&
235        finalValue === true
236      ) {
237        const { isVoiceModeEnabled } = await import(
238          '../../voice/voiceModeEnabled.js'
239        )
240        if (!isVoiceModeEnabled()) {
241          const { isAnthropicAuthEnabled } = await import('../../utils/auth.js')
242          return {
243            data: {
244              success: false,
245              error: !isAnthropicAuthEnabled()
246                ? 'Voice mode requires a Claude.ai account. Please run /login to sign in.'
247                : 'Voice mode is not available.',
248            },
249          }
250        }
251        const { isVoiceStreamAvailable } = await import(
252          '../../services/voiceStreamSTT.js'
253        )
254        const {
255          checkRecordingAvailability,
256          checkVoiceDependencies,
257          requestMicrophonePermission,
258        } = await import('../../services/voice.js')
259  
260        const recording = await checkRecordingAvailability()
261        if (!recording.available) {
262          return {
263            data: {
264              success: false,
265              error:
266                recording.reason ??
267                'Voice mode is not available in this environment.',
268            },
269          }
270        }
271        if (!isVoiceStreamAvailable()) {
272          return {
273            data: {
274              success: false,
275              error:
276                'Voice mode requires a Claude.ai account. Please run /login to sign in.',
277            },
278          }
279        }
280        const deps = await checkVoiceDependencies()
281        if (!deps.available) {
282          return {
283            data: {
284              success: false,
285              error:
286                'No audio recording tool found.' +
287                (deps.installCommand ? ` Run: ${deps.installCommand}` : ''),
288            },
289          }
290        }
291        if (!(await requestMicrophonePermission())) {
292          let guidance: string
293          if (process.platform === 'win32') {
294            guidance = 'Settings \u2192 Privacy \u2192 Microphone'
295          } else if (process.platform === 'linux') {
296            guidance = "your system's audio settings"
297          } else {
298            guidance =
299              'System Settings \u2192 Privacy & Security \u2192 Microphone'
300          }
301          return {
302            data: {
303              success: false,
304              error: `Microphone access is denied. To enable it, go to ${guidance}, then try again.`,
305            },
306          }
307        }
308      }
309  
310      const previousValue = getValue(config.source, path)
311  
312      // 4. Write to storage
313      try {
314        if (config.source === 'global') {
315          const key = path[0]
316          if (!key) {
317            return {
318              data: {
319                success: false,
320                operation: 'set',
321                setting,
322                error: 'Invalid setting path',
323              },
324            }
325          }
326          saveGlobalConfig(prev => {
327            if (prev[key as keyof GlobalConfig] === finalValue) return prev
328            return { ...prev, [key]: finalValue }
329          })
330        } else {
331          const update = buildNestedObject(path, finalValue)
332          const result = updateSettingsForSource('userSettings', update)
333          if (result.error) {
334            return {
335              data: {
336                success: false,
337                operation: 'set',
338                setting,
339                error: result.error.message,
340              },
341            }
342          }
343        }
344  
345        // 5a. Voice needs notifyChange so applySettingsChange resyncs
346        // AppState.settings (useVoiceEnabled reads settings.voiceEnabled)
347        // and the settings cache resets for the next /voice read.
348        if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
349          const { settingsChangeDetector } = await import(
350            '../../utils/settings/changeDetector.js'
351          )
352          settingsChangeDetector.notifyChange('userSettings')
353        }
354  
355        // 5b. Sync to AppState if needed for immediate UI effect
356        if (config.appStateKey) {
357          const appKey = config.appStateKey
358          context.setAppState(prev => {
359            if (prev[appKey] === finalValue) return prev
360            return { ...prev, [appKey]: finalValue }
361          })
362        }
363  
364        // Sync remoteControlAtStartup to AppState so the bridge reacts
365        // immediately (the config key differs from the AppState field name,
366        // so the generic appStateKey mechanism can't handle this).
367        if (setting === 'remoteControlAtStartup') {
368          const resolved = getRemoteControlAtStartup()
369          context.setAppState(prev => {
370            if (
371              prev.replBridgeEnabled === resolved &&
372              !prev.replBridgeOutboundOnly
373            )
374              return prev
375            return {
376              ...prev,
377              replBridgeEnabled: resolved,
378              replBridgeOutboundOnly: false,
379            }
380          })
381        }
382  
383        logEvent('tengu_config_tool_changed', {
384          setting:
385            setting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
386          value: String(
387            finalValue,
388          ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
389        })
390  
391        return {
392          data: {
393            success: true,
394            operation: 'set',
395            setting,
396            previousValue,
397            newValue: finalValue,
398          },
399        }
400      } catch (error) {
401        logError(error)
402        return {
403          data: {
404            success: false,
405            operation: 'set',
406            setting,
407            error: errorMessage(error),
408          },
409        }
410      }
411    },
412    mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
413      if (content.success) {
414        if (content.operation === 'get') {
415          return {
416            tool_use_id: toolUseID,
417            type: 'tool_result' as const,
418            content: `${content.setting} = ${jsonStringify(content.value)}`,
419          }
420        }
421        return {
422          tool_use_id: toolUseID,
423          type: 'tool_result' as const,
424          content: `Set ${content.setting} to ${jsonStringify(content.newValue)}`,
425        }
426      }
427      return {
428        tool_use_id: toolUseID,
429        type: 'tool_result' as const,
430        content: `Error: ${content.error}`,
431        is_error: true,
432      }
433    },
434  } satisfies ToolDef<InputSchema, Output>)
435  
436  function getValue(source: 'global' | 'settings', path: string[]): unknown {
437    if (source === 'global') {
438      const config = getGlobalConfig()
439      const key = path[0]
440      if (!key) return undefined
441      return config[key as keyof GlobalConfig]
442    }
443    const settings = getInitialSettings()
444    let current: unknown = settings
445    for (const key of path) {
446      if (current && typeof current === 'object' && key in current) {
447        current = (current as Record<string, unknown>)[key]
448      } else {
449        return undefined
450      }
451    }
452    return current
453  }
454  
455  function buildNestedObject(
456    path: string[],
457    value: unknown,
458  ): Record<string, unknown> {
459    if (path.length === 0) {
460      return {}
461    }
462    const key = path[0]!
463    if (path.length === 1) {
464      return { [key]: value }
465    }
466    return { [key]: buildNestedObject(path.slice(1), value) }
467  }