tidyf 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.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/cli.js +19340 -0
- package/dist/fsevents-hj42pnne.node +0 -0
- package/dist/index.js +17617 -0
- package/package.json +58 -0
- package/src/cli.ts +63 -0
- package/src/commands/config.ts +630 -0
- package/src/commands/organize.ts +396 -0
- package/src/commands/watch.ts +302 -0
- package/src/index.ts +93 -0
- package/src/lib/config.ts +335 -0
- package/src/lib/opencode.ts +380 -0
- package/src/lib/scanner.ts +296 -0
- package/src/lib/watcher.ts +151 -0
- package/src/types/config.ts +69 -0
- package/src/types/organizer.ts +144 -0
- package/src/utils/files.ts +198 -0
- package/src/utils/icons.ts +195 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tidy - AI-powered file organizer
|
|
3
|
+
*
|
|
4
|
+
* Library exports for programmatic use
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export type {
|
|
9
|
+
FileMetadata,
|
|
10
|
+
FileCategory,
|
|
11
|
+
FileMoveProposal,
|
|
12
|
+
OrganizationProposal,
|
|
13
|
+
OrganizeOptions,
|
|
14
|
+
WatchOptions,
|
|
15
|
+
WatchEvent,
|
|
16
|
+
MoveStatus,
|
|
17
|
+
MoveResult,
|
|
18
|
+
} from "./types/organizer.ts";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
ModelSelection,
|
|
22
|
+
FolderRule,
|
|
23
|
+
CategoryRule,
|
|
24
|
+
TidyConfig,
|
|
25
|
+
ConfigOptions,
|
|
26
|
+
} from "./types/config.ts";
|
|
27
|
+
|
|
28
|
+
// Config
|
|
29
|
+
export {
|
|
30
|
+
resolveConfig,
|
|
31
|
+
getGlobalConfigPath,
|
|
32
|
+
getLocalConfigPath,
|
|
33
|
+
getGlobalRulesPath,
|
|
34
|
+
getLocalRulesPath,
|
|
35
|
+
readConfig,
|
|
36
|
+
writeConfig,
|
|
37
|
+
getRulesPrompt,
|
|
38
|
+
parseModelString,
|
|
39
|
+
expandPath,
|
|
40
|
+
shouldIgnore,
|
|
41
|
+
initGlobalConfig,
|
|
42
|
+
} from "./lib/config.ts";
|
|
43
|
+
|
|
44
|
+
// Scanner
|
|
45
|
+
export {
|
|
46
|
+
scanDirectory,
|
|
47
|
+
getFileMetadata,
|
|
48
|
+
getFileCategory,
|
|
49
|
+
groupFilesByCategory,
|
|
50
|
+
type ScanOptions,
|
|
51
|
+
} from "./lib/scanner.ts";
|
|
52
|
+
|
|
53
|
+
// File utilities
|
|
54
|
+
export {
|
|
55
|
+
moveFile,
|
|
56
|
+
fileExists,
|
|
57
|
+
ensureDirectory,
|
|
58
|
+
generateUniqueName,
|
|
59
|
+
resolveConflict,
|
|
60
|
+
formatFileSize,
|
|
61
|
+
isDirectory,
|
|
62
|
+
isFile,
|
|
63
|
+
type ConflictStrategy,
|
|
64
|
+
} from "./utils/files.ts";
|
|
65
|
+
|
|
66
|
+
// Icons
|
|
67
|
+
export {
|
|
68
|
+
getFileIcon,
|
|
69
|
+
getCategoryIcon,
|
|
70
|
+
getStatusIcon,
|
|
71
|
+
getStatusIndicator,
|
|
72
|
+
} from "./utils/icons.ts";
|
|
73
|
+
|
|
74
|
+
// AI
|
|
75
|
+
export {
|
|
76
|
+
analyzeFiles,
|
|
77
|
+
checkConflicts,
|
|
78
|
+
getAvailableModels,
|
|
79
|
+
cleanup,
|
|
80
|
+
type AnalyzeFilesOptions,
|
|
81
|
+
} from "./lib/opencode.ts";
|
|
82
|
+
|
|
83
|
+
// Watcher
|
|
84
|
+
export {
|
|
85
|
+
FileWatcher,
|
|
86
|
+
createWatcher,
|
|
87
|
+
type WatcherOptions,
|
|
88
|
+
} from "./lib/watcher.ts";
|
|
89
|
+
|
|
90
|
+
// Commands (for programmatic use)
|
|
91
|
+
export { organizeCommand } from "./commands/organize.ts";
|
|
92
|
+
export { watchCommand } from "./commands/watch.ts";
|
|
93
|
+
export { configCommand } from "./commands/config.ts";
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration file management for tidy
|
|
3
|
+
*
|
|
4
|
+
* Manages ~/.tidy/ (global) and .tidy/ (local) configuration
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import type { TidyConfig, ModelSelection } from "../types/config.ts";
|
|
11
|
+
|
|
12
|
+
const CONFIG_DIR = ".tidy";
|
|
13
|
+
const SETTINGS_FILE = "settings.json";
|
|
14
|
+
const RULES_FILE = "rules.md";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MODEL: ModelSelection = {
|
|
17
|
+
provider: "opencode",
|
|
18
|
+
model: "claude-sonnet-4-5",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONFIG: TidyConfig = {
|
|
22
|
+
organizer: DEFAULT_MODEL,
|
|
23
|
+
defaultSource: "~/Downloads",
|
|
24
|
+
defaultTarget: "~/Documents/Organized",
|
|
25
|
+
watchEnabled: false,
|
|
26
|
+
folders: [
|
|
27
|
+
{
|
|
28
|
+
sources: ["~/Downloads"],
|
|
29
|
+
target: "~/Documents/Organized",
|
|
30
|
+
watch: false,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
ignore: [
|
|
34
|
+
".DS_Store",
|
|
35
|
+
"*.tmp",
|
|
36
|
+
"*.partial",
|
|
37
|
+
"*.crdownload",
|
|
38
|
+
"*.download",
|
|
39
|
+
"desktop.ini",
|
|
40
|
+
"Thumbs.db",
|
|
41
|
+
],
|
|
42
|
+
readContent: false,
|
|
43
|
+
maxContentSize: 10240, // 10KB
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DEFAULT_RULES = `# File Organization Rules
|
|
47
|
+
|
|
48
|
+
You are an AI assistant that organizes files from a download folder. Analyze each file and categorize it appropriately.
|
|
49
|
+
|
|
50
|
+
## Categories
|
|
51
|
+
|
|
52
|
+
### Documents
|
|
53
|
+
- PDFs, Word docs, text files, spreadsheets
|
|
54
|
+
- Subcategorize by: Work, Personal, Receipts, Manuals, Ebooks
|
|
55
|
+
|
|
56
|
+
### Images
|
|
57
|
+
- Photos, screenshots, graphics, icons
|
|
58
|
+
- Subcategorize by: Photos, Screenshots, Design, Icons
|
|
59
|
+
|
|
60
|
+
### Videos
|
|
61
|
+
- MP4, MOV, AVI, MKV, WEBM
|
|
62
|
+
- Subcategorize by: Movies, Clips, Tutorials
|
|
63
|
+
|
|
64
|
+
### Audio
|
|
65
|
+
- MP3, WAV, FLAC, AAC, OGG
|
|
66
|
+
- Subcategorize by: Music, Podcasts, Recordings
|
|
67
|
+
|
|
68
|
+
### Archives
|
|
69
|
+
- ZIP, RAR, 7Z, TAR, GZ
|
|
70
|
+
- Keep in Archives folder, possibly extract
|
|
71
|
+
|
|
72
|
+
### Code & Projects
|
|
73
|
+
- Source code files, project archives
|
|
74
|
+
- Keep related files together
|
|
75
|
+
- Detect project names from filenames
|
|
76
|
+
|
|
77
|
+
### Applications
|
|
78
|
+
- DMG, PKG, EXE, APP files
|
|
79
|
+
- Keep in Installers folder
|
|
80
|
+
|
|
81
|
+
## Organization Strategy
|
|
82
|
+
|
|
83
|
+
1. **Primary sort by file type** - Use extension and MIME type
|
|
84
|
+
2. **Secondary sort by context** - Detect from filename patterns
|
|
85
|
+
3. **Date-based subfolders** - For large collections (photos, screenshots)
|
|
86
|
+
4. **Keep related files together** - Same base name, different extensions
|
|
87
|
+
|
|
88
|
+
## Special Rules
|
|
89
|
+
|
|
90
|
+
1. Files with dates in name (2024-01-15, Jan2024) group by month
|
|
91
|
+
2. Receipts/invoices go to Documents/Receipts
|
|
92
|
+
3. Screenshots go to Images/Screenshots
|
|
93
|
+
4. Keep files with same base name together (report.pdf, report.docx)
|
|
94
|
+
5. Installer files go to Applications/Installers
|
|
95
|
+
6. Compressed files stay as Archives unless clearly part of another category
|
|
96
|
+
|
|
97
|
+
## Output Format
|
|
98
|
+
|
|
99
|
+
Return JSON with this exact structure:
|
|
100
|
+
\`\`\`json
|
|
101
|
+
{
|
|
102
|
+
"proposals": [
|
|
103
|
+
{
|
|
104
|
+
"file": "original-filename.pdf",
|
|
105
|
+
"destination": "Documents/Work/Reports",
|
|
106
|
+
"category": {
|
|
107
|
+
"name": "Documents",
|
|
108
|
+
"subcategory": "Work/Reports",
|
|
109
|
+
"suggestedPath": "Documents/Work/Reports",
|
|
110
|
+
"confidence": 0.95,
|
|
111
|
+
"reasoning": "PDF file with report-like naming pattern"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"strategy": "Brief explanation of overall approach",
|
|
116
|
+
"uncategorized": ["files that couldn't be categorized"]
|
|
117
|
+
}
|
|
118
|
+
\`\`\`
|
|
119
|
+
|
|
120
|
+
## Important
|
|
121
|
+
|
|
122
|
+
- Every file from the input MUST appear in either proposals or uncategorized
|
|
123
|
+
- Return ONLY the JSON object, no markdown code blocks or explanations
|
|
124
|
+
- Use forward slashes for paths
|
|
125
|
+
- Destination should be relative to the target directory
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the global config directory path
|
|
130
|
+
*/
|
|
131
|
+
export function getGlobalConfigDir(): string {
|
|
132
|
+
return join(homedir(), CONFIG_DIR);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the global config file path
|
|
137
|
+
*/
|
|
138
|
+
export function getGlobalConfigPath(): string {
|
|
139
|
+
return join(getGlobalConfigDir(), SETTINGS_FILE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get the global rules file path
|
|
144
|
+
*/
|
|
145
|
+
export function getGlobalRulesPath(): string {
|
|
146
|
+
return join(getGlobalConfigDir(), RULES_FILE);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get the local config directory path
|
|
151
|
+
*/
|
|
152
|
+
export function getLocalConfigDir(basePath: string = process.cwd()): string {
|
|
153
|
+
return join(basePath, CONFIG_DIR);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get the local config file path
|
|
158
|
+
*/
|
|
159
|
+
export function getLocalConfigPath(basePath: string = process.cwd()): string {
|
|
160
|
+
return join(getLocalConfigDir(basePath), SETTINGS_FILE);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get the local rules file path
|
|
165
|
+
*/
|
|
166
|
+
export function getLocalRulesPath(basePath: string = process.cwd()): string {
|
|
167
|
+
return join(getLocalConfigDir(basePath), RULES_FILE);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Ensure config directory exists
|
|
172
|
+
*/
|
|
173
|
+
export function ensureConfigDir(path: string): void {
|
|
174
|
+
if (!existsSync(path)) {
|
|
175
|
+
mkdirSync(path, { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read config from a file
|
|
181
|
+
*/
|
|
182
|
+
export function readConfig(path: string): TidyConfig {
|
|
183
|
+
if (!existsSync(path)) {
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const content = readFileSync(path, "utf-8");
|
|
188
|
+
return JSON.parse(content);
|
|
189
|
+
} catch {
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Write config to a file
|
|
196
|
+
*/
|
|
197
|
+
export function writeConfig(path: string, config: TidyConfig): void {
|
|
198
|
+
const dir = dirname(path);
|
|
199
|
+
ensureConfigDir(dir);
|
|
200
|
+
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read rules from a file
|
|
205
|
+
*/
|
|
206
|
+
export function readRules(path: string): string | null {
|
|
207
|
+
if (!existsSync(path)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
return readFileSync(path, "utf-8");
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Write rules to a file
|
|
219
|
+
*/
|
|
220
|
+
export function writeRules(path: string, rules: string): void {
|
|
221
|
+
const dir = dirname(path);
|
|
222
|
+
ensureConfigDir(dir);
|
|
223
|
+
writeFileSync(path, rules, "utf-8");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Initialize global config with defaults if it doesn't exist
|
|
228
|
+
*/
|
|
229
|
+
export function initGlobalConfig(): void {
|
|
230
|
+
const configPath = getGlobalConfigPath();
|
|
231
|
+
const rulesPath = getGlobalRulesPath();
|
|
232
|
+
|
|
233
|
+
if (!existsSync(configPath)) {
|
|
234
|
+
writeConfig(configPath, DEFAULT_CONFIG);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!existsSync(rulesPath)) {
|
|
238
|
+
writeRules(rulesPath, DEFAULT_RULES);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolve the effective configuration by merging global and local configs
|
|
244
|
+
*/
|
|
245
|
+
export function resolveConfig(basePath: string = process.cwd()): TidyConfig {
|
|
246
|
+
// Start with defaults
|
|
247
|
+
const config: TidyConfig = { ...DEFAULT_CONFIG };
|
|
248
|
+
|
|
249
|
+
// Merge global config
|
|
250
|
+
const globalConfig = readConfig(getGlobalConfigPath());
|
|
251
|
+
Object.assign(config, globalConfig);
|
|
252
|
+
|
|
253
|
+
// Merge local config (takes precedence)
|
|
254
|
+
const localConfig = readConfig(getLocalConfigPath(basePath));
|
|
255
|
+
Object.assign(config, localConfig);
|
|
256
|
+
|
|
257
|
+
return config;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get the rules prompt by merging global and local rules
|
|
262
|
+
*/
|
|
263
|
+
export function getRulesPrompt(basePath: string = process.cwd()): string {
|
|
264
|
+
// Try local rules first
|
|
265
|
+
const localRules = readRules(getLocalRulesPath(basePath));
|
|
266
|
+
if (localRules) {
|
|
267
|
+
return localRules;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Fall back to global rules
|
|
271
|
+
const globalRules = readRules(getGlobalRulesPath());
|
|
272
|
+
if (globalRules) {
|
|
273
|
+
return globalRules;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Return default rules
|
|
277
|
+
return DEFAULT_RULES;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Parse a model string "provider/model" into a ModelSelection object
|
|
282
|
+
*/
|
|
283
|
+
export function parseModelString(
|
|
284
|
+
modelString?: string
|
|
285
|
+
): ModelSelection | undefined {
|
|
286
|
+
if (!modelString) return undefined;
|
|
287
|
+
const parts = modelString.split("/");
|
|
288
|
+
if (parts.length < 2) return undefined;
|
|
289
|
+
return {
|
|
290
|
+
provider: parts[0],
|
|
291
|
+
model: parts.slice(1).join("/"),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Expand ~ to home directory
|
|
297
|
+
*/
|
|
298
|
+
export function expandPath(path: string): string {
|
|
299
|
+
if (path.startsWith("~/")) {
|
|
300
|
+
return join(homedir(), path.slice(2));
|
|
301
|
+
}
|
|
302
|
+
return path;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Check if a file should be ignored based on patterns
|
|
307
|
+
*/
|
|
308
|
+
export function shouldIgnore(filename: string, patterns: string[]): boolean {
|
|
309
|
+
for (const pattern of patterns) {
|
|
310
|
+
// Simple glob matching for common patterns
|
|
311
|
+
if (pattern.startsWith("*.")) {
|
|
312
|
+
const ext = pattern.slice(1);
|
|
313
|
+
if (filename.endsWith(ext)) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
} else if (filename === pattern) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Get default config
|
|
325
|
+
*/
|
|
326
|
+
export function getDefaultConfig(): TidyConfig {
|
|
327
|
+
return { ...DEFAULT_CONFIG };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get default rules
|
|
332
|
+
*/
|
|
333
|
+
export function getDefaultRules(): string {
|
|
334
|
+
return DEFAULT_RULES;
|
|
335
|
+
}
|