chore: initialize recovered claude workspace
This commit is contained in:
467
src/tools/ConfigTool/ConfigTool.ts
Normal file
467
src/tools/ConfigTool/ConfigTool.ts
Normal 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) }
|
||||
}
|
||||
38
src/tools/ConfigTool/UI.tsx
Normal file
38
src/tools/ConfigTool/UI.tsx
Normal 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
|
||||
1
src/tools/ConfigTool/constants.ts
Normal file
1
src/tools/ConfigTool/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const CONFIG_TOOL_NAME = 'Config'
|
||||
93
src/tools/ConfigTool/prompt.ts
Normal file
93
src/tools/ConfigTool/prompt.ts
Normal 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)`
|
||||
}
|
||||
}
|
||||
211
src/tools/ConfigTool/supportedSettings.ts
Normal file
211
src/tools/ConfigTool/supportedSettings.ts
Normal 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('.')
|
||||
}
|
||||
Reference in New Issue
Block a user