@vladimirven/openswe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/AGENTS.md +203 -0
  2. package/CLAUDE.md +203 -0
  3. package/README.md +166 -0
  4. package/bun.lock +447 -0
  5. package/bunfig.toml +4 -0
  6. package/package.json +42 -0
  7. package/src/app.tsx +84 -0
  8. package/src/components/App.tsx +526 -0
  9. package/src/components/ConfirmDialog.tsx +88 -0
  10. package/src/components/Footer.tsx +50 -0
  11. package/src/components/HelpModal.tsx +136 -0
  12. package/src/components/IssueSelectorModal.tsx +701 -0
  13. package/src/components/ManualSessionModal.tsx +191 -0
  14. package/src/components/PhaseProgress.tsx +45 -0
  15. package/src/components/Preview.tsx +249 -0
  16. package/src/components/ProviderSwitcherModal.tsx +156 -0
  17. package/src/components/ScrollableText.tsx +120 -0
  18. package/src/components/SessionCard.tsx +60 -0
  19. package/src/components/SessionList.tsx +79 -0
  20. package/src/components/SessionTerminal.tsx +89 -0
  21. package/src/components/StatusBar.tsx +84 -0
  22. package/src/components/ThemeSwitcherModal.tsx +237 -0
  23. package/src/components/index.ts +58 -0
  24. package/src/components/session-utils.ts +337 -0
  25. package/src/components/theme.ts +206 -0
  26. package/src/components/types.ts +215 -0
  27. package/src/config/defaults.ts +44 -0
  28. package/src/config/env.ts +67 -0
  29. package/src/config/global.ts +252 -0
  30. package/src/config/index.ts +171 -0
  31. package/src/config/types.ts +131 -0
  32. package/src/core/.gitkeep +0 -0
  33. package/src/core/index.ts +5 -0
  34. package/src/core/parser.ts +62 -0
  35. package/src/core/process-manager.ts +52 -0
  36. package/src/core/session.ts +423 -0
  37. package/src/core/tmux.ts +206 -0
  38. package/src/git/.gitkeep +0 -0
  39. package/src/git/index.ts +8 -0
  40. package/src/git/repo.ts +443 -0
  41. package/src/git/worktree.ts +317 -0
  42. package/src/github/.gitkeep +0 -0
  43. package/src/github/client.ts +208 -0
  44. package/src/github/index.ts +8 -0
  45. package/src/github/issues.ts +351 -0
  46. package/src/index.ts +369 -0
  47. package/src/prompts/.gitkeep +0 -0
  48. package/src/prompts/index.ts +1 -0
  49. package/src/prompts/swe-system.ts +22 -0
  50. package/src/providers/claude.ts +103 -0
  51. package/src/providers/index.ts +21 -0
  52. package/src/providers/opencode.ts +98 -0
  53. package/src/providers/registry.ts +53 -0
  54. package/src/providers/types.ts +117 -0
  55. package/src/store/buffers.ts +234 -0
  56. package/src/store/db.test.ts +579 -0
  57. package/src/store/db.ts +249 -0
  58. package/src/store/index.ts +101 -0
  59. package/src/store/project.ts +119 -0
  60. package/src/store/schema.sql +71 -0
  61. package/src/store/sessions.ts +454 -0
  62. package/src/store/types.ts +194 -0
  63. package/src/theme/context.tsx +170 -0
  64. package/src/theme/custom.ts +134 -0
  65. package/src/theme/index.ts +58 -0
  66. package/src/theme/loader.ts +264 -0
  67. package/src/theme/themes/aura.json +69 -0
  68. package/src/theme/themes/ayu.json +80 -0
  69. package/src/theme/themes/carbonfox.json +248 -0
  70. package/src/theme/themes/catppuccin-frappe.json +233 -0
  71. package/src/theme/themes/catppuccin-macchiato.json +233 -0
  72. package/src/theme/themes/catppuccin.json +112 -0
  73. package/src/theme/themes/cobalt2.json +228 -0
  74. package/src/theme/themes/cursor.json +249 -0
  75. package/src/theme/themes/dracula.json +219 -0
  76. package/src/theme/themes/everforest.json +241 -0
  77. package/src/theme/themes/flexoki.json +237 -0
  78. package/src/theme/themes/github.json +233 -0
  79. package/src/theme/themes/gruvbox.json +242 -0
  80. package/src/theme/themes/kanagawa.json +77 -0
  81. package/src/theme/themes/lucent-orng.json +237 -0
  82. package/src/theme/themes/material.json +235 -0
  83. package/src/theme/themes/matrix.json +77 -0
  84. package/src/theme/themes/mercury.json +252 -0
  85. package/src/theme/themes/monokai.json +221 -0
  86. package/src/theme/themes/nightowl.json +221 -0
  87. package/src/theme/themes/nord.json +223 -0
  88. package/src/theme/themes/one-dark.json +84 -0
  89. package/src/theme/themes/opencode.json +245 -0
  90. package/src/theme/themes/orng.json +249 -0
  91. package/src/theme/themes/osaka-jade.json +93 -0
  92. package/src/theme/themes/palenight.json +222 -0
  93. package/src/theme/themes/rosepine.json +234 -0
  94. package/src/theme/themes/solarized.json +223 -0
  95. package/src/theme/themes/synthwave84.json +226 -0
  96. package/src/theme/themes/tokyonight.json +243 -0
  97. package/src/theme/themes/vercel.json +245 -0
  98. package/src/theme/themes/vesper.json +218 -0
  99. package/src/theme/themes/zenburn.json +223 -0
  100. package/src/theme/types.ts +225 -0
  101. package/src/types/sql.d.ts +4 -0
  102. package/src/utils/ansi-parser.ts +225 -0
  103. package/src/utils/format.ts +46 -0
  104. package/src/utils/id.ts +15 -0
  105. package/src/utils/logger.ts +112 -0
  106. package/src/utils/prerequisites.ts +118 -0
  107. package/src/utils/shell.ts +9 -0
  108. package/src/wizard/flows.ts +419 -0
  109. package/src/wizard/index.ts +37 -0
  110. package/src/wizard/prompts.ts +190 -0
  111. package/src/workspace/detect.test.ts +51 -0
  112. package/src/workspace/detect.ts +223 -0
  113. package/src/workspace/index.ts +71 -0
  114. package/src/workspace/init.ts +131 -0
  115. package/src/workspace/paths.ts +143 -0
  116. package/src/workspace/project.ts +164 -0
  117. package/tsconfig.json +22 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Component type definitions for OpenSWE TUI
