tidyf 1.0.2 → 1.0.3

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/dist/index.js CHANGED
@@ -13671,6 +13671,20 @@ You are an AI assistant that organizes files from a download folder. Analyze eac
13671
13671
  5. Installer files go to Applications/Installers
13672
13672
  6. Compressed files stay as Archives unless clearly part of another category
13673
13673
 
13674
+ ## Existing Folder Preferences
13675
+
13676
+ When existing folders are provided in the prompt:
13677
+
13678
+ 1. **STRONGLY PREFER existing folders** - Use them when the file fits the category
13679
+ 2. **Match naming conventions** - If "Screenshots" exists, don't create "Screen Captures"
13680
+ 3. **Extend existing hierarchy** - OK to create subfolders under existing folders
13681
+ 4. **Only create new top-level folders** when no existing category applies
13682
+
13683
+ Examples:
13684
+ - If "Images/Screenshots" exists, use it for screenshot files
13685
+ - If "Documents/Work" exists but no "Reports" subfolder, create "Documents/Work/Reports"
13686
+ - If only "Documents" exists, prefer "Documents/Receipts" over top-level "Receipts"
13687
+
13674
13688
  ## Output Format
13675
13689
 
13676
13690
  Return JSON with this exact structure:
@@ -13822,7 +13836,7 @@ function getDefaultRules() {
13822
13836
  }
13823
13837
  // src/lib/scanner.ts
13824
13838
  import { readdir, stat, readFile } from "fs/promises";
13825
- import { join as join2, extname as extname2, basename } from "path";
13839
+ import { join as join2, extname as extname2, basename, relative } from "path";
13826
13840
 
13827
13841
  // node_modules/mime-types/index.js
