@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.
- package/AGENTS.md +203 -0
- package/CLAUDE.md +203 -0
- package/README.md +166 -0
- package/bun.lock +447 -0
- package/bunfig.toml +4 -0
- package/package.json +42 -0
- package/src/app.tsx +84 -0
- package/src/components/App.tsx +526 -0
- package/src/components/ConfirmDialog.tsx +88 -0
- package/src/components/Footer.tsx +50 -0
- package/src/components/HelpModal.tsx +136 -0
- package/src/components/IssueSelectorModal.tsx +701 -0
- package/src/components/ManualSessionModal.tsx +191 -0
- package/src/components/PhaseProgress.tsx +45 -0
- package/src/components/Preview.tsx +249 -0
- package/src/components/ProviderSwitcherModal.tsx +156 -0
- package/src/components/ScrollableText.tsx +120 -0
- package/src/components/SessionCard.tsx +60 -0
- package/src/components/SessionList.tsx +79 -0
- package/src/components/SessionTerminal.tsx +89 -0
- package/src/components/StatusBar.tsx +84 -0
- package/src/components/ThemeSwitcherModal.tsx +237 -0
- package/src/components/index.ts +58 -0
- package/src/components/session-utils.ts +337 -0
- package/src/components/theme.ts +206 -0
- package/src/components/types.ts +215 -0
- package/src/config/defaults.ts +44 -0
- package/src/config/env.ts +67 -0
- package/src/config/global.ts +252 -0
- package/src/config/index.ts +171 -0
- package/src/config/types.ts +131 -0
- package/src/core/.gitkeep +0 -0
- package/src/core/index.ts +5 -0
- package/src/core/parser.ts +62 -0
- package/src/core/process-manager.ts +52 -0
- package/src/core/session.ts +423 -0
- package/src/core/tmux.ts +206 -0
- package/src/git/.gitkeep +0 -0
- package/src/git/index.ts +8 -0
- package/src/git/repo.ts +443 -0
- package/src/git/worktree.ts +317 -0
- package/src/github/.gitkeep +0 -0
- package/src/github/client.ts +208 -0
- package/src/github/index.ts +8 -0
- package/src/github/issues.ts +351 -0
- package/src/index.ts +369 -0
- package/src/prompts/.gitkeep +0 -0
- package/src/prompts/index.ts +1 -0
- package/src/prompts/swe-system.ts +22 -0
- package/src/providers/claude.ts +103 -0
- package/src/providers/index.ts +21 -0
- package/src/providers/opencode.ts +98 -0
- package/src/providers/registry.ts +53 -0
- package/src/providers/types.ts +117 -0
- package/src/store/buffers.ts +234 -0
- package/src/store/db.test.ts +579 -0
- package/src/store/db.ts +249 -0
- package/src/store/index.ts +101 -0
- package/src/store/project.ts +119 -0
- package/src/store/schema.sql +71 -0
- package/src/store/sessions.ts +454 -0
- package/src/store/types.ts +194 -0
- package/src/theme/context.tsx +170 -0
- package/src/theme/custom.ts +134 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/loader.ts +264 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +225 -0
- package/src/types/sql.d.ts +4 -0
- package/src/utils/ansi-parser.ts +225 -0
- package/src/utils/format.ts +46 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/logger.ts +112 -0
- package/src/utils/prerequisites.ts +118 -0
- package/src/utils/shell.ts +9 -0
- package/src/wizard/flows.ts +419 -0
- package/src/wizard/index.ts +37 -0
- package/src/wizard/prompts.ts +190 -0
- package/src/workspace/detect.test.ts +51 -0
- package/src/workspace/detect.ts +223 -0
- package/src/workspace/index.ts +71 -0
- package/src/workspace/init.ts +131 -0
- package/src/workspace/paths.ts +143 -0
- package/src/workspace/project.ts +164 -0
- 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
|
+
}
|