agent-workflow-kit-cli 1.0.0-mvp → 1.2.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.
@@ -0,0 +1,462 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ import { promises as fs } from "fs";
6
+ import path from "path";
7
+ /**
8
+ * Helper to recursively find files with a specific extension, avoiding node_modules and build directories.
9
+ */
10
+ async function findFilesRecursively(dir, extension, limit = 20) {
11
+ const results = [];
12
+ async function traverse(currentDir) {
13
+ if (results.length >= limit)
14
+ return;
15
+ try {
16
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
17
+ for (const entry of entries) {
18
+ if (results.length >= limit)
19
+ return;
20
+ const fullPath = path.join(currentDir, entry.name);
21
+ if (entry.isDirectory()) {
22
+ // Skip build and package manager folders
23
+ if (entry.name.startsWith(".") ||
24
+ entry.name === "node_modules" ||
25
+ entry.name === "target" ||
26
+ entry.name === "build" ||
27
+ entry.name === "dist" ||
28
+ entry.name === "bin" ||
29
+ entry.name === "out" ||
30
+ entry.name === "gradle") {
31
+ continue;
32
+ }
33
+ await traverse(fullPath);
34
+ }
35
+ else if (entry.isFile() && entry.name.endsWith(extension)) {
36
+ results.push(fullPath);
37
+ }
38
+ }
39
+ }
40
+ catch {
41
+ // Ignore read errors
42
+ }
43
+ }
44
+ await traverse(dir);
45
+ return results;
46
+ }
47
+ /**
48
+ * Simple parser for properties files.
49
+ */
50
+ export function parsePropertiesSimple(content) {
51
+ const lines = content.split("\n");
52
+ let appName;
53
+ let port;
54
+ for (const line of lines) {
55
+ const trimmed = line.trim();
56
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!"))
57
+ continue;
58
+ const eqIndex = trimmed.indexOf("=");
59
+ const colonIndex = trimmed.indexOf(":");
60
+ let sepIndex = -1;
61
+ if (eqIndex !== -1 && colonIndex !== -1) {
62
+ sepIndex = Math.min(eqIndex, colonIndex);
63
+ }
64
+ else {
65
+ sepIndex = eqIndex !== -1 ? eqIndex : colonIndex;
66
+ }
67
+ if (sepIndex !== -1) {
68
+ const key = trimmed.substring(0, sepIndex).trim();
69
+ const value = trimmed.substring(sepIndex + 1).trim().replace(/^['"]|['"]$/g, "");
70
+ if (key === "spring.application.name") {
71
+ appName = value;
72
+ }
73
+ else if (key === "server.port") {
74
+ port = value;
75
+ }
76
+ }
77
+ }
78
+ return { appName, port };
79
+ }
80
+ /**
81
+ * Simple parser for YAML files.
82
+ */
83
+ export function parseYamlSimple(content) {
84
+ const lines = content.split("\n");
85
+ const contextPath = [];
86
+ let appName;
87
+ let port;
88
+ for (const line of lines) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed || trimmed.startsWith("#"))
91
+ continue;
92
+ // Determine indentation
93
+ const indent = line.length - line.trimStart().length;
94
+ // Pop context paths that are deeper or equal to current indent level
95
+ while (contextPath.length > 0 && indent <= contextPath[contextPath.length - 1].indent) {
96
+ contextPath.pop();
97
+ }
98
+ const colonIndex = trimmed.indexOf(":");
99
+ if (colonIndex !== -1) {
100
+ const key = trimmed.substring(0, colonIndex).trim();
101
+ const value = trimmed.substring(colonIndex + 1).trim();
102
+ contextPath.push({ key, indent });
103
+ const fullKeyPath = contextPath.map((c) => c.key).join(".");
104
+ if (value) {
105
+ const cleanVal = value.replace(/^['"]|['"]$/g, "");
106
+ if (fullKeyPath === "spring.application.name") {
107
+ appName = cleanVal;
108
+ }
109
+ else if (fullKeyPath === "server.port") {
110
+ port = cleanVal;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ return { appName, port };
116
+ }
117
+ /**
118
+ * Analyzes Java Spring Boot project details.
119
+ */
120
+ async function analyzeSpringBoot(dir) {
121
+ // 1. Detect Build Tool & Commands
122
+ let buildTool = "maven";
123
+ let buildCommand = "./mvnw";
124
+ let buildVerifyArgs = "verify";
125
+ let isMicroservice = false;
126
+ try {
127
+ const isGradle = (await fs.stat(path.join(dir, "build.gradle")).then((s) => s.isFile()).catch(() => false)) ||
128
+ (await fs.stat(path.join(dir, "build.gradle.kts")).then((s) => s.isFile()).catch(() => false));
129
+ if (isGradle) {
130
+ buildTool = "gradle";
131
+ const hasWrapper = await fs.stat(path.join(dir, "gradlew")).then((s) => s.isFile()).catch(() => false);
132
+ buildCommand = hasWrapper ? "./gradlew" : "gradle";
133
+ buildVerifyArgs = "check";
134
+ }
135
+ else {
136
+ const hasWrapper = await fs.stat(path.join(dir, "mvnw")).then((s) => s.isFile()).catch(() => false);
137
+ buildCommand = hasWrapper ? "./mvnw" : "mvn";
138
+ buildVerifyArgs = "verify";
139
+ }
140
+ }
141
+ catch {
142
+ // Use default maven settings
143
+ }
144
+ // Check build files for microservice dependencies
145
+ const buildFilesToCheck = ["pom.xml", "build.gradle", "build.gradle.kts"];
146
+ for (const file of buildFilesToCheck) {
147
+ try {
148
+ const content = await fs.readFile(path.join(dir, file), "utf8");
149
+ if (content.includes("spring-cloud") ||
150
+ content.includes("eureka") ||
151
+ content.includes("openfeign") ||
152
+ content.includes("consul") ||
153
+ content.includes("nacos")) {
154
+ isMicroservice = true;
155
+ }
156
+ }
157
+ catch {
158
+ // Ignore if file doesn't exist
159
+ }
160
+ }
161
+ // 2. Base Package Path and package-to-dot-notation detection
162
+ let basePackage = "com.acme.app";
163
+ let packagePath = "com/acme/app";
164
+ const javaSrcRoot = path.join(dir, "src/main/java");
165
+ let javaSrcRootExists = false;
166
+ try {
167
+ const stat = await fs.stat(javaSrcRoot);
168
+ javaSrcRootExists = stat.isDirectory();
169
+ }
170
+ catch {
171
+ // src/main/java doesn't exist
172
+ }
173
+ let basePackageFullPath = javaSrcRoot;
174
+ if (javaSrcRootExists) {
175
+ let currentDir = javaSrcRoot;
176
+ const packageParts = [];
177
+ while (true) {
178
+ try {
179
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
180
+ const subdirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
181
+ const files = entries.filter((e) => e.isFile());
182
+ // Traverse down if exactly one subdirectory and no Java/static files inside this directory level
183
+ if (subdirs.length === 1 && files.length === 0) {
184
+ const subDirName = subdirs[0].name;
185
+ packageParts.push(subDirName);
186
+ currentDir = path.join(currentDir, subDirName);
187
+ }
188
+ else {
189
+ break;
190
+ }
191
+ }
192
+ catch {
193
+ break;
194
+ }
195
+ }
196
+ if (packageParts.length > 0) {
197
+ basePackage = packageParts.join(".");
198
+ packagePath = packageParts.join("/");
199
+ basePackageFullPath = currentDir;
200
+ }
201
+ }
202
+ // 3. Package Layout Detection (feature-first vs layer-first)
203
+ let packageLayout = "feature-first";
204
+ if (javaSrcRootExists) {
205
+ try {
206
+ const entries = await fs.readdir(basePackageFullPath, { withFileTypes: true });
207
+ const subdirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
208
+ const layerKeywords = new Set([
209
+ "controller",
210
+ "controllers",
211
+ "service",
212
+ "services",
213
+ "repository",
214
+ "repositories",
215
+ "entity",
216
+ "entities",
217
+ "model",
218
+ "models",
219
+ "dto",
220
+ "dtos",
221
+ "mapper",
222
+ "mappers",
223
+ "config",
224
+ "configs",
225
+ "web",
226
+ ]);
227
+ let matchingLayerDirsCount = 0;
228
+ for (const subdir of subdirs) {
229
+ if (layerKeywords.has(subdir.name.toLowerCase())) {
230
+ matchingLayerDirsCount++;
231
+ }
232
+ }
233
+ if (matchingLayerDirsCount >= 2) {
234
+ packageLayout = "layer-first";
235
+ }
236
+ else {
237
+ // Try sampling child packages to see if they hold feature sub-packages with layers inside
238
+ let looksLikeFeatureFirst = false;
239
+ const sampleLimit = Math.min(subdirs.length, 3);
240
+ for (let i = 0; i < sampleLimit; i++) {
241
+ const subDirPath = path.join(basePackageFullPath, subdirs[i].name);
242
+ const subEntries = await fs.readdir(subDirPath, { withFileTypes: true });
243
+ const nestedDirs = subEntries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
244
+ const hasLayerInside = nestedDirs.some((nd) => layerKeywords.has(nd.name.toLowerCase()));
245
+ if (hasLayerInside) {
246
+ looksLikeFeatureFirst = true;
247
+ break;
248
+ }
249
+ }
250
+ packageLayout = looksLikeFeatureFirst ? "feature-first" : "feature-first"; // Default to feature-first
251
+ }
252
+ }
253
+ catch {
254
+ // Fallback
255
+ }
256
+ }
257
+ // 4. Validation Library Detection (Jakarta vs Javax)
258
+ let validationLibrary = "jakarta.validation";
259
+ if (javaSrcRootExists) {
260
+ try {
261
+ const javaFiles = await findFilesRecursively(basePackageFullPath, ".java", 20);
262
+ for (const file of javaFiles) {
263
+ const content = await fs.readFile(file, "utf8");
264
+ if (content.includes("import javax.validation.")) {
265
+ validationLibrary = "javax.validation";
266
+ break;
267
+ }
268
+ else if (content.includes("import jakarta.validation.")) {
269
+ validationLibrary = "jakarta.validation";
270
+ break;
271
+ }
272
+ }
273
+ }
274
+ catch {
275
+ // Fallback
276
+ }
277
+ }
278
+ // 5. Test Framework Detection
279
+ let testFramework = "JUnit 5";
280
+ const javaTestRoot = path.join(dir, "src/test/java");
281
+ let javaTestRootExists = false;
282
+ try {
283
+ const stat = await fs.stat(javaTestRoot);
284
+ javaTestRootExists = stat.isDirectory();
285
+ }
286
+ catch {
287
+ // src/test/java doesn't exist
288
+ }
289
+ if (javaTestRootExists) {
290
+ try {
291
+ const testFiles = await findFilesRecursively(javaTestRoot, ".java", 20);
292
+ for (const file of testFiles) {
293
+ const content = await fs.readFile(file, "utf8");
294
+ if (content.includes("org.junit.jupiter.")) {
295
+ testFramework = "JUnit 5";
296
+ break;
297
+ }
298
+ else if (content.includes("org.junit.Test")) {
299
+ testFramework = "JUnit 4";
300
+ break;
301
+ }
302
+ }
303
+ }
304
+ catch {
305
+ // Fallback
306
+ }
307
+ }
308
+ // 6. Microservice properties scanning (service name and port)
309
+ let springApplicationName = path.basename(dir);
310
+ let serverPort = "8080";
311
+ const configFiles = [
312
+ "application.properties",
313
+ "application.yml",
314
+ "application.yaml",
315
+ "bootstrap.properties",
316
+ "bootstrap.yml",
317
+ "bootstrap.yaml",
318
+ ];
319
+ const resourcesDir = path.join(dir, "src/main/resources");
320
+ for (const file of configFiles) {
321
+ try {
322
+ const filePath = path.join(resourcesDir, file);
323
+ const content = await fs.readFile(filePath, "utf8");
324
+ const parsed = file.endsWith(".properties")
325
+ ? parsePropertiesSimple(content)
326
+ : parseYamlSimple(content);
327
+ if (parsed.appName) {
328
+ springApplicationName = parsed.appName;
329
+ isMicroservice = true;
330
+ }
331
+ if (parsed.port) {
332
+ serverPort = parsed.port;
333
+ }
334
+ }
335
+ catch {
336
+ // Ignore if file doesn't exist or is unreadable
337
+ }
338
+ }
339
+ return {
340
+ buildTool,
341
+ buildCommand,
342
+ buildVerifyArgs,
343
+ packageLayout,
344
+ basePackage,
345
+ packagePath,
346
+ validationLibrary,
347
+ testFramework,
348
+ isMicroservice,
349
+ springApplicationName,
350
+ serverPort,
351
+ };
352
+ }
353
+ /**
354
+ * Analyzes React TypeScript project details.
355
+ */
356
+ async function analyzeReactTs(dir) {
357
+ let packageManager = "npm";
358
+ let runCommand = "npm run";
359
+ try {
360
+ const hasYarnLock = await fs.stat(path.join(dir, "yarn.lock")).then((s) => s.isFile()).catch(() => false);
361
+ const hasPnpmLock = await fs.stat(path.join(dir, "pnpm-lock.yaml")).then((s) => s.isFile()).catch(() => false);
362
+ if (hasYarnLock) {
363
+ packageManager = "yarn";
364
+ runCommand = "yarn";
365
+ }
366
+ else if (hasPnpmLock) {
367
+ packageManager = "pnpm";
368
+ runCommand = "pnpm";
369
+ }
370
+ }
371
+ catch {
372
+ // Use default
373
+ }
374
+ return { packageManager, runCommand };
375
+ }
376
+ /**
377
+ * Analyzes FastAPI project details.
378
+ */
379
+ async function analyzeFastApi(dir) {
380
+ let packageManager = "pip";
381
+ let runCommand = "python";
382
+ try {
383
+ const hasPoetry = await fs.stat(path.join(dir, "poetry.lock")).then((s) => s.isFile()).catch(() => false);
384
+ const hasPipenv = await fs.stat(path.join(dir, "Pipfile")).then((s) => s.isFile()).catch(() => false);
385
+ if (hasPoetry) {
386
+ packageManager = "poetry";
387
+ runCommand = "poetry run";
388
+ }
389
+ else if (hasPipenv) {
390
+ packageManager = "pipenv";
391
+ runCommand = "pipenv run";
392
+ }
393
+ }
394
+ catch {
395
+ // Use default
396
+ }
397
+ return { packageManager, runCommand };
398
+ }
399
+ /**
400
+ * Analyzes Python AI project details.
401
+ */
402
+ async function analyzePythonAi(dir) {
403
+ let packageManager = "pip";
404
+ let runCommand = "python";
405
+ let hasGpuLibraries = false;
406
+ let hasHardwareLibraries = false;
407
+ try {
408
+ const hasPoetry = await fs.stat(path.join(dir, "poetry.lock")).then((s) => s.isFile()).catch(() => false);
409
+ const hasPipenv = await fs.stat(path.join(dir, "Pipfile")).then((s) => s.isFile()).catch(() => false);
410
+ if (hasPoetry) {
411
+ packageManager = "poetry";
412
+ runCommand = "poetry run python";
413
+ }
414
+ else if (hasPipenv) {
415
+ packageManager = "pipenv";
416
+ runCommand = "pipenv run python";
417
+ }
418
+ }
419
+ catch {
420
+ // Use default
421
+ }
422
+ // Scan requirements / pyproject to check for GPU and Hardware references
423
+ const checkGpuKws = ["torch", "tensorflow", "cuda", "onnxruntime-gpu"];
424
+ const checkHwKws = ["opencv-python", "mediapipe", "pyserial", "pyaudio", "picamera", "opencv"];
425
+ const buildFiles = ["requirements.txt", "pyproject.toml", "Pipfile"];
426
+ for (const file of buildFiles) {
427
+ try {
428
+ const content = await fs.readFile(path.join(dir, file), "utf8");
429
+ if (checkGpuKws.some(kw => content.includes(kw))) {
430
+ hasGpuLibraries = true;
431
+ }
432
+ if (checkHwKws.some(kw => content.includes(kw))) {
433
+ hasHardwareLibraries = true;
434
+ }
435
+ }
436
+ catch {
437
+ // Ignore
438
+ }
439
+ }
440
+ return { packageManager, runCommand, hasGpuLibraries, hasHardwareLibraries };
441
+ }
442
+ /**
443
+ * Entry point to analyze module configurations.
444
+ */
445
+ export async function analyzeModule(dir, stacks) {
446
+ const context = {};
447
+ for (const stack of stacks) {
448
+ if (stack === "spring-boot") {
449
+ context["spring-boot"] = await analyzeSpringBoot(dir);
450
+ }
451
+ else if (stack === "react-ts") {
452
+ context["react-ts"] = await analyzeReactTs(dir);
453
+ }
454
+ else if (stack === "fastapi") {
455
+ context["fastapi"] = await analyzeFastApi(dir);
456
+ }
457
+ else if (stack === "python-ai") {
458
+ context["python-ai"] = await analyzePythonAi(dir);
459
+ }
460
+ }
461
+ return context;
462
+ }
@@ -9,7 +9,7 @@ import path from "path";
9
9
  */
10
10
  async function detectProjectStackDirect(cwd) {
11
11
  const stacks = [];
12
- // 1. Detect Java Spring Boot (pom.xml)
12
+ // 1. Detect Java Spring Boot (pom.xml, build.gradle, or build.gradle.kts)
13
13
  try {
14
14
  const pomPath = path.join(cwd, "pom.xml");
15
15
  const stat = await fs.stat(pomPath);
@@ -18,7 +18,25 @@ async function detectProjectStackDirect(cwd) {
18
18
  }
19
19
  }
20
20
  catch {
21
- // Ignore
21
+ try {
22
+ const gradlePath = path.join(cwd, "build.gradle");
23
+ const stat = await fs.stat(gradlePath);
24
+ if (stat.isFile()) {
25
+ stacks.push("spring-boot");
26
+ }
27
+ }
28
+ catch {
29
+ try {
30
+ const gradleKtsPath = path.join(cwd, "build.gradle.kts");
31
+ const stat = await fs.stat(gradleKtsPath);
32
+ if (stat.isFile()) {
33
+ stacks.push("spring-boot");
34
+ }
35
+ }
36
+ catch {
37
+ // Ignore
38
+ }
39
+ }
22
40
  }
23
41
  // 2. Detect React + TypeScript (package.json containing react dependency)
24
42
  try {
@@ -34,13 +52,28 @@ async function detectProjectStackDirect(cwd) {
34
52
  catch {
35
53
  // Ignore
36
54
  }
37
- // 3. Detect Python FastAPI (pyproject.toml or requirements.txt containing fastapi)
55
+ // 3. Detect Python FastAPI & Python AI
56
+ const aiKeywords = [
57
+ "torch",
58
+ "tensorflow",
59
+ "scikit-learn",
60
+ "transformers",
61
+ "langchain",
62
+ "llama-index",
63
+ "opencv-python",
64
+ "mediapipe",
65
+ "numpy",
66
+ "pandas"
67
+ ];
38
68
  try {
39
69
  const pyprojectPath = path.join(cwd, "pyproject.toml");
40
70
  const content = await fs.readFile(pyprojectPath, "utf8");
41
71
  if (content.includes("fastapi")) {
42
72
  stacks.push("fastapi");
43
73
  }
74
+ if (aiKeywords.some(kw => content.includes(kw))) {
75
+ stacks.push("python-ai");
76
+ }
44
77
  }
45
78
  catch {
46
79
  try {
@@ -49,75 +82,89 @@ async function detectProjectStackDirect(cwd) {
49
82
  if (content.includes("fastapi")) {
50
83
  stacks.push("fastapi");
51
84
  }
85
+ if (aiKeywords.some(kw => content.includes(kw))) {
86
+ stacks.push("python-ai");
87
+ }
52
88
  }
53
89
  catch {
54
- // Ignore
55
- }
56
- }
57
- return stacks;
58
- }
59
- /**
60
- * Scans the workspace directory and subfolders (1-level deep) for monorepo detection.
61
- */
62
- export async function detectProjectStack(cwd) {
63
- const stacks = await detectProjectStackDirect(cwd);
64
- // Scan 1-level deep subdirectories for potential monorepos/multimodules
65
- try {
66
- const entries = await fs.readdir(cwd, { withFileTypes: true });
67
- for (const entry of entries) {
68
- if (entry.isDirectory() &&
69
- !entry.name.startsWith(".") &&
70
- entry.name !== "node_modules" &&
71
- entry.name !== "dist") {
72
- const subPath = path.join(cwd, entry.name);
73
- const subStacks = await detectProjectStackDirect(subPath);
74
- for (const stack of subStacks) {
75
- if (!stacks.includes(stack)) {
76
- stacks.push(stack);
77
- }
90
+ try {
91
+ const pipfilePath = path.join(cwd, "Pipfile");
92
+ const content = await fs.readFile(pipfilePath, "utf8");
93
+ if (content.includes("fastapi")) {
94
+ stacks.push("fastapi");
95
+ }
96
+ if (aiKeywords.some(kw => content.includes(kw))) {
97
+ stacks.push("python-ai");
78
98
  }
79
99
  }
100
+ catch {
101
+ // Ignore
102
+ }
80
103
  }
81
104
  }
82
- catch {
83
- // Ignore
84
- }
85
105
  return stacks;
86
106
  }
87
107
  /**
88
- * Detects stacks grouped by module directories (root + 1-level deep directories).
108
+ * Helper to recursively search for modules containing manifest configurations up to maxDepth.
89
109
  */
90
- export async function detectProjectModules(cwd) {
110
+ async function findModulesRecursively(baseDir, currentDir, maxDepth, currentDepth = 0) {
91
111
  const modules = [];
92
- const rootStacks = await detectProjectStackDirect(cwd);
93
- if (rootStacks.length > 0) {
112
+ // Skip standard build and internal directories to avoid deep scans
113
+ const dirName = path.basename(currentDir);
114
+ if (dirName.startsWith(".") ||
115
+ dirName === "node_modules" ||
116
+ dirName === "target" ||
117
+ dirName === "build" ||
118
+ dirName === "dist" ||
119
+ dirName === "bin" ||
120
+ dirName === "out" ||
121
+ dirName === "venv" ||
122
+ dirName === ".venv") {
123
+ return modules;
124
+ }
125
+ // Detect direct stack presets in this current directory
126
+ const directStacks = await detectProjectStackDirect(currentDir);
127
+ if (directStacks.length > 0) {
94
128
  modules.push({
95
- dir: cwd,
96
- name: ".",
97
- stacks: rootStacks,
129
+ dir: currentDir,
130
+ name: currentDir === baseDir ? "." : path.relative(baseDir, currentDir).replace(/\\/g, "/"),
131
+ stacks: directStacks,
98
132
  });
99
133
  }
100
- try {
101
- const entries = await fs.readdir(cwd, { withFileTypes: true });
102
- for (const entry of entries) {
103
- if (entry.isDirectory() &&
104
- !entry.name.startsWith(".") &&
105
- entry.name !== "node_modules" &&
106
- entry.name !== "dist") {
107
- const subPath = path.join(cwd, entry.name);
108
- const subStacks = await detectProjectStackDirect(subPath);
109
- if (subStacks.length > 0) {
110
- modules.push({
111
- dir: subPath,
112
- name: entry.name,
113
- stacks: subStacks,
114
- });
134
+ // If we haven't hit max depth, traverse subdirectories
135
+ if (currentDepth < maxDepth) {
136
+ try {
137
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
138
+ for (const entry of entries) {
139
+ if (entry.isDirectory()) {
140
+ const subPath = path.join(currentDir, entry.name);
141
+ const subModules = await findModulesRecursively(baseDir, subPath, maxDepth, currentDepth + 1);
142
+ modules.push(...subModules);
115
143
  }
116
144
  }
117
145
  }
118
- }
119
- catch {
120
- // Ignore
146
+ catch {
147
+ // Ignore directory read errors
148
+ }
121
149
  }
122
150
  return modules;
123
151
  }
152
+ /**
153
+ * Scans the workspace directory and subfolders (up to 3 levels deep) for monorepo detection.
154
+ */
155
+ export async function detectProjectStack(cwd) {
156
+ const modules = await detectProjectModules(cwd);
157
+ const stacks = new Set();
158
+ for (const mod of modules) {
159
+ for (const stack of mod.stacks) {
160
+ stacks.add(stack);
161
+ }
162
+ }
163
+ return Array.from(stacks);
164
+ }
165
+ /**
166
+ * Detects stacks grouped by module directories (root + up to 3 levels deep directories).
167
+ */
168
+ export async function detectProjectModules(cwd) {
169
+ return findModulesRecursively(cwd, cwd, 3);
170
+ }