/ src / utils / settings / validationTips.ts
validationTips.ts
  1  import type { ZodIssueCode } from 'zod/v4'
  2  
  3  // v4 ZodIssueCode is a value, not a type - use typeof to get the type
  4  type ZodIssueCodeType = (typeof ZodIssueCode)[keyof typeof ZodIssueCode]
  5  
  6  export type ValidationTip = {
  7    suggestion?: string
  8    docLink?: string
  9  }
 10  
 11  export type TipContext = {
 12    path: string
 13    code: ZodIssueCodeType | string
 14    expected?: string
 15    received?: unknown
 16    enumValues?: string[]
 17    message?: string
 18    value?: unknown
 19  }
 20  
 21  type TipMatcher = {
 22    matches: (context: TipContext) => boolean
 23    tip: ValidationTip
 24  }
 25  
 26  const DOCUMENTATION_BASE = 'https://code.claude.com/docs/en'
 27  
 28  const TIP_MATCHERS: TipMatcher[] = [
 29    {
 30      matches: (ctx): boolean =>
 31        ctx.path === 'permissions.defaultMode' && ctx.code === 'invalid_value',
 32      tip: {
 33        suggestion:
 34          'Valid modes: "acceptEdits" (ask before file changes), "plan" (analysis only), "bypassPermissions" (auto-accept all), or "default" (standard behavior)',
 35        docLink: `${DOCUMENTATION_BASE}/iam#permission-modes`,
 36      },
 37    },
 38    {
 39      matches: (ctx): boolean =>
 40        ctx.path === 'apiKeyHelper' && ctx.code === 'invalid_type',
 41      tip: {
 42        suggestion:
 43          'Provide a shell command that outputs your API key to stdout. The script should output only the API key. Example: "/bin/generate_temp_api_key.sh"',
 44      },
 45    },
 46    {
 47      matches: (ctx): boolean =>
 48        ctx.path === 'cleanupPeriodDays' &&
 49        ctx.code === 'too_small' &&
 50        ctx.expected === '0',
 51      tip: {
 52        suggestion:
 53          'Must be 0 or greater. Set a positive number for days to retain transcripts (default is 30). Setting 0 disables session persistence entirely: no transcripts are written and existing transcripts are deleted at startup.',
 54      },
 55    },
 56    {
 57      matches: (ctx): boolean =>
 58        ctx.path.startsWith('env.') && ctx.code === 'invalid_type',
 59      tip: {
 60        suggestion:
 61          'Environment variables must be strings. Wrap numbers and booleans in quotes. Example: "DEBUG": "true", "PORT": "3000"',
 62        docLink: `${DOCUMENTATION_BASE}/settings#environment-variables`,
 63      },
 64    },
 65    {
 66      matches: (ctx): boolean =>
 67        (ctx.path === 'permissions.allow' || ctx.path === 'permissions.deny') &&
 68        ctx.code === 'invalid_type' &&
 69        ctx.expected === 'array',
 70      tip: {
 71        suggestion:
 72          'Permission rules must be in an array. Format: ["Tool(specifier)"]. Examples: ["Bash(npm run build)", "Edit(docs/**)", "Read(~/.zshrc)"]. Use * for wildcards.',
 73      },
 74    },
 75    {
 76      matches: (ctx): boolean =>
 77        ctx.path.includes('hooks') && ctx.code === 'invalid_type',
 78      tip: {
 79        suggestion:
 80          // gh-31187 / CC-282: prior example showed {"matcher": {"tools": ["BashTool"]}}
 81          // — an object format that never existed in the schema (matcher is z.string(),
 82          // always has been). Users copied the tip's example and got the same validation
 83          // error again. See matchesPattern() in hooks.ts: matcher is exact-match,
 84          // pipe-separated ("Edit|Write"), or regex. Empty/"*" matches all.
 85          'Hooks use a matcher + hooks array. The matcher is a string: a tool name ("Bash"), pipe-separated list ("Edit|Write"), or empty to match all. Example: {"PostToolUse": [{"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "echo Done"}]}]}',
 86      },
 87    },
 88    {
 89      matches: (ctx): boolean =>
 90        ctx.code === 'invalid_type' && ctx.expected === 'boolean',
 91      tip: {
 92        suggestion:
 93          'Use true or false without quotes. Example: "includeCoAuthoredBy": true',
 94      },
 95    },
 96    {
 97      matches: (ctx): boolean => ctx.code === 'unrecognized_keys',
 98      tip: {
 99        suggestion:
100          'Check for typos or refer to the documentation for valid fields',
101        docLink: `${DOCUMENTATION_BASE}/settings`,
102      },
103    },
104    {
105      matches: (ctx): boolean =>
106        ctx.code === 'invalid_value' && ctx.enumValues !== undefined,
107      tip: {
108        suggestion: undefined,
109      },
110    },
111    {
112      matches: (ctx): boolean =>
113        ctx.code === 'invalid_type' &&
114        ctx.expected === 'object' &&
115        ctx.received === null &&
116        ctx.path === '',
117      tip: {
118        suggestion:
119          'Check for missing commas, unmatched brackets, or trailing commas. Use a JSON validator to identify the exact syntax error.',
120      },
121    },
122    {
123      matches: (ctx): boolean =>
124        ctx.path === 'permissions.additionalDirectories' &&
125        ctx.code === 'invalid_type',
126      tip: {
127        suggestion:
128          'Must be an array of directory paths. Example: ["~/projects", "/tmp/workspace"]. You can also use --add-dir flag or /add-dir command',
129        docLink: `${DOCUMENTATION_BASE}/iam#working-directories`,
130      },
131    },
132  ]
133  
134  const PATH_DOC_LINKS: Record<string, string> = {
135    permissions: `${DOCUMENTATION_BASE}/iam#configuring-permissions`,
136    env: `${DOCUMENTATION_BASE}/settings#environment-variables`,
137    hooks: `${DOCUMENTATION_BASE}/hooks`,
138  }
139  
140  export function getValidationTip(context: TipContext): ValidationTip | null {
141    const matcher = TIP_MATCHERS.find(m => m.matches(context))
142  
143    if (!matcher) return null
144  
145    const tip: ValidationTip = { ...matcher.tip }
146  
147    if (
148      context.code === 'invalid_value' &&
149      context.enumValues &&
150      !tip.suggestion
151    ) {
152      tip.suggestion = `Valid values: ${context.enumValues.map(v => `"${v}"`).join(', ')}`
153    }
154  
155    // Add documentation link based on path prefix
156    if (!tip.docLink && context.path) {
157      const pathPrefix = context.path.split('.')[0]
158      if (pathPrefix) {
159        tip.docLink = PATH_DOC_LINKS[pathPrefix]
160      }
161    }
162  
163    return tip
164  }