13828
13842
  /*!
@@ -13871,6 +13885,55 @@ function populateMaps(extensions, types) {
13871
13885
  }
13872
13886
 
13873
13887
  // src/lib/scanner.ts
13888
+ async function scanFolderStructure(dirPath, options = {}) {
13889
+ const {
13890
+ maxDepth = 3,
13891
+ includeEmpty = false,
13892
+ ignore = [],
13893
+ maxFolders = 100
13894
+ } = options;
13895
+ const folders = [];
13896
+ await scanFoldersInternal(dirPath, dirPath, 0, maxDepth, includeEmpty, ignore, folders, maxFolders);
13897
+ folders.sort((a, b) => {
13898
+ const depthA = a.split("/").length;
13899
+ const depthB = b.split("/").length;
13900
+ if (depthA !== depthB)
13901
+ return depthA - depthB;
13902
+ return a.localeCompare(b);
13903
+ });
13904
+ return folders.slice(0, maxFolders);
13905
+ }
13906
+ async function scanFoldersInternal(basePath, currentPath, currentDepth, maxDepth, includeEmpty, ignore, folders, maxFolders) {
13907
+ if (currentDepth >= maxDepth || folders.length >= maxFolders) {
13908
+ return;
13909
+ }
13910
+ try {
13911
+ const entries = await readdir(currentPath, { withFileTypes: true });
13912
+ for (const entry of entries) {
13913
+ if (folders.length >= maxFolders)
13914
+ break;
13915
+ if (!entry.isDirectory())
13916
+ continue;
13917
+ if (entry.name.startsWith("."))
13918
+ continue;
13919
+ if (shouldIgnore(entry.name, ignore))
13920
+ continue;
13921
+ const fullPath = join2(currentPath, entry.name);
13922
+ const relativePath = relative(basePath, fullPath);
13923
+ if (!includeEmpty) {
13924
+ try {
13925
+ const contents = await readdir(fullPath);
13926
+ if (contents.length === 0)
13927
+ continue;
13928
+ } catch {
13929
+ continue;
13930
+ }
13931
+ }
13932
+ folders.push(relativePath);
13933
+ await scanFoldersInternal(basePath, fullPath, currentDepth + 1, maxDepth, includeEmpty, ignore, folders, maxFolders);
13934
+ }
13935
+ } catch {}
13936
+ }
13874
13937
  async function scanDirectory(dirPath, options = {}) {
13875
13938
  const {
13876
13939
  recursive = false,
@@ -16578,7 +16641,7 @@ async function checkConflicts(proposal) {
16578
16641
  };
16579
16642
  }
16580
16643
  async function analyzeFiles(options) {
16581
- const { files, targetDir, instructions, model } = options;
16644
+ const { files, targetDir, instructions, model, existingFolders } = options;
16582
16645
  if (files.length === 0) {
16583
16646
  return {
16584
16647
  proposals: [],
@@ -16592,11 +16655,18 @@ async function analyzeFiles(options) {
16592
16655
  const config = resolveConfig();
16593
16656
  const rulesPrompt = getRulesPrompt();
16594
16657
  const filesJson = formatFilesForPrompt(files);
16658
+ const existingFoldersSection = existingFolders?.length ? `
16659
+ EXISTING FOLDERS in target directory:
16660
+ ${existingFolders.join(`
16661
+ `)}
16662
+
16663
+ IMPORTANT: Prefer using these existing folders when appropriate. Only create new folders when no suitable existing folder matches the file's category.
16664
+ ` : "";
16595
16665
  const userPrompt = `
16596
16666
  Analyze the following files and organize them according to the rules.
16597
16667
 
16598
16668
  Target directory: ${targetDir}
16599
-
16669
+ ${existingFoldersSection}
16600
16670
  ${instructions ? `Additional instructions: ${instructions}
16601
16671
  ` : ""}
16602
16672
  Files to organize:
@@ -16989,13 +17059,25 @@ async function organizeCommand(options) {
16989
17059
  return;
16990
17060
  }
16991
17061
  M2.info(`Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`);
17062
+ let existingFolders = [];
17063
+ try {
17064
+ existingFolders = await scanFolderStructure(targetPath, {
17065
+ maxDepth: 3,
17066
+ includeEmpty: false,
17067
+ ignore: config.ignore
17068
+ });
17069
+ if (existingFolders.length > 0) {
17070
+ M2.info(`Found ${import_picocolors4.default.bold(String(existingFolders.length))} existing folders in target`);
17071
+ }
17072
+ } catch {}
16992
17073
  spinner.start("Analyzing files with AI...");
16993
17074
  let proposal;
16994
17075
  try {
16995
17076
  proposal = await analyzeFiles({
16996
17077
  files,
16997
17078
  targetDir: targetPath,
16998
- model: parseModelString(options.model)
17079
+ model: parseModelString(options.model),
17080
+ existingFolders
16999
17081
  });
17000
17082
  spinner.stop("Analysis complete");
17001
17083
  } catch (error) {
@@ -17077,7 +17159,8 @@ async function organizeCommand(options) {
17077
17159
  files,
17078
17160
  targetDir: targetPath,
17079
17161
  instructions: newInstructions || undefined,
17080
- model: parseModelString(options.model)
17162
+ model: parseModelString(options.model),
17163
+ existingFolders
17081
17164
  });
17082
17165
  spinner.stop("Analysis complete");
17083
17166
  displayAllProposals(proposal);
@@ -17105,7 +17188,8 @@ async function organizeCommand(options) {
17105
17188
  files,
17106
17189
  targetDir: targetPath,
17107
17190
  instructions: newInstructions || undefined,
17108
- model: pickedModel
17191
+ model: pickedModel,
17192
+ existingFolders
17109
17193
  });
17110
17194
  spinner.stop("Analysis complete");
17111
17195
  displayAllProposals(proposal);
@@ -17123,6 +17207,26 @@ async function organizeCommand(options) {
17123
17207
  }
17124
17208
  // src/commands/watch.ts
17125
17209
  var import_picocolors5 = __toESM(require_picocolors(), 1);
17210
+ var folderCache = [];
17211
+ var lastFolderScan = 0;
17212
+ var FOLDER_CACHE_TTL = 5 * 60 * 1000;
17213
+ async function getExistingFolders(targetPath, ignore = []) {
17214
+ const now = Date.now();
17215
+ if (now - lastFolderScan > FOLDER_CACHE_TTL) {
17216
+ try {
17217
+ folderCache = await scanFolderStructure(targetPath, {
17218
+ maxDepth: 3,
17219
+ includeEmpty: false,
17220
+ ignore
17221
+ });
17222
+ lastFolderScan = now;
17223
+ } catch {}
17224
+ }
17225
+ return folderCache;
17226
+ }
17227
+ function invalidateFolderCache() {
17228
+ lastFolderScan = 0;
17229
+ }
17126
17230
  function displayProposalBrief(proposals) {
17127
17231
  for (const prop of proposals) {
17128
17232
  const icon = getFileIcon(prop.file.name);
@@ -17143,6 +17247,9 @@ async function executeProposalsSilent(proposals) {
17143
17247
  failed++;
17144
17248
  }
17145
17249
  }
17250
+ if (success > 0) {
17251
+ invalidateFolderCache();
17252
+ }
17146
17253
  return { success, failed };
17147
17254
  }
17148
17255
  async function interactiveReview(proposal) {
@@ -17233,10 +17340,12 @@ ${import_picocolors5.default.green("+")} ${newFilePaths.length} new file(s) dete
17233
17340
  s.start("Analyzing files with AI...");
17234
17341
  let proposal;
17235
17342
  try {
17343
+ const existingFolders = await getExistingFolders(targetPath, config.ignore);
17236
17344
  proposal = await analyzeFiles({
17237
17345
  files,
17238
17346
  targetDir: targetPath,
17239
- model: parseModelString(options.model)
17347
+ model: parseModelString(options.model),
17348
+ existingFolders
17240
17349
  });
17241
17350
  s.stop("Analysis complete");
17242
17351
  } catch (error) {
@@ -17281,10 +17390,12 @@ ${import_picocolors5.default.green("+")} ${newFilePaths.length} new file(s) dete
17281
17390
  const s = Y2();
17282
17391
  s.start("Analyzing queued files...");
17283
17392
  try {
17393
+ const existingFolders = await getExistingFolders(targetPath, config.ignore);
17284
17394
  const proposal = await analyzeFiles({
17285
17395
  files: reviewQueue,
17286
17396
  targetDir: targetPath,
17287
- model: parseModelString(options.model)
17397
+ model: parseModelString(options.model),
17398
+ existingFolders
17288
17399
  });
17289
17400
  s.stop("Analysis complete");
17290
17401
  await interactiveReview(proposal);
package/package.json CHANGED
@@ -1,58 +1,59 @@
1
1
  {
2
- "name": "tidyf",
3
- "version": "1.0.2",
4
- "description": "AI-powered file organizer using opencode.ai",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "bin": {
8
- "tidyf": "./dist/cli.js",
9
- "td": "./dist/cli.js",
10
- "tidyfiles": "./dist/cli.js"
11
- },
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/yafyx/tidyf.git"
15
- },
16
- "publishConfig": {
17
- "access": "public",
18
- "registry": "https://registry.npmjs.org/"
19
- },
20
- "scripts": {
21
- "start": "node dist/cli.js",
22
- "dev": "bun --watch src/cli.ts",
23
- "build": "bun build src/cli.ts --outdir=dist --target=node --format=esm && bun build src/index.ts --outdir=dist --target=node --format=esm",
24
- "typecheck": "tsc --noEmit",
25
- "prepublishOnly": "bun run build"
26
- },
27
- "engines": {
28
- "node": ">=18.0.0"
29
- },
30
- "keywords": [
31
- "file-organizer",
32
- "ai",
33
- "opencode",
34
- "cli",
35
- "downloads",
36
- "tidy",
37
- "automation"
38
- ],
39
- "author": "",
40
- "license": "MIT",
41
- "files": [
42
- "dist",
43
- "src"
44
- ],
45
- "dependencies": {
46
- "@clack/prompts": "^0.11.0",
47
- "@opencode-ai/sdk": "^1.0.155",
48
- "chokidar": "^3.5.3",
49
- "commander": "^12.1.0",
50
- "mime-types": "^2.1.35",
51
- "picocolors": "^1.1.1"
52
- },
53
- "devDependencies": {
54
- "@types/bun": "latest",
55
- "@types/mime-types": "^2.1.4",
56
- "typescript": "^5.7.2"
57
- }
2
+ "name": "tidyf",
3
+ "version": "1.0.3",
4
+ "description": "AI-powered file organizer using opencode.ai",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "tidyf": "./dist/cli.js",
9
+ "td": "./dist/cli.js",
10
+ "tidyfiles": "./dist/cli.js"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/yafyx/tidyf.git"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org/"
19
+ },
20
+ "scripts": {
21
+ "start": "node dist/cli.js",
22
+ "dev": "bun --watch src/cli.ts",
23
+ "build": "bun build src/cli.ts --outdir=dist --target=node --format=esm && bun build src/index.ts --outdir=dist --target=node --format=esm",
24
+ "typecheck": "tsc --noEmit",
25
+ "prepublishOnly": "bun run build"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "keywords": [
31
+ "file-organizer",
32
+ "ai",
33
+ "opencode",
34
+ "cli",
35
+ "downloads",
36
+ "tidy",
37
+ "automation"
38
+ ],
39
+ "author": "",
40
+ "license": "MIT",
41
+ "files": [
42
+ "dist",
43
+ "src"
44
+ ],
45
+ "dependencies": {
46
+ "@clack/prompts": "^0.11.0",
47
+ "@opencode-ai/sdk": "^1.0.155",
48
+ "chokidar": "^3.5.3",
49
+ "commander": "^12.1.0",
50
+ "mime-types": "^2.1.35",
51
+ "picocolors": "^1.1.1",
52
+ "simple-update-notifier": "^2.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/bun": "latest",
56
+ "@types/mime-types": "^2.1.4",
57
+ "typescript": "^5.7.2"
58
+ }
58
59
  }
package/src/cli.ts CHANGED
@@ -5,16 +5,24 @@
5
5
  */
