@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,419 @@
1
+ /**
2
+ * Wizard flows
3
+ *
4
+ * Orchestrates the first-run wizard based on workspace type.
5
+ * Coordinates prompts, validation, cloning, and configuration saving.
6
+ */
7
+
8
+ import {
9
+ promptRepoInput,
10
+ promptAiBackend,
11
+ promptAdoptRepo,
12
+ promptFetchIssues,
13
+ isCancelled,
14
+ intro,
15
+ outro,
16
+ note,
17
+ spinner,
18
+ logError,
19
+ logSuccess,
20
+ logWarning,
21
+ type AIBackendChoice,
22
+ } from "./prompts"
23
+ import { checkGhCli, validateGhRepo } from "../github/client"
24
+ import { cloneRepo, isValidOwnerRepo, getCloneUrl } from "../git/repo"
25
+ import { initProject } from "../workspace/init"
26
+ import {
27
+ saveProjectConfig,
28
+ createProjectConfig,
29
+ } from "../workspace/project"
30
+ import { saveGlobalConfig } from "../config/global"
31
+ import type { PartialConfig } from "../config/types"
32
+ import { fetchIssues } from "../github"
33
+
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+
38
+ /** Result of a wizard flow */
39
+ export interface WizardResult {
40
+ /** Whether the wizard completed successfully */
41
+ completed: boolean
42
+ /** Whether the user cancelled the wizard */
43
+ cancelled: boolean
44
+ /** Error message if the wizard failed */
45
+ error?: string
46
+ /** Repository full name (owner/repo) if set */
47
+ repoFullName?: string
48
+ /** Selected AI backend */
49
+ aiBackend?: AIBackendChoice
50
+ }
51
+
52
+ // ============================================================================
53
+ // Prerequisite Checks
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Check prerequisites for the wizard (gh CLI)
58
+ * Returns true if all prerequisites are met
59
+ */
60
+ async function checkPrerequisites(): Promise<{ ok: boolean; error?: string }> {
61
+ const ghCheck = await checkGhCli()
62
+
63
+ if (!ghCheck.installed) {
64
+ return {
65
+ ok: false,
66
+ error: ghCheck.error ?? "GitHub CLI (gh) is not installed.",
67
+ }
68
+ }
69
+
70
+ if (!ghCheck.authenticated) {
71
+ return {
72
+ ok: false,
73
+ error: ghCheck.error ?? "Not authenticated with GitHub CLI.",
74
+ }
75
+ }
76
+
77
+ return { ok: true }
78
+ }
79
+
80
+ // ============================================================================
81
+ // Empty Directory Flow
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Run the wizard for an empty directory
86
+ *
87
+ * This flow:
88
+ * 1. Validates prerequisites (gh CLI)
89
+ * 2. Prompts for repository (or uses --repo flag value)
90
+ * 3. Validates the repository exists
91
+ * 4. Clones the repository
92
+ * 5. Prompts for configuration (AI backend)
93
+ * 6. Saves configuration
94
+ *
95
+ * @param cwd - Current working directory (empty directory)
96
+ * @param cliRepo - Repository from --repo flag (optional)
97
+ */
98
+ export async function runEmptyDirectoryWizard(
99
+ cwd: string,
100
+ cliRepo?: string
101
+ ): Promise<WizardResult> {
102
+ intro("OpenSWE Setup")
103
+
104
+ // Check prerequisites
105
+ const prereq = await checkPrerequisites()
106
+ if (!prereq.ok) {
107
+ logError(prereq.error!)
108
+ return { completed: false, cancelled: false, error: prereq.error }
109
+ }
110
+
111
+ // Get repository (from flag or prompt)
112
+ let repoFullName: string
113
+
114
+ if (cliRepo) {
115
+ // Validate format
116
+ if (!isValidOwnerRepo(cliRepo)) {
117
+ logError(`Invalid repository format: ${cliRepo}`)
118
+ return {
119
+ completed: false,
120
+ cancelled: false,
121
+ error: "Invalid repository format. Use owner/repo format.",
122
+ }
123
+ }
124
+ repoFullName = cliRepo
125
+ note(`Repository: ${repoFullName}`, "From --repo flag")
126
+ } else {
127
+ // Prompt for repository
128
+ const repoResult = await promptRepoInput()
129
+ if (isCancelled(repoResult)) {
130
+ return { completed: false, cancelled: true }
131
+ }
132
+ repoFullName = repoResult
133
+ }
134
+
135
+ // Validate repository exists and is accessible
136
+ const s = spinner()
137
+ s.start(`Validating repository ${repoFullName}...`)
138
+
139
+ const validation = await validateGhRepo(repoFullName)
140
+
141
+ if (!validation.exists || !validation.accessible) {
142
+ s.stop(`Repository validation failed`)
143
+ logError(validation.error ?? `Cannot access repository: ${repoFullName}`)
144
+ return {
145
+ completed: false,
146
+ cancelled: false,
147
+ error: validation.error ?? "Repository validation failed",
148
+ }
149
+ }
150
+
151
+ s.stop(`Repository validated: ${repoFullName}`)
152
+
153
+ if (validation.isPrivate) {
154
+ note("This is a private repository", "Access")
155
+ }
156
+
157
+ // Clone repository
158
+ s.start(`Cloning ${repoFullName}...`)
159
+
160
+ const cloneResult = await cloneRepo(repoFullName, cwd, {
161
+ protocol: "ssh",
162
+ onProgress: (msg) => s.message(msg),
163
+ })
164
+
165
+ if (!cloneResult.success) {
166
+ s.stop("Clone failed")
167
+
168
+ // Suggest HTTPS if SSH failed
169
+ if (cloneResult.error?.includes("Permission denied")) {
170
+ logWarning("SSH clone failed. Trying HTTPS...")
171
+
172
+ s.start(`Cloning ${repoFullName} via HTTPS...`)
173
+ const httpsResult = await cloneRepo(repoFullName, cwd, {
174
+ protocol: "https",
175
+ onProgress: (msg) => s.message(msg),
176
+ })
177
+
178
+ if (!httpsResult.success) {
179
+ s.stop("HTTPS clone also failed")
180
+ logError(httpsResult.error ?? "Clone failed")
181
+ return {
182
+ completed: false,
183
+ cancelled: false,
184
+ error: httpsResult.error ?? "Clone failed",
185
+ }
186
+ }
187
+ s.stop(`Cloned ${repoFullName}`)
188
+ } else {
189
+ logError(cloneResult.error ?? "Clone failed")
190
+ return {
191
+ completed: false,
192
+ cancelled: false,
193
+ error: cloneResult.error ?? "Clone failed",
194
+ }
195
+ }
196
+ } else {
197
+ s.stop(`Cloned ${repoFullName}`)
198
+ }
199
+
200
+ // Now gather configuration preferences
201
+ const configResult = await gatherConfiguration()
202
+ if (configResult.cancelled) {
203
+ return { completed: false, cancelled: true }
204
+ }
205
+
206
+ // Initialize project
207
+ s.start("Initializing OpenSWE project...")
208
+
209
+ const repoUrl = getCloneUrl(repoFullName, "ssh")
210
+ await initProject(cwd, { fullName: repoFullName, remoteUrl: repoUrl })
211
+
212
+ // Save project config
213
+ const projectConfig = createProjectConfig(repoFullName, repoUrl)
214
+ await saveProjectConfig(cwd, projectConfig)
215
+
216
+ // Save global config
217
+ const globalConfig: PartialConfig = {
218
+ ai: { backend: configResult.aiBackend },
219
+ }
220
+ await saveGlobalConfig(globalConfig)
221
+
222
+ s.stop("Project initialized")
223
+
224
+ logSuccess("Created .openswe/ directory")
225
+ logSuccess("Created .worktrees/ directory")
226
+ logSuccess("Saved configuration")
227
+
228
+ outro("Setup complete! OpenSWE is ready to use.")
229
+
230
+ return {
231
+ completed: true,
232
+ cancelled: false,
233
+ repoFullName,
234
+ aiBackend: configResult.aiBackend,
235
+ }
236
+ }
237
+
238
+ // ============================================================================
239
+ // Existing Repository Flow
240
+ // ============================================================================
241
+
242
+ /**
243
+ * Run the wizard for an existing git repository
244
+ *
245
+ * This flow:
246
+ * 1. Asks user to confirm adoption
247
+ * 2. Prompts for configuration
248
+ * 3. Initializes OpenSWE project
249
+ * 4. Saves configuration
250
+ *
251
+ * @param cwd - Current working directory (git repo)
252
+ * @param repoFullName - Repository name in owner/repo format
253
+ * @param remoteUrl - Git remote URL (optional)
254
+ */
255
+ export async function runExistingRepoWizard(
256
+ cwd: string,
257
+ repoFullName: string,
258
+ remoteUrl?: string
259
+ ): Promise<WizardResult> {
260
+ intro("OpenSWE Setup")
261
+
262
+ const prereq = await checkPrerequisites()
263
+ if (!prereq.ok) {
264
+ logError(prereq.error!)
265
+ return { completed: false, cancelled: false, error: prereq.error }
266
+ }
267
+
268
+ note(`Repository: ${repoFullName}`, "Detected")
269
+
270
+ // Ask to adopt
271
+ const adopt = await promptAdoptRepo(repoFullName)
272
+ if (isCancelled(adopt)) {
273
+ return { completed: false, cancelled: true }
274
+ }
275
+
276
+ if (!adopt) {
277
+ outro("Setup cancelled.")
278
+ return { completed: false, cancelled: true }
279
+ }
280
+
281
+ // Ask permission to fetch issues immediately
282
+ const fetchConsent = await promptFetchIssues(repoFullName)
283
+ if (isCancelled(fetchConsent)) {
284
+ return { completed: false, cancelled: true }
285
+ }
286
+
287
+ if (fetchConsent) {
288
+ const issueSpinner = spinner()
289
+ issueSpinner.start("Fetching GitHub issues...")
290
+
291
+ const issuesResult = await fetchIssues(repoFullName, { state: "open", limit: 30 })
292
+
293
+ if (issuesResult.success) {
294
+ issueSpinner.stop(`Fetched ${issuesResult.issues.length} issues`)
295
+ if (issuesResult.issues.length === 0) {
296
+ note("No issues found. You can fetch again later from the TUI.", "Issues")
297
+ }
298
+ } else {
299
+ issueSpinner.stop("Issue fetch skipped")
300
+ logWarning(issuesResult.error ?? "Failed to fetch issues")
301
+ }
302
+ } else {
303
+ note("Issue fetching skipped. You can load issues later from the TUI.", "Skipped")
304
+ }
305
+
306
+ // Gather configuration
307
+ const configResult = await gatherConfiguration()
308
+ if (configResult.cancelled) {
309
+ return { completed: false, cancelled: true }
310
+ }
311
+
312
+ // Initialize project
313
+ const s = spinner()
314
+ s.start("Initializing OpenSWE project...")
315
+
316
+ const repoUrl = remoteUrl ?? getCloneUrl(repoFullName, "ssh")
317
+ await initProject(cwd, { fullName: repoFullName, remoteUrl: repoUrl })
318
+
319
+ // Save project config
320
+ const projectConfig = createProjectConfig(repoFullName, repoUrl)
321
+ await saveProjectConfig(cwd, projectConfig)
322
+
323
+ // Save global config
324
+ const globalConfig: PartialConfig = {
325
+ ai: { backend: configResult.aiBackend },
326
+ }
327
+ await saveGlobalConfig(globalConfig)
328
+
329
+ s.stop("Project initialized")
330
+
331
+ logSuccess("Created .openswe/ directory")
332
+ logSuccess("Created .worktrees/ directory")
333
+ logSuccess("Saved configuration")
334
+
335
+ outro("Setup complete! OpenSWE is ready to use.")
336
+
337
+ return {
338
+ completed: true,
339
+ cancelled: false,
340
+ repoFullName,
341
+ aiBackend: configResult.aiBackend,
342
+ }
343
+ }
344
+
345
+ // ============================================================================
346
+ // Configuration Gathering
347
+ // ============================================================================
348
+
349
+ interface ConfigurationResult {
350
+ cancelled: boolean
351
+ aiBackend?: AIBackendChoice
352
+ }
353
+
354
+ /**
355
+ * Gather configuration preferences from the user
356
+ */
357
+ async function gatherConfiguration(): Promise<ConfigurationResult> {
358
+ // AI backend
359
+ const backend = await promptAiBackend()
360
+ if (isCancelled(backend)) {
361
+ return { cancelled: true }
362
+ }
363
+
364
+ return {
365
+ cancelled: false,
366
+ aiBackend: backend,
367
+ }
368
+ }
369
+
370
+ // ============================================================================
371
+ // Reconfiguration Flow
372
+ // ============================================================================
373
+
374
+ /**
375
+ * Re-run configuration for an existing project (--setup flag)
376
+ *
377
+ * @param cwd - Project root directory
378
+ * @param repoFullName - Repository name
379
+ */
380
+ export async function runReconfigureWizard(
381
+ cwd: string,
382
+ repoFullName: string
383
+ ): Promise<WizardResult> {
384
+ intro("OpenSWE Reconfiguration")
385
+
386
+ const prereq = await checkPrerequisites()
387
+ if (!prereq.ok) {
388
+ logError(prereq.error!)
389
+ return { completed: false, cancelled: false, error: prereq.error }
390
+ }
391
+
392
+ note(`Repository: ${repoFullName}`, "Current Project")
393
+
394
+ // Gather configuration
395
+ const configResult = await gatherConfiguration()
396
+ if (configResult.cancelled) {
397
+ return { completed: false, cancelled: true }
398
+ }
399
+
400
+ // Update global config
401
+ const s = spinner()
402
+ s.start("Saving configuration...")
403
+
404
+ const globalConfig: PartialConfig = {
405
+ ai: { backend: configResult.aiBackend },
406
+ }
407
+ await saveGlobalConfig(globalConfig)
408
+
409
+ s.stop("Configuration saved")
410
+
411
+ outro("Reconfiguration complete!")
412
+
413
+ return {
414
+ completed: true,
415
+ cancelled: false,
416
+ repoFullName,
417
+ aiBackend: configResult.aiBackend,
418
+ }
419
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Wizard module
3
+ *
4
+ * First-run wizard for project setup using @clack/prompts.
5
+ */
6
+
7
+ // Re-export prompts (individual components)
8
+ export {
9
+ // Prompt functions
10
+ promptRepoInput,
11
+ promptAiBackend,
12
+ promptAdoptRepo,
13
+ // Utility functions
14
+ isCancelled,
15
+ intro,
16
+ outro,
17
+ note,
18
+ handleCancel,
19
+ spinner,
20
+ log,
21
+ logInfo,
22
+ logSuccess,
23
+ logWarning,
24
+ logError,
25
+ // Types
26
+ type AIBackendChoice,
27
+ } from "./prompts"
28
+
29
+ // Re-export flows (orchestrated wizard sequences)
30
+ export {
31
+ // Flow functions
32
+ runEmptyDirectoryWizard,
33
+ runExistingRepoWizard,
34
+ runReconfigureWizard,
35
+ // Types
36
+ type WizardResult,
37
+ } from "./flows"
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Wizard prompts
3
+ *
4
+ * Individual prompt components using @clack/prompts for the first-run wizard.
5
+ */
6
+
7
+ import * as p from "@clack/prompts"
8
+ import { isValidOwnerRepo } from "../git/repo"
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ /** AI backend options */
15
+ export type AIBackendChoice = "opencode" | "claude"
16
+
17
+ // ============================================================================
18
+ // Repository Input
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Prompt the user for a GitHub repository in owner/repo format
23
+ *
24
+ * @returns The repository string or a symbol if cancelled
25
+ */
26
+ export async function promptRepoInput(): Promise<string | symbol> {
27
+ const repo = await p.text({
28
+ message: "Enter GitHub repository (owner/repo):",
29
+ placeholder: "owner/repo",
30
+ validate: (value) => {
31
+ if (!value.trim()) {
32
+ return "Repository is required"
33
+ }
34
+ if (!isValidOwnerRepo(value.trim())) {
35
+ return "Please enter a valid repository in owner/repo format"
36
+ }
37
+ },
38
+ })
39
+
40
+ if (typeof repo === "string") {
41
+ return repo.trim()
42
+ }
43
+ return repo
44
+ }
45
+
46
+ // ============================================================================
47
+ // AI Backend Selection
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Prompt the user to select an AI backend
52
+ *
53
+ * @returns The selected backend or a symbol if cancelled
54
+ */
55
+ export async function promptAiBackend(): Promise<AIBackendChoice | symbol> {
56
+ const backend = await p.select({
57
+ message: "Select AI backend:",
58
+ options: [
59
+ {
60
+ value: "opencode" as const,
61
+ label: "OpenCode",
62
+ hint: "Open-source, self-hosted AI agents",
63
+ },
64
+ {
65
+ value: "claude" as const,
66
+ label: "Claude",
67
+ hint: "Anthropic's Claude via API",
68
+ },
69
+ ],
70
+ })
71
+
72
+ return backend
73
+ }
74
+
75
+ // ============================================================================
76
+ // Repository Adoption
77
+ // ============================================================================
78
+
79
+ /**
80
+ * Prompt to adopt an existing repository
81
+ *
82
+ * @param repoName - The repository name to display
83
+ * @returns true/false or a symbol if cancelled
84
+ */
85
+ export async function promptAdoptRepo(repoName: string): Promise<boolean | symbol> {
86
+ const adopt = await p.confirm({
87
+ message: `Initialize OpenSWE for ${repoName}?`,
88
+ initialValue: true,
89
+ })
90
+
91
+ return adopt
92
+ }
93
+
94
+ // =========================================================================
95
+ // Issue Fetch Consent
96
+ // =========================================================================
97
+
98
+ /**
99
+ * Ask for permission to fetch GitHub issues for a detected repository
100
+ */
101
+ export async function promptFetchIssues(repoName: string): Promise<boolean | symbol> {
102
+ const consent = await p.confirm({
103
+ message: `Fetch GitHub issues for ${repoName}?`,
104
+ initialValue: true,
105
+ })
106
+
107
+ return consent
108
+ }
109
+
110
+ // ============================================================================
111
+ // Utility Functions
112
+ // ============================================================================
113
+
114
+ /**
115
+ * Check if a prompt result indicates cancellation
116
+ */
117
+ export function isCancelled(value: unknown): value is symbol {
118
+ return p.isCancel(value)
119
+ }
120
+
121
+ /**
122
+ * Display an intro message
123
+ */
124
+ export function intro(message: string): void {
125
+ p.intro(message)
126
+ }
127
+
128
+ /**
129
+ * Display an outro message
130
+ */
131
+ export function outro(message: string): void {
132
+ p.outro(message)
133
+ }
134
+
135
+ /**
136
+ * Display a note
137
+ */
138
+ export function note(message: string, title?: string): void {
139
+ p.note(message, title)
140
+ }
141
+
142
+ /**
143
+ * Display a cancellation message and exit
144
+ */
145
+ export function handleCancel(message = "Setup cancelled."): never {
146
+ p.cancel(message)
147
+ process.exit(0)
148
+ }
149
+
150
+ /**
151
+ * Create a spinner for long-running operations
152
+ */
153
+ export function spinner(): ReturnType<typeof p.spinner> {
154
+ return p.spinner()
155
+ }
156
+
157
+ /**
158
+ * Log a message (styled)
159
+ */
160
+ export function log(message: string): void {
161
+ p.log.message(message)
162
+ }
163
+
164
+ /**
165
+ * Log an info message
166
+ */
167
+ export function logInfo(message: string): void {
168
+ p.log.info(message)
169
+ }
170
+
171
+ /**
172
+ * Log a success message
173
+ */
174
+ export function logSuccess(message: string): void {
175
+ p.log.success(message)
176
+ }
177
+
178
+ /**
179
+ * Log a warning message
180
+ */
181
+ export function logWarning(message: string): void {
182
+ p.log.warn(message)
183
+ }
184
+
185
+ /**
186
+ * Log an error message
187
+ */
188
+ export function logError(message: string): void {
189
+ p.log.error(message)
190
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdtemp, mkdir } from "fs/promises"
2
+ import { tmpdir } from "os"
3
+ import { join } from "path"
4
+ import { describe, expect, test } from "bun:test"
5
+ import { detectWorkspace } from "./detect"
6
+
7
+ async function createTempDir(name: string): Promise<string> {
8
+ return mkdtemp(join(tmpdir(), `${name}-`))
9
+ }
10
+
11
+ describe("detectWorkspace", () => {
12
+ test("detects existing project in an ancestor directory", async () => {
13
+ const base = await createTempDir("openswe-detect-project")
14
+ const projectRoot = join(base, "project")
15
+ const nested = join(projectRoot, "nested")
16
+
17
+ await mkdir(join(projectRoot, ".openswe"), { recursive: true })
18
+ await mkdir(nested, { recursive: true })
19
+
20
+ const result = await detectWorkspace(nested)
21
+
22
+ expect(result.type).toBe("existing-project")
23
+ expect(result.projectRoot).toBe(projectRoot)
24
+ })
25
+
26
+ test("detects git repo in an ancestor directory", async () => {
27
+ const base = await createTempDir("openswe-detect-git")
28
+ const repoRoot = join(base, "repo")
29
+ const nested = join(repoRoot, "nested")
30
+
31
+ await mkdir(join(repoRoot, ".git"), { recursive: true })
32
+ await mkdir(nested, { recursive: true })
33
+
34
+ const result = await detectWorkspace(nested)
35
+
36
+ expect(result.type).toBe("existing-repo")
37
+ expect(result.projectRoot).toBe(repoRoot)
38
+ })
39
+
40
+ test("returns empty when no markers are found", async () => {
41
+ const base = await createTempDir("openswe-detect-empty")
42
+ const nested = join(base, "child")
43
+
44
+ await mkdir(nested, { recursive: true })
45
+
46
+ const result = await detectWorkspace(nested)
47
+
48
+ expect(result.type).toBe("empty")
49
+ expect(result.projectRoot).toBe(nested)
50
+ })
51
+ })