popeye-cli 1.0.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 (209) hide show
  1. package/.env.example +25 -0
  2. package/.prettierrc +8 -0
  3. package/README.md +320 -0
  4. package/dist/adapters/claude.d.ts +82 -0
  5. package/dist/adapters/claude.d.ts.map +1 -0
  6. package/dist/adapters/claude.js +230 -0
  7. package/dist/adapters/claude.js.map +1 -0
  8. package/dist/adapters/openai.d.ts +48 -0
  9. package/dist/adapters/openai.d.ts.map +1 -0
  10. package/dist/adapters/openai.js +257 -0
  11. package/dist/adapters/openai.js.map +1 -0
  12. package/dist/auth/claude.d.ts +44 -0
  13. package/dist/auth/claude.d.ts.map +1 -0
  14. package/dist/auth/claude.js +139 -0
  15. package/dist/auth/claude.js.map +1 -0
  16. package/dist/auth/index.d.ts +61 -0
  17. package/dist/auth/index.d.ts.map +1 -0
  18. package/dist/auth/index.js +141 -0
  19. package/dist/auth/index.js.map +1 -0
  20. package/dist/auth/keychain.d.ts +66 -0
  21. package/dist/auth/keychain.d.ts.map +1 -0
  22. package/dist/auth/keychain.js +125 -0
  23. package/dist/auth/keychain.js.map +1 -0
  24. package/dist/auth/openai-entry.d.ts +9 -0
  25. package/dist/auth/openai-entry.d.ts.map +1 -0
  26. package/dist/auth/openai-entry.js +410 -0
  27. package/dist/auth/openai-entry.js.map +1 -0
  28. package/dist/auth/openai.d.ts +71 -0
  29. package/dist/auth/openai.d.ts.map +1 -0
  30. package/dist/auth/openai.js +212 -0
  31. package/dist/auth/openai.js.map +1 -0
  32. package/dist/auth/server.d.ts +32 -0
  33. package/dist/auth/server.d.ts.map +1 -0
  34. package/dist/auth/server.js +213 -0
  35. package/dist/auth/server.js.map +1 -0
  36. package/dist/cli/commands/auth.d.ts +10 -0
  37. package/dist/cli/commands/auth.d.ts.map +1 -0
  38. package/dist/cli/commands/auth.js +162 -0
  39. package/dist/cli/commands/auth.js.map +1 -0
  40. package/dist/cli/commands/config.d.ts +10 -0
  41. package/dist/cli/commands/config.d.ts.map +1 -0
  42. package/dist/cli/commands/config.js +215 -0
  43. package/dist/cli/commands/config.js.map +1 -0
  44. package/dist/cli/commands/create.d.ts +10 -0
  45. package/dist/cli/commands/create.d.ts.map +1 -0
  46. package/dist/cli/commands/create.js +240 -0
  47. package/dist/cli/commands/create.js.map +1 -0
  48. package/dist/cli/commands/index.d.ts +10 -0
  49. package/dist/cli/commands/index.d.ts.map +1 -0
  50. package/dist/cli/commands/index.js +10 -0
  51. package/dist/cli/commands/index.js.map +1 -0
  52. package/dist/cli/commands/resume.d.ts +18 -0
  53. package/dist/cli/commands/resume.d.ts.map +1 -0
  54. package/dist/cli/commands/resume.js +241 -0
  55. package/dist/cli/commands/resume.js.map +1 -0
  56. package/dist/cli/commands/status.d.ts +18 -0
  57. package/dist/cli/commands/status.d.ts.map +1 -0
  58. package/dist/cli/commands/status.js +154 -0
  59. package/dist/cli/commands/status.js.map +1 -0
  60. package/dist/cli/index.d.ts +17 -0
  61. package/dist/cli/index.d.ts.map +1 -0
  62. package/dist/cli/index.js +71 -0
  63. package/dist/cli/index.js.map +1 -0
  64. package/dist/cli/interactive.d.ts +9 -0
  65. package/dist/cli/interactive.d.ts.map +1 -0
  66. package/dist/cli/interactive.js +330 -0
  67. package/dist/cli/interactive.js.map +1 -0
  68. package/dist/cli/output.d.ts +182 -0
  69. package/dist/cli/output.d.ts.map +1 -0
  70. package/dist/cli/output.js +355 -0
  71. package/dist/cli/output.js.map +1 -0
  72. package/dist/config/defaults.d.ts +57 -0
  73. package/dist/config/defaults.d.ts.map +1 -0
  74. package/dist/config/defaults.js +103 -0
  75. package/dist/config/defaults.js.map +1 -0
  76. package/dist/config/index.d.ts +138 -0
  77. package/dist/config/index.d.ts.map +1 -0
  78. package/dist/config/index.js +244 -0
  79. package/dist/config/index.js.map +1 -0
  80. package/dist/config/schema.d.ts +220 -0
  81. package/dist/config/schema.d.ts.map +1 -0
  82. package/dist/config/schema.js +141 -0
  83. package/dist/config/schema.js.map +1 -0
  84. package/dist/generators/index.d.ts +101 -0
  85. package/dist/generators/index.d.ts.map +1 -0
  86. package/dist/generators/index.js +200 -0
  87. package/dist/generators/index.js.map +1 -0
  88. package/dist/generators/python.d.ts +48 -0
  89. package/dist/generators/python.d.ts.map +1 -0
  90. package/dist/generators/python.js +262 -0
  91. package/dist/generators/python.js.map +1 -0
  92. package/dist/generators/templates/index.d.ts +6 -0
  93. package/dist/generators/templates/index.d.ts.map +1 -0
  94. package/dist/generators/templates/index.js +6 -0
  95. package/dist/generators/templates/index.js.map +1 -0
  96. package/dist/generators/templates/python.d.ts +53 -0
  97. package/dist/generators/templates/python.d.ts.map +1 -0
  98. package/dist/generators/templates/python.js +454 -0
  99. package/dist/generators/templates/python.js.map +1 -0
  100. package/dist/generators/templates/typescript.d.ts +53 -0
  101. package/dist/generators/templates/typescript.d.ts.map +1 -0
  102. package/dist/generators/templates/typescript.js +394 -0
  103. package/dist/generators/templates/typescript.js.map +1 -0
  104. package/dist/generators/typescript.d.ts +64 -0
  105. package/dist/generators/typescript.d.ts.map +1 -0
  106. package/dist/generators/typescript.js +271 -0
  107. package/dist/generators/typescript.js.map +1 -0
  108. package/dist/index.d.ts +7 -0
  109. package/dist/index.d.ts.map +1 -0
  110. package/dist/index.js +12 -0
  111. package/dist/index.js.map +1 -0
  112. package/dist/state/index.d.ts +168 -0
  113. package/dist/state/index.d.ts.map +1 -0
  114. package/dist/state/index.js +338 -0
  115. package/dist/state/index.js.map +1 -0
  116. package/dist/state/persistence.d.ts +91 -0
  117. package/dist/state/persistence.d.ts.map +1 -0
  118. package/dist/state/persistence.js +201 -0
  119. package/dist/state/persistence.js.map +1 -0
  120. package/dist/types/cli.d.ts +132 -0
  121. package/dist/types/cli.d.ts.map +1 -0
  122. package/dist/types/cli.js +17 -0
  123. package/dist/types/cli.js.map +1 -0
  124. package/dist/types/consensus.d.ts +111 -0
  125. package/dist/types/consensus.d.ts.map +1 -0
  126. package/dist/types/consensus.js +29 -0
  127. package/dist/types/consensus.js.map +1 -0
  128. package/dist/types/index.d.ts +9 -0
  129. package/dist/types/index.d.ts.map +1 -0
  130. package/dist/types/index.js +13 -0
  131. package/dist/types/index.js.map +1 -0
  132. package/dist/types/project.d.ts +73 -0
  133. package/dist/types/project.d.ts.map +1 -0
  134. package/dist/types/project.js +55 -0
  135. package/dist/types/project.js.map +1 -0
  136. package/dist/types/workflow.d.ts +236 -0
  137. package/dist/types/workflow.d.ts.map +1 -0
  138. package/dist/types/workflow.js +74 -0
  139. package/dist/types/workflow.js.map +1 -0
  140. package/dist/workflow/consensus.d.ts +89 -0
  141. package/dist/workflow/consensus.d.ts.map +1 -0
  142. package/dist/workflow/consensus.js +220 -0
  143. package/dist/workflow/consensus.js.map +1 -0
  144. package/dist/workflow/execution-mode.d.ts +82 -0
  145. package/dist/workflow/execution-mode.d.ts.map +1 -0
  146. package/dist/workflow/execution-mode.js +346 -0
  147. package/dist/workflow/execution-mode.js.map +1 -0
  148. package/dist/workflow/index.d.ts +110 -0
  149. package/dist/workflow/index.d.ts.map +1 -0
  150. package/dist/workflow/index.js +283 -0
  151. package/dist/workflow/index.js.map +1 -0
  152. package/dist/workflow/plan-mode.d.ts +83 -0
  153. package/dist/workflow/plan-mode.d.ts.map +1 -0
  154. package/dist/workflow/plan-mode.js +241 -0
  155. package/dist/workflow/plan-mode.js.map +1 -0
  156. package/dist/workflow/test-runner.d.ts +87 -0
  157. package/dist/workflow/test-runner.d.ts.map +1 -0
  158. package/dist/workflow/test-runner.js +273 -0
  159. package/dist/workflow/test-runner.js.map +1 -0
  160. package/eslint.config.js +25 -0
  161. package/package.json +66 -0
  162. package/src/adapters/claude.ts +298 -0
  163. package/src/adapters/openai.ts +300 -0
  164. package/src/auth/claude.ts +166 -0
  165. package/src/auth/index.ts +171 -0
  166. package/src/auth/keychain.ts +138 -0
  167. package/src/auth/openai-entry.ts +410 -0
  168. package/src/auth/openai.ts +260 -0
  169. package/src/auth/server.ts +252 -0
  170. package/src/cli/commands/auth.ts +194 -0
  171. package/src/cli/commands/config.ts +241 -0
  172. package/src/cli/commands/create.ts +308 -0
  173. package/src/cli/commands/index.ts +10 -0
  174. package/src/cli/commands/resume.ts +304 -0
  175. package/src/cli/commands/status.ts +189 -0
  176. package/src/cli/index.ts +90 -0
  177. package/src/cli/interactive.ts +418 -0
  178. package/src/cli/output.ts +410 -0
  179. package/src/config/defaults.ts +114 -0
  180. package/src/config/index.ts +315 -0
  181. package/src/config/schema.ts +164 -0
  182. package/src/generators/index.ts +251 -0
  183. package/src/generators/python.ts +318 -0
  184. package/src/generators/templates/index.ts +6 -0
  185. package/src/generators/templates/python.ts +465 -0
  186. package/src/generators/templates/typescript.ts +417 -0
  187. package/src/generators/typescript.ts +340 -0
  188. package/src/index.ts +13 -0
  189. package/src/state/index.ts +454 -0
  190. package/src/state/persistence.ts +230 -0
  191. package/src/types/cli.ts +146 -0
  192. package/src/types/consensus.ts +116 -0
  193. package/src/types/index.ts +64 -0
  194. package/src/types/project.ts +85 -0
  195. package/src/types/workflow.ts +149 -0
  196. package/src/workflow/consensus.ts +299 -0
  197. package/src/workflow/execution-mode.ts +517 -0
  198. package/src/workflow/index.ts +396 -0
  199. package/src/workflow/plan-mode.ts +356 -0
  200. package/src/workflow/test-runner.ts +345 -0
  201. package/tests/adapters/openai.test.ts +145 -0
  202. package/tests/config/config.test.ts +208 -0
  203. package/tests/generators/generators.test.ts +185 -0
  204. package/tests/types/consensus.test.ts +152 -0
  205. package/tests/types/project.test.ts +134 -0
  206. package/tests/workflow/consensus.test.ts +221 -0
  207. package/tests/workflow/test-runner.test.ts +214 -0
  208. package/tsconfig.json +25 -0
  209. package/vitest.config.ts +22 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Plan Mode workflow
