@sooink/ai-session-tidy 0.1.1

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,181 @@
1
+ import { readFile, writeFile, mkdir, copyFile } from 'fs/promises';
2
+ import { existsSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { homedir } from 'os';
5
+
6
+ import type { OrphanedSession } from '../scanners/types.js';
7
+ import { moveToTrash, permanentDelete } from './trash.js';
8
+
9
+ export interface CleanOptions {
10
+ dryRun: boolean;
11
+ useTrash?: boolean;
12
+ }
13
+
14
+ export interface CleanError {
15
+ sessionPath: string;
16
+ error: Error;
17
+ }
18
+
19
+ export interface CleanCountByType {
20
+ session: number;
21
+ sessionEnv: number;
22
+ todos: number;
23
+ fileHistory: number;
24
+ }
25
+
26
+ export interface CleanResult {
27
+ deletedCount: number;
28
+ deletedByType: CleanCountByType;
29
+ skippedCount: number;
30
+ alreadyGoneCount: number;
31
+ configEntriesRemoved: number;
32
+ totalSizeDeleted: number;
33
+ errors: CleanError[];
34
+ backupPath?: string;
35
+ }
36
+
37
+ interface ClaudeConfig {
38
+ projects?: Record<string, unknown>;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ function getBackupDir(): string {
43
+ return join(homedir(), '.ai-session-tidy', 'backups');
44
+ }
45
+
46
+ export class Cleaner {
47
+ async clean(
48
+ sessions: OrphanedSession[],
49
+ options: CleanOptions
50
+ ): Promise<CleanResult> {
51
+ const result: CleanResult = {
52
+ deletedCount: 0,
53
+ deletedByType: {
54
+ session: 0,
55
+ sessionEnv: 0,
56
+ todos: 0,
57
+ fileHistory: 0,
58
+ },
59
+ skippedCount: 0,
60
+ alreadyGoneCount: 0,
61
+ configEntriesRemoved: 0,
62
+ totalSizeDeleted: 0,
63
+ errors: [],
64
+ };
65
+
66
+ const useTrash = options.useTrash ?? true;
67
+
68
+ // Separate session folders and config entries
69
+ const folderSessions = sessions.filter((s) => s.type !== 'config');
70
+ const configEntries = sessions.filter((s) => s.type === 'config');
71
+
72
+ // 1. Clean session folders/files
73
+ for (const session of folderSessions) {
74
+ if (options.dryRun) {
75
+ result.skippedCount++;
76
+ continue;
77
+ }
78
+
79
+ try {
80
+ const deleted = useTrash
81
+ ? await moveToTrash(session.sessionPath)
82
+ : await permanentDelete(session.sessionPath);
83
+
84
+ if (deleted) {
85
+ result.deletedCount++;
86
+ result.totalSizeDeleted += session.size;
87
+
88
+ // Count by type
89
+ switch (session.type) {
90
+ case 'session-env':
91
+ result.deletedByType.sessionEnv++;
92
+ break;
93
+ case 'todos':
94
+ result.deletedByType.todos++;
95
+ break;
96
+ case 'file-history':
97
+ result.deletedByType.fileHistory++;
98
+ break;
99
+ default:
100
+ result.deletedByType.session++;
101
+ }
102
+ } else {
103
+ // Already deleted path - not an error
104
+ result.alreadyGoneCount++;
105
+ }
106
+ } catch (error) {
107
+ result.errors.push({
108
+ sessionPath: session.sessionPath,
109
+ error: error instanceof Error ? error : new Error(String(error)),
110
+ });
111
+ }
112
+ }
113
+
114
+ // 2. Clean config entries
115
+ if (configEntries.length > 0 && !options.dryRun) {
116
+ try {
117
+ const configResult = await this.cleanConfigEntries(configEntries);
118
+ result.configEntriesRemoved = configResult.removed;
119
+ result.backupPath = configResult.backupPath;
120
+ } catch (error) {
121
+ result.errors.push({
122
+ sessionPath: configEntries[0].sessionPath,
123
+ error: error instanceof Error ? error : new Error(String(error)),
124
+ });
125
+ }
126
+ } else if (options.dryRun) {
127
+ result.skippedCount += configEntries.length;
128
+ }
129
+
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Remove orphaned project entries from ~/.claude.json
135
+ */
136
+ private async cleanConfigEntries(
137
+ entries: OrphanedSession[]
138
+ ): Promise<{ removed: number; backupPath: string }> {
139
+ if (entries.length === 0) {
140
+ return { removed: 0, backupPath: '' };
141
+ }
142
+
143
+ const configPath = entries[0].sessionPath;
144
+ const projectPathsToRemove = new Set(entries.map((e) => e.projectPath));
145
+
146
+ // Create backup directory
147
+ const backupDir = getBackupDir();
148
+ if (!existsSync(backupDir)) {
149
+ await mkdir(backupDir, { recursive: true });
150
+ }
151
+
152
+ // Create backup file (with timestamp)
153
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
154
+ const backupPath = join(backupDir, `claude.json.${timestamp}.bak`);
155
+ await copyFile(configPath, backupPath);
156
+
157
+ // Read config file
158
+ const content = await readFile(configPath, 'utf-8');
159
+ const config: ClaudeConfig = JSON.parse(content);
160
+
161
+ if (!config.projects) {
162
+ return { removed: 0, backupPath };
163
+ }
164
+
165
+ // Remove orphaned project entries
166
+ let removedCount = 0;
167
+ for (const projectPath of projectPathsToRemove) {
168
+ if (projectPath in config.projects) {
169
+ delete config.projects[projectPath];
170
+ removedCount++;
171
+ }
172
+ }
173
+
174
+ // Save modified config
175
+ if (removedCount > 0) {
176
+ await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
177
+ }
178
+
179
+ return { removed: removedCount, backupPath };
180
+ }
181
+ }
@@ -0,0 +1,236 @@
1
+ import { homedir } from 'os';
2
+ import { join, dirname } from 'path';
3
+ import { readFile, writeFile, unlink, mkdir } from 'fs/promises';
4
+ import { existsSync } from 'fs';
5
+ import { execSync } from 'child_process';
6
+
7
+ const SERVICE_LABEL = 'sooink.ai-session-tidy.watcher';
8
+ const PLIST_FILENAME = `${SERVICE_LABEL}.plist`;
9
+
10
+ export type ServiceStatus = 'running' | 'stopped' | 'not_installed';
11
+
12
+ export interface ServiceInfo {
13
+ status: ServiceStatus;
14
+ pid?: number;
15
+ label: string;
16
+ plistPath: string;
17
+ }
18
+
19
+ function getPlistPath(): string {
20
+ return join(homedir(), 'Library', 'LaunchAgents', PLIST_FILENAME);
21
+ }
22
+
23
+ function getNodePath(): string {
24
+ // Get absolute path to node executable
25
+ return process.execPath;
26
+ }
27
+
28
+ function getScriptPath(): string {
29
+ // Get absolute path to the CLI script
30
+ const scriptPath = process.argv[1];
31
+ if (scriptPath && existsSync(scriptPath)) {
32
+ return scriptPath;
33
+ }
34
+ throw new Error('Could not determine script path');
35
+ }
36
+
37
+ function generatePlist(options: {
38
+ label: string;
39
+ nodePath: string;
40
+ scriptPath: string;
41
+ args: string[];
42
+ }): string {
43
+ const allArgs = [options.nodePath, options.scriptPath, ...options.args];
44
+ const argsXml = allArgs.map((arg) => ` <string>${arg}</string>`).join('\n');
45
+
46
+ const home = homedir();
47
+
48
+ return `<?xml version="1.0" encoding="UTF-8"?>
49
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
50
+ <plist version="1.0">
51
+ <dict>
52
+ <key>Label</key>
53
+ <string>${options.label}</string>
54
+ <key>EnvironmentVariables</key>
55
+ <dict>
56
+ <key>HOME</key>
57
+ <string>${home}</string>
58
+ </dict>
59
+ <key>ProgramArguments</key>
60
+ <array>
61
+ ${argsXml}
62
+ </array>
63
+ <key>RunAtLoad</key>
64
+ <true/>
65
+ <key>KeepAlive</key>
66
+ <true/>
67
+ <key>StandardOutPath</key>
68
+ <string>${join(home, '.ai-session-tidy', 'watcher.log')}</string>
69
+ <key>StandardErrorPath</key>
70
+ <string>${join(home, '.ai-session-tidy', 'watcher.error.log')}</string>
71
+ </dict>
72
+ </plist>`;
73
+ }
74
+
75
+ export class ServiceManager {
76
+ private readonly plistPath: string;
77
+
78
+ constructor() {
79
+ this.plistPath = getPlistPath();
80
+ }
81
+
82
+ isSupported(): boolean {
83
+ return process.platform === 'darwin';
84
+ }
85
+
86
+ async install(): Promise<void> {
87
+ if (!this.isSupported()) {
88
+ throw new Error('Service management is only supported on macOS');
89
+ }
90
+
91
+ // Ensure LaunchAgents directory exists
92
+ const launchAgentsDir = dirname(this.plistPath);
93
+ if (!existsSync(launchAgentsDir)) {
94
+ await mkdir(launchAgentsDir, { recursive: true });
95
+ }
96
+
97
+ // Ensure log directory exists and clear old logs
98
+ const logDir = join(homedir(), '.ai-session-tidy');
99
+ if (!existsSync(logDir)) {
100
+ await mkdir(logDir, { recursive: true });
101
+ }
102
+
103
+ // Clear old log files
104
+ const stdoutPath = join(logDir, 'watcher.log');
105
+ const stderrPath = join(logDir, 'watcher.error.log');
106
+ await writeFile(stdoutPath, '', 'utf-8');
107
+ await writeFile(stderrPath, '', 'utf-8');
108
+
109
+ const plistContent = generatePlist({
110
+ label: SERVICE_LABEL,
111
+ nodePath: getNodePath(),
112
+ scriptPath: getScriptPath(),
113
+ args: ['watch', 'run'],
114
+ });
115
+
116
+ await writeFile(this.plistPath, plistContent, 'utf-8');
117
+ }
118
+
119
+ async uninstall(): Promise<void> {
120
+ if (existsSync(this.plistPath)) {
121
+ await unlink(this.plistPath);
122
+ }
123
+ }
124
+
125
+ async start(): Promise<void> {
126
+ if (!this.isSupported()) {
127
+ throw new Error('Service management is only supported on macOS');
128
+ }
129
+
130
+ if (!existsSync(this.plistPath)) {
131
+ throw new Error('Service not installed. Run "watch start" to install and start.');
132
+ }
133
+
134
+ try {
135
+ execSync(`launchctl load "${this.plistPath}"`, { stdio: 'pipe' });
136
+ } catch (error) {
137
+ // Already loaded is not an error
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ if (!message.includes('already loaded')) {
140
+ throw error;
141
+ }
142
+ }
143
+ }
144
+
145
+ async stop(): Promise<void> {
146
+ if (!this.isSupported()) {
147
+ throw new Error('Service management is only supported on macOS');
148
+ }
149
+
150
+ if (!existsSync(this.plistPath)) {
151
+ return; // Nothing to stop
152
+ }
153
+
154
+ try {
155
+ execSync(`launchctl unload "${this.plistPath}"`, { stdio: 'pipe' });
156
+ } catch {
157
+ // Ignore errors when stopping (might not be running)
158
+ }
159
+ }
160
+
161
+ async status(): Promise<ServiceInfo> {
162
+ const info: ServiceInfo = {
163
+ status: 'not_installed',
164
+ label: SERVICE_LABEL,
165
+ plistPath: this.plistPath,
166
+ };
167
+
168
+ if (!this.isSupported()) {
169
+ return info;
170
+ }
171
+
172
+ if (!existsSync(this.plistPath)) {
173
+ return info;
174
+ }
175
+
176
+ try {
177
+ const output = execSync('launchctl list', { encoding: 'utf-8' });
178
+ const lines = output.split('\n');
179
+
180
+ for (const line of lines) {
181
+ if (line.includes(SERVICE_LABEL)) {
182
+ const parts = line.split(/\s+/);
183
+ const pid = parseInt(parts[0] ?? '', 10);
184
+
185
+ if (!isNaN(pid) && pid > 0) {
186
+ info.status = 'running';
187
+ info.pid = pid;
188
+ } else {
189
+ info.status = 'stopped';
190
+ }
191
+ return info;
192
+ }
193
+ }
194
+
195
+ // plist exists but not loaded
196
+ info.status = 'stopped';
197
+ return info;
198
+ } catch {
199
+ info.status = 'stopped';
200
+ return info;
201
+ }
202
+ }
203
+
204
+ async getLogs(lines: number = 50): Promise<{ stdout: string; stderr: string }> {
205
+ const logDir = join(homedir(), '.ai-session-tidy');
206
+ const stdoutPath = join(logDir, 'watcher.log');
207
+ const stderrPath = join(logDir, 'watcher.error.log');
208
+
209
+ let stdout = '';
210
+ let stderr = '';
211
+
212
+ try {
213
+ if (existsSync(stdoutPath)) {
214
+ const content = await readFile(stdoutPath, 'utf-8');
215
+ const logLines = content.split('\n');
216
+ stdout = logLines.slice(-lines).join('\n');
217
+ }
218
+ } catch {
219
+ // Ignore read errors
220
+ }
221
+
222
+ try {
223
+ if (existsSync(stderrPath)) {
224
+ const content = await readFile(stderrPath, 'utf-8');
225
+ const logLines = content.split('\n');
226
+ stderr = logLines.slice(-lines).join('\n');
227
+ }
228
+ } catch {
229
+ // Ignore read errors
230
+ }
231
+
232
+ return { stdout, stderr };
233
+ }
234
+ }
235
+
236
+ export const serviceManager = new ServiceManager();
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, writeFile, rm, access } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+
6
+ import { moveToTrash, permanentDelete } from './trash.js';
7
+
8
+ describe('moveToTrash', () => {
9
+ const testDir = join(tmpdir(), 'trashtest' + Date.now());
10
+
11
+ beforeEach(async () => {
12
+ await mkdir(testDir, { recursive: true });
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await rm(testDir, { recursive: true, force: true });
17
+ });
18
+
19
+ it('moves file to trash', async () => {
20
+ const filePath = join(testDir, 'testfile.txt');
21
+ await writeFile(filePath, 'test content');
22
+
23
+ await moveToTrash(filePath);
24
+
25
+ // File should no longer exist
26
+ await expect(access(filePath)).rejects.toThrow();
27
+ });
28
+
29
+ it('moves directory to trash', async () => {
30
+ const dirPath = join(testDir, 'testdir');
31
+ await mkdir(dirPath);
32
+ await writeFile(join(dirPath, 'file.txt'), 'content');
33
+
34
+ await moveToTrash(dirPath);
35
+
36
+ // Directory should no longer exist
37
+ await expect(access(dirPath)).rejects.toThrow();
38
+ });
39
+
40
+ it('returns false for non-existent path (silent skip)', async () => {
41
+ const nonExistent = join(testDir, 'nonexistent12345');
42
+
43
+ const result = await moveToTrash(nonExistent);
44
+ expect(result).toBe(false);
45
+ });
46
+
47
+ it('returns true on successful file deletion', async () => {
48
+ const filePath = join(testDir, 'testfile2.txt');
49
+ await writeFile(filePath, 'test content');
50
+
51
+ const result = await moveToTrash(filePath);
52
+ expect(result).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('permanentDelete', () => {
57
+ const testDir = join(tmpdir(), 'delettest' + Date.now());
58
+
59
+ beforeEach(async () => {
60
+ await mkdir(testDir, { recursive: true });
61
+ });
62
+
63
+ afterEach(async () => {
64
+ await rm(testDir, { recursive: true, force: true });
65
+ });
66
+
67
+ it('permanently deletes file', async () => {
68
+ const filePath = join(testDir, 'todelete.txt');
69
+ await writeFile(filePath, 'to be deleted');
70
+
71
+ await permanentDelete(filePath);
72
+
73
+ await expect(access(filePath)).rejects.toThrow();
74
+ });
75
+
76
+ it('permanently deletes directory (recursive)', async () => {
77
+ const dirPath = join(testDir, 'todelete');
78
+ await mkdir(dirPath);
79
+ await writeFile(join(dirPath, 'file.txt'), 'content');
80
+
81
+ await permanentDelete(dirPath);
82
+
83
+ await expect(access(dirPath)).rejects.toThrow();
84
+ });
85
+
86
+ it('returns false for non-existent path (silent skip)', async () => {
87
+ const nonExistent = join(testDir, 'nonexistent12345');
88
+
89
+ const result = await permanentDelete(nonExistent);
90
+ expect(result).toBe(false);
91
+ });
92
+
93
+ it('returns true on successful file deletion', async () => {
94
+ const filePath = join(testDir, 'todelete2.txt');
95
+ await writeFile(filePath, 'to be deleted');
96
+
97
+ const result = await permanentDelete(filePath);
98
+ expect(result).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,40 @@
1
+ import { rm, access } from 'fs/promises';
2
+ import trash from 'trash';
3
+
4
+ /**
5
+ * Check if path exists
6
+ */
7
+ async function pathExists(path: string): Promise<boolean> {
8
+ try {
9
+ await access(path);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Move file or directory to trash
18
+ * @returns true if deleted, false if already gone
19
+ */
20
+ export async function moveToTrash(path: string): Promise<boolean> {
21
+ if (!(await pathExists(path))) {
22
+ return false; // Already deleted - silent skip
23
+ }
24
+
25
+ await trash(path);
26
+ return true;
27
+ }
28
+
29
+ /**
30
+ * Permanently delete file or directory
31
+ * @returns true if deleted, false if already gone
32
+ */
33
+ export async function permanentDelete(path: string): Promise<boolean> {
34
+ if (!(await pathExists(path))) {
35
+ return false; // Already deleted - silent skip
36
+ }
37
+
38
+ await rm(path, { recursive: true, force: true });
39
+ return true;
40
+ }