@rhseung/claude-mv 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hyunseung Ryu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # claude-mv
2
+
3
+ Claude Code는 프로젝트 상태를 절대 경로로 키를 삼아 저장한다. 그래서 디렉터리를 옮기면
4
+ 세션 기록을 더는 찾지 못한다. 이 도구는 디렉터리와 상태를 함께 옮긴다.
5
+
6
+ ```
7
+ npx @rhseung/claude-mv doctor 연결이 끊긴 상태가 있는지 확인
8
+ npx @rhseung/claude-mv --dry-run <old> <new> 무엇이 바뀌는지만 확인
9
+ npx @rhseung/claude-mv <old> <new> 디렉터리와 상태를 함께 이동
10
+ npx @rhseung/claude-mv --state-only <old> <new> 이미 옮긴 디렉터리의 상태만 이관
11
+ ```
12
+
13
+ ## 설치
14
+
15
+ ```
16
+ bun install -g @rhseung/claude-mv
17
+ npm install -g @rhseung/claude-mv
18
+ ```
19
+
20
+ ## 명령
21
+
22
+ | 명령 | 동작 |
23
+ | ------------------- | ---------------------------------------------------------------- |
24
+ | `mv <src> <dst>` | 디렉터리와 상태를 함께 옮긴다. 명령을 생략하면 이것이 기본값이다 |
25
+ | `ls` | Claude가 알고 있는 프로젝트를 전부 보여준다 |
26
+ | `info <경로>` | 프로젝트 하나의 상태를 자세히 보여준다 |
27
+ | `doctor` | 연결이 끊긴 상태를 찾아내고 어디로 옮겨졌는지 추정한다 |
28
+ | `rm <경로>` | 프로젝트 하나의 Claude 상태를 삭제한다 (백업 후 진행) |
29
+ | `merge <from> <to>` | 프로젝트 디렉터리 두 개를 합친다 |
30
+ | `backup [경로]` | 이동하지 않고 스냅샷만 만든다 |
31
+ | `rollback [id]` | 백업 목록을 보여주거나 지정한 백업으로 복구한다 |
32
+ | `export <경로>` | 상태를 tarball로 내보낸다 |
33
+ | `import <파일>` | tarball에서 들여온다. `--to`를 지정하면 이어서 이관한다 |
34
+ | `completion <셸>` | 자동완성 스크립트를 출력한다 |
35
+
36
+ | 플래그 | 의미 |
37
+ | ---------------------- | -------------------------------------------------------- |
38
+ | `-n, --dry-run` | 계획만 보여주고 아무것도 기록하지 않는다 |
39
+ | `-y, --yes` | 확인 프롬프트를 건너뛴다 |
40
+ | `-f, --force` | lock과 상태 충돌을 차단 대신 경고로 낮춘다 |
41
+ | `--state-only` | 디렉터리는 그대로 두고 상태만 옮긴다 |
42
+ | `--skip-live` | 실행 중인 세션의 transcript는 건드리지 않는다 |
43
+ | `--rewrite-prose` | 대화 본문에 포함된 경로까지 바꾼다 |
44
+ | `--no-backup` | 백업을 만들지 않는다. 이 경우 rollback도 불가능하다 |
45
+ | `--json` | 기계가 읽는 출력. 진행 상황은 stderr, 결과 하나는 stdout |
46
+ | `--claude-home <경로>` | `~/.claude` 위치를 재지정한다 |
47
+
48
+ ## 종료 코드
49
+
50
+ | 코드 | 의미 |
51
+ | ---- | --------------------------------------------- |
52
+ | 0 | 성공 |
53
+ | 2 | 사용법 오류 |
54
+ | 10 | 계획 생성 완료. **실패가 아니다** |
55
+ | 11 | 연결이 끊긴 상태 발견 |
56
+ | 12 | 이관 대상 없음 |
57
+ | 20 | 옛 경로에서 세션 실행 중 |
58
+ | 21 | 대상 경로에 상태가 이미 존재 |
59
+ | 22 | 전제 조건 위반. 원본 없음 또는 dst가 src 내부 |
60
+ | 30 | 실패 후 전체 복구 완료 |
61
+ | 31 | 실패 후 복구도 실패. 수동 복구 필요 |
62
+
63
+ ## 자동완성
64
+
65
+ ```
66
+ eval "$(claude-mv completion zsh)"
67
+ ```
68
+
69
+ 후보는 셸이 CLI에 다시 질의해서 받아가므로, `claude-mv info <TAB>`은 일반 파일이 아니라
70
+ Claude가 알고 있는 프로젝트 경로를 보여준다. zsh 스크립트는 `compadd`로 후보를 등록하기
71
+ 때문에 fzf-tab이 별도 설정 없이 동작한다. 인자를 생략하면 fzf picker가 나타난다
72
+ (`CLAUDE_MV_PICKER=fzf|ink|none`).
73
+
74
+ ## 플러그인
75
+
76
+ ```
77
+ /plugin marketplace add rhseung/claude-mv
78
+ /plugin install claude-mv@rhseung
79
+ ```
80
+
81
+ SessionStart hook은 현재 디렉터리에 프로젝트 기록이 없고 이름이 비슷하면서 연결이 끊긴
82
+ 상태가 남아 있을 때 이를 알려준다. 알림만 보내며 자동으로 옮기지는 않는다. `/claude-mv`
83
+ 슬래시 명령도 함께 설치된다.
84
+
85
+ ## 이관 대상
86
+
87
+ `mv`를 한 번 실행하면 아래가 모두 원래 경로와의 연결을 잃는다.
88
+
89
+ ```
90
+ ~/.claude/projects/-Users-me-dev-old-name/ 세션 기록(transcript)
91
+ ~/.claude.json projects["/Users/me/dev/old-name"]
92
+ ~/.claude/history.jsonl 의 project 필드
93
+ ~/Library/Caches/claude-cli-nodejs/-Users-me-dev-old-name/ MCP 로그
94
+ ~/.claude/plans/*.md 안의 경로 문자열
95
+ ```
96
+
97
+ 디렉터리 이름은 경로에서 영숫자가 아닌 문자를 전부 `-`로 바꾼 것이다. 이 변환은
98
+ **되돌릴 수 없다.** `/a/b-c`와 `/a/b/c`가 같은 이름이 되기 때문이다. 따라서 디렉터리
99
+ 이름만 보고 원래 경로를 알아낼 수는 없고, 안에 들어 있는 transcript의 `cwd`를 읽어야 한다.
100
+
101
+ `sessions/`, `file-history/`, `shell-snapshots/`, `session-env/`는 세션 id나 파일 해시를
102
+ 키로 삼고 있어 디렉터리 이름과 무관하므로 건드리지 않는다.
103
+
104
+ ## 수정 범위
105
+
106
+ transcript의 필드를 두 갈래로 나눈다.
107
+
108
+ **구조 필드**는 Claude Code가 실제로 읽는 값이다. `cwd`, `wireIngestContext.*.cwd`,
109
+ `attachment.snapshot.workingDirectory`가 여기에 해당한다. 경로가 바뀌면 반드시 따라가야 한다.
110
+
111
+ **서술 필드**는 대화 본문과 도구 출력이다. "그때 그 경로에서 이 명령을 실행했다"는 사실을
112
+ 남긴 기록이므로, 고치면 과거가 사실과 달라진다. 기본적으로 손대지 않으며, 필요하면
113
+ `--rewrite-prose`로 포함시킨다.
114
+
115
+ **plan 파일은 예외다.** 과거의 기록이 아니라 앞으로 따라야 할 지시서이므로, 낡은 경로가
116
+ 남아 있으면 그 경로를 따라 잘못된 작업을 하게 된다. 어떤 plan이 어느 프로젝트에 속하는지는
117
+ transcript의 `planFilePath` 구조 필드로 판정한다. 전역 plan 디렉터리를 경로 문자열로 훑으면
118
+ 다른 프로젝트의 plan까지 수정하게 되기 때문이다.
119
+
120
+ ## 안전장치
121
+
122
+ **백업.** 기록하기 전에 수정 대상 파일을 전부 스냅샷으로 남긴다. 같은 파일시스템이면
123
+ 복사 대신 하드링크를 걸기 때문에 상태가 수 GB에 이르러도 비용이 거의 들지 않는다.
124
+
125
+ **commit barrier.** 느리지만 되돌릴 수 있는 작업을 전부 앞쪽에 모으고, 실제 교체는 뒤쪽에
126
+ 짧게 배치한다. 스테이징 단계에서 실패하면 사용자에게 보이는 상태는 전혀 바뀌지 않는다.
127
+
128
+ **lock 감지.** 옛 경로에서 claude 세션이 실행 중이면 작업을 거부한다. pid 재사용을
129
+ 걸러내려고 프로세스 시작 시각까지 대조한다.
130
+
131
+ **동시 수정 감지.** 재작성하는 동안 파일이 바뀌면 덮어쓰지 않고 실패한다. 실행 중인
132
+ 세션이 덧붙인 줄을 말없이 잃어버리는 것보다 낫기 때문이다.
133
+
134
+ **rollback.** journal을 역순으로 재생해서 원래 상태로 복구한다. 복구 도중에 중단되어도
135
+ 다시 실행하면 이어서 마무리한다.
136
+
137
+ ## 개발
138
+
139
+ ```
140
+ bun install
141
+ bun run verify format, lint, typecheck, test
142
+ bun run build
143
+ ```
144
+
145
+ `plugin/hooks/session-start.mjs`는 빌드 산출물이지만 저장소에 커밋한다. 플러그인 설치가
146
+ 빌드 단계 없는 `git clone`이라, 커밋하지 않으면 hook이 빠진 플러그인이 배포되기 때문이다.
147
+ CI가 소스와 산출물이 어긋나지 않았는지 검사한다.
148
+
149
+ ## 라이선스
150
+
151
+ MIT
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/render/plain/reporter.ts
4
+ import { createInterface } from "readline/promises";
5
+
6
+ // src/render/format.ts
7
+ import prettyBytes from "pretty-bytes";
8
+ import prettyMs from "pretty-ms";
9
+ import stringWidth from "string-width";
10
+ function describeStep(step) {
11
+ switch (step.kind) {
12
+ case "move-project-dir":
13
+ return { label: "project dir", action: "rename", detail: `${step.files} files` };
14
+ case "rewrite-transcript":
15
+ return {
16
+ label: step.movesWithDir ? "transcript" : "transcript (\uC81C\uC790\uB9AC)",
17
+ action: "rewrite",
18
+ detail: `${step.affected}/${step.total} \uC904`
19
+ };
20
+ case "edit-config":
21
+ return {
22
+ label: "claude.json",
23
+ action: step.renameKey ? "rekey" : "edit",
24
+ detail: step.githubRepos.length ? `+ ${step.githubRepos.length} repo` : "1 key"
25
+ };
26
+ case "rewrite-history":
27
+ return { label: "history", action: "rewrite", detail: `${step.affected} \uC904` };
28
+ case "move-cache-dir":
29
+ return { label: "mcp logs", action: "rename", detail: "" };
30
+ case "rewrite-plan":
31
+ return { label: "plan", action: "rewrite", detail: basename(step.file) };
32
+ case "move-directory":
33
+ return { label: "working tree", action: "move", detail: "" };
34
+ case "repair-worktrees":
35
+ return { label: "worktrees", action: "repair", detail: "" };
36
+ }
37
+ }
38
+ function basename(path) {
39
+ return path.split(/[/\\]/).pop() ?? path;
40
+ }
41
+ function bytes(n) {
42
+ return prettyBytes(n);
43
+ }
44
+ function duration(ms) {
45
+ return prettyMs(ms, { compact: true });
46
+ }
47
+ function ago(ms) {
48
+ const delta = Date.now() - ms;
49
+ if (delta < 6e4) return "\uBC29\uAE08";
50
+ return `${prettyMs(delta, { compact: true })} \uC804`;
51
+ }
52
+ function pad(text, width) {
53
+ return text + " ".repeat(Math.max(0, width - stringWidth(text)));
54
+ }
55
+ function table(rows, gap = 2) {
56
+ const widths = rows[0]?.map((_, i) => Math.max(...rows.map((r) => stringWidth(r[i] ?? "")))) ?? [];
57
+ return rows.map(
58
+ (row) => row.map((cell, i) => i === row.length - 1 ? cell : pad(cell, widths[i] + gap)).join("").trimEnd()
59
+ );
60
+ }
61
+
62
+ // src/render/plain/reporter.ts
63
+ var PlainReporter = class {
64
+ constructor(out = process.stdout, err = process.stderr) {
65
+ this.out = out;
66
+ this.err = err;
67
+ }
68
+ out;
69
+ err;
70
+ write(line) {
71
+ this.out.write(`${line}
72
+ `);
73
+ }
74
+ plan(plan, opts) {
75
+ this.write(
76
+ `plan: ${plan.steps.length} steps${opts.backupId ? `, backup ${opts.backupId}` : ""}`
77
+ );
78
+ for (const step of plan.steps) {
79
+ const d = describeStep(step);
80
+ this.write(` ${d.action.padEnd(8)}${d.label.padEnd(22)}${d.detail}`);
81
+ }
82
+ for (const warning of plan.warnings) this.warn(JSON.stringify(warning));
83
+ if (plan.proseLeftBehind > 0) {
84
+ this.write(` skip prose ${plan.proseLeftBehind}\uACF3 (--rewrite-prose \uB85C \uD3EC\uD568)`);
85
+ }
86
+ }
87
+ async confirm(question) {
88
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
89
+ try {
90
+ const answer = await rl.question(`${question} [y/N] `);
91
+ return /^y(es)?$/i.test(answer.trim());
92
+ } finally {
93
+ rl.close();
94
+ }
95
+ }
96
+ progress(event) {
97
+ if (event.kind === "step-done") {
98
+ const d = describeStep(event.step);
99
+ this.write(`ok ${d.label}${event.detail ? ` ${event.detail}` : ""}`);
100
+ } else if (event.kind === "step-failed") {
101
+ this.write(`failed ${describeStep(event.step).label} ${event.error.message}`);
102
+ }
103
+ }
104
+ applied(plan, opts) {
105
+ this.write(`done: ${plan.steps.length} steps in ${duration(opts.durationMs)}`);
106
+ if (opts.backupId) this.write(`rollback: claude-mv rollback ${opts.backupId}`);
107
+ }
108
+ failed(_plan, opts) {
109
+ this.err.write(`error: ${opts.error.message}
110
+ `);
111
+ this.err.write(opts.rolledBack ? "\uC804\uBD80 \uC6D0\uB798\uB300\uB85C \uB3CC\uB838\uC2B5\uB2C8\uB2E4.\n" : "\uB418\uB3CC\uB9AC\uAE30\uB3C4 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4.\n");
112
+ if (opts.backupId) this.err.write(`backup: ${opts.backupId}
113
+ `);
114
+ }
115
+ locks(report) {
116
+ for (const finding of report.findings) {
117
+ this.write(
118
+ `lock pid ${finding.session.pid} ${finding.verdict} ${finding.relation} ${finding.session.cwd}`
119
+ );
120
+ }
121
+ }
122
+ doctor(report) {
123
+ this.write(`orphans: ${report.orphans.length}, healthy: ${report.healthy.length}`);
124
+ for (const entry of report.orphans) {
125
+ this.write(`orphan ${entry.project.primaryCwd ?? entry.project.dirName}`);
126
+ for (const s of entry.suggestions) {
127
+ this.write(
128
+ ` guess ${s.confidence.toFixed(2)} ${s.candidate} (${s.evidence.join(", ")})`
129
+ );
130
+ }
131
+ }
132
+ for (const c of report.collisions) this.warn(`collision ${c.dirName}: ${c.paths.join(" , ")}`);
133
+ }
134
+ list(projects) {
135
+ for (const p of projects) {
136
+ this.write(
137
+ `${p.health.padEnd(16)}${bytes(p.bytes).padStart(9)} ${String(p.transcripts.length).padStart(3)} ${p.primaryCwd ?? p.dirName}`
138
+ );
139
+ }
140
+ }
141
+ info(project) {
142
+ this.write(`path ${project.primaryCwd ?? "(\uD310\uC815 \uBD88\uAC00)"}`);
143
+ this.write(`dir ${project.dirPath}`);
144
+ this.write(`health ${project.health}`);
145
+ this.write(`size ${bytes(project.bytes)}`);
146
+ for (const t of project.transcripts) {
147
+ this.write(` ${t.kind.padEnd(9)}${t.sessionId} ${t.lines} \uC904 ${bytes(t.bytes)}`);
148
+ }
149
+ for (const [path, lines] of [...project.cwdCensus].sort((a, b) => b[1] - a[1])) {
150
+ this.write(` cwd ${String(lines).padStart(5)} ${path}`);
151
+ }
152
+ }
153
+ backups(items) {
154
+ if (items.length === 0) {
155
+ this.write("\uBC31\uC5C5\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
156
+ return;
157
+ }
158
+ for (const line of table(
159
+ items.map((b) => [
160
+ b.id,
161
+ ago(b.createdAt),
162
+ b.src,
163
+ "->",
164
+ b.dst,
165
+ b.restorable ? "" : "(\uB300\uC0C1 \uC5C6\uC74C)"
166
+ ])
167
+ )) {
168
+ this.write(line);
169
+ }
170
+ }
171
+ note(message) {
172
+ this.write(message);
173
+ }
174
+ warn(message) {
175
+ this.err.write(`warn: ${message}
176
+ `);
177
+ }
178
+ async close() {
179
+ }
180
+ };
181
+
182
+ export {
183
+ describeStep,
184
+ duration,
185
+ pad,
186
+ PlainReporter
187
+ };