tidyf 1.0.3 → 1.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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Undo command - revert file organization operations
3
+ */
4
+
5
+ import * as p from "@clack/prompts";
6
+ import { existsSync } from "fs";
7
+ import { rename, mkdir } from "fs/promises";
8
+ import { dirname } from "path";
9
+ import color from "picocolors";
10
+ import { initGlobalConfig } from "../lib/config.ts";
11
+ import { cleanup } from "../lib/opencode.ts";
12
+ import {
13
+ deleteHistoryEntry,
14
+ getHistoryEntry,
15
+ getRecentHistory,
16
+ type HistoryEntry,
17
+ } from "../lib/history.ts";
18
+
19
+ /**
20
+ * Select a history entry to undo
21
+ */
22
+ async function selectHistoryEntry(): Promise<HistoryEntry | null> {
23
+ const history = getRecentHistory();
24
+
25
+ if (history.length === 0) {
26
+ p.log.warn("No history to undo");
27
+ return null;
28
+ }
29
+
30
+ const options = history.map((entry) => ({
31
+ value: entry.id,
32
+ label: `${new Date(entry.timestamp).toLocaleString()}`,
33
+ hint: `${entry.moves.length} files moved`,
34
+ }));
35
+
36
+ const selectedId = await p.select({
37
+ message: "Which operation to undo?",
38
+ options,
39
+ });
40
+
41
+ if (p.isCancel(selectedId)) {
42
+ return null;
43
+ }
44
+
45
+ return getHistoryEntry(selectedId as string);
46
+ }
47
+
48
+ /**
49
+ * Undo a file move
50
+ */
51
+ async function undoMove(source: string, destination: string): Promise<boolean> {
52
+ try {
53
+ // Ensure source directory exists (in case it was deleted)
54
+ await mkdir(dirname(source), { recursive: true });
55
+
56
+ await rename(destination, source);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Main undo command
65
+ */
66
+ export async function undoCommand(): Promise<void> {
67
+ p.intro(color.bgRed(color.black(" tidyf undo ")));
68
+
69
+ initGlobalConfig();
70
+
71
+ const entry = await selectHistoryEntry();
72
+
73
+ if (!entry) {
74
+ p.outro(color.yellow("Nothing to undo"));
75
+ cleanup();
76
+ return;
77
+ }
78
+
79
+ console.log();
80
+ p.log.info(color.bold("Operation details:"));
81
+ p.log.message(` Timestamp: ${new Date(entry.timestamp).toLocaleString()}`);
82
+ p.log.message(` Source: ${entry.source}`);
83
+ p.log.message(` Target: ${entry.target}`);
84
+ p.log.message(` Files moved: ${entry.moves.length}`);
85
+
86
+ const confirm = await p.confirm({
87
+ message: `Undo this operation? This will move ${entry.moves.length} files back to ${entry.source}`,
88
+ initialValue: false,
89
+ });
90
+
91
+ if (p.isCancel(confirm) || !confirm) {
92
+ p.outro(color.yellow("Cancelled"));
93
+ cleanup();
94
+ return;
95
+ }
96
+
97
+ const s = p.spinner();
98
+ s.start("Undoing file moves...");
99
+
100
+ let successCount = 0;
101
+ let failCount = 0;
102
+
103
+ for (const move of entry.moves) {
104
+ const destExists = existsSync(move.destination);
105
+
106
+ if (!destExists) {
107
+ p.log.warn(` Skipped: ${move.destination} (already moved/deleted)`);
108
+ failCount++;
109
+ continue;
110
+ }
111
+
112
+ if (await undoMove(move.source, move.destination)) {
113
+ successCount++;
114
+ } else {
115
+ failCount++;
116
+ }
117
+ }
118
+
119
+ s.stop("Undo complete");
120
+
121
+ p.log.success(`\nMoved ${successCount} files back to ${color.cyan(entry.source)}`);
122
+
123
+ if (failCount > 0) {
124
+ p.log.warn(`${failCount} files could not be moved`);
125
+ }
126
+
127
+ const deleteFromHistory = await p.confirm({
128
+ message: "Remove this operation from history?",
129
+ initialValue: true,
130
+ });
131
+
132
+ if (p.isCancel(deleteFromHistory) || deleteFromHistory) {
133
+ deleteHistoryEntry(entry.id);
134
+ p.log.success("Removed from history");
135
+ }
136
+
137
+ p.outro(color.green("Done!"));
138
+ cleanup();
139
+ }
@@ -9,8 +9,10 @@ import {
9
9
  initGlobalConfig,
10
10
  parseModelString,
11
11
  resolveConfig,
12
+ resolveConfigWithProfile,
12
13
  } from "../lib/config.ts";
13
14
  import { analyzeFiles, cleanup } from "../lib/opencode.ts";
15
+ import { listProfiles, profileExists } from "../lib/profiles.ts";
14
16
  import { getFileMetadata, scanFolderStructure } from "../lib/scanner.ts";
15
17
  import { createWatcher, type FileWatcher } from "../lib/watcher.ts";
16
18
  import type {
@@ -152,8 +154,26 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
152
154
  // Initialize global config if needed
153
155
  initGlobalConfig();
154
156
 
155
- // Resolve configuration
156
- const config = resolveConfig();
157
+ // Validate and resolve profile if specified
158
+ if (options.profile) {
159
+ if (!profileExists(options.profile)) {
160
+ p.log.error(`Profile "${options.profile}" not found`);
161
+ const profiles = listProfiles();
162
+ if (profiles.length > 0) {
163
+ p.log.info("Available profiles:");
164
+ profiles.forEach((pr) => p.log.message(` - ${pr.name}`));
165
+ }
166
+ p.outro("Canceled");
167
+ cleanup();
168
+ process.exit(1);
169
+ }
170
+ p.log.info(`Profile: ${color.cyan(options.profile)}`);
171
+ }
172
+
173
+ // Resolve configuration (with profile if specified)
174
+ const config = options.profile
175
+ ? resolveConfigWithProfile(options.profile)
176
+ : resolveConfig();
157
177
 
158
178
  // Determine paths to watch
159
179
  let watchPaths: string[];
@@ -253,6 +273,7 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
253
273
  targetDir: targetPath,
254
274
  model: parseModelString(options.model),
255
275
  existingFolders,
276
+ profileName: options.profile,
256
277
  });
257
278
  s.stop("Analysis complete");
258
279
  } catch (error: any) {
@@ -324,6 +345,7 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
324
345
  targetDir: targetPath,
325
346
  model: parseModelString(options.model),
326
347
  existingFolders,
348
+ profileName: options.profile,
327
349
  });