3
+ * Handles idea expansion, plan creation, and consensus building
4
+ */
5
+
6
+ import { promises as fs } from 'node:fs';
7
+ import path from 'node:path';
8
+ import type { ProjectSpec } from '../types/project.js';
9
+ import type { ProjectState, Milestone, Task } from '../types/workflow.js';
10
+ import type { ConsensusConfig } from '../types/consensus.js';
11
+ import { expandIdea as openaiExpandIdea } from '../adapters/openai.js';
12
+ import { createPlan as claudeCreatePlan, analyzeCodebase } from '../adapters/claude.js';
13
+ import {
14
+ createProject,
15
+ loadProject,
16
+ setPhase,
17
+ storePlan,
18
+ storeSpecification,
19
+ addMilestones,
20
+ } from '../state/index.js';
21
+ import { iterateUntilConsensus, type ConsensusProcessResult } from './consensus.js';
22
+
23
+ /**
24
+ * Options for plan mode
25
+ */
26
+ export interface PlanModeOptions {
27
+ projectDir: string;
28
+ consensusConfig?: Partial<ConsensusConfig>;
29
+ onProgress?: (phase: string, message: string) => void;
30
+ }
31
+
32
+ /**
33
+ * Result of plan mode
34
+ */
35
+ export interface PlanModeResult {
36
+ success: boolean;
37
+ state: ProjectState;
38
+ consensusResult?: ConsensusProcessResult;
39
+ error?: string;
40
+ }
41
+
42
+ /**
43
+ * Expand a brief idea into a detailed specification
44
+ *
45
+ * @param idea - The brief project idea
46
+ * @param language - Target programming language
47
+ * @param onProgress - Progress callback
48
+ * @returns Expanded specification
49
+ */
50
+ export async function expandIdea(
51
+ idea: string,
52
+ language: 'python' | 'typescript',
53
+ onProgress?: (message: string) => void
54
+ ): Promise<string> {
55
+ onProgress?.('Expanding idea into specification...');
56
+
57
+ const specification = await openaiExpandIdea(idea, language);
58
+
59
+ onProgress?.('Specification created');
60
+ return specification;
61
+ }
62
+
63
+ /**
64
+ * Create a development plan from a specification
65
+ *
66
+ * @param specification - The project specification
67
+ * @param context - Additional context
68
+ * @param onProgress - Progress callback
69
+ * @returns Development plan
70
+ */
71
+ export async function createPlan(
72
+ specification: string,
73
+ context: string = '',
74
+ onProgress?: (message: string) => void
75
+ ): Promise<string> {
76
+ onProgress?.('Creating development plan...');
77
+
78
+ const result = await claudeCreatePlan(specification, context);
79
+
80
+ if (!result.success) {
81
+ throw new Error(`Failed to create plan: ${result.error}`);
82
+ }
83
+
84
+ onProgress?.('Development plan created');
85
+ return result.response;
86
+ }
87
+
88
+ /**
89
+ * Get existing project context by analyzing the codebase
90
+ *
91
+ * @param projectDir - The project directory
92
+ * @param onProgress - Progress callback
93
+ * @returns Context string
94
+ */
95
+ export async function getProjectContext(
96
+ projectDir: string,
97
+ onProgress?: (message: string) => void
98
+ ): Promise<string> {
99
+ onProgress?.('Analyzing existing codebase...');
100
+
101
+ // Check if directory has any code
102
+ try {
103
+ const files = await fs.readdir(projectDir);
104
+ const hasCode = files.some((f) =>
105
+ ['.py', '.ts', '.js', '.tsx', '.jsx'].some((ext) => f.endsWith(ext))
106
+ );
107
+
108
+ if (!hasCode) {
109
+ onProgress?.('No existing code found');
110
+ return 'New project - no existing codebase';
111
+ }
112
+
113
+ const result = await analyzeCodebase(projectDir);
114
+
115
+ if (result.success) {
116
+ onProgress?.('Codebase analysis complete');
117
+ return result.response;
118
+ }
119
+
120
+ return 'Unable to analyze codebase';
121
+ } catch {
122
+ return 'New project - no existing codebase';
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Save the plan to a markdown file
128
+ *
129
+ * @param projectDir - The project directory
130
+ * @param plan - The plan content
131
+ * @param filename - The filename (default: PLAN.md)
132
+ */
133
+ export async function documentPlan(
134
+ projectDir: string,
135
+ plan: string,
136
+ filename: string = 'PLAN.md'
137
+ ): Promise<string> {
138
+ const planPath = path.join(projectDir, filename);
139
+
140
+ const content = `# Development Plan
141
+
142
+ Generated: ${new Date().toISOString()}
143
+
144
+ ${plan}
145
+ `;
146
+
147
+ await fs.writeFile(planPath, content, 'utf-8');
148
+ return planPath;
149
+ }
150
+
151
+ /**
152
+ * Parse milestones and tasks from a plan
153
+ *
154
+ * @param plan - The plan content
155
+ * @returns Parsed milestones with tasks
156
+ */
157
+ export function parsePlanMilestones(plan: string): Omit<Milestone, 'id'>[] {
158
+ const milestones: Omit<Milestone, 'id'>[] = [];
159
+
160
+ // Look for milestone sections
161
+ const milestonePattern = /#+\s*(?:Milestone\s*\d+[:\s]*)?([^\n]+)\n([\s\S]*?)(?=#+\s*(?:Milestone|$)|$)/gi;
162
+ const taskPattern = /[-*]\s*(?:\[[ x]\]\s*)?(?:Task[:\s]*)?(.+)/gi;
163
+
164
+ let match;
165
+ while ((match = milestonePattern.exec(plan)) !== null) {
166
+ const name = match[1].trim();
167
+ const content = match[2];
168
+
169
+ // Skip non-milestone sections
170
+ if (name.toLowerCase().includes('background') ||
171
+ name.toLowerCase().includes('goal') ||
172
+ name.toLowerCase().includes('risk') ||
173
+ name.toLowerCase().includes('summary')) {
174
+ continue;
175
+ }
176
+
177
+ const tasks: Omit<Task, 'id' | 'status' | 'testsPassed'>[] = [];
178
+ let taskMatch;
179
+
180
+ while ((taskMatch = taskPattern.exec(content)) !== null) {
181
+ const taskName = taskMatch[1].trim();
182
+ if (taskName && !taskName.toLowerCase().startsWith('test')) {
183
+ tasks.push({
184
+ name: taskName,
185
+ description: taskName,
186
+ });
187
+ }
188
+ }
189
+
190
+ if (tasks.length > 0 || name.toLowerCase().includes('milestone')) {
191
+ milestones.push({
192
+ name,
193
+ description: content.slice(0, 200).trim(),
194
+ tasks: tasks as Task[],
195
+ status: 'pending',
196
+ });
197
+ }
198
+ }
199
+
200
+ // If no milestones found, create a default one
201
+ if (milestones.length === 0) {
202
+ milestones.push({
203
+ name: 'Implementation',
204
+ description: 'Main implementation milestone',
205
+ tasks: [],
206
+ status: 'pending',
207
+ });
208
+ }
209
+
210
+ return milestones;
211
+ }
212
+
213
+ /**
214
+ * Run the complete plan mode workflow
215
+ *
216
+ * @param spec - The project specification
217
+ * @param options - Plan mode options
218
+ * @returns Plan mode result
219
+ */
220
+ export async function runPlanMode(
221
+ spec: ProjectSpec,
222
+ options: PlanModeOptions
223
+ ): Promise<PlanModeResult> {
224
+ const { projectDir, consensusConfig, onProgress } = options;
225
+
226
+ try {
227
+ // Create or load project
228
+ onProgress?.('plan-init', 'Initializing project...');
229
+
230
+ let state: ProjectState;
231
+ try {
232
+ state = await loadProject(projectDir);
233
+ onProgress?.('plan-init', 'Loaded existing project');
234
+ } catch {
235
+ state = await createProject(spec, projectDir);
236
+ onProgress?.('plan-init', 'Created new project');
237
+ }
238
+
239
+ // Expand idea if we don't have a specification
240
+ if (!state.specification) {
241
+ onProgress?.('expand-idea', 'Expanding idea into specification...');
242
+ const specification = await expandIdea(
243
+ spec.idea,
244
+ spec.language,
245
+ (msg) => onProgress?.('expand-idea', msg)
246
+ );
247
+
248
+ state = await storeSpecification(projectDir, specification);
249
+ onProgress?.('expand-idea', 'Specification complete');
250
+ }
251
+
252
+ // Get project context
253
+ onProgress?.('get-context', 'Gathering project context...');
254
+ const context = await getProjectContext(
255
+ projectDir,
256
+ (msg) => onProgress?.('get-context', msg)
257
+ );
258
+
259
+ // Create initial plan if we don't have one
260
+ if (!state.plan) {
261
+ onProgress?.('create-plan', 'Creating development plan...');
262
+ const plan = await createPlan(
263
+ state.specification!,
264
+ context,
265
+ (msg) => onProgress?.('create-plan', msg)
266
+ );
267
+
268
+ state = await storePlan(projectDir, plan);
269
+ onProgress?.('create-plan', 'Initial plan created');
270
+ }
271
+
272
+ // Run consensus loop
273
+ onProgress?.('consensus', 'Starting consensus review...');
274
+ const consensusResult = await iterateUntilConsensus(
275
+ state.plan!,
276
+ context,
277
+ {
278
+ projectDir,
279
+ config: consensusConfig,
280
+ onIteration: (iteration, result) => {
281
+ onProgress?.(
282
+ 'consensus',
283
+ `Iteration ${iteration}: Score ${result.score}%`
284
+ );
285
+ },
286
+ onRevision: (iteration, _plan) => {
287
+ onProgress?.('consensus', `Revising plan (iteration ${iteration})...`);
288
+ },
289
+ }
290
+ );
291
+
292
+ // Store final plan
293
+ if (consensusResult.approved) {
294
+ state = await storePlan(projectDir, consensusResult.finalPlan);
295
+
296
+ // Parse and add milestones
297
+ const milestones = parsePlanMilestones(consensusResult.finalPlan);
298
+ state = await addMilestones(projectDir, milestones);
299
+
300
+ // Document the plan
301
+ await documentPlan(projectDir, consensusResult.finalPlan);
302
+
303
+ // Transition to execution phase
304
+ state = await setPhase(projectDir, 'execution');
305
+
306
+ onProgress?.('complete', `Plan approved with ${consensusResult.finalScore}% consensus`);
307
+ } else {
308
+ onProgress?.(
309
+ 'failed',
310
+ `Consensus not reached after ${consensusResult.totalIterations} iterations (${consensusResult.finalScore}%)`
311
+ );
312
+ }
313
+
314
+ return {
315
+ success: consensusResult.approved,
316
+ state,
317
+ consensusResult,
318
+ };
319
+ } catch (error) {
320
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
321
+ onProgress?.('error', errorMessage);
322
+
323
+ return {
324
+ success: false,
325
+ state: await loadProject(projectDir).catch(() => ({} as ProjectState)),
326
+ error: errorMessage,
327
+ };
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Resume plan mode from where it left off
333
+ *
334
+ * @param projectDir - The project directory
335
+ * @param options - Plan mode options
336
+ * @returns Plan mode result
337
+ */
338
+ export async function resumePlanMode(
339
+ projectDir: string,
340
+ options: Omit<PlanModeOptions, 'projectDir'>
341
+ ): Promise<PlanModeResult> {
342
+ const state = await loadProject(projectDir);
343
+
344
+ return runPlanMode(
345
+ {
346
+ idea: state.idea,
347
+ name: state.name,
348
+ language: state.language,
349
+ openaiModel: state.openaiModel,
350
+ },
351
+ {
352
+ ...options,
353
+ projectDir,
354
+ }
355
+ );
356
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Test runner module
3
+ * Handles running tests for Python and TypeScript projects
4
+ */
5
+
6
+ import { runTests as claudeRunTests } from '../adapters/claude.js';
7
+ import type { OutputLanguage } from '../types/project.js';
8
+
9
+ /**
10
+ * Test result from running tests
11
+ */
12
+ export interface TestResult {
13
+ success: boolean;
14
+ passed: number;
15
+ failed: number;
16
+ total: number;
17
+ output: string;
18
+ failedTests?: string[];
19
+ error?: string;
20
+ }
21
+
22
+ /**
23
+ * Test configuration
24
+ */
25
+ export interface TestConfig {
26
+ language: OutputLanguage;
27
+ testDir?: string;
28
+ coverage?: boolean;
29
+ verbose?: boolean;
30
+ timeout?: number;
31
+ }
32
+
33
+ /**
34
+ * Default test commands by language
35
+ */
36
+ export const DEFAULT_TEST_COMMANDS: Record<OutputLanguage, string> = {
37
+ python: 'python -m pytest tests/ -v',
38
+ typescript: 'npm test',
39
+ };
40
+
41
+ /**
42
+ * Build the test command for a language
43
+ *
44
+ * @param config - Test configuration
45
+ * @returns The test command to run
46
+ */
47
+ export function buildTestCommand(config: TestConfig): string {
48
+ const { language, testDir, coverage, verbose } = config;
49
+
50
+ switch (language) {
51
+ case 'python': {
52
+ const parts = ['python', '-m', 'pytest'];
53
+
54
+ if (testDir) {
55
+ parts.push(testDir);
56
+ } else {
57
+ parts.push('tests/');
58
+ }
59
+
60
+ if (verbose) {
61
+ parts.push('-v');
62
+ }
63
+
64
+ if (coverage) {
65
+ parts.push('--cov=src', '--cov-report=term-missing');
66
+ }
67
+
68
+ return parts.join(' ');
69
+ }
70
+
71
+ case 'typescript': {
72
+ const parts = ['npm', 'test'];
73
+
74
+ if (coverage) {
75
+ parts.push('--', '--coverage');
76
+ }
77
+
78
+ return parts.join(' ');
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Parse test output to extract results
85
+ *
86
+ * @param output - The test command output
87
+ * @param language - The project language
88
+ * @returns Parsed test result
89
+ */
90
+ export function parseTestOutput(output: string, language: OutputLanguage): TestResult {
91
+ let passed = 0;
92
+ let failed = 0;
93
+ let total = 0;
94
+ const failedTests: string[] = [];
95
+
96
+ switch (language) {
97
+ case 'python': {
98
+ // Parse pytest output
99
+ // Example: "5 passed, 2 failed, 1 skipped in 2.34s"
100
+ const summaryMatch = output.match(/(\d+)\s+passed/);
101
+ const failedMatch = output.match(/(\d+)\s+failed/);
102
+
103
+ if (summaryMatch) {
104
+ passed = parseInt(summaryMatch[1], 10);
105
+ }
106
+
107
+ if (failedMatch) {
108
+ failed = parseInt(failedMatch[1], 10);
109
+ }
110
+
111
+ total = passed + failed;
112
+
113
+ // Extract failed test names
114
+ const failedTestMatches = output.matchAll(/FAILED\s+([^\s]+)/g);
115
+ for (const match of failedTestMatches) {
116
+ failedTests.push(match[1]);
117
+ }
118
+ break;
119
+ }
120
+
121
+ case 'typescript': {
122
+ // Parse Jest/Vitest output
123
+ // Example: "Tests: 2 failed, 5 passed, 7 total"
124
+ const summaryMatch = output.match(/Tests:\s*(?:(\d+)\s+failed,\s*)?(\d+)\s+passed,\s*(\d+)\s+total/);
125
+
126
+ if (summaryMatch) {
127
+ failed = summaryMatch[1] ? parseInt(summaryMatch[1], 10) : 0;
128
+ passed = parseInt(summaryMatch[2], 10);
129
+ total = parseInt(summaryMatch[3], 10);
130
+ }
131
+
132
+ // Extract failed test names
133
+ const failedTestMatches = output.matchAll(/✕\s+(.+)/g);
134
+ for (const match of failedTestMatches) {
135
+ failedTests.push(match[1].trim());
136
+ }
137
+ break;
138
+ }
139
+ }
140
+
141
+ const success = failed === 0 && total > 0;
142
+
143
+ return {
144
+ success,
145
+ passed,
146
+ failed,
147
+ total,
148
+ output,
149
+ failedTests: failedTests.length > 0 ? failedTests : undefined,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Run Python tests
155
+ *
156
+ * @param cwd - Working directory
157
+ * @param config - Test configuration
158
+ * @returns Test result
159
+ */
160
+ export async function runPythonTests(
161
+ cwd: string,
162
+ config: Partial<TestConfig> = {}
163
+ ): Promise<TestResult> {
164
+ const testCommand = buildTestCommand({
165
+ language: 'python',
166
+ ...config,
167
+ });
168
+
169
+ try {
170
+ const result = await claudeRunTests(testCommand, cwd);
171
+
172
+ if (!result.success && result.error) {
173
+ return {
174
+ success: false,
175
+ passed: 0,
176
+ failed: 0,
177
+ total: 0,
178
+ output: result.response,
179
+ error: result.error,
180
+ };
181
+ }
182
+
183
+ return parseTestOutput(result.response, 'python');
184
+ } catch (error) {
185
+ return {
186
+ success: false,
187
+ passed: 0,
188
+ failed: 0,
189
+ total: 0,
190
+ output: '',
191
+ error: error instanceof Error ? error.message : 'Unknown error running tests',
192
+ };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Run TypeScript tests
198
+ *
199
+ * @param cwd - Working directory
200
+ * @param config - Test configuration
201
+ * @returns Test result
202
+ */
203
+ export async function runTypeScriptTests(
204
+ cwd: string,
205
+ config: Partial<TestConfig> = {}
206
+ ): Promise<TestResult> {
207
+ const testCommand = buildTestCommand({
208
+ language: 'typescript',
209
+ ...config,
210
+ });
211
+
212
+ try {
213
+ const result = await claudeRunTests(testCommand, cwd);
214
+
215
+ if (!result.success && result.error) {
216
+ return {
217
+ success: false,
218
+ passed: 0,
219
+ failed: 0,
220
+ total: 0,
221
+ output: result.response,
222
+ error: result.error,
223
+ };
224
+ }
225
+
226
+ return parseTestOutput(result.response, 'typescript');
227
+ } catch (error) {
228
+ return {
229
+ success: false,
230
+ passed: 0,
231
+ failed: 0,
232
+ total: 0,
233
+ output: '',
234
+ error: error instanceof Error ? error.message : 'Unknown error running tests',
235
+ };
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Run tests for a project
241
+ *
242
+ * @param cwd - Working directory
243
+ * @param language - Project language
244
+ * @param config - Test configuration
245
+ * @returns Test result
246
+ */
247
+ export async function runTests(
248
+ cwd: string,
249
+ language: OutputLanguage,
250
+ config: Partial<TestConfig> = {}
251
+ ): Promise<TestResult> {
252
+ switch (language) {
253
+ case 'python':
254
+ return runPythonTests(cwd, config);
255
+ case 'typescript':
256
+ return runTypeScriptTests(cwd, config);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Check if tests exist in a project
262
+ *
263
+ * @param cwd - Working directory
264
+ * @param language - Project language
265
+ * @returns True if tests exist
266
+ */
267
+ export async function testsExist(
268
+ cwd: string,
269
+ language: OutputLanguage
270
+ ): Promise<boolean> {
271
+ const { promises: fs } = await import('node:fs');
272
+ const path = await import('node:path');
273
+
274
+ try {
275
+ switch (language) {
276
+ case 'python': {
277
+ // Check for tests/ directory or test_*.py files
278
+ const testsDir = path.join(cwd, 'tests');
279
+ try {
280
+ await fs.access(testsDir);
281
+ return true;
282
+ } catch {
283
+ // Check for test files in root
284
+ const files = await fs.readdir(cwd);
285
+ return files.some((f) => f.startsWith('test_') && f.endsWith('.py'));
286
+ }
287
+ }
288
+
289
+ case 'typescript': {
290
+ // Check for tests/, __tests__, or *.test.ts files
291
+ const testsDir = path.join(cwd, 'tests');
292
+ const testsDirAlt = path.join(cwd, '__tests__');
293
+
294
+ try {
295
+ await fs.access(testsDir);
296
+ return true;
297
+ } catch {
298
+ try {
299
+ await fs.access(testsDirAlt);
300
+ return true;
301
+ } catch {
302
+ // Check for test files in src
303
+ const srcDir = path.join(cwd, 'src');
304
+ try {
305
+ const files = await fs.readdir(srcDir, { recursive: true });
306
+ return files.some(
307
+ (f) =>
308
+ (f.toString().endsWith('.test.ts') || f.toString().endsWith('.spec.ts'))
309
+ );
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+ }
315
+ }
316
+ }
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Get test summary string
324
+ *
325
+ * @param result - Test result
326
+ * @returns Human-readable summary
327
+ */
328
+ export function getTestSummary(result: TestResult): string {
329
+ if (result.error) {
330
+ return `Tests failed to run: ${result.error}`;
331
+ }
332
+
333
+ if (result.total === 0) {
334
+ return 'No tests found';
335
+ }
336
+
337
+ const status = result.success ? '✓' : '✗';
338
+ let summary = `${status} ${result.passed}/${result.total} tests passed`;
339
+
340
+ if (result.failed > 0) {
341
+ summary += ` (${result.failed} failed)`;
342
+ }
343
+
344
+ return summary;
345
+ }