init claude-code
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user