328
350
  s.stop("Analysis complete");
329
351
 
package/src/lib/config.ts CHANGED
@@ -8,6 +8,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
8
  import { join, dirname } from "path";
9
9
  import { homedir } from "os";
10
10
  import type { TidyConfig, ModelSelection } from "../types/config.ts";
11
+ import {
12
+ profileExists,
13
+ readProfile,
14
+ readProfileRules,
15
+ getProfileConfigFields,
16
+ } from "./profiles.ts";
11
17
 
12
18
  const CONFIG_DIR = ".tidy";
13
19
  const SETTINGS_FILE = "settings.json";
@@ -347,3 +353,66 @@ export function getDefaultConfig(): TidyConfig {
347
353
  export function getDefaultRules(): string {
348
354
  return DEFAULT_RULES;
349
355
  }
356
+
357
+ /**
358
+ * Resolve the effective configuration with profile support
359
+ * Resolution order: defaults → global → profile → local
360
+ */
361
+ export function resolveConfigWithProfile(
362
+ profileName?: string,
363
+ basePath: string = process.cwd(),
364
+ ): TidyConfig {
365
+ // Start with defaults
366
+ const config: TidyConfig = { ...DEFAULT_CONFIG };
367
+
368
+ // Merge global config
369
+ const globalConfig = readConfig(getGlobalConfigPath());
370
+ Object.assign(config, globalConfig);
371
+
372
+ // Merge profile config (if specified and exists)
373
+ if (profileName && profileExists(profileName)) {
374
+ const profile = readProfile(profileName);
375
+ if (profile) {
376
+ const profileConfig = getProfileConfigFields(profile);
377
+ Object.assign(config, profileConfig);
378
+ }
379
+ }
380
+
381
+ // Merge local config (takes precedence)
382
+ const localConfig = readConfig(getLocalConfigPath(basePath));
383
+ Object.assign(config, localConfig);
384
+
385
+ return config;
386
+ }
387
+
388
+ /**
389
+ * Get the rules prompt with profile support
390
+ * Resolution order: local → profile → global → default
391
+ */
392
+ export function getRulesPromptWithProfile(
393
+ profileName?: string,
394
+ basePath: string = process.cwd(),
395
+ ): string {
396
+ // Try local rules first (always highest priority)
397
+ const localRules = readRules(getLocalRulesPath(basePath));
398
+ if (localRules) {
399
+ return localRules;
400
+ }
401
+
402
+ // Try profile rules (if specified and exists)
403
+ if (profileName && profileExists(profileName)) {
404
+ const profileRules = readProfileRules(profileName);
405
+ if (profileRules) {
406
+ return profileRules;
407
+ }
408
+ }
409
+
410
+ // Fall back to global rules
411
+ const globalRules = readRules(getGlobalRulesPath());
412
+ if (globalRules) {
413
+ return globalRules;
414
+ }
415
+
416
+ // Return default rules
417
+ return DEFAULT_RULES;
418
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * History management for file operations
3
+ *
4
+ * Logs file moves to ~/.tidy/history.json for undo functionality
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { homedir } from "os";
10
+ import { ensureDirectory } from "../utils/files.ts";
11
+
12
+ const HISTORY_DIR = ".tidy";
13
+ const HISTORY_FILE = "history.json";
14
+
15
+ /**
16
+ * Get the history file path
17
+ */
18
+ function getHistoryPath(): string {
19
+ return join(homedir(), HISTORY_DIR, HISTORY_FILE);
20
+ }
21
+
22
+ /**
23
+ * History entry for a file operation
24
+ */
25
+ export interface HistoryEntry {
26
+ /** Unique ID for this operation */
27
+ id: string;
28
+ /** Timestamp of operation */
29
+ timestamp: string;
30
+ /** Source directory */
31
+ source: string;
32
+ /** Target directory */
33
+ target: string;
34
+ /** Files that were moved */
35
+ moves: Array<{
36
+ source: string;
37
+ destination: string;
38
+ timestamp: string;
39
+ }>;
40
+ }
41
+
42
+ /**
43
+ * Read history from file
44
+ */
45
+ export function readHistory(): HistoryEntry[] {
46
+ const historyPath = getHistoryPath();
47
+
48
+ if (!existsSync(historyPath)) {
49
+ return [];
50
+ }
51
+
52
+ try {
53
+ const content = readFileSync(historyPath, "utf-8");
54
+ return JSON.parse(content);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Write history to file
62
+ */
63
+ function writeHistory(history: HistoryEntry[]): void {
64
+ const historyPath = getHistoryPath();
65
+ ensureDirectory(dirname(historyPath));
66
+ writeFileSync(historyPath, JSON.stringify(history, null, 2), "utf-8");
67
+ }
68
+
69
+ /**
70
+ * Create a new history entry
71
+ */
72
+ export function createHistoryEntry(
73
+ source: string,
74
+ target: string,
75
+ ): HistoryEntry {
76
+ return {
77
+ id: Date.now().toString(),
78
+ timestamp: new Date().toISOString(),
79
+ source,
80
+ target,
81
+ moves: [],
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Add a file move to history entry
87
+ */
88
+ export function addMoveToHistory(
89
+ entry: HistoryEntry,
90
+ source: string,
91
+ destination: string,
92
+ ): void {
93
+ entry.moves.push({
94
+ source,
95
+ destination,
96
+ timestamp: new Date().toISOString(),
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Save a history entry
102
+ */
103
+ export function saveHistoryEntry(entry: HistoryEntry): void {
104
+ const history = readHistory();
105
+ history.unshift(entry);
106
+ writeHistory(history);
107
+ }
108
+
109
+ /**
110
+ * Get history entry by ID
111
+ */
112
+ export function getHistoryEntry(id: string): HistoryEntry | null {
113
+ const history = readHistory();
114
+ return history.find((entry) => entry.id === id) || null;
115
+ }
116
+
117
+ /**
118
+ * Get the last N history entries
119
+ */
120
+ export function getRecentHistory(limit: number = 10): HistoryEntry[] {
121
+ const history = readHistory();
122
+ return history.slice(0, limit);
123
+ }
124
+
125
+ /**
126
+ * Delete history entry by ID
127
+ */
128
+ export function deleteHistoryEntry(id: string): void {
129
+ const history = readHistory();
130
+ const filtered = history.filter((entry) => entry.id !== id);
131
+ writeHistory(filtered);
132
+ }
133
+
134
+ /**
135
+ * Clear all history
136
+ */
137
+ export function clearHistory(): void {
138
+ writeHistory([]);
139
+ }
@@ -20,7 +20,11 @@ import type {
20
20
  OrganizationProposal,
21
21
  } from "../types/organizer.ts";
22
22
  import { fileExists } from "../utils/files.ts";
23
- import { expandPath, getRulesPrompt, resolveConfig } from "./config.ts";
23
+ import {
24
+ expandPath,
25
+ getRulesPromptWithProfile,
26
+ resolveConfigWithProfile,
27
+ } from "./config.ts";
24
28
 
25
29
  const execAsync = promisify(exec);
26
30
 
@@ -171,6 +175,8 @@ export interface AnalyzeFilesOptions {
171
175
  model?: ModelSelection;
172
176
  /** Existing folders in target directory for consistency */
173
177
  existingFolders?: string[];
178
+ /** Profile name to use for config and rules */
179
+ profileName?: string;
174
180
  }
175
181
 
176
182
  /**
@@ -313,7 +319,7 @@ export async function checkConflicts(
313
319
  export async function analyzeFiles(
314
320
  options: AnalyzeFilesOptions,
315
321
  ): Promise<OrganizationProposal> {
316
- const { files, targetDir, instructions, model, existingFolders } = options;
322
+ const { files, targetDir, instructions, model, existingFolders, profileName } = options;
317
323
 
318
324
  if (files.length === 0) {
319
325
  return {
@@ -327,9 +333,9 @@ export async function analyzeFiles(
327
333
  const opencodeClient = await getClient();
328
334
  const sid = await getSessionId();
329
335
 
330
- // Get configuration
331
- const config = resolveConfig();
332
- const rulesPrompt = getRulesPrompt();
336
+ // Get configuration with profile awareness
337
+ const config = resolveConfigWithProfile(profileName);
338
+ const rulesPrompt = getRulesPromptWithProfile(profileName);
333
339
 
334
340
  // Build the prompt
335
341
  const filesJson = formatFilesForPrompt(files);
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Built-in profile presets for common use cases
3
+ */
4
+
5
+ import type { Profile } from "../types/profile.ts";
6
+
7
+ export interface PresetDefinition {
8
+ name: string;
9
+ description: string;
10
+ profile: Omit<Profile, "name" | "createdAt" | "modifiedAt">;
11
+ rules: string;
12
+ }
13
+
14
+ export const PRESET_DEFINITIONS: PresetDefinition[] = [
15
+ {
16
+ name: "developer",
17
+ description: "Organize source code, configs, and project files",
18
+ profile: {
19
+ description: "Developer-focused organization for code and configs",
20
+ defaultTarget: "~/Documents/Dev",
21
+ },
22
+ rules: `# Developer Profile Rules
23
+
24
+ You are organizing files for a software developer. Focus on project structure and code organization.
25
+
26
+ ## Categories
27
+
28
+ ### Code
29
+ - Source files: .ts, .tsx, .js, .jsx, .py, .go, .rs, .java, .c, .cpp, .swift, .kt
30
+ - Subcategorize by language family: JavaScript, Python, Go, Rust, Swift, etc.
31
+
32
+ ### Config
33
+ - Configuration files: .json, .yaml, .yml, .toml, .env, .ini
34
+ - Subcategorize: Project Config, Editor Config, CI/CD
35
+
36
+ ### Documentation
37
+ - README, CHANGELOG, LICENSE, .md files
38
+ - API docs, design docs
39
+
40
+ ### Data
41
+ - Database files, SQL scripts, seed data
42
+ - JSON/CSV data files
43
+
44
+ ### Scripts
45
+ - Shell scripts, batch files, automation scripts
46
+ - Build scripts, deployment scripts
47
+
48
+ ## Strategy
49
+
50
+ 1. **Group by project** - If filename contains project identifiers, keep files together
51
+ 2. **Separate configs from code** - Configs go to their own folder
52
+ 3. **Archive old files** - Anything with "old", "backup", or date suffixes
53
+
54
+ ## Output Format
55
+
56
+ Return JSON:
57
+ \`\`\`json
58
+ {
59
+ "proposals": [{ "file": "name", "destination": "Code/JavaScript/project", "category": {...} }],
60
+ "strategy": "...",
61
+ "uncategorized": []
62
+ }
63
+ \`\`\`
64
+ `,
65
+ },
66
+ {
67
+ name: "creative",
68
+ description: "Organize images, videos, design files, and media",
69
+ profile: {
70
+ description: "Creative workflow for designers and content creators",
71
+ defaultTarget: "~/Documents/Creative",
72
+ },
73
+ rules: `# Creative Profile Rules
74
+
75
+ You are organizing files for a designer or content creator. Focus on visual assets and media organization.
76
+
77
+ ## Categories
78
+
79
+ ### Images
80
+ - Photos: .jpg, .jpeg, .png, .heic, .raw, .cr2, .nef
81
+ - Graphics: .svg, .webp, .gif
82
+ - Subcategorize: Photos, Screenshots, Icons, Illustrations
83
+
84
+ ### Design
85
+ - Figma exports, Sketch files, PSD, AI
86
+ - Subcategorize by project or client name if detectable
87
+
88
+ ### Video
89
+ - .mp4, .mov, .avi, .mkv, .webm
90
+ - Subcategorize: Raw Footage, Exports, Clips
91
+
92
+ ### Audio
93
+ - .mp3, .wav, .aiff, .flac, .m4a
94
+ - Subcategorize: Music, SFX, Voiceover
95
+
96
+ ### Fonts
97
+ - .ttf, .otf, .woff, .woff2
98
+ - Keep in centralized Fonts folder
99
+
100
+ ### 3D
101
+ - .obj, .fbx, .blend, .gltf
102
+ - 3D models and assets
103
+
104
+ ## Strategy
105
+
106
+ 1. **Date-based for photos** - Organize photos by YYYY/MM if dates detected in filename
107
+ 2. **Project-based for design** - Group by client or project name
108
+ 3. **Keep exports together** - Files with "export", "final", "v2" stay in same project folder
109
+
110
+ ## Output Format
111
+
112
+ Return JSON:
113
+ \`\`\`json
114
+ {
115
+ "proposals": [{ "file": "name", "destination": "Images/Photos/2024/January", "category": {...} }],
116
+ "strategy": "...",
117
+ "uncategorized": []
118
+ }
119
+ \`\`\`
120
+ `,
121
+ },
122
+ {
123
+ name: "student",
124
+ description: "Organize documents, notes, and academic materials",
125
+ profile: {
126
+ description: "Academic organization for students and researchers",
127
+ defaultTarget: "~/Documents/School",
128
+ },
129
+ rules: `# Student Profile Rules
130
+
131
+ You are organizing files for a student. Focus on academic organization and study materials.
132
+
133
+ ## Categories
134
+
135
+ ### Notes
136
+ - Text files, markdown notes, OneNote exports
137
+ - Subcategorize by subject if detectable
138
+
139
+ ### Documents
140
+ - PDFs, Word docs, essays, reports
141
+ - Subcategorize: Assignments, Readings, Submissions
142
+
143
+ ### Slides
144
+ - PowerPoint, Keynote, Google Slides exports
145
+ - Lecture slides, presentations
146
+
147
+ ### Spreadsheets
148
+ - Excel, CSV, data analysis files
149
+ - Lab data, calculations
150
+
151
+ ### Textbooks
152
+ - E-books: .epub, .mobi, .pdf (large PDFs)
153
+ - Reference materials
154
+
155
+ ### Research
156
+ - Papers, citations, bibliography files
157
+ - Research data and notes
158
+
159
+ ## Strategy
160
+
161
+ 1. **Subject detection** - Look for subject names in filenames (math, history, physics, etc.)
162
+ 2. **Semester organization** - Group by term if dates/semesters detected
163
+ 3. **Assignment priority** - Files with "assignment", "hw", "lab" get special handling
164
+
165
+ ## Output Format
166
+
167
+ Return JSON:
168
+ \`\`\`json
169
+ {
170
+ "proposals": [{ "file": "name", "destination": "Notes/Physics/Chapter1", "category": {...} }],
171
+ "strategy": "...",
172
+ "uncategorized": []
173
+ }
174
+ \`\`\`
175
+ `,
176
+ },
177
+ {
178
+ name: "downloads",
179
+ description: "Aggressive cleanup for messy Downloads folders",
180
+ profile: {
181
+ description: "Fast cleanup for Downloads folder chaos",
182
+ defaultSource: "~/Downloads",
183
+ defaultTarget: "~/Documents/Organized",
184
+ },
185
+ rules: `# Downloads Cleanup Profile
186
+
187
+ You are aggressively cleaning a messy Downloads folder. Be decisive and organize everything.
188
+
189
+ ## Categories
190
+
191
+ ### Installers
192
+ - DMG, PKG, EXE, MSI, APP, DEB, RPM
193
+ - Move to Installers folder, suggest deletion after install
194
+
195
+ ### Archives
196
+ - ZIP, RAR, 7Z, TAR.GZ
197
+ - Keep in Archives, note if should be extracted
198
+
199
+ ### Documents
200
+ - PDF, DOCX, XLSX, PPTX, TXT
201
+ - Subcategorize: Receipts, Manuals, Forms, Other
202
+
203
+ ### Images
204
+ - All image formats
205
+ - Screenshots go to Screenshots subfolder
206
+
207
+ ### Videos
208
+ - All video formats
209
+ - Downloads, Clips, Tutorials
210
+
211
+ ### Audio
212
+ - All audio formats
213
+ - Music, Podcasts, Recordings
214
+
215
+ ### Code
216
+ - Source files, scripts, configs
217
+ - Move to Development folder
218
+
219
+ ### Temporary
220
+ - .tmp, .part, .crdownload, incomplete downloads
221
+ - Flag for deletion
222
+
223
+ ## Strategy
224
+
225
+ 1. **Be aggressive** - Everything gets categorized, nothing stays in Downloads
226
+ 2. **Detect duplicates** - Files like "file (1).pdf" are duplicates
227
+ 3. **Date cleanup** - Old files (>30 days) can go to Archive
228
+ 4. **Installers cleanup** - Suggest keeping only latest versions
229
+
230
+ ## Output Format
231
+
232
+ Return JSON:
233
+ \`\`\`json
234
+ {
235
+ "proposals": [{ "file": "name", "destination": "Documents/Receipts", "category": {...} }],
236
+ "strategy": "...",
237
+ "uncategorized": []
238
+ }
239
+ \`\`\`
240
+ `,
241
+ },
242
+ ];
243
+
244
+ export function getPresetNames(): string[] {
245
+ return PRESET_DEFINITIONS.map((p) => p.name);
246
+ }
247
+
248
+ export function getPreset(name: string): PresetDefinition | undefined {
249
+ return PRESET_DEFINITIONS.find((p) => p.name === name);
250
+ }
251
+
252
+ export function listPresets(): { name: string; description: string }[] {
253
+ return PRESET_DEFINITIONS.map((p) => ({
254
+ name: p.name,
255
+ description: p.description,
256
+ }));
257
+ }