ccmv 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/lib/index.js ADDED
@@ -0,0 +1,352 @@
1
+ import { existsSync, renameSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import {
4
+ logInfo,
5
+ logOk,
6
+ logCheck,
7
+ logError,
8
+ logDone,
9
+ logDryrun,
10
+ logRollback,
11
+ setVerbose,
12
+ } from './logger.js';
13
+ import {
14
+ encodePath,
15
+ pathToFileUri,
16
+ resolvePath,
17
+ resolveNewPath,
18
+ } from './utils.js';
19
+ import {
20
+ checkClaudeExists,
21
+ createBackup,
22
+ renameClaudeDir,
23
+ updateProjectFiles,
24
+ updateHistory,
25
+ verifyClaude,
26
+ rollbackClaude,
27
+ removeBackup,
28
+ } from './claude.js';
29
+ import {
30
+ detectCursor,
31
+ checkCursorNotRunning,
32
+ findCursorWorkspaces,
33
+ backupCursorData,
34
+ renameCursorWorkspace,
35
+ updateCursorStorageJson,
36
+ updateCursorStateVscdb,
37
+ updateCursorWorkspaceStorage,
38
+ verifyCursorMigration,
39
+ rollbackCursor,
40
+ } from './cursor.js';
41
+
42
+ const USAGE = `Usage: ccmv [OPTIONS] <old-path> <new-path>
43
+
44
+ Moves a project directory and updates all Claude Code references.
45
+ If Cursor is installed, also updates Cursor's workspace data automatically.
46
+
47
+ Options:
48
+ --refs-only Only update Claude's references (don't move the actual directory)
49
+ --dry-run Show what would be done without making changes
50
+ --keep-backup Don't remove backups after successful migration
51
+ --no-cursor Skip Cursor data updates even if Cursor is installed
52
+ --verbose Show detailed logs (default: on)
53
+ --quiet Suppress detailed logs
54
+ --help Show this help message
55
+
56
+ Examples:
57
+ ccmv /Users/jane/old-project /Users/jane/new-project
58
+ ccmv --dry-run ~/projects/myapp ~/work/myapp
59
+ ccmv --refs-only /old/path /new/path
60
+ ccmv --no-cursor ~/proj-a ~/proj-b
61
+ `;
62
+
63
+ /**
64
+ * Parse command line arguments
65
+ */
66
+ function parseArgs(args) {
67
+ const options = {
68
+ dryRun: false,
69
+ refsOnly: false,
70
+ keepBackup: false,
71
+ verbose: true,
72
+ noCursor: false,
73
+ help: false,
74
+ paths: [],
75
+ };
76
+
77
+ for (const arg of args) {
78
+ switch (arg) {
79
+ case '--dry-run':
80
+ options.dryRun = true;
81
+ break;
82
+ case '--refs-only':
83
+ options.refsOnly = true;
84
+ break;
85
+ case '--keep-backup':
86
+ options.keepBackup = true;
87
+ break;
88
+ case '--verbose':
89
+ options.verbose = true;
90
+ break;
91
+ case '--quiet':
92
+ options.verbose = false;
93
+ break;
94
+ case '--no-cursor':
95
+ options.noCursor = true;
96
+ break;
97
+ case '--help':
98
+ case '-h':
99
+ options.help = true;
100
+ break;
101
+ default:
102
+ if (arg.startsWith('-')) {
103
+ logError(`Unknown option: ${arg}`);
104
+ console.log('Use --help for usage information');
105
+ process.exit(1);
106
+ }
107
+ options.paths.push(arg);
108
+ }
109
+ }
110
+
111
+ return options;
112
+ }
113
+
114
+ /**
115
+ * Validate paths and environment
116
+ */
117
+ function validate(oldPath, newPath, refsOnly) {
118
+ logCheck('Validating paths...');
119
+
120
+ // Check old path exists
121
+ if (!existsSync(oldPath)) {
122
+ if (refsOnly) {
123
+ logInfo("Old path doesn't exist (OK for --refs-only mode)");
124
+ } else {
125
+ logError(`Old path does not exist: ${oldPath}`);
126
+ return false;
127
+ }
128
+ } else {
129
+ logOk('Old path exists');
130
+ }
131
+
132
+ // Check new path doesn't exist (unless refs-only)
133
+ if (!refsOnly) {
134
+ if (existsSync(newPath)) {
135
+ logError(`New path already exists: ${newPath}`);
136
+ return false;
137
+ }
138
+ logOk('New path does not exist');
139
+ }
140
+
141
+ console.log('');
142
+ return true;
143
+ }
144
+
145
+ /**
146
+ * Move the actual project directory
147
+ */
148
+ function moveProject(oldPath, newPath, refsOnly, dryRun) {
149
+ if (refsOnly) {
150
+ return;
151
+ }
152
+
153
+ if (dryRun) {
154
+ logDryrun(`Would move: ${oldPath} -> ${newPath}`);
155
+ console.log('');
156
+ return;
157
+ }
158
+
159
+ logInfo('Moving project directory...');
160
+ renameSync(oldPath, newPath);
161
+ logOk(`Moved: ${oldPath} -> ${newPath}`);
162
+ console.log('');
163
+ }
164
+
165
+ /**
166
+ * Rollback on error
167
+ */
168
+ function rollback(backupDir, oldPath, newPath, encodedOld, encodedNew, refsOnly, cursorDetected) {
169
+ if (!backupDir || !existsSync(backupDir)) {
170
+ logError('No backup available for rollback');
171
+ return;
172
+ }
173
+
174
+ logError('Migration failed! Rolling back...');
175
+
176
+ // Rollback Claude data
177
+ rollbackClaude(backupDir, encodedOld, encodedNew);
178
+
179
+ // Restore original project location if moved
180
+ if (!refsOnly) {
181
+ if (existsSync(newPath) && !existsSync(oldPath)) {
182
+ renameSync(newPath, oldPath);
183
+ logRollback('Moved project back to original location');
184
+ }
185
+ }
186
+
187
+ // Rollback Cursor data
188
+ if (cursorDetected) {
189
+ rollbackCursor(backupDir);
190
+ }
191
+
192
+ logRollback('Complete. Your project is unchanged.');
193
+ console.log('');
194
+ console.log(`Backup kept at: ${backupDir}`);
195
+ }
196
+
197
+ /**
198
+ * Main entry point
199
+ */
200
+ export async function main(args) {
201
+ const options = parseArgs(args);
202
+
203
+ if (options.help) {
204
+ console.log(USAGE);
205
+ process.exit(0);
206
+ }
207
+
208
+ if (options.paths.length !== 2) {
209
+ logError('Expected 2 arguments: old-path and new-path');
210
+ console.log('Use --help for usage information');
211
+ process.exit(1);
212
+ }
213
+
214
+ setVerbose(options.verbose);
215
+
216
+ // Resolve paths
217
+ const oldPath = resolvePath(options.paths[0]);
218
+ const newPath = resolveNewPath(options.paths[1]);
219
+
220
+ // Encode paths for Claude
221
+ const encodedOld = encodePath(oldPath);
222
+ const encodedNew = encodePath(newPath);
223
+
224
+ // Generate file:// URIs for Cursor
225
+ const oldFileUri = pathToFileUri(oldPath);
226
+ const newFileUri = pathToFileUri(newPath);
227
+
228
+ // Display info
229
+ console.log('');
230
+ logInfo(`Old path: ${oldPath}`);
231
+ logInfo(`New path: ${newPath}`);
232
+ logInfo(`Encoded old: ${encodedOld}`);
233
+ logInfo(`Encoded new: ${encodedNew}`);
234
+
235
+ if (options.dryRun) {
236
+ console.log('');
237
+ logDryrun('Running in dry-run mode - no changes will be made');
238
+ }
239
+
240
+ if (options.refsOnly) {
241
+ console.log('');
242
+ logInfo('Running in refs-only mode - project directory will not be moved');
243
+ }
244
+
245
+ console.log('');
246
+
247
+ // State for rollback
248
+ let backupDir = null;
249
+ let cursorDetected = false;
250
+ let claudeExists = false;
251
+ let cursorWorkspaceInfo = { oldWorkspaceHash: null, workspaceDirs: [] };
252
+
253
+ try {
254
+ // Validate paths
255
+ if (!validate(oldPath, newPath, options.refsOnly)) {
256
+ process.exit(1);
257
+ }
258
+
259
+ // Check Claude data exists
260
+ claudeExists = checkClaudeExists(encodedOld);
261
+
262
+ // Detect and check Cursor
263
+ cursorDetected = detectCursor(options.noCursor);
264
+ if (!checkCursorNotRunning(cursorDetected, options.dryRun)) {
265
+ process.exit(1);
266
+ }
267
+
268
+ // Find Cursor workspaces
269
+ cursorWorkspaceInfo = findCursorWorkspaces(cursorDetected, oldPath);
270
+
271
+ // Fail if neither Claude nor Cursor data exists
272
+ const hasCursorData = cursorWorkspaceInfo.workspaceDirs.length > 0;
273
+ if (!claudeExists && !hasCursorData) {
274
+ logError('No Claude Code or Cursor data found for this project.');
275
+ logError('Use regular mv command to move the directory.');
276
+ process.exit(1);
277
+ }
278
+
279
+ // Create backups
280
+ backupDir = createBackup(encodedOld, options.dryRun, claudeExists);
281
+ backupCursorData(cursorDetected, cursorWorkspaceInfo.workspaceDirs, backupDir, options.dryRun);
282
+
283
+ // Move project
284
+ moveProject(oldPath, newPath, options.refsOnly, options.dryRun);
285
+
286
+ // Rename Cursor workspace (after move, uses new birthtime)
287
+ const renamedWorkspace = renameCursorWorkspace(
288
+ cursorDetected,
289
+ cursorWorkspaceInfo.oldWorkspaceHash,
290
+ newPath,
291
+ oldFileUri,
292
+ newFileUri,
293
+ options.dryRun
294
+ );
295
+ if (renamedWorkspace.workspaceDirs.length > 0) {
296
+ cursorWorkspaceInfo.workspaceDirs = renamedWorkspace.workspaceDirs;
297
+ }
298
+
299
+ // Rename Claude dir
300
+ renameClaudeDir(encodedOld, encodedNew, options.dryRun, claudeExists);
301
+
302
+ // Update files
303
+ updateProjectFiles(oldPath, newPath, encodedOld, encodedNew, options.dryRun, claudeExists);
304
+ updateHistory(oldPath, newPath, options.dryRun);
305
+
306
+ // Update Cursor data
307
+ updateCursorStorageJson(cursorDetected, oldPath, newPath, oldFileUri, newFileUri, options.dryRun);
308
+ await updateCursorStateVscdb(cursorDetected, oldPath, newPath, oldFileUri, newFileUri, options.dryRun);
309
+ await updateCursorWorkspaceStorage(
310
+ cursorDetected,
311
+ cursorWorkspaceInfo.workspaceDirs,
312
+ oldPath,
313
+ newPath,
314
+ oldFileUri,
315
+ newFileUri,
316
+ options.dryRun
317
+ );
318
+
319
+ // Verify
320
+ if (!verifyClaude(oldPath, encodedNew, options.dryRun, claudeExists)) {
321
+ throw new Error('Claude verification failed');
322
+ }
323
+ if (!verifyCursorMigration(cursorDetected, cursorWorkspaceInfo.workspaceDirs, oldPath, newPath, options.dryRun)) {
324
+ throw new Error('Cursor verification failed');
325
+ }
326
+
327
+ // Cleanup
328
+ if (!options.dryRun) {
329
+ if (options.keepBackup) {
330
+ logInfo(`Backup kept at: ${backupDir.replace(homedir(), '~')}/`);
331
+ } else {
332
+ removeBackup(backupDir);
333
+ }
334
+ }
335
+
336
+ // Success
337
+ if (options.dryRun) {
338
+ logDone('Dry-run complete! No changes were made.');
339
+ } else {
340
+ logDone('Migration complete!');
341
+ if (!options.refsOnly) {
342
+ logInfo(`Your project is now at: ${newPath}`);
343
+ }
344
+ }
345
+ } catch (err) {
346
+ if (!options.dryRun && backupDir) {
347
+ rollback(backupDir, oldPath, newPath, encodedOld, encodedNew, options.refsOnly, cursorDetected);
348
+ }
349
+ logError(err.message);
350
+ process.exit(1);
351
+ }
352
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,60 @@
1
+ // ANSI color codes
2
+ const colors = {
3
+ red: '\x1b[0;31m',
4
+ green: '\x1b[0;32m',
5
+ yellow: '\x1b[0;33m',
6
+ blue: '\x1b[0;34m',
7
+ reset: '\x1b[0m',
8
+ };
9
+
10
+ let verbose = true;
11
+
12
+ export function setVerbose(value) {
13
+ verbose = value;
14
+ }
15
+
16
+ export function logInfo(message) {
17
+ console.log(`${colors.blue}[INFO]${colors.reset} ${message}`);
18
+ }
19
+
20
+ export function logOk(message) {
21
+ console.log(`${colors.green}[OK]${colors.reset} ${message}`);
22
+ }
23
+
24
+ export function logCheck(message) {
25
+ console.log(`${colors.yellow}[CHECK]${colors.reset} ${message}`);
26
+ }
27
+
28
+ export function logBackup(message) {
29
+ console.log(`${colors.yellow}[BACKUP]${colors.reset} ${message}`);
30
+ }
31
+
32
+ export function logFile(message) {
33
+ console.log(`${colors.blue}[FILE]${colors.reset} ${message}`);
34
+ }
35
+
36
+ export function logReplace(message) {
37
+ if (verbose) {
38
+ console.log(` ${colors.green}[REPLACE]${colors.reset} ${message}`);
39
+ }
40
+ }
41
+
42
+ export function logRename(message) {
43
+ console.log(`${colors.blue}[RENAME]${colors.reset} ${message}`);
44
+ }
45
+
46
+ export function logDone(message) {
47
+ console.log(`${colors.green}[DONE]${colors.reset} ${message}`);
48
+ }
49
+
50
+ export function logError(message) {
51
+ console.error(`${colors.red}[ERROR]${colors.reset} ${message}`);
52
+ }
53
+
54
+ export function logRollback(message) {
55
+ console.log(`${colors.yellow}[ROLLBACK]${colors.reset} ${message}`);
56
+ }
57
+
58
+ export function logDryrun(message) {
59
+ console.log(`${colors.yellow}[DRY-RUN]${colors.reset} ${message}`);
60
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,93 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { statSync, existsSync } from 'node:fs';
3
+ import { resolve, dirname, basename } from 'node:path';
4
+
5
+ /**
6
+ * Encode path for Claude projects directory
7
+ * Rule: / -> -, : -> -, space -> -
8
+ */
9
+ export function encodePath(path) {
10
+ return path.replace(/[\/: ]/g, '-');
11
+ }
12
+
13
+ /**
14
+ * Convert filesystem path to file:// URI with URL encoding
15
+ */
16
+ export function pathToFileUri(path) {
17
+ // URL encode the path, keeping / as safe character
18
+ const encoded = path
19
+ .split('/')
20
+ .map((segment) => encodeURIComponent(segment))
21
+ .join('/');
22
+ return `file://${encoded}`;
23
+ }
24
+
25
+ /**
26
+ * Calculate VSCode/Cursor workspace hash from path
27
+ * Hash = MD5(path + birthtime_ms)
28
+ */
29
+ export function getWorkspaceHash(path) {
30
+ if (!existsSync(path)) {
31
+ return null;
32
+ }
33
+
34
+ try {
35
+ const stat = statSync(path);
36
+ const birthtimeMs = stat.birthtime.getTime();
37
+ const hash = createHash('md5')
38
+ .update(path)
39
+ .update(String(birthtimeMs))
40
+ .digest('hex');
41
+ return hash;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve path to absolute, handling relative paths
49
+ */
50
+ export function resolvePath(inputPath) {
51
+ // Expand ~ to home directory
52
+ if (inputPath.startsWith('~')) {
53
+ inputPath = inputPath.replace(/^~/, process.env.HOME || '');
54
+ }
55
+ return resolve(inputPath).replace(/\/$/, ''); // Remove trailing slash
56
+ }
57
+
58
+ /**
59
+ * Resolve new path (which may not exist yet)
60
+ */
61
+ export function resolveNewPath(inputPath) {
62
+ // Expand ~ to home directory
63
+ if (inputPath.startsWith('~')) {
64
+ inputPath = inputPath.replace(/^~/, process.env.HOME || '');
65
+ }
66
+
67
+ const parent = dirname(inputPath);
68
+ const name = basename(inputPath);
69
+
70
+ // Resolve parent (which should exist)
71
+ const resolvedParent = resolve(parent);
72
+ return `${resolvedParent}/${name}`.replace(/\/$/, '');
73
+ }
74
+
75
+ /**
76
+ * Convert path to tilde form if under home directory
77
+ */
78
+ export function pathToTilde(path) {
79
+ const home = process.env.HOME || '';
80
+ if (path.startsWith(home)) {
81
+ return path.replace(home, '~');
82
+ }
83
+ return path;
84
+ }
85
+
86
+ /**
87
+ * Format timestamp for backup directory
88
+ */
89
+ export function formatTimestamp() {
90
+ const now = new Date();
91
+ const pad = (n) => String(n).padStart(2, '0');
92
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
93
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "ccmv",
3
+ "version": "1.0.0",
4
+ "description": "Claude Code project directory migration tool",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "bin": {
8
+ "ccmv": "./bin/ccmv.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test"
12
+ },
13
+ "keywords": [
14
+ "claude",
15
+ "claude-code",
16
+ "migration",
17
+ "cursor",
18
+ "vscode"
19
+ ],
20
+ "author": "Saqoosha",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/saqoosha/ccmv.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/saqoosha/ccmv/issues"
28
+ },
29
+ "homepage": "https://github.com/saqoosha/ccmv#readme",
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ },
33
+ "dependencies": {
34
+ "better-sqlite3": "^11.0.0"
35
+ }
36
+ }