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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * AI integration for tidy using @opencode-ai/sdk
3
+ */
4
+
5
+ import * as p from "@clack/prompts";
6
+ import {
7
+ createOpencode,
8
+ createOpencodeClient,
9
+ type OpencodeClient,
10
+ } from "@opencode-ai/sdk";
11
+ import { exec } from "child_process";
12
+ import { join } from "path";
13
+ import color from "picocolors";
14
+ import { promisify } from "util";
15
+ import type { ModelSelection } from "../types/config.ts";
16
+ import type {
17
+ FileCategory,
18
+ FileMetadata,
19
+ FileMoveProposal,
20
+ OrganizationProposal,
21
+ } from "../types/organizer.ts";
22
+ import { fileExists } from "../utils/files.ts";
23
+ import { expandPath, getRulesPrompt, resolveConfig } from "./config.ts";
24
+
25
+ const execAsync = promisify(exec);
26
+
27
+ // Server state
28
+ let clientInstance: OpencodeClient | null = null;
29
+ let serverInstance: { close: () => void } | null = null;
30
+ let sessionId: string | null = null;
31
+
32
+ /**
33
+ * Check if opencode CLI is installed
34
+ */
35
+ async function isOpencodeInstalled(): Promise<boolean> {
36
+ try {
37
+ await execAsync("which opencode");
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Check if user is authenticated with opencode
46
+ */
47
+ async function checkAuth(client: OpencodeClient): Promise<boolean> {
48
+ try {
49
+ const config = await client.config.get();
50
+ return !!config;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Get or create the Opencode client
58
+ * Tries to connect to existing server first, spawns new one if needed
59
+ */
60
+ export async function getClient(): Promise<OpencodeClient> {
61
+ if (clientInstance) {
62
+ return clientInstance;
63
+ }
64
+
65
+ // Try connecting to existing server first
66
+ try {
67
+ const client = createOpencodeClient({
68
+ baseUrl: "http://localhost:4096",
69
+ });
70
+ // Test connection
71
+ await client.config.get();
72
+ clientInstance = client;
73
+ return client;
74
+ } catch {
75
+ // No existing server, need to spawn one
76
+ }
77
+
78
+ // Check if opencode is installed
79
+ if (!(await isOpencodeInstalled())) {
80
+ p.log.error("OpenCode CLI is not installed");
81
+ p.log.info(
82
+ `Install it with: ${color.cyan("npm install -g opencode")} or ${color.cyan("brew install sst/tap/opencode")}`,
83
+ );
84
+ process.exit(1);
85
+ }
86
+
87
+ // Spawn new server
88
+ try {
89
+ const opencode = await createOpencode({
90
+ timeout: 10000,
91
+ });
92
+
93
+ clientInstance = opencode.client;
94
+ serverInstance = opencode.server;
95
+
96
+ // Check authentication
97
+ if (!(await checkAuth(opencode.client))) {
98
+ p.log.warn("Not authenticated with OpenCode");
99
+ p.log.info(`Run ${color.cyan("opencode auth")} to authenticate`);
100
+ process.exit(1);
101
+ }
102
+
103
+ // Clean up server on process exit
104
+ process.on("exit", () => {
105
+ serverInstance?.close();
106
+ });
107
+ process.on("SIGINT", () => {
108
+ serverInstance?.close();
109
+ process.exit(0);
110
+ });
111
+ process.on("SIGTERM", () => {
112
+ serverInstance?.close();
113
+ process.exit(0);
114
+ });
115
+
116
+ return opencode.client;
117
+ } catch (error: any) {
118
+ p.log.error(`Failed to start OpenCode server: ${error.message}`);
119
+ p.log.info(`Make sure OpenCode is installed and configured correctly`);
120
+ process.exit(1);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Create a new session for file analysis
126
+ */
127
+ export async function createSession(): Promise<string> {
128
+ const opencodeClient = await getClient();
129
+ const session = await opencodeClient.session.create();
130
+ sessionId = session.data?.id ?? "";
131
+ return sessionId;
132
+ }
133
+
134
+ /**
135
+ * Get current session ID or create one
136
+ */
137
+ export async function getSessionId(): Promise<string> {
138
+ if (!sessionId) {
139
+ return createSession();
140
+ }
141
+ return sessionId;
142
+ }
143
+
144
+ /**
145
+ * Clean up resources
146
+ */
147
+ export function cleanup(): void {
148
+ if (sessionId && clientInstance) {
149
+ // Attempt to abort/clean up the session
150
+ clientInstance.session.abort({ path: { id: sessionId } }).catch(() => {
151
+ // Ignore errors during cleanup
152
+ });
153
+ }
154
+ sessionId = null;
155
+ serverInstance?.close();
156
+ serverInstance = null;
157
+ clientInstance = null;
158
+ }
159
+
160
+ /**
161
+ * Options for analyzing files
162
+ */
163
+ export interface AnalyzeFilesOptions {
164
+ /** Files to analyze */
165
+ files: FileMetadata[];
166
+ /** Target directory for organized files */
167
+ targetDir: string;
168
+ /** Additional instructions from user */
169
+ instructions?: string;
170
+ /** Model override */
171
+ model?: ModelSelection;
172
+ }
173
+
174
+ /**
175
+ * Format file metadata for AI prompt
176
+ */
177
+ function formatFilesForPrompt(files: FileMetadata[]): string {
178
+ const formatted = files.map((f) => ({
179
+ name: f.name,
180
+ extension: f.extension,
181
+ size: f.size,
182
+ mimeType: f.mimeType,
183
+ modifiedAt: f.modifiedAt.toISOString(),
184
+ contentPreview: f.contentPreview?.slice(0, 500), // Limit preview size
185
+ }));
186
+
187
+ return JSON.stringify(formatted, null, 2);
188
+ }
189
+
190
+ /**
191
+ * Parse AI response into OrganizationProposal
192
+ */
193
+ function parseAIResponse(
194
+ response: string,
195
+ files: FileMetadata[],
196
+ targetDir: string,
197
+ ): OrganizationProposal {
198
+ // Try to extract JSON from the response
199
+ let jsonStr = response;
200
+
201
+ // Try to find JSON in the response if wrapped in text
202
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
203
+ if (jsonMatch) {
204
+ jsonStr = jsonMatch[0];
205
+ }
206
+
207
+ try {
208
+ const parsed = JSON.parse(jsonStr);
209
+
210
+ // Build a map of files by name for lookup
211
+ const fileMap = new Map<string, FileMetadata>();
212
+ for (const file of files) {
213
+ fileMap.set(file.name, file);
214
+ }
215
+
216
+ const proposals: FileMoveProposal[] = [];
217
+ const uncategorized: FileMetadata[] = [];
218
+
219
+ // Process proposals from AI
220
+ if (parsed.proposals && Array.isArray(parsed.proposals)) {
221
+ for (const p of parsed.proposals) {
222
+ const fileName = p.file || p.filename || p.name;
223
+ const file = fileMap.get(fileName);
224
+
225
+ if (!file) {
226
+ continue;
227
+ }
228
+
229
+ // Remove from map to track processed files
230
+ fileMap.delete(fileName);
231
+
232
+ const destination = join(
233
+ expandPath(targetDir),
234
+ p.destination || p.suggestedPath || "",
235
+ file.name,
236
+ );
237
+
238
+ const category: FileCategory = {
239
+ name: p.category?.name || "Other",
240
+ subcategory: p.category?.subcategory,
241
+ suggestedPath: p.destination || p.category?.suggestedPath || "",
242
+ confidence: p.category?.confidence || 0.5,
243
+ reasoning: p.category?.reasoning || p.reasoning || "",
244
+ };
245
+
246
+ proposals.push({
247
+ sourcePath: file.path,
248
+ file,
249
+ destination,
250
+ category,
251
+ conflictExists: false, // Will be checked later
252
+ });
253
+ }
254
+ }
255
+
256
+ // Process uncategorized files
257
+ if (parsed.uncategorized && Array.isArray(parsed.uncategorized)) {
258
+ for (const fileName of parsed.uncategorized) {
259
+ const file = fileMap.get(fileName);
260
+ if (file) {
261
+ uncategorized.push(file);
262
+ fileMap.delete(fileName);
263
+ }
264
+ }
265
+ }
266
+
267
+ // Any remaining files in the map are also uncategorized
268
+ for (const file of fileMap.values()) {
269
+ uncategorized.push(file);
270
+ }
271
+
272
+ return {
273
+ proposals,
274
+ strategy: parsed.strategy || parsed.overall_reasoning || "",
275
+ uncategorized,
276
+ analyzedAt: new Date(),
277
+ };
278
+ } catch {
279
+ // If parsing fails, treat all files as uncategorized
280
+ return {
281
+ proposals: [],
282
+ strategy: "Failed to parse AI response",
283
+ uncategorized: [...files],
284
+ analyzedAt: new Date(),
285
+ };
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Check for file conflicts in proposals
291
+ */
292
+ export async function checkConflicts(
293
+ proposal: OrganizationProposal,
294
+ ): Promise<OrganizationProposal> {
295
+ const updatedProposals = await Promise.all(
296
+ proposal.proposals.map(async (p) => ({
297
+ ...p,
298
+ conflictExists: await fileExists(p.destination),
299
+ })),
300
+ );
301
+
302
+ return {
303
+ ...proposal,
304
+ proposals: updatedProposals,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Analyze files using AI
310
+ */
311
+ export async function analyzeFiles(
312
+ options: AnalyzeFilesOptions,
313
+ ): Promise<OrganizationProposal> {
314
+ const { files, targetDir, instructions, model } = options;
315
+
316
+ if (files.length === 0) {
317
+ return {
318
+ proposals: [],
319
+ strategy: "No files to analyze",
320
+ uncategorized: [],
321
+ analyzedAt: new Date(),
322
+ };
323
+ }
324
+
325
+ const opencodeClient = await getClient();
326
+ const sid = await getSessionId();
327
+
328
+ // Get configuration
329
+ const config = resolveConfig();
330
+ const rulesPrompt = getRulesPrompt();
331
+
332
+ // Build the prompt
333
+ const filesJson = formatFilesForPrompt(files);
334
+
335
+ const userPrompt = `
336
+ Analyze the following files and organize them according to the rules.
337
+
338
+ Target directory: ${targetDir}
339
+
340
+ ${instructions ? `Additional instructions: ${instructions}\n` : ""}
341
+ Files to organize:
342
+ ${filesJson}
343
+
344
+ Respond with ONLY a JSON object (no markdown code blocks) following the format specified in the rules.
345
+ `;
346
+
347
+ // Send the message and get response using session.prompt
348
+ const response = await opencodeClient.session.prompt({
349
+ path: { id: sid },
350
+ body: {
351
+ system: rulesPrompt,
352
+ model: {
353
+ providerID: model?.provider || config.organizer?.provider || "opencode",
354
+ modelID: model?.model || config.organizer?.model || "claude-sonnet-4-5",
355
+ },
356
+ parts: [{ type: "text", text: userPrompt }],
357
+ },
358
+ });
359
+
360
+ // Extract the text content from the response
361
+ const parts = response.data?.parts || [];
362
+ const responseText = parts
363
+ .filter((p) => p.type === "text" && "text" in p)
364
+ .map((p) => (p as { type: "text"; text: string }).text)
365
+ .join("");
366
+
367
+ // Parse the response
368
+ const proposal = parseAIResponse(responseText, files, targetDir);
369
+
370
+ // Check for conflicts
371
+ return checkConflicts(proposal);
372
+ }
373
+
374
+ /**
375
+ * Get available models
376
+ */
377
+ export async function getAvailableModels() {
378
+ const client = await getClient();
379
+ return client.config.providers();
380
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * File scanning utilities for tidy
3
+ */
4
+
5
+ import { readdir, stat, readFile } from "fs/promises";
6
+ import { join, extname, basename } from "path";
7
+ import { lookup as lookupMimeType } from "mime-types";
8
+ import type { FileMetadata } from "../types/organizer.ts";
9
+ import { shouldIgnore } from "./config.ts";
10
+ import { isDirectory, isFile } from "../utils/files.ts";
11
+
12
+ /**
13
+ * Options for scanning a directory
14
+ */
15
+ export interface ScanOptions {
16
+ /** Scan subdirectories */
17
+ recursive?: boolean;
18
+ /** Max depth for recursive scan (0 = no limit) */
19
+ maxDepth?: number;
20
+ /** Patterns to ignore */
21
+ ignore?: string[];
22
+ /** Whether to read file content preview */
23
+ readContent?: boolean;
24
+ /** Max file size to read content (bytes) */
25
+ maxContentSize?: number;
26
+ }
27
+
28
+ /**
29
+ * Scan a directory and return metadata for all files
30
+ */
31
+ export async function scanDirectory(
32
+ dirPath: string,
33
+ options: ScanOptions = {}
34
+ ): Promise<FileMetadata[]> {
35
+ const {
36
+ recursive = false,
37
+ maxDepth = 1,
38
+ ignore = [],
39
+ readContent = false,
40
+ maxContentSize = 10240,
41
+ } = options;
42
+
43
+ return scanDirectoryInternal(dirPath, {
44
+ recursive,
45
+ maxDepth,
46
+ ignore,
47
+ readContent,
48
+ maxContentSize,
49
+ currentDepth: 0,
50
+ });
51
+ }
52
+
53
+ interface InternalScanOptions extends ScanOptions {
54
+ currentDepth: number;
55
+ }
56
+
57
+ async function scanDirectoryInternal(
58
+ dirPath: string,
59
+ options: InternalScanOptions
60
+ ): Promise<FileMetadata[]> {
61
+ const files: FileMetadata[] = [];
62
+
63
+ try {
64
+ const entries = await readdir(dirPath, { withFileTypes: true });
65
+
66
+ for (const entry of entries) {
67
+ const fullPath = join(dirPath, entry.name);
68
+
69
+ // Check if should be ignored
70
+ if (shouldIgnore(entry.name, options.ignore || [])) {
71
+ continue;
72
+ }
73
+
74
+ if (entry.isFile()) {
75
+ const metadata = await getFileMetadata(fullPath, {
76
+ readContent: options.readContent,
77
+ maxContentSize: options.maxContentSize,
78
+ });
79
+ if (metadata) {
80
+ files.push(metadata);
81
+ }
82
+ } else if (
83
+ entry.isDirectory() &&
84
+ options.recursive &&
85
+ ((options.maxDepth ?? 0) === 0 || options.currentDepth < (options.maxDepth ?? 0))
86
+ ) {
87
+ // Recursively scan subdirectory
88
+ const subFiles = await scanDirectoryInternal(fullPath, {
89
+ ...options,
90
+ currentDepth: options.currentDepth + 1,
91
+ });
92
+ files.push(...subFiles);
93
+ }
94
+ }
95
+ } catch (error: any) {
96
+ // Silently skip directories we can't read
97
+ console.error(`Warning: Could not scan ${dirPath}: ${error.message}`);
98
+ }
99
+
100
+ return files;
101
+ }
102
+
103
+ /**
104
+ * Get metadata for a single file
105
+ */
106
+ export async function getFileMetadata(
107
+ filePath: string,
108
+ options: { readContent?: boolean; maxContentSize?: number } = {}
109
+ ): Promise<FileMetadata | null> {
110
+ try {
111
+ const stats = await stat(filePath);
112
+
113
+ if (!stats.isFile()) {
114
+ return null;
115
+ }
116
+
117
+ const name = basename(filePath);
118
+ const extension = extname(name).slice(1).toLowerCase();
119
+ const mimeType = lookupMimeType(name) || undefined;
120
+
121
+ let contentPreview: string | undefined;
122
+ if (
123
+ options.readContent &&
124
+ stats.size <= (options.maxContentSize || 10240)
125
+ ) {
126
+ contentPreview = await readFilePreview(
127
+ filePath,
128
+ options.maxContentSize || 10240,
129
+ mimeType
130
+ );
131
+ }
132
+
133
+ return {
134
+ path: filePath,
135
+ name,
136
+ extension,
137
+ size: stats.size,
138
+ modifiedAt: stats.mtime,
139
+ createdAt: stats.birthtime,
140
+ mimeType,
141
+ contentPreview,
142
+ };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Read a preview of a file's content
150
+ */
151
+ export async function readFilePreview(
152
+ filePath: string,
153
+ maxSize: number,
154
+ mimeType?: string
155
+ ): Promise<string | undefined> {
156
+ try {
157
+ // Only read text-based files
158
+ if (!isTextFile(mimeType)) {
159
+ return undefined;
160
+ }
161
+
162
+ const buffer = Buffer.alloc(maxSize);
163
+ const file = await readFile(filePath);
164
+ const bytesToRead = Math.min(file.length, maxSize);
165
+ file.copy(buffer, 0, 0, bytesToRead);
166
+
167
+ const content = buffer.toString("utf-8", 0, bytesToRead);
168
+
169
+ // Clean up the content - first 20 lines
170
+ const lines = content.split("\n").slice(0, 20);
171
+ return lines.join("\n");
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Check if a file is text-based (readable)
179
+ */
180
+ function isTextFile(mimeType?: string): boolean {
181
+ if (!mimeType) return false;
182
+
183
+ const textMimeTypes = [
184
+ "text/",
185
+ "application/json",
186
+ "application/javascript",
187
+ "application/typescript",
188
+ "application/xml",
189
+ "application/x-yaml",
190
+ "application/x-sh",
191
+ "application/x-python",
192
+ ];
193
+
194
+ return textMimeTypes.some(
195
+ (type) => mimeType.startsWith(type) || mimeType === type
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Get file category based on extension
201
+ */
202
+ export function getFileCategory(extension: string): string {
203
+ const categories: Record<string, string[]> = {
204
+ Documents: [
205
+ "pdf",
206
+ "doc",
207
+ "docx",
208
+ "txt",
209
+ "rtf",
210
+ "odt",
211
+ "md",
212
+ "pages",
213
+ ],
214
+ Spreadsheets: ["xls", "xlsx", "csv", "ods", "numbers"],
215
+ Presentations: ["ppt", "pptx", "key", "odp"],
216
+ Images: [
217
+ "jpg",
218
+ "jpeg",
219
+ "png",
220
+ "gif",
221
+ "svg",
222
+ "webp",
223
+ "heic",
224
+ "ico",
225
+ "bmp",
226
+ "tiff",
227
+ "psd",
228
+ "ai",
229
+ "sketch",
230
+ "fig",
231
+ ],
232
+ Videos: ["mp4", "mov", "avi", "mkv", "webm", "wmv", "flv", "m4v"],
233
+ Audio: ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"],
234
+ Archives: ["zip", "rar", "7z", "tar", "gz", "bz2", "xz"],
235
+ Code: [
236
+ "ts",
237
+ "tsx",
238
+ "js",
239
+ "jsx",
240
+ "py",
241
+ "rb",
242
+ "go",
243
+ "rs",
244
+ "java",
245
+ "c",
246
+ "cpp",
247
+ "h",
248
+ "hpp",
249
+ "cs",
250
+ "swift",
251
+ "kt",
252
+ "php",
253
+ "html",
254
+ "css",
255
+ "scss",
256
+ "less",
257
+ "json",
258
+ "xml",
259
+ "yaml",
260
+ "yml",
261
+ "toml",
262
+ ],
263
+ Applications: ["dmg", "pkg", "exe", "msi", "app", "apk", "ipa", "deb", "rpm"],
264
+ Ebooks: ["epub", "mobi", "azw", "azw3"],
265
+ Fonts: ["ttf", "otf", "woff", "woff2"],
266
+ Data: ["sql", "db", "sqlite"],
267
+ };
268
+
269
+ const ext = extension.toLowerCase();
270
+ for (const [category, extensions] of Object.entries(categories)) {
271
+ if (extensions.includes(ext)) {
272
+ return category;
273
+ }
274
+ }
275
+
276
+ return "Other";
277
+ }
278
+
279
+ /**
280
+ * Group files by their base category
281
+ */
282
+ export function groupFilesByCategory(
283
+ files: FileMetadata[]
284
+ ): Record<string, FileMetadata[]> {
285
+ const groups: Record<string, FileMetadata[]> = {};
286
+
287
+ for (const file of files) {
288
+ const category = getFileCategory(file.extension);
289
+ if (!groups[category]) {
290
+ groups[category] = [];
291
+ }
292
+ groups[category].push(file);
293
+ }
294
+
295
+ return groups;
296
+ }