941 lines · 27.9 KB · TypeScript
RawDownload
/** * ConfigManager service - Manages app configuration stored at ~/.claude/claude-devtools-config.json. * * Responsibilities: * - Load configuration from disk on initialization * - Provide default values for all configuration fields * - Save configuration changes to disk * - Manage notification settings (ignore patterns, projects, snooze) * - Handle JSON parse errors gracefully */ import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';import { validateRegexPattern } from '@main/utils/regexValidation';import { createLogger } from '@shared/utils/logger';import * as fs from 'fs';import * as os from 'os';import * as path from 'path'; import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager'; import type { TriggerColor } from '@shared/constants/triggerColors';import type { SshConnectionProfile } from '@shared/types/api'; const logger = createLogger('Service:ConfigManager'); const CONFIG_DIR = path.join(os.homedir(), '.claude');const CONFIG_FILENAME = 'claude-devtools-config.json';const DEFAULT_CONFIG_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME); // ===========================================================================// Types// =========================================================================== export interface NotificationConfig {  enabled: boolean;  soundEnabled: boolean;  ignoredRegex: string[];  ignoredRepositories: string[]; // Repository group IDs to ignore  snoozedUntil: number | null; // Unix timestamp (ms) when snooze ends  snoozeMinutes: number; // Default snooze duration  /** Whether to include errors from subagent sessions */  includeSubagentErrors: boolean;  /** Notification triggers - define when to generate notifications */  triggers: NotificationTrigger[];} /** * Content types that can trigger notifications. */export type TriggerContentType = 'tool_result' | 'tool_use' | 'thinking' | 'text'; /** * Known tool names that can be filtered for tool_use triggers. */// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used for type derivation onlyconst KNOWN_TOOL_NAMES = [  'Bash',  'Task',  'TodoWrite',  'Read',  'Write',  'Edit',  'Grep',  'Glob',  'WebFetch',  'WebSearch',  'LSP',  'Skill',  'NotebookEdit',  'AskUserQuestion',  'KillShell',  'TaskOutput',] as const; /** * Tool names that can be filtered for tool_use triggers. * Accepts known tool names or any custom tool name. */export type TriggerToolName = (typeof KNOWN_TOOL_NAMES)[number] | (string & Record<never, never>); /** * Match fields available for different content types and tools. */export type MatchFieldForToolResult = 'content';export type MatchFieldForBash = 'command' | 'description';export type MatchFieldForTask = 'description' | 'prompt' | 'subagent_type';export type MatchFieldForRead = 'file_path';export type MatchFieldForWrite = 'file_path' | 'content';export type MatchFieldForEdit = 'file_path' | 'old_string' | 'new_string';export type MatchFieldForGlob = 'pattern' | 'path';export type MatchFieldForGrep = 'pattern' | 'path' | 'glob';export type MatchFieldForWebFetch = 'url' | 'prompt';export type MatchFieldForWebSearch = 'query';export type MatchFieldForSkill = 'skill' | 'args';export type MatchFieldForThinking = 'thinking';export type MatchFieldForText = 'text'; /** * Combined type for all possible match fields. */export type TriggerMatchField =  | MatchFieldForToolResult  | MatchFieldForBash  | MatchFieldForTask  | MatchFieldForRead  | MatchFieldForWrite  | MatchFieldForEdit  | MatchFieldForGlob  | MatchFieldForGrep  | MatchFieldForWebFetch  | MatchFieldForWebSearch  | MatchFieldForSkill  | MatchFieldForThinking  | MatchFieldForText; /** * Trigger mode determines how the trigger evaluates conditions. * - 'error_status': Triggers when is_error is true (simple boolean check) * - 'content_match': Triggers when content matches a regex pattern * - 'token_threshold': Triggers when token count exceeds threshold */export type TriggerMode = 'error_status' | 'content_match' | 'token_threshold'; /** * Token type for threshold triggers. */export type TriggerTokenType = 'input' | 'output' | 'total'; /** * Notification trigger configuration. * Defines when notifications should be generated. */export interface NotificationTrigger {  /** Unique identifier for this trigger */  id: string;  /** Human-readable name for this trigger */  name: string;  /** Whether this trigger is enabled */  enabled: boolean;  /** Content type to match */  contentType: TriggerContentType;  /** For tool_use/tool_result: specific tool name to match */  toolName?: TriggerToolName;  /** Whether this is a built-in trigger (cannot be deleted) */  isBuiltin?: boolean;  /** Regex patterns to IGNORE (skip notification if content matches any of these) */  ignorePatterns?: string[];   // === Discriminated Union Mode ===  /** Trigger evaluation mode */  mode: TriggerMode;   // === Mode: error_status ===  /** For error_status mode: always triggers on is_error=true */  requireError?: boolean;   // === Mode: content_match ===  /** For content_match mode: field to match against */  matchField?: TriggerMatchField;  /** For content_match mode: regex pattern to match */  matchPattern?: string;   // === Mode: token_threshold ===  /** For token_threshold mode: minimum token count to trigger */  tokenThreshold?: number;  /** For token_threshold mode: which token type to check */  tokenType?: TriggerTokenType;   // === Repository Scope ===  /** If set, this trigger only applies to these repository group IDs */  repositoryIds?: string[];   // === Display ===  /** Color for notification dot and navigation highlight (preset key or hex string) */  color?: TriggerColor;} export interface GeneralConfig {  launchAtLogin: boolean;  showDockIcon: boolean;  theme: 'dark' | 'light' | 'system';  defaultTab: 'dashboard' | 'last-session';  claudeRootPath: string | null;} export interface DisplayConfig {  showTimestamps: boolean;  compactMode: boolean;  syntaxHighlighting: boolean;} export interface SessionsConfig {  pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;  hiddenSessions: Record<string, { sessionId: string; hiddenAt: number }[]>;} export interface SshPersistConfig {  lastConnection: {    host: string;    port: number;    username: string;    authMethod: 'password' | 'privateKey' | 'agent' | 'auto';    privateKeyPath?: string;  } | null;  autoReconnect: boolean;  profiles: SshConnectionProfile[];  lastActiveContextId: string;} export interface HttpServerConfig {  enabled: boolean;  port: number;} export interface AppConfig {  notifications: NotificationConfig;  general: GeneralConfig;  display: DisplayConfig;  sessions: SessionsConfig;  ssh: SshPersistConfig;  httpServer: HttpServerConfig;} // Config section keys for type-safe updatesexport type ConfigSection = keyof AppConfig; // ===========================================================================// Default Configuration// =========================================================================== // Default regex patterns for common non-actionable notificationsconst DEFAULT_IGNORED_REGEX = ["The user doesn't want to proceed with this tool use\\."]; const DEFAULT_CONFIG: AppConfig = {  notifications: {    enabled: true,    soundEnabled: true,    ignoredRegex: [...DEFAULT_IGNORED_REGEX],    ignoredRepositories: [],    snoozedUntil: null,    snoozeMinutes: 30,    includeSubagentErrors: true,    triggers: DEFAULT_TRIGGERS,  },  general: {    launchAtLogin: false,    showDockIcon: true,    theme: 'dark',    defaultTab: 'dashboard',    claudeRootPath: null,  },  display: {    showTimestamps: true,    compactMode: false,    syntaxHighlighting: true,  },  sessions: {    pinnedSessions: {},    hiddenSessions: {},  },  ssh: {    lastConnection: null,    autoReconnect: false,    profiles: [],    lastActiveContextId: 'local',  },  httpServer: {    enabled: false,    port: 3456,  },}; function normalizeConfiguredClaudeRootPath(value: unknown): string | null {  if (typeof value !== 'string') {    return null;  }   const trimmed = value.trim();  if (!trimmed) {    return null;  }   const normalized = path.normalize(trimmed);  if (!path.isAbsolute(normalized)) {    return null;  }   const resolved = path.resolve(normalized);  const root = path.parse(resolved).root;  if (resolved === root) {    return resolved;  }  let end = resolved.length;  while (end > root.length) {    const char = resolved[end - 1];    if (char !== '/' && char !== '\\') {      break;    }    end--;  }   return resolved.slice(0, end);} // ===========================================================================// ConfigManager Class// =========================================================================== export class ConfigManager {  private config: AppConfig;  private readonly configPath: string;  private static instance: ConfigManager | null = null;  private triggerManager: TriggerManager;   constructor(configPath?: string) {    this.configPath = configPath ?? DEFAULT_CONFIG_PATH;    this.config = this.loadConfig();    setClaudeBasePathOverride(this.config.general.claudeRootPath);    this.triggerManager = new TriggerManager(this.config.notifications.triggers, () =>      this.saveConfig()    );  }   // ===========================================================================  // Singleton Pattern  // ===========================================================================   /**   * Gets the singleton instance of ConfigManager.   */  static getInstance(): ConfigManager {    ConfigManager.instance ??= new ConfigManager();    return ConfigManager.instance;  }   /**   * Resets the singleton instance (useful for testing).   */  static resetInstance(): void {    ConfigManager.instance = null;  }   // ===========================================================================  // Config Loading & Saving  // ===========================================================================   /**   * Loads configuration from disk.   * Returns default config if file doesn't exist or is invalid.   */  private loadConfig(): AppConfig {    try {      if (!fs.existsSync(this.configPath)) {        logger.info('No config file found, using defaults');        return this.deepClone(DEFAULT_CONFIG);      }       const content = fs.readFileSync(this.configPath, 'utf8');      const parsed = JSON.parse(content) as Partial<AppConfig>;       // Merge with defaults to ensure all fields exist      return this.mergeWithDefaults(parsed);    } catch (error) {      logger.error('Error loading config, using defaults:', error);      return this.deepClone(DEFAULT_CONFIG);    }  }   /**   * Saves the current configuration to disk.   */  private saveConfig(): void {    try {      this.persistConfig(this.config);      logger.info('Config saved');    } catch (error) {      logger.error('Error saving config:', error);    }  }   /**   * Persists configuration to the canonical path.   */  private persistConfig(config: AppConfig): void {    const configDir = path.dirname(this.configPath);    if (!fs.existsSync(configDir)) {      fs.mkdirSync(configDir, { recursive: true });    }     const content = JSON.stringify(config, null, 2);    fs.writeFileSync(this.configPath, content, 'utf8');  }   /**   * Merges loaded config with defaults to ensure all fields exist.   * Special handling for triggers array to preserve existing triggers   * and add any missing builtin triggers.   */  private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {    const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);    const loadedTriggers = loadedNotifications.triggers ?? [];    const mergedGeneral: GeneralConfig = {      ...DEFAULT_CONFIG.general,      ...(loaded.general ?? {}),    };    mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);     // Merge triggers: preserve existing triggers, add missing builtin ones    const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS);     return {      notifications: {        ...DEFAULT_CONFIG.notifications,        ...loadedNotifications,        triggers: mergedTriggers,      },      general: mergedGeneral,      display: {        ...DEFAULT_CONFIG.display,        ...(loaded.display ?? {}),      },      sessions: {        ...DEFAULT_CONFIG.sessions,        ...(loaded.sessions ?? {}),      },      ssh: {        ...DEFAULT_CONFIG.ssh,        ...(loaded.ssh ?? {}),      },      httpServer: {        ...DEFAULT_CONFIG.httpServer,        ...(loaded.httpServer ?? {}),      },    };  }   /**   * Deep clones an object.   */  private deepClone<T>(obj: T): T {    return JSON.parse(JSON.stringify(obj)) as T;  }   // ===========================================================================  // Config Access  // ===========================================================================   /**   * Gets the full configuration object.   */  getConfig(): AppConfig {    return this.deepClone(this.config);  }   /**   * Gets the configuration file path.   */  getConfigPath(): string {    return this.configPath;  }   // ===========================================================================  // Config Updates  // ===========================================================================   /**   * Updates a section of the configuration.   * @param section - The config section to update ('notifications', 'general', 'display')   * @param data - Partial data to merge into the section   */  updateConfig<K extends ConfigSection>(section: K, data: Partial<AppConfig[K]>): AppConfig {    const normalizedData = this.normalizeSectionUpdate(section, data);    this.config[section] = {      ...this.config[section],      ...normalizedData,    };     if (section === 'general') {      setClaudeBasePathOverride(this.config.general.claudeRootPath);    }     this.saveConfig();    return this.getConfig();  }   private normalizeSectionUpdate<K extends ConfigSection>(    section: K,    data: Partial<AppConfig[K]>  ): Partial<AppConfig[K]> {    if (section !== 'general') {      return data;    }     if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {      return data;    }     const generalUpdate = data as Partial<GeneralConfig>;    return {      ...generalUpdate,

Lines 1–500 of 941.