claude-settings-sync 0.3.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 +21 -0
- package/README.ko.md +203 -0
- package/README.md +203 -0
- package/bin/claudesync.js +2 -0
- package/dist/chunk-45GTBXRR.js +145 -0
- package/dist/chunk-45GTBXRR.js.map +1 -0
- package/dist/chunk-BRTRPVT7.js +101 -0
- package/dist/chunk-BRTRPVT7.js.map +1 -0
- package/dist/chunk-VBOSEAEH.js +81 -0
- package/dist/chunk-VBOSEAEH.js.map +1 -0
- package/dist/chunk-XTJEVOK3.js +867 -0
- package/dist/chunk-XTJEVOK3.js.map +1 -0
- package/dist/cli.js +1693 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-7KJ6CZMX.js +33 -0
- package/dist/config-7KJ6CZMX.js.map +1 -0
- package/dist/gist-AMKOY723.js +21 -0
- package/dist/gist-AMKOY723.js.map +1 -0
- package/dist/scanner-M3SQGNTI.js +11 -0
- package/dist/scanner-M3SQGNTI.js.map +1 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cares0
|
|
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.ko.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# claudesync
|
|
2
|
+
|
|
3
|
+
[Claude Code](https://docs.anthropic.com/en/docs/claude-code) 설정을 GitHub Gist로 동기화합니다.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/claudesync)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
9
|
+
[English](./README.md)
|
|
10
|
+
|
|
11
|
+
## 왜 만들었나요?
|
|
12
|
+
|
|
13
|
+
Claude Code의 설정(`~/.claude/`)은 기기 간 동기화가 안 됩니다. 커스텀 인스트럭션, 훅, 스킬, 키바인딩 등을 여러 머신에서 매번 다시 설정해야 하죠.
|
|
14
|
+
|
|
15
|
+
**claudesync**는 이 설정들을 비공개 GitHub Gist에 저장해서, 어디서든 한 줄로 복원할 수 있게 해줍니다.
|
|
16
|
+
|
|
17
|
+
- **카테고리별 동기화** — `--only hooks`, `--only skills` 등 필요한 것만 골라서
|
|
18
|
+
- **diff 미리보기** — 적용 전에 뭐가 바뀌는지 정확히 확인
|
|
19
|
+
- **버전 관리** — 리비전 탐색, 원하는 시점으로 롤백
|
|
20
|
+
- **자동 동기화** — OS 스케줄러를 활용한 주기적 push/pull
|
|
21
|
+
- **비밀 탐지** — push 전에 API 키, 토큰, 개인 키를 자동 스캔
|
|
22
|
+
- **암호화** — AES-256-GCM 선택적 파일 암호화
|
|
23
|
+
- **토큰 안전 저장** — macOS Keychain / Linux libsecret / 암호화 파일 fallback
|
|
24
|
+
- **런타임 의존성 제로** — Node.js 내장 모듈만 사용
|
|
25
|
+
|
|
26
|
+
## 설치
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g claudesync
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
<details>
|
|
33
|
+
<summary>소스에서 빌드</summary>
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/cares0/claudesync.git
|
|
37
|
+
cd claudesync
|
|
38
|
+
npm install
|
|
39
|
+
npm run build
|
|
40
|
+
npm link
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
</details>
|
|
44
|
+
|
|
45
|
+
**Node.js 18+** 필요.
|
|
46
|
+
|
|
47
|
+
## 시작하기
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 1. GitHub 인증 (브라우저 열림)
|
|
51
|
+
claudesync init
|
|
52
|
+
|
|
53
|
+
# 2. 설정 업로드
|
|
54
|
+
claudesync push -m "첫 동기화"
|
|
55
|
+
|
|
56
|
+
# 3. 다른 머신에서 가져오기
|
|
57
|
+
claudesync init
|
|
58
|
+
claudesync pull
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
실행 예시:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
$ claudesync push -m "첫 동기화"
|
|
65
|
+
~/.claude/ 스캔 중...
|
|
66
|
+
|
|
67
|
+
변경사항:
|
|
68
|
+
+ [settings] settings.json
|
|
69
|
+
+ [settings] keybindings.json
|
|
70
|
+
+ [instructions] CLAUDE.md
|
|
71
|
+
+ [hooks] hooks/pre-tool-use.sh
|
|
72
|
+
+ [skills] skills/my-skill.md
|
|
73
|
+
|
|
74
|
+
5개 파일 업로드. 계속할까요? (Y/n) y
|
|
75
|
+
|
|
76
|
+
✔ Gist에 푸시 완료 (5개 파일)
|
|
77
|
+
https://gist.github.com/you/abc123
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 동기화 대상
|
|
81
|
+
|
|
82
|
+
| 카테고리 | 파일 |
|
|
83
|
+
|----------|------|
|
|
84
|
+
| **settings** | `settings.json`, `keybindings.json`, `policy-limits.json`, `remote-settings.json` |
|
|
85
|
+
| **instructions** | `CLAUDE.md` |
|
|
86
|
+
| **hooks** | `hooks/` 디렉토리 |
|
|
87
|
+
| **skills** | `skills/` 디렉토리 |
|
|
88
|
+
| **plugins** | `plugins/installed_plugins.json`, `known_marketplaces.json`, `blocklist.json` |
|
|
89
|
+
| **teams** | `teams/` 디렉토리 |
|
|
90
|
+
| **ui** | `statusline-command.sh` |
|
|
91
|
+
|
|
92
|
+
대화 로그, 세션, 캐시, `~/.claude.json` 등은 동기화하지 **않습니다**.
|
|
93
|
+
|
|
94
|
+
## 명령어
|
|
95
|
+
|
|
96
|
+
### 인증
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
claudesync init # OAuth Device Flow (브라우저)
|
|
100
|
+
claudesync init --token # PAT 직접 입력
|
|
101
|
+
claudesync link <gist-id> # 기존 Gist 연결
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 동기화
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
claudesync push # Gist에 업로드
|
|
108
|
+
claudesync push -m "메시지" # 메시지와 함께 업로드
|
|
109
|
+
claudesync push --only hooks # 단일 카테고리
|
|
110
|
+
claudesync push --encrypt # 암호화 후 업로드
|
|
111
|
+
claudesync push --force # 확인 없이 실행
|
|
112
|
+
|
|
113
|
+
claudesync pull # Gist에서 가져오기 (diff 먼저 표시)
|
|
114
|
+
claudesync pull --only skills # 단일 카테고리
|
|
115
|
+
claudesync pull --force # 확인 없이 실행
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 비교
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
claudesync diff # 전체 diff: 로컬 vs 원격
|
|
122
|
+
claudesync diff --only settings # 단일 카테고리 diff
|
|
123
|
+
claudesync status # 인증/동기화 상태
|
|
124
|
+
claudesync list # 동기화 대상 파일 목록
|
|
125
|
+
claudesync list --only hooks # 단일 카테고리 목록
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 히스토리
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
claudesync history # 최근 리비전 (최대 10개)
|
|
132
|
+
claudesync rollback <version> # 특정 리비전으로 복원
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 자동 동기화
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
claudesync auto # 대화형 설정 (방향, 간격, 카테고리)
|
|
139
|
+
claudesync auto status # 현재 설정 및 최근 로그
|
|
140
|
+
claudesync auto disable # 자동 동기화 중지 및 해제
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**launchd** (macOS), **systemd** (Linux), **Task Scheduler** (Windows)를 통해 주기적 동기화를 설정합니다. push/pull 방향, 간격(최소 60초), 카테고리 필터, 암호화, 충돌 정책(덮어쓰기 / 건너뛰기 / 백업)을 지원합니다.
|
|
144
|
+
|
|
145
|
+
### 설정
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
claudesync config list # 현재 설정 보기
|
|
149
|
+
claudesync config lang ko # 한국어로 변경
|
|
150
|
+
claudesync config lang en # 영어로 변경
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## 옵션
|
|
154
|
+
|
|
155
|
+
| 플래그 | 설명 |
|
|
156
|
+
|--------|------|
|
|
157
|
+
| `-m, --message <msg>` | 동기화 메시지 (`history`에서 확인 가능) |
|
|
158
|
+
| `--only <category>` | 필터: `settings` / `instructions` / `hooks` / `skills` / `plugins` / `teams` / `ui` |
|
|
159
|
+
| `--force` | 확인 프롬프트 건너뛰기 |
|
|
160
|
+
| `--encrypt` | AES-256-GCM으로 암호화 후 업로드 |
|
|
161
|
+
| `--lang ko\|en` | 출력 언어 변경 |
|
|
162
|
+
| `-h, --help` | 도움말 |
|
|
163
|
+
| `-v, --version` | 버전 정보 |
|
|
164
|
+
|
|
165
|
+
## 작동 방식
|
|
166
|
+
|
|
167
|
+
설정 파일은 하나의 **비공개 GitHub Gist**에 저장됩니다. 각 파일이 Gist 파일에 1:1로 매핑되며, 디렉토리 구분자는 `--`로 변환됩니다 (예: `hooks/pre-tool-use.sh` → `hooks--pre-tool-use.sh`).
|
|
168
|
+
|
|
169
|
+
Gist 안의 `_meta.json`에 파일 매핑, 카테고리, 암호화 여부, 동기화 시각, 머신 정보, push 메시지가 기록됩니다. Gist의 내장 리비전 히스토리가 `history`와 `rollback`을 지원합니다.
|
|
170
|
+
|
|
171
|
+
## 보안
|
|
172
|
+
|
|
173
|
+
- **토큰 저장** — macOS Keychain, Linux libsecret, 또는 `chmod 600` 파일
|
|
174
|
+
- **비밀 스캔** — 매 push마다 15개 정규식 패턴 + Shannon 엔트로피 분석
|
|
175
|
+
- **수동 제외** — `# claudesync:redact`를 줄에 추가하면 항상 제외
|
|
176
|
+
- **암호화** — `--encrypt` 옵션으로 AES-256-GCM + scrypt 키 유도 적용
|
|
177
|
+
- **비공개 Gist** — 모든 Gist는 비공개로 생성
|
|
178
|
+
- **경로 순회 방지** — pull/rollback 시 위험한 경로 차단
|
|
179
|
+
- **동시 실행 잠금** — 파일 기반 락으로 자동 동기화 중복 실행 방지
|
|
180
|
+
|
|
181
|
+
## 자주 묻는 질문
|
|
182
|
+
|
|
183
|
+
**내 설정이 외부에 노출되지는 않나요?**
|
|
184
|
+
Gist는 비공개로 생성됩니다. GitHub 토큰이나 직접 URL이 없으면 접근할 수 없습니다. `--encrypt`로 추가 보호가 가능합니다.
|
|
185
|
+
|
|
186
|
+
**프로젝트별 설정도 동기화할 수 있나요?**
|
|
187
|
+
아직은 전역 설정(`~/.claude/`)만 지원합니다.
|
|
188
|
+
|
|
189
|
+
**두 머신에서 설정을 각각 수정했으면 어떻게 되나요?**
|
|
190
|
+
`pull` 하면 diff를 먼저 보여줍니다. 기존 파일은 `.bak`으로 백업한 뒤 덮어씁니다.
|
|
191
|
+
|
|
192
|
+
**OAuth 말고 다른 인증 방법은?**
|
|
193
|
+
`claudesync init --token`으로 [Personal Access Token](https://github.com/settings/tokens)을 직접 입력할 수 있습니다. `gist` 권한만 필요합니다.
|
|
194
|
+
|
|
195
|
+
**여러 머신에서 자동 push를 하면 충돌이 생기지 않나요?**
|
|
196
|
+
자동 동기화는 **primary device**를 추적합니다. primary로 지정된 머신만 자동으로 push하므로 충돌이 방지됩니다.
|
|
197
|
+
|
|
198
|
+
**Windows에서도 되나요?**
|
|
199
|
+
네. 자동 동기화는 Windows Task Scheduler를 사용합니다. 토큰 저장은 암호화 파일로 fallback됩니다.
|
|
200
|
+
|
|
201
|
+
## 라이선스
|
|
202
|
+
|
|
203
|
+
[MIT](LICENSE)
|
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# claudesync
|
|
2
|
+
|
|
3
|
+
Sync your [Claude Code](https://docs.anthropic.com/en/docs/claude-code) settings across machines via GitHub Gist.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/claudesync)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
9
|
+
[한국어](./README.ko.md)
|
|
10
|
+
|
|
11
|
+
## Why?
|
|
12
|
+
|
|
13
|
+
Claude Code stores settings in `~/.claude/` — instructions, hooks, skills, keybindings, and more. These don't sync between machines.
|
|
14
|
+
|
|
15
|
+
**claudesync** saves them to a private GitHub Gist so you can restore your setup anywhere with one command.
|
|
16
|
+
|
|
17
|
+
- **Selective sync** — push/pull by category (`--only hooks`, `--only skills`)
|
|
18
|
+
- **Diff preview** — see exactly what changes before applying
|
|
19
|
+
- **Version history** — browse revisions and rollback to any state
|
|
20
|
+
- **Auto sync** — scheduled push/pull with OS-native schedulers
|
|
21
|
+
- **Secret detection** — scans for API keys, tokens, private keys before upload
|
|
22
|
+
- **Encryption** — optional AES-256-GCM encryption for all files
|
|
23
|
+
- **Secure token storage** — macOS Keychain / Linux libsecret / encrypted file fallback
|
|
24
|
+
- **Zero runtime dependencies** — Node.js built-ins only
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g claudesync
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
<details>
|
|
33
|
+
<summary>Build from source</summary>
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/cares0/claudesync.git
|
|
37
|
+
cd claudesync
|
|
38
|
+
npm install
|
|
39
|
+
npm run build
|
|
40
|
+
npm link
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
</details>
|
|
44
|
+
|
|
45
|
+
Requires **Node.js 18+**.
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 1. Authenticate with GitHub (opens browser)
|
|
51
|
+
claudesync init
|
|
52
|
+
|
|
53
|
+
# 2. Upload your settings
|
|
54
|
+
claudesync push -m "initial sync"
|
|
55
|
+
|
|
56
|
+
# 3. On another machine — pull them down
|
|
57
|
+
claudesync init
|
|
58
|
+
claudesync pull
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Example output:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
$ claudesync push -m "initial sync"
|
|
65
|
+
Scanning ~/.claude/ ...
|
|
66
|
+
|
|
67
|
+
Changes:
|
|
68
|
+
+ [settings] settings.json
|
|
69
|
+
+ [settings] keybindings.json
|
|
70
|
+
+ [instructions] CLAUDE.md
|
|
71
|
+
+ [hooks] hooks/pre-tool-use.sh
|
|
72
|
+
+ [skills] skills/my-skill.md
|
|
73
|
+
|
|
74
|
+
5 files to upload. Proceed? (Y/n) y
|
|
75
|
+
|
|
76
|
+
✔ Pushed to Gist (5 files)
|
|
77
|
+
https://gist.github.com/you/abc123
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## What Gets Synced
|
|
81
|
+
|
|
82
|
+
| Category | Files |
|
|
83
|
+
|----------|-------|
|
|
84
|
+
| **settings** | `settings.json`, `keybindings.json`, `policy-limits.json`, `remote-settings.json` |
|
|
85
|
+
| **instructions** | `CLAUDE.md` |
|
|
86
|
+
| **hooks** | `hooks/` directory |
|
|
87
|
+
| **skills** | `skills/` directory |
|
|
88
|
+
| **plugins** | `plugins/installed_plugins.json`, `known_marketplaces.json`, `blocklist.json` |
|
|
89
|
+
| **teams** | `teams/` directory |
|
|
90
|
+
| **ui** | `statusline-command.sh` |
|
|
91
|
+
|
|
92
|
+
Conversation logs, sessions, caches, and `~/.claude.json` are **never** synced.
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
### Auth
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
claudesync init # OAuth Device Flow (browser)
|
|
100
|
+
claudesync init --token # Manual PAT input
|
|
101
|
+
claudesync link <gist-id> # Link an existing Gist
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Sync
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
claudesync push # Upload to Gist
|
|
108
|
+
claudesync push -m "message" # With a sync message
|
|
109
|
+
claudesync push --only hooks # Single category
|
|
110
|
+
claudesync push --encrypt # Encrypt before upload
|
|
111
|
+
claudesync push --force # Skip confirmation
|
|
112
|
+
|
|
113
|
+
claudesync pull # Download from Gist (shows diff first)
|
|
114
|
+
claudesync pull --only skills # Single category
|
|
115
|
+
claudesync pull --force # Skip confirmation
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Compare
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
claudesync diff # Full diff: local vs remote
|
|
122
|
+
claudesync diff --only settings # Diff single category
|
|
123
|
+
claudesync status # Auth & sync status
|
|
124
|
+
claudesync list # List all syncable local files
|
|
125
|
+
claudesync list --only hooks # List single category
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### History
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
claudesync history # Recent revisions (last 10)
|
|
132
|
+
claudesync rollback <version> # Restore a specific revision
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Auto Sync
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
claudesync auto # Interactive setup (direction, interval, categories)
|
|
139
|
+
claudesync auto status # Show current auto-sync config & recent logs
|
|
140
|
+
claudesync auto disable # Stop and unregister auto-sync
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Sets up periodic sync via **launchd** (macOS), **systemd** (Linux), or **Task Scheduler** (Windows). Supports push/pull direction, configurable interval (min 60s), category filter, encryption, and conflict policies (overwrite / skip / backup).
|
|
144
|
+
|
|
145
|
+
### Config
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
claudesync config list # Show all settings
|
|
149
|
+
claudesync config lang ko # Set language to Korean
|
|
150
|
+
claudesync config lang en # Set language to English
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Options
|
|
154
|
+
|
|
155
|
+
| Flag | Description |
|
|
156
|
+
|------|-------------|
|
|
157
|
+
| `-m, --message <msg>` | Sync message (shown in `history`) |
|
|
158
|
+
| `--only <category>` | Filter: `settings` / `instructions` / `hooks` / `skills` / `plugins` / `teams` / `ui` |
|
|
159
|
+
| `--force` | Skip confirmation prompts |
|
|
160
|
+
| `--encrypt` | AES-256-GCM encrypt files before upload |
|
|
161
|
+
| `--lang ko\|en` | Set display language |
|
|
162
|
+
| `-h, --help` | Show help |
|
|
163
|
+
| `-v, --version` | Show version |
|
|
164
|
+
|
|
165
|
+
## How It Works
|
|
166
|
+
|
|
167
|
+
Settings are stored as files in a single **private GitHub Gist**. Each file maps 1:1 to a Gist file, with directory separators converted to `--` (e.g. `hooks/pre-tool-use.sh` becomes `hooks--pre-tool-use.sh`).
|
|
168
|
+
|
|
169
|
+
A `_meta.json` file in the Gist tracks file mappings, categories, encryption status, sync timestamps, machine info, and push messages. Gist's built-in revision history powers `history` and `rollback`.
|
|
170
|
+
|
|
171
|
+
## Security
|
|
172
|
+
|
|
173
|
+
- **Token storage** — macOS Keychain, Linux libsecret, or file with `chmod 600`
|
|
174
|
+
- **Secret scanning** — 15 regex patterns + Shannon entropy analysis on every push
|
|
175
|
+
- **Manual redaction** — add `# claudesync:redact` to any line to always exclude it
|
|
176
|
+
- **Encryption** — `--encrypt` applies AES-256-GCM with scrypt key derivation
|
|
177
|
+
- **Private Gists** — all Gists are created as private
|
|
178
|
+
- **Path traversal protection** — blocks unsafe paths on pull/rollback
|
|
179
|
+
- **Concurrent lock** — file-based lock prevents overlapping auto-sync runs
|
|
180
|
+
|
|
181
|
+
## FAQ
|
|
182
|
+
|
|
183
|
+
**Is my data safe?**
|
|
184
|
+
Gists are private. Only someone with your GitHub token or the direct URL can see them. Use `--encrypt` for extra protection.
|
|
185
|
+
|
|
186
|
+
**Can I sync project-specific settings?**
|
|
187
|
+
Not yet — only global `~/.claude/` settings are supported.
|
|
188
|
+
|
|
189
|
+
**What if settings changed on both machines?**
|
|
190
|
+
`pull` shows a diff first. Existing files are backed up as `.bak` before overwriting.
|
|
191
|
+
|
|
192
|
+
**Can I skip OAuth?**
|
|
193
|
+
Yes. `claudesync init --token` accepts a [Personal Access Token](https://github.com/settings/tokens) with `gist` scope.
|
|
194
|
+
|
|
195
|
+
**What about multiple machines pushing?**
|
|
196
|
+
Auto-sync tracks a **primary device** for push mode. Only the primary machine pushes automatically, preventing conflicts.
|
|
197
|
+
|
|
198
|
+
**Does it work on Windows?**
|
|
199
|
+
Yes. Auto-sync uses Windows Task Scheduler. Token storage falls back to an encrypted file.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
machineName,
|
|
4
|
+
platformString
|
|
5
|
+
} from "./chunk-VBOSEAEH.js";
|
|
6
|
+
|
|
7
|
+
// src/core/gist.ts
|
|
8
|
+
import { hostname } from "os";
|
|
9
|
+
|
|
10
|
+
// src/types.ts
|
|
11
|
+
var CATEGORIES = [
|
|
12
|
+
"settings",
|
|
13
|
+
"instructions",
|
|
14
|
+
"hooks",
|
|
15
|
+
"skills",
|
|
16
|
+
"plugins",
|
|
17
|
+
"teams",
|
|
18
|
+
"ui"
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// src/core/gist.ts
|
|
22
|
+
var API = "https://api.github.com";
|
|
23
|
+
var GIST_DESC = "claudesync: Claude Code settings";
|
|
24
|
+
var META_FILE = "_meta.json";
|
|
25
|
+
function headers(token) {
|
|
26
|
+
return {
|
|
27
|
+
Authorization: `token ${token}`,
|
|
28
|
+
Accept: "application/vnd.github+json",
|
|
29
|
+
"User-Agent": "claudesync",
|
|
30
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function apiRequest(url, token, options) {
|
|
34
|
+
const res = await fetch(url, {
|
|
35
|
+
...options,
|
|
36
|
+
headers: { ...headers(token), ...options?.headers }
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const body = await res.text();
|
|
40
|
+
throw new Error(`GitHub API ${res.status}: ${body}`);
|
|
41
|
+
}
|
|
42
|
+
return res.json();
|
|
43
|
+
}
|
|
44
|
+
async function findGist(token) {
|
|
45
|
+
const gists = await apiRequest(`${API}/gists?per_page=100`, token);
|
|
46
|
+
return gists.find((g) => g.description === GIST_DESC && g.files[META_FILE]) ?? null;
|
|
47
|
+
}
|
|
48
|
+
async function getGist(token, gistId) {
|
|
49
|
+
return apiRequest(`${API}/gists/${gistId}`, token);
|
|
50
|
+
}
|
|
51
|
+
async function createGist(token, files, encryptedFiles, message, primaryDevice) {
|
|
52
|
+
const meta = buildMeta(files, encryptedFiles, message, primaryDevice);
|
|
53
|
+
const gistFiles = {
|
|
54
|
+
[META_FILE]: { content: JSON.stringify(meta, null, 2) }
|
|
55
|
+
};
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
gistFiles[f.gistFilename] = { content: f.content };
|
|
58
|
+
}
|
|
59
|
+
return apiRequest(`${API}/gists`, token, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
description: GIST_DESC,
|
|
63
|
+
public: false,
|
|
64
|
+
files: gistFiles
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async function updateGist(token, gistId, files, deletedFiles, encryptedFiles, message, primaryDevice) {
|
|
69
|
+
const meta = buildMeta(files, encryptedFiles, message, primaryDevice);
|
|
70
|
+
const gistFiles = {
|
|
71
|
+
[META_FILE]: { content: JSON.stringify(meta, null, 2) }
|
|
72
|
+
};
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
gistFiles[f.gistFilename] = { content: f.content };
|
|
75
|
+
}
|
|
76
|
+
if (deletedFiles) {
|
|
77
|
+
for (const name of deletedFiles) {
|
|
78
|
+
gistFiles[name] = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return apiRequest(`${API}/gists/${gistId}`, token, {
|
|
82
|
+
method: "PATCH",
|
|
83
|
+
body: JSON.stringify({ files: gistFiles })
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async function getHistory(token, gistId) {
|
|
87
|
+
const gist = await apiRequest(`${API}/gists/${gistId}`, token);
|
|
88
|
+
return gist.history ?? [];
|
|
89
|
+
}
|
|
90
|
+
async function getGistAtRevision(token, gistId, sha) {
|
|
91
|
+
return apiRequest(`${API}/gists/${gistId}/${sha}`, token);
|
|
92
|
+
}
|
|
93
|
+
function parseMeta(gist) {
|
|
94
|
+
const metaFile = gist.files[META_FILE];
|
|
95
|
+
if (!metaFile?.content) return null;
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(metaFile.content);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function buildMeta(files, encryptedFiles, message, primaryDevice) {
|
|
103
|
+
const fileMap = {};
|
|
104
|
+
for (const f of files) {
|
|
105
|
+
fileMap[f.gistFilename] = {
|
|
106
|
+
path: f.relativePath,
|
|
107
|
+
category: f.category,
|
|
108
|
+
...encryptedFiles?.has(f.gistFilename) && { encrypted: true }
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
version: 1,
|
|
113
|
+
tool: "claudesync",
|
|
114
|
+
last_sync: {
|
|
115
|
+
machine: machineName(),
|
|
116
|
+
hostname: getHostname(),
|
|
117
|
+
platform: platformString(),
|
|
118
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
119
|
+
file_count: files.length,
|
|
120
|
+
...message && { message }
|
|
121
|
+
},
|
|
122
|
+
file_map: fileMap,
|
|
123
|
+
categories: [...CATEGORIES],
|
|
124
|
+
...primaryDevice && { primary_device: primaryDevice }
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function getHostname() {
|
|
128
|
+
try {
|
|
129
|
+
return hostname();
|
|
130
|
+
} catch {
|
|
131
|
+
return "unknown";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export {
|
|
136
|
+
CATEGORIES,
|
|
137
|
+
findGist,
|
|
138
|
+
getGist,
|
|
139
|
+
createGist,
|
|
140
|
+
updateGist,
|
|
141
|
+
getHistory,
|
|
142
|
+
getGistAtRevision,
|
|
143
|
+
parseMeta
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=chunk-45GTBXRR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/gist.ts","../src/types.ts"],"sourcesContent":["import { hostname } from 'node:os';\nimport type { Gist, GistRevision, SyncMeta, ScannedFile, PrimaryDevice } from '../types.js';\nimport { machineName, platformString } from '../utils/paths.js';\nimport { CATEGORIES } from '../types.js';\n\nconst API = 'https://api.github.com';\nconst GIST_DESC = 'claudesync: Claude Code settings';\nconst META_FILE = '_meta.json';\n\nfunction headers(token: string): Record<string, string> {\n return {\n Authorization: `token ${token}`,\n Accept: 'application/vnd.github+json',\n 'User-Agent': 'claudesync',\n 'X-GitHub-Api-Version': '2022-11-28',\n };\n}\n\nasync function apiRequest<T>(url: string, token: string, options?: RequestInit): Promise<T> {\n const res = await fetch(url, {\n ...options,\n headers: { ...headers(token), ...options?.headers },\n });\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`GitHub API ${res.status}: ${body}`);\n }\n return res.json() as Promise<T>;\n}\n\n// ── Find existing claudesync Gist ───────────────────────────\nexport async function findGist(token: string): Promise<Gist | null> {\n const gists = await apiRequest<Gist[]>(`${API}/gists?per_page=100`, token);\n return gists.find((g) => g.description === GIST_DESC && g.files[META_FILE]) ?? null;\n}\n\n// ── Get Gist by ID ──────────────────────────────────────────\nexport async function getGist(token: string, gistId: string): Promise<Gist> {\n return apiRequest<Gist>(`${API}/gists/${gistId}`, token);\n}\n\n// ── Create new Gist ─────────────────────────────────────────\nexport async function createGist(\n token: string,\n files: ScannedFile[],\n encryptedFiles?: Set<string>,\n message?: string,\n primaryDevice?: PrimaryDevice,\n): Promise<Gist> {\n const meta = buildMeta(files, encryptedFiles, message, primaryDevice);\n const gistFiles: Record<string, { content: string }> = {\n [META_FILE]: { content: JSON.stringify(meta, null, 2) },\n };\n\n for (const f of files) {\n gistFiles[f.gistFilename] = { content: f.content };\n }\n\n return apiRequest<Gist>(`${API}/gists`, token, {\n method: 'POST',\n body: JSON.stringify({\n description: GIST_DESC,\n public: false,\n files: gistFiles,\n }),\n });\n}\n\n// ── Update existing Gist (partial) ─────────────────────────\nexport async function updateGist(\n token: string,\n gistId: string,\n files: ScannedFile[],\n deletedFiles?: string[],\n encryptedFiles?: Set<string>,\n message?: string,\n primaryDevice?: PrimaryDevice,\n): Promise<Gist> {\n const meta = buildMeta(files, encryptedFiles, message, primaryDevice);\n const gistFiles: Record<string, { content: string } | null> = {\n [META_FILE]: { content: JSON.stringify(meta, null, 2) },\n };\n\n for (const f of files) {\n gistFiles[f.gistFilename] = { content: f.content };\n }\n\n // Mark deleted files as null to remove from Gist\n if (deletedFiles) {\n for (const name of deletedFiles) {\n gistFiles[name] = null;\n }\n }\n\n return apiRequest<Gist>(`${API}/gists/${gistId}`, token, {\n method: 'PATCH',\n body: JSON.stringify({ files: gistFiles }),\n });\n}\n\n// ── Get Gist revision history ───────────────────────────────\nexport async function getHistory(token: string, gistId: string): Promise<GistRevision[]> {\n const gist = await apiRequest<Gist>(`${API}/gists/${gistId}`, token);\n return gist.history ?? [];\n}\n\n// ── Get Gist at a specific revision ─────────────────────────\nexport async function getGistAtRevision(\n token: string,\n gistId: string,\n sha: string,\n): Promise<Gist> {\n return apiRequest<Gist>(`${API}/gists/${gistId}/${sha}`, token);\n}\n\n// ── Parse meta from Gist ────────────────────────────────────\nexport function parseMeta(gist: Gist): SyncMeta | null {\n const metaFile = gist.files[META_FILE];\n if (!metaFile?.content) return null;\n try {\n return JSON.parse(metaFile.content) as SyncMeta;\n } catch {\n return null;\n }\n}\n\n// ── Build _meta.json ────────────────────────────────────────\nfunction buildMeta(files: ScannedFile[], encryptedFiles?: Set<string>, message?: string, primaryDevice?: PrimaryDevice): SyncMeta {\n const fileMap: SyncMeta['file_map'] = {};\n for (const f of files) {\n fileMap[f.gistFilename] = {\n path: f.relativePath,\n category: f.category,\n ...(encryptedFiles?.has(f.gistFilename) && { encrypted: true }),\n };\n }\n\n return {\n version: 1,\n tool: 'claudesync',\n last_sync: {\n machine: machineName(),\n hostname: getHostname(),\n platform: platformString(),\n timestamp: new Date().toISOString(),\n file_count: files.length,\n ...(message && { message }),\n },\n file_map: fileMap,\n categories: [...CATEGORIES],\n ...(primaryDevice && { primary_device: primaryDevice }),\n };\n}\n\nfunction getHostname(): string {\n try {\n return hostname();\n } catch {\n return 'unknown';\n }\n}\n","// ── Categories ──────────────────────────────────────────────\nexport const CATEGORIES = [\n 'settings',\n 'instructions',\n 'hooks',\n 'skills',\n 'plugins',\n 'teams',\n 'ui',\n] as const;\n\nexport type Category = (typeof CATEGORIES)[number];\n\n// ── Gist _meta.json ─────────────────────────────────────────\nexport interface SyncMeta {\n version: number;\n tool: 'claudesync';\n last_sync: {\n machine: string;\n hostname: string;\n platform: string;\n timestamp: string;\n file_count: number;\n message?: string;\n };\n file_map: Record<string, FileMapEntry>;\n categories: Category[];\n primary_device?: PrimaryDevice;\n}\n\nexport interface FileMapEntry {\n path: string; // relative to ~/.claude/, e.g. \"hooks/pre-tool-use.sh\"\n category: Category;\n encrypted?: boolean;\n}\n\n// ── Scanner ─────────────────────────────────────────────────\nexport interface ScannedFile {\n /** Absolute path on disk */\n absolutePath: string;\n /** Relative path from ~/.claude/ */\n relativePath: string;\n /** Gist-safe filename (slashes → --) */\n gistFilename: string;\n /** Category for selective sync */\n category: Category;\n /** File content (utf-8) */\n content: string;\n}\n\n// ── Gist API ────────────────────────────────────────────────\nexport interface GistFile {\n filename: string;\n content: string;\n truncated?: boolean;\n raw_url?: string;\n size?: number;\n}\n\nexport interface Gist {\n id: string;\n description: string;\n public: boolean;\n files: Record<string, GistFile>;\n html_url: string;\n created_at: string;\n updated_at: string;\n history?: GistRevision[];\n}\n\nexport interface GistRevision {\n version: string;\n user: { login: string } | null;\n committed_at: string;\n change_status: {\n total: number;\n additions: number;\n deletions: number;\n };\n}\n\n// ── Conflict ────────────────────────────────────────────────\nexport type ConflictAction = 'keep-local' | 'use-remote' | 'show-diff' | 'skip';\n\nexport interface FileChange {\n gistFilename: string;\n relativePath: string;\n category: Category;\n status: 'added' | 'modified' | 'deleted' | 'unchanged';\n localContent?: string;\n remoteContent?: string;\n}\n\n// ── Auth ────────────────────────────────────────────────────\nexport interface AuthConfig {\n token: string;\n gist_id?: string;\n machine_name?: string;\n}\n\n// ── CLI ─────────────────────────────────────────────────────\nexport interface PushOptions {\n only?: Category;\n force?: boolean;\n encrypt?: boolean;\n message?: string;\n}\n\nexport interface PullOptions {\n only?: Category;\n force?: boolean;\n}\n\nexport interface DiffOptions {\n only?: Category;\n}\n\n// ── Auto Sync ──────────────────────────────────────────────\nexport type AutoDirection = 'push' | 'pull';\n\nexport type PullConflictPolicy = 'overwrite' | 'skip' | 'backup';\n\nexport interface AutoConfig {\n direction: AutoDirection;\n interval_seconds: number;\n categories: Category[];\n encrypt: boolean;\n enabled: boolean;\n created_at: string;\n conflict_policy?: PullConflictPolicy;\n}\n\nexport interface PrimaryDevice {\n machine_id: string;\n machine: string;\n hostname: string;\n platform: string;\n registered_at: string;\n}\n"],"mappings":"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","names":[]}
|