3
+ */
4
+
5
+ import type { Session, Phase, Status, ProjectState } from "../store"
6
+ import type { GlobalConfig, AIBackend } from "../config"
7
+ import type { ProviderBranding } from "../providers"
8
+ import type { SessionManager } from "../core/session"
9
+
10
+ // ============================================================================
11
+ // Constants
12
+ // ============================================================================
13
+
14
+ /** Status icons for session display */
15
+ export const STATUS_ICONS: Record<Status, string> = {
16
+ active: "●",
17
+ queued: "○",
18
+ needs_attention: "!",
19
+ completed: "✓",
20
+ paused: "P",
21
+ failed: "✗",
22
+ }
23
+
24
+ /** Ordered list of phases for progress calculation */
25
+ export const PHASE_ORDER: readonly Phase[] = [
26
+ "pending",
27
+ "planning",
28
+ "working",
29
+ "completed",
30
+ "failed",
31
+ ]
32
+
33
+ /** Human-readable phase display names */
34
+ export const PHASE_DISPLAY_NAMES: Record<Phase, string> = {
35
+ pending: "Pending",
36
+ planning: "Planning",
37
+ working: "Building",
38
+ completed: "Done",
39
+ failed: "Failed",
40
+ }
41
+
42
+ // ============================================================================
43
+ // Modal Types
44
+ // ============================================================================
45
+
46
+ /** Active modal state */
47
+ export type ModalType = "none" | "tasks" | "issues" | "help" | "confirm-delete" | "manual" | "provider" | "theme"
48
+
49
+ // ============================================================================
50
+ // Component Props
51
+ // ============================================================================
52
+
53
+ /** Props for the root App component */
54
+ export interface AppProps {
55
+ config: GlobalConfig
56
+ projectRoot: string
57
+ }
58
+
59
+ /** Props for the session list component */
60
+ export interface SessionListProps {
61
+ sessions: Session[]
62
+ selectedIndex: number
63
+ onSelect: (index: number) => void
64
+ listWidth: number
65
+ }
66
+
67
+ /** Props for individual session cards */
68
+ export interface SessionCardProps {
69
+ session: Session
70
+ isSelected: boolean
71
+ availableWidth: number
72
+ }
73
+
74
+ /** Props for the preview pane */
75
+ export interface PreviewProps {
76
+ session: Session | null
77
+ startedAt?: Date
78
+ snapshotLines?: string[]
79
+ providerBranding?: ProviderBranding
80
+ }
81
+
82
+ /** Props for the status bar */
83
+ export interface StatusBarProps {
84
+ variant: "header" | "footer"
85
+ // Header variant props
86
+ repoName?: string
87
+ taskCount?: number
88
+ backend?: string
89
+ sessionId?: string
90
+ providerBranding?: ProviderBranding
91
+ // Footer variant props (keybinding hints)
92
+ }
93
+
94
+ /** Props for phase progress indicator */
95
+ export interface PhaseProgressProps {
96
+ phase: Phase
97
+ variant: "inline" | "full"
98
+ }
99
+
100
+ /** Props for HelpModal component */
101
+ export interface HelpModalProps {
102
+ onClose: () => void
103
+ }
104
+
105
+ /** Props for ConfirmDialog component */
106
+ export interface ConfirmDialogProps {
107
+ title: string
108
+ message: string
109
+ onConfirm: () => void
110
+ onCancel: () => void
111
+ }
112
+
113
+ /** Props for IssueSelectorModal component */
114
+ export interface IssueSelectorModalProps {
115
+ /** Repository in "owner/repo" format */
116
+ ownerRepo: string
117
+ /** Absolute path to the project root */
118
+ projectRoot: string
119
+ /** Current AI backend */
120
+ currentBackend: AIBackend
121
+ /** Callback when modal is closed */
122
+ onClose: () => void
123
+ /** Callback when sessions are created - receives created sessions for auto-start */
124
+ onSessionsCreated: (sessions: Session[]) => void
125
+ }
126
+
127
+ /** Props for ManualSessionModal component */
128
+ export interface ManualSessionModalProps {
129
+ /** Absolute path to the project root */
130
+ projectRoot: string
131
+ /** Current AI backend */
132
+ currentBackend: AIBackend
133
+ /** Session manager for starting the session */
134
+ sessionManager: SessionManager
135
+ /** Callback when modal is closed */
136
+ onClose: () => void
137
+ /** Callback when sessions are created (for refreshing the list) */
138
+ onSessionCreated: () => void
139
+ }
140
+
141
+ /** Props for TaskQueueModal component */
142
+ export interface TaskQueueModalProps {
143
+ /** Callback when modal is closed */
144
+ onClose: () => void
145
+ /** Callback when user selects a task to jump to its session */
146
+ onJumpToSession: (sessionId: string) => void
147
+ }
148
+
149
+ /** Props for ProviderSwitcherModal component */
150
+ export interface ProviderSwitcherModalProps {
151
+ /** Current AI backend */
152
+ currentBackend: AIBackend
153
+ /** Callback when a provider is selected */
154
+ onSelect: (backend: AIBackend) => void
155
+ /** Callback when modal is closed */
156
+ onClose: () => void
157
+ }
158
+
159
+ /** Props for ThemeSwitcherModal component */
160
+ export interface ThemeSwitcherModalProps {
161
+ /** Current active theme name */
162
+ currentTheme: string
163
+ /** Callback when a theme is selected */
164
+ onSelect: (themeName: string) => void
165
+ /** Callback when modal is closed */
166
+ onClose: () => void
167
+ }
168
+
169
+ /** Pending action for confirmation dialogs */
170
+ export interface PendingAction {
171
+ type: "delete"
172
+ sessionId: string
173
+ sessionName: string
174
+ }
175
+
176
+ // ============================================================================
177
+ // Derived Types
178
+ // ============================================================================
179
+
180
+ /** Project info for header display */
181
+ export interface ProjectInfo {
182
+ repoFullName: string
183
+ repoUrl: string
184
+ }
185
+
186
+ // ============================================================================
187
+ // Helper Functions
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Get the progress percentage for a given phase
192
+ * Returns 0-100 based on phase position in workflow
193
+ */
194
+ export function getPhaseProgress(phase: Phase): number {
195
+ const index = PHASE_ORDER.indexOf(phase)
196
+ if (index === -1) return 0
197
+ // failed and completed are at the end, treat them as 100%
198
+ if (phase === "completed" || phase === "failed") return 100
199
+ // Calculate progress (excluding completed/failed which are terminal states)
200
+ const progressPhases = PHASE_ORDER.slice(0, -2)
201
+ const progressIndex = progressPhases.indexOf(phase)
202
+ if (progressIndex === -1) return 100
203
+ return Math.round((progressIndex / (progressPhases.length - 1)) * 100)
204
+ }
205
+
206
+ /**
207
+ * Generate a progress bar string
208
+ * @param progress - Progress percentage (0-100)
209
+ * @param width - Total width of the progress bar
210
+ */
211
+ export function generateProgressBar(progress: number, width: number = 10): string {
212
+ const filled = Math.round((progress / 100) * width)
213
+ const empty = width - filled
214
+ return "█".repeat(filled) + "░".repeat(empty)
215
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Default configuration values for OpenSWE
3
+ */
4
+
5
+ import type { GlobalConfig } from "./types"
6
+
7
+ /**
8
+ * Default global configuration
9
+ * Used as the base when no config file exists or for missing values
10
+ */
11
+ export const DEFAULT_CONFIG: GlobalConfig = {
12
+ ai: {
13
+ backend: "opencode",
14
+ opencode: {
15
+ model: "claude-sonnet",
16
+ provider: "anthropic",
17
+ },
18
+ claude: {
19
+ model: "claude-sonnet-4-20250514",
20
+ },
21
+ },
22
+
23
+ keybindings: {
24
+ navigateUp: "k",
25
+ navigateDown: "j",
26
+ select: "Enter",
27
+ newSession: "n",
28
+ deleteSession: "d",
29
+ pauseSession: "p",
30
+ taskQueue: "t",
31
+ issues: "i",
32
+ quit: "q",
33
+ help: "?",
34
+ },
35
+
36
+ advanced: {
37
+ logLevel: "info",
38
+ },
39
+
40
+ ui: {
41
+ theme: "tokyonight",
42
+ themeMode: "dark",
43
+ },
44
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Environment variable configuration loader
3
+ *
4
+ * Parses OPENSWE_* environment variables into a partial config.
5
+ * Uses lenient validation - warns and skips invalid values.
6
+ */
7
+
8
+ import type { PartialConfig } from "./types"
9
+ import { isValidBackend, isValidLogLevel } from "./types"
10
+
11
+ /**
12
+ * Parse a string to boolean
13
+ * Accepts: "true", "1", "yes" as true; "false", "0", "no" as false
14
+ */
15
+ function parseBoolean(val: string): boolean | undefined {
16
+ const lower = val.toLowerCase().trim()
17
+ if (["true", "1", "yes"].includes(lower)) return true
18
+ if (["false", "0", "no"].includes(lower)) return false
19
+ return undefined
20
+ }
21
+
22
+ /**
23
+ * Log a warning for invalid environment variable values
24
+ * Uses console.warn directly to avoid circular dependency with logger
25
+ */
26
+ function warnInvalidEnv(varName: string, value: string, expected: string): void {
27
+ console.warn(
28
+ `[OpenSWE] Invalid value for ${varName}="${value}". Expected ${expected}. Using default.`
29
+ )
30
+ }
31
+
32
+ /**
33
+ * Load configuration from environment variables
34
+ *
35
+ * Supported variables:
36
+ * - OPENSWE_BACKEND: "opencode" | "claude"
37
+ * - OPENSWE_LOG_LEVEL: "debug" | "info" | "warn" | "error"
38
+ */
39
+ export function loadEnvConfig(): PartialConfig {
40
+ const config: PartialConfig = {}
41
+
42
+ // AI Backend
43
+ const backend = process.env.OPENSWE_BACKEND
44
+ if (backend !== undefined) {
45
+ if (isValidBackend(backend)) {
46
+ config.ai = { backend }
47
+ } else {
48
+ warnInvalidEnv("OPENSWE_BACKEND", backend, '"opencode" or "claude"')
49
+ }
50
+ }
51
+
52
+ // Log Level
53
+ const logLevel = process.env.OPENSWE_LOG_LEVEL
54
+ if (logLevel !== undefined) {
55
+ if (isValidLogLevel(logLevel)) {
56
+ config.advanced = { logLevel }
57
+ } else {
58
+ warnInvalidEnv(
59
+ "OPENSWE_LOG_LEVEL",
60
+ logLevel,
61
+ '"debug", "info", "warn", or "error"'
62
+ )
63
+ }
64
+ }
65
+
66
+ return config
67
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Global configuration file loader and saver
3
+ *
4
+ * Handles loading and saving TOML config files with XDG support.
5
+ * Location: $XDG_CONFIG_HOME/openswe/config.toml or ~/.config/openswe/config.toml
6
+ */
7
+
8
+ import { parse as parseToml, stringify as stringifyToml, type JsonMap } from "@iarna/toml"
9
+ import { homedir } from "os"
10
+ import { join } from "path"
11
+ import { mkdir } from "fs/promises"
12
+ import type { PartialConfig } from "./types"
13
+ import { isValidBackend, isValidLogLevel, isBoolean, isNonEmptyString } from "./types"
14
+
15
+ // ============================================================================
16
+ // Path Utilities
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Get the configuration directory path (XDG-compliant)
21
+ */
22
+ export function getConfigDir(): string {
23
+ const xdgConfig = process.env.XDG_CONFIG_HOME
24
+ if (xdgConfig) {
25
+ return join(xdgConfig, "openswe")
26
+ }
27
+ return join(homedir(), ".config", "openswe")
28
+ }
29
+
30
+ /**
31
+ * Get the full path to the config file
32
+ */
33
+ export function getConfigPath(): string {
34
+ return join(getConfigDir(), "config.toml")
35
+ }
36
+
37
+ /**
38
+ * Check if the config file exists
39
+ */
40
+ export async function configFileExists(): Promise<boolean> {
41
+ const file = Bun.file(getConfigPath())
42
+ return file.exists()
43
+ }
44
+
45
+ // ============================================================================
46
+ // Key Transformation (snake_case <-> camelCase)
47
+ // ============================================================================
48
+
49
+ /** Map of camelCase keys to snake_case TOML keys */
50
+ const CAMEL_TO_SNAKE: Record<string, string> = {
51
+ navigateUp: "navigate_up",
52
+ navigateDown: "navigate_down",
53
+ newSession: "new_session",
54
+ deleteSession: "delete_session",
55
+ pauseSession: "pause_session",
56
+ taskQueue: "task_queue",
57
+ logLevel: "log_level",
58
+ }
59
+
60
+ /** Map of snake_case TOML keys to camelCase keys */
61
+ const SNAKE_TO_CAMEL: Record<string, string> = Object.fromEntries(
62
+ Object.entries(CAMEL_TO_SNAKE).map(([k, v]) => [v, k])
63
+ )
64
+
65
+ /**
66
+ * Convert a snake_case key to camelCase
67
+ */
68
+ function snakeToCamel(key: string): string {
69
+ return SNAKE_TO_CAMEL[key] ?? key
70
+ }
71
+
72
+ /**
73
+ * Convert a camelCase key to snake_case
74
+ */
75
+ function camelToSnake(key: string): string {
76
+ return CAMEL_TO_SNAKE[key] ?? key
77
+ }
78
+
79
+ /**
80
+ * Recursively transform object keys from snake_case to camelCase
81
+ */
82
+ function transformKeysSnakeToCamel(obj: unknown): unknown {
83
+ if (obj === null || obj === undefined) return obj
84
+ if (Array.isArray(obj)) return obj.map(transformKeysSnakeToCamel)
85
+ if (typeof obj !== "object") return obj
86
+
87
+ const result: Record<string, unknown> = {}
88
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
89
+ const camelKey = snakeToCamel(key)
90
+ result[camelKey] = transformKeysSnakeToCamel(value)
91
+ }
92
+ return result
93
+ }
94
+
95
+ /**
96
+ * Recursively transform object keys from camelCase to snake_case
97
+ */
98
+ function transformKeysCamelToSnake(obj: unknown): unknown {
99
+ if (obj === null || obj === undefined) return obj
100
+ if (Array.isArray(obj)) return obj.map(transformKeysCamelToSnake)
101
+ if (typeof obj !== "object") return obj
102
+
103
+ const result: Record<string, unknown> = {}
104
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
105
+ const snakeKey = camelToSnake(key)
106
+ result[snakeKey] = transformKeysCamelToSnake(value)
107
+ }
108
+ return result
109
+ }
110
+
111
+ // ============================================================================
112
+ // Validation
113
+ // ============================================================================
114
+
115
+ /**
116
+ * Validate and sanitize parsed TOML config
117
+ * Returns a partial config with only valid values
118
+ */
119
+ function validateParsedConfig(parsed: unknown): PartialConfig {
120
+ if (typeof parsed !== "object" || parsed === null) {
121
+ return {}
122
+ }
123
+
124
+ const raw = parsed as Record<string, unknown>
125
+ const config: PartialConfig = {}
126
+
127
+ // Validate ai section
128
+ if (raw.ai && typeof raw.ai === "object") {
129
+ const ai = raw.ai as Record<string, unknown>
130
+ config.ai = {}
131
+
132
+ if (isValidBackend(ai.backend)) {
133
+ config.ai.backend = ai.backend
134
+ } else if (ai.backend !== undefined) {
135
+ warnInvalidConfig("ai.backend", ai.backend, '"opencode" or "claude"')
136
+ }
137
+
138
+ if (ai.opencode && typeof ai.opencode === "object") {
139
+ const oc = ai.opencode as Record<string, unknown>
140
+ config.ai.opencode = {}
141
+ if (isNonEmptyString(oc.model)) config.ai.opencode.model = oc.model
142
+ if (isNonEmptyString(oc.provider)) config.ai.opencode.provider = oc.provider
143
+ }
144
+
145
+ if (ai.claude && typeof ai.claude === "object") {
146
+ const cl = ai.claude as Record<string, unknown>
147
+ config.ai.claude = {}
148
+ if (isNonEmptyString(cl.model)) config.ai.claude.model = cl.model
149
+ }
150
+ }
151
+
152
+ // Validate keybindings section
153
+ if (raw.keybindings && typeof raw.keybindings === "object") {
154
+ const kb = raw.keybindings as Record<string, unknown>
155
+ config.keybindings = {}
156
+
157
+ const keyFields = [
158
+ "navigateUp", "navigateDown", "select", "newSession",
159
+ "deleteSession", "pauseSession", "taskQueue", "issues", "quit", "help"
160
+ ] as const
161
+
162
+ for (const field of keyFields) {
163
+ if (isNonEmptyString(kb[field])) {
164
+ (config.keybindings as Record<string, string>)[field] = kb[field] as string
165
+ }
166
+ }
167
+ }
168
+
169
+ // Validate advanced section
170
+ if (raw.advanced && typeof raw.advanced === "object") {
171
+ const adv = raw.advanced as Record<string, unknown>
172
+ config.advanced = {}
173
+
174
+ if (isValidLogLevel(adv.logLevel)) {
175
+ config.advanced.logLevel = adv.logLevel
176
+ } else if (adv.logLevel !== undefined) {
177
+ warnInvalidConfig("advanced.logLevel", adv.logLevel, '"debug", "info", "warn", or "error"')
178
+ }
179
+ }
180
+
181
+ return config
182
+ }
183
+
184
+ /**
185
+ * Log a warning for invalid config file values
186
+ */
187
+ function warnInvalidConfig(path: string, value: unknown, expected: string): void {
188
+ console.warn(
189
+ `[OpenSWE] Invalid config value at "${path}": ${JSON.stringify(value)}. Expected ${expected}. Using default.`
190
+ )
191
+ }
192
+
193
+ // ============================================================================
194
+ // Load and Save Functions
195
+ // ============================================================================
196
+
197
+ /**
198
+ * Load global configuration from TOML file
199
+ * Returns empty partial config if file doesn't exist or has errors
200
+ */
201
+ export async function loadGlobalConfig(): Promise<PartialConfig> {
202
+ const configPath = getConfigPath()
203
+
204
+ try {
205
+ const file = Bun.file(configPath)
206
+ if (!(await file.exists())) {
207
+ return {} // No config file yet - use defaults
208
+ }
209
+
210
+ const content = await file.text()
211
+ const parsed = parseToml(content)
212
+
213
+ // Transform snake_case keys to camelCase
214
+ const transformed = transformKeysSnakeToCamel(parsed)
215
+
216
+ // Validate and return
217
+ return validateParsedConfig(transformed)
218
+ } catch (err) {
219
+ if (err instanceof Error) {
220
+ console.warn(`[OpenSWE] Failed to load config from ${configPath}: ${err.message}`)
221
+ } else {
222
+ console.warn(`[OpenSWE] Failed to load config from ${configPath}`)
223
+ }
224
+ return {}
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Save configuration to TOML file
230
+ * Creates the config directory if it doesn't exist
231
+ */
232
+ export async function saveGlobalConfig(config: PartialConfig): Promise<void> {
233
+ const configDir = getConfigDir()
234
+ const configPath = getConfigPath()
235
+
236
+ try {
237
+ // Ensure config directory exists
238
+ await mkdir(configDir, { recursive: true })
239
+
240
+ // Transform camelCase keys to snake_case for TOML
241
+ const transformed = transformKeysCamelToSnake(config) as Record<string, unknown>
242
+
243
+ // Stringify and write
244
+ const tomlContent = stringifyToml(transformed as JsonMap)
245
+ await Bun.write(configPath, tomlContent)
246
+ } catch (err) {
247
+ if (err instanceof Error) {
248
+ throw new Error(`Failed to save config to ${configPath}: ${err.message}`)
249
+ }
250
+ throw new Error(`Failed to save config to ${configPath}`)
251
+ }
252
+ }