6
6
 
7
7
  import { Command } from "commander";
8
+ import updateNotifier from "simple-update-notifier";
9
+ import { createRequire } from "module";
8
10
  import { configCommand } from "./commands/config.ts";
9
11
  import { organizeCommand } from "./commands/organize.ts";
10
12
  import { watchCommand } from "./commands/watch.ts";
11
13
 
14
+ const require = createRequire(import.meta.url);
15
+ const pkg = require("../package.json");
16
+
17
+ // Check for updates (non-blocking, cached for 1 day)
18
+ updateNotifier({ pkg });
19
+
12
20
  const program = new Command();
13
21
 
14
22
  program
15
23
  .name("tidyf")
16
24
  .description("AI-powered file organizer using opencode.ai")
17
- .version("1.0.0");
25
+ .version(pkg.version);
18
26
 
19
27
  // Default command - organize files
20
28
  program
@@ -19,7 +19,7 @@ import {
19
19
  resolveConfig,
20
20
  } from "../lib/config.ts";
21
21
  import { analyzeFiles, cleanup } from "../lib/opencode.ts";
22
- import { scanDirectory } from "../lib/scanner.ts";
22
+ import { scanDirectory, scanFolderStructure } from "../lib/scanner.ts";
23
23
  import type {
24
24
  FileMoveProposal,
25
25
  MoveResult,
@@ -276,6 +276,21 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
276
276
  `Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
277
277
  );
278
278
 
279
+ // Scan existing folder structure in target directory
280
+ let existingFolders: string[] = [];
281
+ try {
282
+ existingFolders = await scanFolderStructure(targetPath, {
283
+ maxDepth: 3,
284
+ includeEmpty: false,
285
+ ignore: config.ignore,
286
+ });
287
+ if (existingFolders.length > 0) {
288
+ p.log.info(`Found ${color.bold(String(existingFolders.length))} existing folders in target`);
289
+ }
290
+ } catch {
291
+ // Target directory might not exist yet - OK
292
+ }
293
+
279
294
  // Analyze with AI
280
295
  spinner.start("Analyzing files with AI...");
281
296
 
@@ -285,6 +300,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
285
300
  files,
286
301
  targetDir: targetPath,
287
302
  model: parseModelString(options.model),
303
+ existingFolders,
288
304
  });
289
305
  spinner.stop("Analysis complete");
290
306
  } catch (error: any) {
@@ -383,6 +399,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
383
399
  targetDir: targetPath,
384
400
  instructions: newInstructions || undefined,
385
401
  model: parseModelString(options.model),
402
+ existingFolders,
386
403
  });
387
404
  spinner.stop("Analysis complete");
388
405
  displayAllProposals(proposal);
@@ -416,6 +433,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
416
433
  targetDir: targetPath,
417
434
  instructions: newInstructions || undefined,
418
435
  model: pickedModel,
436
+ existingFolders,
419
437
  });
420
438
  spinner.stop("Analysis complete");
421
439
  displayAllProposals(proposal);
@@ -11,7 +11,7 @@ import {
11
11
  resolveConfig,
12
12
  } from "../lib/config.ts";
13
13
  import { analyzeFiles, cleanup } from "../lib/opencode.ts";
14
- import { getFileMetadata } from "../lib/scanner.ts";
14
+ import { getFileMetadata, scanFolderStructure } from "../lib/scanner.ts";
15
15
  import { createWatcher, type FileWatcher } from "../lib/watcher.ts";
16
16
  import type {
17
17
  FileMetadata,
@@ -23,6 +23,41 @@ import type {
23
23
  import { formatFileSize, moveFile } from "../utils/files.ts";
24
24
  import { getCategoryIcon, getFileIcon } from "../utils/icons.ts";
25
25
 
26
+ // Folder cache for watch mode to avoid re-scanning on every file event
27
+ let folderCache: string[] = [];
28
+ let lastFolderScan = 0;
29
+ const FOLDER_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
30
+
31
+ /**
32
+ * Get existing folders with caching
33
+ */
34
+ async function getExistingFolders(
35
+ targetPath: string,
36
+ ignore: string[] = [],
37
+ ): Promise<string[]> {
38
+ const now = Date.now();
39
+ if (now - lastFolderScan > FOLDER_CACHE_TTL) {
40
+ try {
41
+ folderCache = await scanFolderStructure(targetPath, {
42
+ maxDepth: 3,
43
+ includeEmpty: false,
44
+ ignore,
45
+ });
46
+ lastFolderScan = now;
47
+ } catch {
48
+ // Target directory might not exist yet - use cached or empty
49
+ }
50
+ }
51
+ return folderCache;
52
+ }
53
+
54
+ /**
55
+ * Invalidate folder cache (call after successful file moves)
56
+ */
57
+ function invalidateFolderCache(): void {
58
+ lastFolderScan = 0;
59
+ }
60
+
26
61
  /**
27
62
  * Display a proposal briefly for watch mode
28
63
  */
@@ -57,6 +92,11 @@ async function executeProposalsSilent(
57
92
  }
58
93
  }
59
94
 
95
+ // Invalidate folder cache since new folders may have been created
96
+ if (success > 0) {
97
+ invalidateFolderCache();
98
+ }
99
+
60
100
  return { success, failed };
61
101
  }
62
102
 
@@ -207,10 +247,12 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
207
247
 
208
248
  let proposal: OrganizationProposal;
209
249
  try {
250
+ const existingFolders = await getExistingFolders(targetPath, config.ignore);
210
251
  proposal = await analyzeFiles({
211
252
  files,
212
253
  targetDir: targetPath,
213
254
  model: parseModelString(options.model),
255
+ existingFolders,
214
256
  });
215
257
  s.stop("Analysis complete");
216
258
  } catch (error: any) {
@@ -276,10 +318,12 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
276
318
  s.start("Analyzing queued files...");
277
319
 
278
320
  try {
321
+ const existingFolders = await getExistingFolders(targetPath, config.ignore);
279
322
  const proposal = await analyzeFiles({
280
323
  files: reviewQueue,
281
324
  targetDir: targetPath,
282
325
  model: parseModelString(options.model),
326
+ existingFolders,
283
327
  });
284
328
  s.stop("Analysis complete");
285
329
 
package/src/lib/config.ts CHANGED
@@ -94,6 +94,20 @@ You are an AI assistant that organizes files from a download folder. Analyze eac
94
94
  5. Installer files go to Applications/Installers
95
95
  6. Compressed files stay as Archives unless clearly part of another category
96
96
 
97
+ ## Existing Folder Preferences
98
+
99
+ When existing folders are provided in the prompt:
100
+
101
+ 1. **STRONGLY PREFER existing folders** - Use them when the file fits the category
102
+ 2. **Match naming conventions** - If "Screenshots" exists, don't create "Screen Captures"
103
+ 3. **Extend existing hierarchy** - OK to create subfolders under existing folders
104
+ 4. **Only create new top-level folders** when no existing category applies
105
+
106
+ Examples:
107
+ - If "Images/Screenshots" exists, use it for screenshot files
108
+ - If "Documents/Work" exists but no "Reports" subfolder, create "Documents/Work/Reports"
109
+ - If only "Documents" exists, prefer "Documents/Receipts" over top-level "Receipts"
110
+
97
111
  ## Output Format
98
112
 
99
113
  Return JSON with this exact structure:
@@ -169,6 +169,8 @@ export interface AnalyzeFilesOptions {
169
169
  instructions?: string;
170
170
  /** Model override */
171
171
  model?: ModelSelection;
172
+ /** Existing folders in target directory for consistency */
173
+ existingFolders?: string[];
172
174
  }
173
175
 
174
176
  /**
@@ -311,7 +313,7 @@ export async function checkConflicts(
311
313
  export async function analyzeFiles(
312
314
  options: AnalyzeFilesOptions,
313
315
  ): Promise<OrganizationProposal> {
314
- const { files, targetDir, instructions, model } = options;
316
+ const { files, targetDir, instructions, model, existingFolders } = options;
315
317
 
316
318
  if (files.length === 0) {
317
319
  return {
@@ -332,11 +334,21 @@ export async function analyzeFiles(
332
334
  // Build the prompt
333
335
  const filesJson = formatFilesForPrompt(files);
334
336
 
337
+ // Build existing folders section if available
338
+ const existingFoldersSection = existingFolders?.length
339
+ ? `
340
+ EXISTING FOLDERS in target directory:
341
+ ${existingFolders.join("\n")}
342
+
343
+ IMPORTANT: Prefer using these existing folders when appropriate. Only create new folders when no suitable existing folder matches the file's category.
344
+ `
345
+ : "";
346
+
335
347
  const userPrompt = `
336
348
  Analyze the following files and organize them according to the rules.
337
349
 
338
350
  Target directory: ${targetDir}
339
-
351
+ ${existingFoldersSection}
340
352
  ${instructions ? `Additional instructions: ${instructions}\n` : ""}
341
353
  Files to organize:
342
354
  ${filesJson}
@@ -3,12 +3,114 @@
3
3
  */
4
4
 
5
5
  import { readdir, stat, readFile } from "fs/promises";
6
- import { join, extname, basename } from "path";
6
+ import { join, extname, basename, relative } from "path";
7
7
  import { lookup as lookupMimeType } from "mime-types";
8
8
  import type { FileMetadata } from "../types/organizer.ts";
9
9
  import { shouldIgnore } from "./config.ts";
10
10
  import { isDirectory, isFile } from "../utils/files.ts";
11
11
 
12
+ /**
13
+ * Options for scanning folder structure
14
+ */
15
+ export interface ScanFolderOptions {
16
+ /** Maximum depth to scan (default: 3) */
17
+ maxDepth?: number;
18
+ /** Include empty folders (default: false) */
19
+ includeEmpty?: boolean;
20
+ /** Patterns to ignore */
21
+ ignore?: string[];
22
+ /** Maximum number of folders to return (default: 100) */
23
+ maxFolders?: number;
24
+ }
25
+
26
+ /**
27
+ * Scan a directory and return existing folder structure as relative paths
28
+ * Used to inform AI about existing organization
29
+ */
30
+ export async function scanFolderStructure(
31
+ dirPath: string,
32
+ options: ScanFolderOptions = {}
33
+ ): Promise<string[]> {
34
+ const {
35
+ maxDepth = 3,
36
+ includeEmpty = false,
37
+ ignore = [],
38
+ maxFolders = 100,
39
+ } = options;
40
+
41
+ const folders: string[] = [];
42
+
43
+ await scanFoldersInternal(dirPath, dirPath, 0, maxDepth, includeEmpty, ignore, folders, maxFolders);
44
+
45
+ // Sort by depth (shallower first) then alphabetically
46
+ folders.sort((a, b) => {
47
+ const depthA = a.split("/").length;
48
+ const depthB = b.split("/").length;
49
+ if (depthA !== depthB) return depthA - depthB;
50
+ return a.localeCompare(b);
51
+ });
52
+
53
+ return folders.slice(0, maxFolders);
54
+ }
55
+
56
+ async function scanFoldersInternal(
57
+ basePath: string,
58
+ currentPath: string,
59
+ currentDepth: number,
60
+ maxDepth: number,
61
+ includeEmpty: boolean,
62
+ ignore: string[],
63
+ folders: string[],
64
+ maxFolders: number
65
+ ): Promise<void> {
66
+ if (currentDepth >= maxDepth || folders.length >= maxFolders) {
67
+ return;
68
+ }
69
+
70
+ try {
71
+ const entries = await readdir(currentPath, { withFileTypes: true });
72
+
73
+ for (const entry of entries) {
74
+ if (folders.length >= maxFolders) break;
75
+
76
+ if (!entry.isDirectory()) continue;
77
+
78
+ // Skip hidden folders and ignored patterns
79
+ if (entry.name.startsWith(".")) continue;
80
+ if (shouldIgnore(entry.name, ignore)) continue;
81
+
82
+ const fullPath = join(currentPath, entry.name);
83
+ const relativePath = relative(basePath, fullPath);
84
+
85
+ // Check if folder has contents (if we care about empty folders)
86
+ if (!includeEmpty) {
87
+ try {
88
+ const contents = await readdir(fullPath);
89
+ if (contents.length === 0) continue;
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+
95
+ folders.push(relativePath);
96
+
97
+ // Recursively scan subdirectories
98
+ await scanFoldersInternal(
99
+ basePath,
100
+ fullPath,
101
+ currentDepth + 1,
102
+ maxDepth,
103
+ includeEmpty,
104
+ ignore,
105
+ folders,
106
+ maxFolders
107
+ );
108
+ }
109
+ } catch {
110
+ // Silently skip directories we can't read
111
+ }
112
+ }
113
+
12
114
  /**
13
115
  * Options for scanning a directory
14
116
  */