@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.
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.ko.md +171 -0
- package/README.md +169 -0
- package/assets/demo-interactive.gif +0 -0
- package/assets/demo.gif +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1917 -0
- package/dist/index.js.map +1 -0
- package/eslint.config.js +29 -0
- package/package.json +54 -0
- package/src/cli.ts +21 -0
- package/src/commands/clean.ts +335 -0
- package/src/commands/config.ts +144 -0
- package/src/commands/list.ts +86 -0
- package/src/commands/scan.ts +200 -0
- package/src/commands/watch.ts +359 -0
- package/src/core/cleaner.test.ts +125 -0
- package/src/core/cleaner.ts +181 -0
- package/src/core/service.ts +236 -0
- package/src/core/trash.test.ts +100 -0
- package/src/core/trash.ts +40 -0
- package/src/core/watcher.test.ts +210 -0
- package/src/core/watcher.ts +194 -0
- package/src/index.ts +5 -0
- package/src/scanners/claude-code.test.ts +112 -0
- package/src/scanners/claude-code.ts +452 -0
- package/src/scanners/cursor.test.ts +140 -0
- package/src/scanners/cursor.ts +133 -0
- package/src/scanners/index.ts +39 -0
- package/src/scanners/types.ts +34 -0
- package/src/utils/config.ts +132 -0
- package/src/utils/logger.ts +29 -0
- package/src/utils/paths.test.ts +95 -0
- package/src/utils/paths.ts +92 -0
- package/src/utils/size.test.ts +80 -0
- package/src/utils/size.ts +50 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readdir, readFile, stat, access } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
import type { Scanner, ScanResult, OrphanedSession } from './types.js';
|
|
6
|
+
import { getCursorWorkspaceDir } from '../utils/paths.js';
|
|
7
|
+
import { getDirectorySize } from '../utils/size.js';
|
|
8
|
+
|
|
9
|
+
interface WorkspaceJson {
|
|
10
|
+
folder?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class CursorScanner implements Scanner {
|
|
14
|
+
readonly name = 'cursor' as const;
|
|
15
|
+
private readonly workspaceDir: string;
|
|
16
|
+
|
|
17
|
+
constructor(workspaceDir?: string) {
|
|
18
|
+
this.workspaceDir = workspaceDir ?? getCursorWorkspaceDir();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async isAvailable(): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
await access(this.workspaceDir);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async scan(): Promise<ScanResult> {
|
|
31
|
+
const startTime = performance.now();
|
|
32
|
+
const sessions: OrphanedSession[] = [];
|
|
33
|
+
|
|
34
|
+
if (!(await this.isAvailable())) {
|
|
35
|
+
return {
|
|
36
|
+
toolName: this.name,
|
|
37
|
+
sessions: [],
|
|
38
|
+
totalSize: 0,
|
|
39
|
+
scanDuration: performance.now() - startTime,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entries = await readdir(this.workspaceDir, { withFileTypes: true });
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!entry.isDirectory()) continue;
|
|
47
|
+
|
|
48
|
+
const sessionPath = join(this.workspaceDir, entry.name);
|
|
49
|
+
const workspaceJsonPath = join(sessionPath, 'workspace.json');
|
|
50
|
+
|
|
51
|
+
// Read workspace.json
|
|
52
|
+
const projectPath = await this.parseWorkspaceJson(workspaceJsonPath);
|
|
53
|
+
if (!projectPath) continue;
|
|
54
|
+
|
|
55
|
+
// Check if original project exists
|
|
56
|
+
const projectExists = await this.pathExists(projectPath);
|
|
57
|
+
if (projectExists) continue;
|
|
58
|
+
|
|
59
|
+
// Calculate session size
|
|
60
|
+
const size = await getDirectorySize(sessionPath);
|
|
61
|
+
|
|
62
|
+
// Get last modified time
|
|
63
|
+
const lastModified = await this.getLastModified(sessionPath);
|
|
64
|
+
|
|
65
|
+
sessions.push({
|
|
66
|
+
toolName: this.name,
|
|
67
|
+
sessionPath,
|
|
68
|
+
projectPath,
|
|
69
|
+
size,
|
|
70
|
+
lastModified,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const totalSize = sessions.reduce((sum, s) => sum + s.size, 0);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
toolName: this.name,
|
|
78
|
+
sessions,
|
|
79
|
+
totalSize,
|
|
80
|
+
scanDuration: performance.now() - startTime,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async parseWorkspaceJson(
|
|
85
|
+
workspaceJsonPath: string
|
|
86
|
+
): Promise<string | null> {
|
|
87
|
+
try {
|
|
88
|
+
const content = await readFile(workspaceJsonPath, 'utf-8');
|
|
89
|
+
const data: WorkspaceJson = JSON.parse(content);
|
|
90
|
+
|
|
91
|
+
if (!data.folder) return null;
|
|
92
|
+
|
|
93
|
+
// Convert file:// URL to regular path
|
|
94
|
+
if (data.folder.startsWith('file://')) {
|
|
95
|
+
return fileURLToPath(data.folder);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return data.folder;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async pathExists(path: string): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
await access(path);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async getLastModified(dirPath: string): Promise<Date> {
|
|
114
|
+
try {
|
|
115
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
116
|
+
let latestTime = 0;
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const fullPath = join(dirPath, entry.name);
|
|
120
|
+
const fileStat = await stat(fullPath);
|
|
121
|
+
const mtime = fileStat.mtimeMs;
|
|
122
|
+
|
|
123
|
+
if (mtime > latestTime) {
|
|
124
|
+
latestTime = mtime;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return latestTime > 0 ? new Date(latestTime) : new Date();
|
|
129
|
+
} catch {
|
|
130
|
+
return new Date();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Scanner, ScanResult } from './types.js';
|
|
2
|
+
import { ClaudeCodeScanner } from './claude-code.js';
|
|
3
|
+
import { CursorScanner } from './cursor.js';
|
|
4
|
+
|
|
5
|
+
export { ClaudeCodeScanner } from './claude-code.js';
|
|
6
|
+
export { CursorScanner } from './cursor.js';
|
|
7
|
+
export * from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create all scanner instances
|
|
11
|
+
*/
|
|
12
|
+
export function createAllScanners(): Scanner[] {
|
|
13
|
+
return [new ClaudeCodeScanner(), new CursorScanner()];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Filter to available scanners only
|
|
18
|
+
*/
|
|
19
|
+
export async function getAvailableScanners(
|
|
20
|
+
scanners: Scanner[]
|
|
21
|
+
): Promise<Scanner[]> {
|
|
22
|
+
const results = await Promise.all(
|
|
23
|
+
scanners.map(async (scanner) => ({
|
|
24
|
+
scanner,
|
|
25
|
+
available: await scanner.isAvailable(),
|
|
26
|
+
}))
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return results.filter((r) => r.available).map((r) => r.scanner);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run all scanners and merge results
|
|
34
|
+
*/
|
|
35
|
+
export async function runAllScanners(
|
|
36
|
+
scanners: Scanner[]
|
|
37
|
+
): Promise<ScanResult[]> {
|
|
38
|
+
return Promise.all(scanners.map((scanner) => scanner.scan()));
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ToolName = 'claude-code' | 'cursor';
|
|
2
|
+
|
|
3
|
+
export type SessionType = 'session' | 'config' | 'session-env' | 'todos' | 'file-history';
|
|
4
|
+
|
|
5
|
+
export interface ConfigStats {
|
|
6
|
+
lastCost?: number;
|
|
7
|
+
lastTotalInputTokens?: number;
|
|
8
|
+
lastTotalOutputTokens?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OrphanedSession {
|
|
12
|
+
toolName: ToolName;
|
|
13
|
+
sessionPath: string;
|
|
14
|
+
projectPath: string;
|
|
15
|
+
size: number;
|
|
16
|
+
lastModified: Date;
|
|
17
|
+
/** 'session' = folder, 'config' = JSON config entry */
|
|
18
|
+
type?: SessionType;
|
|
19
|
+
/** Stats for config entries (cost, tokens) */
|
|
20
|
+
configStats?: ConfigStats;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScanResult {
|
|
24
|
+
toolName: ToolName;
|
|
25
|
+
sessions: OrphanedSession[];
|
|
26
|
+
totalSize: number;
|
|
27
|
+
scanDuration: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Scanner {
|
|
31
|
+
name: ToolName;
|
|
32
|
+
isAvailable(): Promise<boolean>;
|
|
33
|
+
scan(): Promise<ScanResult>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { dirname, join, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const CONFIG_VERSION = '0.1';
|
|
7
|
+
|
|
8
|
+
export function getAppVersion(): string {
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
// Try multiple paths (source vs bundled)
|
|
11
|
+
const paths = [
|
|
12
|
+
join(__dirname, '..', '..', 'package.json'), // from src/utils/
|
|
13
|
+
join(__dirname, '..', 'package.json'), // from dist/
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
for (const packagePath of paths) {
|
|
17
|
+
try {
|
|
18
|
+
if (existsSync(packagePath)) {
|
|
19
|
+
const content = readFileSync(packagePath, 'utf-8');
|
|
20
|
+
const pkg = JSON.parse(content) as { version: string };
|
|
21
|
+
return pkg.version;
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return 'unknown';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Config {
|
|
31
|
+
version?: string; // app version that last saved this config
|
|
32
|
+
configVersion?: string; // config schema version
|
|
33
|
+
watchPaths?: string[];
|
|
34
|
+
watchDelay?: number; // minutes
|
|
35
|
+
watchDepth?: number; // folder depth (default: 3, max: 5)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getConfigPath(): string {
|
|
39
|
+
return join(homedir(), '.config', 'ai-session-tidy', 'config.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadConfig(): Config {
|
|
43
|
+
const configPath = getConfigPath();
|
|
44
|
+
|
|
45
|
+
if (!existsSync(configPath)) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
51
|
+
return JSON.parse(content) as Config;
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function saveConfig(config: Config): void {
|
|
58
|
+
const configPath = getConfigPath();
|
|
59
|
+
const configDir = dirname(configPath);
|
|
60
|
+
|
|
61
|
+
if (!existsSync(configDir)) {
|
|
62
|
+
mkdirSync(configDir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const configWithVersion: Config = {
|
|
66
|
+
version: getAppVersion(),
|
|
67
|
+
configVersion: CONFIG_VERSION,
|
|
68
|
+
...config,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
writeFileSync(configPath, JSON.stringify(configWithVersion, null, 2), 'utf-8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getWatchPaths(): string[] | undefined {
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
return config.watchPaths;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function setWatchPaths(paths: string[]): void {
|
|
80
|
+
const config = loadConfig();
|
|
81
|
+
config.watchPaths = paths;
|
|
82
|
+
saveConfig(config);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function addWatchPath(path: string): void {
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
const resolved = resolve(path.replace(/^~/, homedir()));
|
|
88
|
+
const paths = config.watchPaths ?? [];
|
|
89
|
+
if (!paths.includes(resolved)) {
|
|
90
|
+
paths.push(resolved);
|
|
91
|
+
config.watchPaths = paths;
|
|
92
|
+
saveConfig(config);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function removeWatchPath(path: string): boolean {
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
const resolved = resolve(path.replace(/^~/, homedir()));
|
|
99
|
+
const paths = config.watchPaths ?? [];
|
|
100
|
+
const index = paths.indexOf(resolved);
|
|
101
|
+
if (index === -1) return false;
|
|
102
|
+
paths.splice(index, 1);
|
|
103
|
+
config.watchPaths = paths;
|
|
104
|
+
saveConfig(config);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getWatchDelay(): number | undefined {
|
|
109
|
+
const config = loadConfig();
|
|
110
|
+
return config.watchDelay;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function setWatchDelay(minutes: number): void {
|
|
114
|
+
const config = loadConfig();
|
|
115
|
+
config.watchDelay = minutes;
|
|
116
|
+
saveConfig(config);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getWatchDepth(): number | undefined {
|
|
120
|
+
const config = loadConfig();
|
|
121
|
+
return config.watchDepth;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function setWatchDepth(depth: number): void {
|
|
125
|
+
const config = loadConfig();
|
|
126
|
+
config.watchDepth = Math.min(depth, 5); // max 5
|
|
127
|
+
saveConfig(config);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resetConfig(): void {
|
|
131
|
+
saveConfig({});
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export interface Logger {
|
|
4
|
+
info(message: string): void;
|
|
5
|
+
warn(message: string): void;
|
|
6
|
+
error(message: string): void;
|
|
7
|
+
success(message: string): void;
|
|
8
|
+
debug(message: string): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const logger: Logger = {
|
|
12
|
+
info(message: string): void {
|
|
13
|
+
console.log(chalk.blue('ℹ'), message);
|
|
14
|
+
},
|
|
15
|
+
warn(message: string): void {
|
|
16
|
+
console.log(chalk.yellow('⚠'), message);
|
|
17
|
+
},
|
|
18
|
+
error(message: string): void {
|
|
19
|
+
console.log(chalk.red('✖'), message);
|
|
20
|
+
},
|
|
21
|
+
success(message: string): void {
|
|
22
|
+
console.log(chalk.green('✔'), message);
|
|
23
|
+
},
|
|
24
|
+
debug(message: string): void {
|
|
25
|
+
if (process.env['DEBUG']) {
|
|
26
|
+
console.log(chalk.gray('🐛'), message);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
import { encodePath, decodePath, getConfigDir } from './paths.js';
|
|
6
|
+
|
|
7
|
+
describe('encodePath', () => {
|
|
8
|
+
it('converts slashes to dashes', () => {
|
|
9
|
+
expect(encodePath('/home/user/project')).toBe('-home-user-project');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('handles empty string', () => {
|
|
13
|
+
expect(encodePath('')).toBe('');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('handles root path', () => {
|
|
17
|
+
expect(encodePath('/')).toBe('-');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('keeps Windows style paths unchanged', () => {
|
|
21
|
+
expect(encodePath('C:\\Users\\project')).toBe('C:\\Users\\project');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('decodePath', () => {
|
|
26
|
+
it('restores dashes to slashes', () => {
|
|
27
|
+
expect(decodePath('-home-user-project')).toBe('/home/user/project');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('handles empty string', () => {
|
|
31
|
+
expect(decodePath('')).toBe('');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('restores single dash to root', () => {
|
|
35
|
+
expect(decodePath('-')).toBe('/');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('getConfigDir', () => {
|
|
40
|
+
const originalPlatform = process.platform;
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns Application Support path on macOS', () => {
|
|
47
|
+
vi.stubGlobal('process', { ...process, platform: 'darwin' });
|
|
48
|
+
expect(getConfigDir('TestApp')).toBe(
|
|
49
|
+
join(homedir(), 'Library/Application Support', 'TestApp')
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns APPDATA path on Windows', () => {
|
|
54
|
+
const mockAppData = 'C:\\Users\\Test\\AppData\\Roaming';
|
|
55
|
+
vi.stubGlobal('process', {
|
|
56
|
+
...process,
|
|
57
|
+
platform: 'win32',
|
|
58
|
+
env: { ...process.env, APPDATA: mockAppData },
|
|
59
|
+
});
|
|
60
|
+
expect(getConfigDir('TestApp')).toBe(join(mockAppData, 'TestApp'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns .config path on Linux', () => {
|
|
64
|
+
vi.stubGlobal('process', { ...process, platform: 'linux' });
|
|
65
|
+
expect(getConfigDir('TestApp')).toBe(join(homedir(), '.config', 'TestApp'));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('getClaudeProjectsDir', () => {
|
|
70
|
+
it('returns ~/.claude/projects path', async () => {
|
|
71
|
+
const { getClaudeProjectsDir } = await import('./paths.js');
|
|
72
|
+
expect(getClaudeProjectsDir()).toBe(join(homedir(), '.claude', 'projects'));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('getCursorWorkspaceDir', () => {
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.unstubAllGlobals();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns Cursor workspaceStorage path on macOS', async () => {
|
|
82
|
+
vi.stubGlobal('process', { ...process, platform: 'darwin' });
|
|
83
|
+
const { getCursorWorkspaceDir } = await import('./paths.js');
|
|
84
|
+
expect(getCursorWorkspaceDir()).toBe(
|
|
85
|
+
join(
|
|
86
|
+
homedir(),
|
|
87
|
+
'Library/Application Support',
|
|
88
|
+
'Cursor',
|
|
89
|
+
'User',
|
|
90
|
+
'workspaceStorage'
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Encode Unix path to Claude Code style
|
|
6
|
+
* /home/user/project → -home-user-project
|
|
7
|
+
*/
|
|
8
|
+
export function encodePath(path: string): string {
|
|
9
|
+
if (path === '') return '';
|
|
10
|
+
// Only convert Unix slashes, leave Windows paths as-is
|
|
11
|
+
if (!path.includes('/')) return path;
|
|
12
|
+
return path.replace(/\//g, '-');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decode Claude Code encoded path to Unix path
|
|
17
|
+
* -home-user-project → /home/user/project
|
|
18
|
+
*/
|
|
19
|
+
export function decodePath(encoded: string): string {
|
|
20
|
+
if (encoded === '') return '';
|
|
21
|
+
// Treat as Unix encoding if it starts with a dash
|
|
22
|
+
if (!encoded.startsWith('-')) return encoded;
|
|
23
|
+
return encoded.replace(/-/g, '/');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Return platform-specific application config directory
|
|
28
|
+
*/
|
|
29
|
+
export function getConfigDir(appName: string): string {
|
|
30
|
+
switch (process.platform) {
|
|
31
|
+
case 'darwin':
|
|
32
|
+
return join(homedir(), 'Library/Application Support', appName);
|
|
33
|
+
case 'win32':
|
|
34
|
+
return join(process.env['APPDATA'] || '', appName);
|
|
35
|
+
default:
|
|
36
|
+
return join(homedir(), '.config', appName);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Claude Code projects directory path
|
|
42
|
+
*/
|
|
43
|
+
export function getClaudeProjectsDir(): string {
|
|
44
|
+
return join(homedir(), '.claude', 'projects');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Claude Code global config file path (~/.claude.json)
|
|
49
|
+
*/
|
|
50
|
+
export function getClaudeConfigPath(): string {
|
|
51
|
+
return join(homedir(), '.claude.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cursor workspaceStorage directory path
|
|
56
|
+
*/
|
|
57
|
+
export function getCursorWorkspaceDir(): string {
|
|
58
|
+
return join(getConfigDir('Cursor'), 'User', 'workspaceStorage');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Claude Code session environment directory path (~/.claude/session-env)
|
|
63
|
+
*/
|
|
64
|
+
export function getClaudeSessionEnvDir(): string {
|
|
65
|
+
return join(homedir(), '.claude', 'session-env');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Claude Code todos directory path (~/.claude/todos)
|
|
70
|
+
*/
|
|
71
|
+
export function getClaudeTodosDir(): string {
|
|
72
|
+
return join(homedir(), '.claude', 'todos');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Claude Code file-history directory path (~/.claude/file-history)
|
|
77
|
+
*/
|
|
78
|
+
export function getClaudeFileHistoryDir(): string {
|
|
79
|
+
return join(homedir(), '.claude', 'file-history');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Replace home directory with ~ for display
|
|
84
|
+
* /Users/user/.ai-session-tidy → ~/.ai-session-tidy
|
|
85
|
+
*/
|
|
86
|
+
export function tildify(path: string): string {
|
|
87
|
+
const home = homedir();
|
|
88
|
+
if (path.startsWith(home)) {
|
|
89
|
+
return path.replace(home, '~');
|
|
90
|
+
}
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
import { formatSize, getDirectorySize } from './size.js';
|
|
7
|
+
|
|
8
|
+
describe('formatSize', () => {
|
|
9
|
+
it('formats bytes (< 1KB)', () => {
|
|
10
|
+
expect(formatSize(0)).toBe('0 B');
|
|
11
|
+
expect(formatSize(500)).toBe('500 B');
|
|
12
|
+
expect(formatSize(1023)).toBe('1023 B');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('formats KB (< 1MB)', () => {
|
|
16
|
+
expect(formatSize(1024)).toBe('1.0 KB');
|
|
17
|
+
expect(formatSize(1536)).toBe('1.5 KB');
|
|
18
|
+
expect(formatSize(10240)).toBe('10.0 KB');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('formats MB (< 1GB)', () => {
|
|
22
|
+
expect(formatSize(1048576)).toBe('1.0 MB');
|
|
23
|
+
expect(formatSize(1572864)).toBe('1.5 MB');
|
|
24
|
+
expect(formatSize(104857600)).toBe('100.0 MB');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('formats GB', () => {
|
|
28
|
+
expect(formatSize(1073741824)).toBe('1.0 GB');
|
|
29
|
+
expect(formatSize(1610612736)).toBe('1.5 GB');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats negative as 0', () => {
|
|
33
|
+
expect(formatSize(-100)).toBe('0 B');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('getDirectorySize', () => {
|
|
38
|
+
const testDir = join(tmpdir(), 'size-test-' + Date.now());
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await mkdir(testDir, { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await rm(testDir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns 0 for empty directory', async () => {
|
|
49
|
+
const size = await getDirectorySize(testDir);
|
|
50
|
+
expect(size).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('calculates single file size', async () => {
|
|
54
|
+
const content = 'a'.repeat(1000);
|
|
55
|
+
await writeFile(join(testDir, 'file.txt'), content);
|
|
56
|
+
const size = await getDirectorySize(testDir);
|
|
57
|
+
expect(size).toBe(1000);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('calculates total size of multiple files', async () => {
|
|
61
|
+
await writeFile(join(testDir, 'file1.txt'), 'a'.repeat(500));
|
|
62
|
+
await writeFile(join(testDir, 'file2.txt'), 'b'.repeat(300));
|
|
63
|
+
const size = await getDirectorySize(testDir);
|
|
64
|
+
expect(size).toBe(800);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('includes nested directory sizes', async () => {
|
|
68
|
+
const subDir = join(testDir, 'subdir');
|
|
69
|
+
await mkdir(subDir, { recursive: true });
|
|
70
|
+
await writeFile(join(testDir, 'root.txt'), 'a'.repeat(100));
|
|
71
|
+
await writeFile(join(subDir, 'nested.txt'), 'b'.repeat(200));
|
|
72
|
+
const size = await getDirectorySize(testDir);
|
|
73
|
+
expect(size).toBe(300);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns 0 for non-existent directory', async () => {
|
|
77
|
+
const size = await getDirectorySize('/nonexistent/path/12345');
|
|
78
|
+
expect(size).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readdir, stat } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format byte size to human-readable format
|
|
8
|
+
*/
|
|
9
|
+
export function formatSize(bytes: number): string {
|
|
10
|
+
if (bytes <= 0) return '0 B';
|
|
11
|
+
|
|
12
|
+
let size = bytes;
|
|
13
|
+
let unitIndex = 0;
|
|
14
|
+
|
|
15
|
+
while (size >= 1024 && unitIndex < UNITS.length - 1) {
|
|
16
|
+
size /= 1024;
|
|
17
|
+
unitIndex++;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (unitIndex === 0) {
|
|
21
|
+
return `${Math.floor(size)} ${UNITS[unitIndex]}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return `${size.toFixed(1)} ${UNITS[unitIndex]}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursively calculate total size of a directory
|
|
29
|
+
*/
|
|
30
|
+
export async function getDirectorySize(dirPath: string): Promise<number> {
|
|
31
|
+
try {
|
|
32
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
33
|
+
let totalSize = 0;
|
|
34
|
+
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const fullPath = join(dirPath, entry.name);
|
|
37
|
+
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
totalSize += await getDirectorySize(fullPath);
|
|
40
|
+
} else if (entry.isFile()) {
|
|
41
|
+
const fileStat = await stat(fullPath);
|
|
42
|
+
totalSize += fileStat.size;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return totalSize;
|
|
47
|
+
} catch {
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
}
|