/ utils / errors.ts
errors.ts
  1  import { APIUserAbortError } from '@anthropic-ai/sdk'
  2  
  3  export class ClaudeError extends Error {
  4    constructor(message: string) {
  5      super(message)
  6      this.name = this.constructor.name
  7    }
  8  }
  9  
 10  export class MalformedCommandError extends Error {}
 11  
 12  export class AbortError extends Error {
 13    constructor(message?: string) {
 14      super(message)
 15      this.name = 'AbortError'
 16    }
 17  }
 18  
 19  /**
 20   * True iff `e` is any of the abort-shaped errors the codebase encounters:
 21   * our AbortError class, a DOMException from AbortController.abort()
 22   * (.name === 'AbortError'), or the SDK's APIUserAbortError. The SDK class
 23   * is checked via instanceof because minified builds mangle class names —
 24   * constructor.name becomes something like 'nJT' and the SDK never sets
 25   * this.name, so string matching silently fails in production.
 26   */
 27  export function isAbortError(e: unknown): boolean {
 28    return (
 29      e instanceof AbortError ||
 30      e instanceof APIUserAbortError ||
 31      (e instanceof Error && e.name === 'AbortError')
 32    )
 33  }
 34  
 35  /**
 36   * Custom error class for configuration file parsing errors
 37   * Includes the file path and the default configuration that should be used
 38   */
 39  export class ConfigParseError extends Error {
 40    filePath: string
 41    defaultConfig: unknown
 42  
 43    constructor(message: string, filePath: string, defaultConfig: unknown) {
 44      super(message)
 45      this.name = 'ConfigParseError'
 46      this.filePath = filePath
 47      this.defaultConfig = defaultConfig
 48    }
 49  }
 50  
 51  export class ShellError extends Error {
 52    constructor(
 53      public readonly stdout: string,
 54      public readonly stderr: string,
 55      public readonly code: number,
 56      public readonly interrupted: boolean,
 57    ) {
 58      super('Shell command failed')
 59      this.name = 'ShellError'
 60    }
 61  }
 62  
 63  export class TeleportOperationError extends Error {
 64    constructor(
 65      message: string,
 66      public readonly formattedMessage: string,
 67    ) {
 68      super(message)
 69      this.name = 'TeleportOperationError'
 70    }
 71  }
 72  
 73  /**
 74   * Error with a message that is safe to log to telemetry.
 75   * Use the long name to confirm you've verified the message contains no
 76   * sensitive data (file paths, URLs, code snippets).
 77   *
 78   * Single-arg: same message for user and telemetry
 79   * Two-arg: different messages (e.g., full message has file path, telemetry doesn't)
 80   *
 81   * @example
 82   * // Same message for both
 83   * throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
 84   *   'MCP server "slack" connection timed out'
 85   * )
 86   *
 87   * // Different messages
 88   * throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
 89   *   `MCP tool timed out after ${ms}ms`,  // Full message for logs/user
 90   *   'MCP tool timed out'                  // Telemetry message
 91   * )
 92   */
 93  export class TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS extends Error {
 94    readonly telemetryMessage: string
 95  
 96    constructor(message: string, telemetryMessage?: string) {
 97      super(message)
 98      this.name = 'TelemetrySafeError'
 99      this.telemetryMessage = telemetryMessage ?? message
100    }
101  }
102  
103  export function hasExactErrorMessage(error: unknown, message: string): boolean {
104    return error instanceof Error && error.message === message
105  }
106  
107  /**
108   * Normalize an unknown value into an Error.
109   * Use at catch-site boundaries when you need an Error instance.
110   */
111  export function toError(e: unknown): Error {
112    return e instanceof Error ? e : new Error(String(e))
113  }
114  
115  /**
116   * Extract a string message from an unknown error-like value.
117   * Use when you only need the message (e.g., for logging or display).
118   */
119  export function errorMessage(e: unknown): string {
120    return e instanceof Error ? e.message : String(e)
121  }
122  
123  /**
124   * Extract the errno code (e.g., 'ENOENT', 'EACCES') from a caught error.
125   * Returns undefined if the error has no code or is not an ErrnoException.
126   * Replaces the `(e as NodeJS.ErrnoException).code` cast pattern.
127   */
128  export function getErrnoCode(e: unknown): string | undefined {
129    if (e && typeof e === 'object' && 'code' in e && typeof e.code === 'string') {
130      return e.code
131    }
132    return undefined
133  }
134  
135  /**
136   * True if the error is ENOENT (file or directory does not exist).
137   * Replaces `(e as NodeJS.ErrnoException).code === 'ENOENT'`.
138   */
139  export function isENOENT(e: unknown): boolean {
140    return getErrnoCode(e) === 'ENOENT'
141  }
142  
143  /**
144   * Extract the errno path (the filesystem path that triggered the error)
145   * from a caught error. Returns undefined if the error has no path.
146   * Replaces the `(e as NodeJS.ErrnoException).path` cast pattern.
147   */
148  export function getErrnoPath(e: unknown): string | undefined {
149    if (e && typeof e === 'object' && 'path' in e && typeof e.path === 'string') {
150      return e.path
151    }
152    return undefined
153  }
154  
155  /**
156   * Extract error message + top N stack frames from an unknown error.
157   * Use when the error flows to the model as a tool_result — full stack
158   * traces are ~500-2000 chars of mostly-irrelevant internal frames and
159   * waste context tokens. Keep the full stack in debug logs instead.
160   */
161  export function shortErrorStack(e: unknown, maxFrames = 5): string {
162    if (!(e instanceof Error)) return String(e)
163    if (!e.stack) return e.message
164    // V8/Bun stack format: "Name: message\n    at frame1\n    at frame2..."
165    // First line is the message; subsequent "    at " lines are frames.
166    const lines = e.stack.split('\n')
167    const header = lines[0] ?? e.message
168    const frames = lines.slice(1).filter(l => l.trim().startsWith('at '))
169    if (frames.length <= maxFrames) return e.stack
170    return [header, ...frames.slice(0, maxFrames)].join('\n')
171  }
172  
173  /**
174   * True if the error means the path is missing, inaccessible, or
175   * structurally unreachable — use in catch blocks after fs operations to
176   * distinguish expected "nothing there / no access" from unexpected errors.
177   *
178   * Covers:
179   *  ENOENT    — path does not exist
180   *  EACCES    — permission denied
181   *  EPERM     — operation not permitted
182   *  ENOTDIR   — a path component is not a directory (e.g. a file named
183   *              `.claude` exists where a directory is expected)
184   *  ELOOP     — too many symlink levels (circular symlinks)
185   */
186  export function isFsInaccessible(e: unknown): e is NodeJS.ErrnoException {
187    const code = getErrnoCode(e)
188    return (
189      code === 'ENOENT' ||
190      code === 'EACCES' ||
191      code === 'EPERM' ||
192      code === 'ENOTDIR' ||
193      code === 'ELOOP'
194    )
195  }
196  
197  export type AxiosErrorKind =
198    | 'auth' // 401/403 — caller typically sets skipRetry
199    | 'timeout' // ECONNABORTED
200    | 'network' // ECONNREFUSED/ENOTFOUND
201    | 'http' // other axios error (may have status)
202    | 'other' // not an axios error
203  
204  /**
205   * Classify a caught error from an axios request into one of a few buckets.
206   * Replaces the ~20-line isAxiosError → 401/403 → ECONNABORTED → ECONNREFUSED
207   * chain duplicated across sync-style services (settingsSync, policyLimits,
208   * remoteManagedSettings, teamMemorySync).
209   *
210   * Checks the `.isAxiosError` marker property directly (same as
211   * axios.isAxiosError()) to keep this module dependency-free.
212   */
213  export function classifyAxiosError(e: unknown): {
214    kind: AxiosErrorKind
215    status?: number
216    message: string
217  } {
218    const message = errorMessage(e)
219    if (
220      !e ||
221      typeof e !== 'object' ||
222      !('isAxiosError' in e) ||
223      !e.isAxiosError
224    ) {
225      return { kind: 'other', message }
226    }
227    const err = e as {
228      response?: { status?: number }
229      code?: string
230    }
231    const status = err.response?.status
232    if (status === 401 || status === 403) return { kind: 'auth', status, message }
233    if (err.code === 'ECONNABORTED') return { kind: 'timeout', status, message }
234    if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
235      return { kind: 'network', status, message }
236    }
237    return { kind: 'http', status, message }
238  }