jun-claude-code 0.0.16 → 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/README.md +73 -62
- package/dist/__tests__/merge-settings.test.d.ts +1 -0
- package/dist/__tests__/merge-settings.test.js +340 -0
- package/dist/cli.js +3 -1
- package/dist/copy.d.ts +7 -0
- package/dist/copy.js +35 -21
- package/dist/init-context.js +50 -17
- package/dist/init-project.d.ts +5 -0
- package/dist/init-project.js +21 -9
- package/dist/utils.d.ts +12 -0
- package/dist/utils.js +26 -0
- package/package.json +4 -2
- package/templates/global/CLAUDE.md +31 -182
- package/templates/global/agents/context-manager.md +9 -0
- package/templates/global/hooks/dangerous-command-blocker.sh +67 -0
- package/templates/global/hooks/skill-forced-subagent.sh +3 -22
- package/templates/global/hooks/skill-forced.sh +6 -36
- package/templates/global/hooks/workflow-enforced.sh +4 -41
- package/templates/global/settings.json +15 -0
- package/templates/global/skills/ContextHandoff/SKILL.md +54 -0
- package/templates/global/skills/PromptStructuring/SKILL.md +10 -58
- package/templates/global/skills/PromptStructuring/output-optimization.md +66 -0
- package/templates/global/skills/PromptStructuring/positive-phrasing.md +17 -194
- package/templates/global/skills/PromptStructuring/xml-tags.md +24 -317
- package/templates/global/statusline-command.sh +84 -0
package/README.md
CHANGED
|
@@ -2,66 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Claude Code 설정 템플릿 CLI. 미리 정의된 Agents, Skills, Hooks, Workflow를 프로젝트에 설치하여 Claude Code 환경을 빠르게 구축합니다.
|
|
4
4
|
|
|
5
|
-
## 포함 내용
|
|
6
|
-
|
|
7
|
-
### Agents (`templates/global/agents/`)
|
|
8
|
-
|
|
9
|
-
작업별 전문 Subagent 13종. Main Agent의 Context Window를 절약하면서 각 작업을 위임합니다.
|
|
10
|
-
|
|
11
|
-
| Agent | 역할 |
|
|
12
|
-
|-------|------|
|
|
13
|
-
| `explore` | 코드베이스 탐색 |
|
|
14
|
-
| `task-planner` | 작업 계획 수립 |
|
|
15
|
-
| `code-writer` | 코드 작성 (Opus) |
|
|
16
|
-
| `simple-code-writer` | 단순 수정 (Haiku) |
|
|
17
|
-
| `code-reviewer` | 셀프 코드 리뷰 |
|
|
18
|
-
| `git-manager` | Git 커밋/PR |
|
|
19
|
-
| `impact-analyzer` | 사이드이펙트 분석 |
|
|
20
|
-
| `qa-tester` | 테스트/빌드 검증 |
|
|
21
|
-
| `architect` | 아키텍처 설계 |
|
|
22
|
-
| `designer` | UI/UX 스타일링 |
|
|
23
|
-
| `director` | 작업 총괄 디렉터 |
|
|
24
|
-
| `context-collector` | 소스 코드 기반 Context 수집 |
|
|
25
|
-
| `context-manager` | Context 문서 관리 |
|
|
26
|
-
|
|
27
|
-
### Skills (`templates/global/skills/`)
|
|
28
|
-
|
|
29
|
-
| Skill | 설명 |
|
|
30
|
-
|-------|------|
|
|
31
|
-
| `Coding` | 공통 코딩 원칙 (SRP, 응집도, 가독성) |
|
|
32
|
-
| `Git` | Git 커밋/PR 규칙, PR 리뷰, 피드백 적용 |
|
|
33
|
-
| `Backend` | 백엔드 개발 원칙 (레이어, TypeORM) |
|
|
34
|
-
| `React` | React 개발 (TanStack Router, React Hook Form, Tailwind) |
|
|
35
|
-
| `Documentation` | .claude 문서 작성 가이드 |
|
|
36
|
-
| `Director` | 디렉터 Agent 운영 스킬 |
|
|
37
|
-
|
|
38
|
-
### Hooks (`templates/global/hooks/`)
|
|
39
|
-
|
|
40
|
-
| Hook | 설명 |
|
|
41
|
-
|------|------|
|
|
42
|
-
| `workflow-enforced.sh` | 워크플로우 순서 강제 프로토콜 |
|
|
43
|
-
| `skill-forced.sh` | Skill/Agent 평가 프로토콜 |
|
|
44
|
-
|
|
45
|
-
### Project Agents (`templates/project/agents/`)
|
|
46
|
-
|
|
47
|
-
프로젝트 `.claude/`에 설치되는 프로젝트별 Agent.
|
|
48
|
-
|
|
49
|
-
| Agent | 역할 |
|
|
50
|
-
|-------|------|
|
|
51
|
-
| `project-task-manager` | GitHub Project 태스크 관리 |
|
|
52
|
-
| `context-generator` | Context 자동 생성 |
|
|
53
|
-
| `project-context-collector` | .claude/context/ 문서 기반 프로젝트 배경 수집 |
|
|
54
|
-
|
|
55
|
-
### Project Skills (`templates/project/skills/`)
|
|
56
|
-
|
|
57
|
-
| Skill | 설명 |
|
|
58
|
-
|-------|------|
|
|
59
|
-
| `ContextGeneration` | Context 자동 생성 스킬 |
|
|
60
|
-
|
|
61
|
-
### Workflow
|
|
62
|
-
|
|
63
|
-
Planning -> Validation -> Implementation -> Review 순서의 작업 워크플로우와 Context 절약 원칙(Subagent 위임 규칙)을 정의합니다.
|
|
64
|
-
|
|
65
5
|
## 설치
|
|
66
6
|
|
|
67
7
|
```bash
|
|
@@ -79,6 +19,7 @@ npm install -g jun-claude-code
|
|
|
79
19
|
| 명령어 | 설명 | 활성화되는 기능 |
|
|
80
20
|
|--------|------|----------------|
|
|
81
21
|
| `jun-claude-code` | 전역 설정 (`~/.claude/`) 설치 | Agents 13종, Skills 6종, Hooks 2종, Workflow |
|
|
22
|
+
| `jun-claude-code --project` | 프로젝트 설정 (`.claude/`) 설치 | 전역과 동일 (프로젝트별 오버라이드용) |
|
|
82
23
|
| `jun-claude-code init-project` | GitHub Project 연동 | 세션 시작 시 태스크 자동 로드, 태스크 관리 Agent |
|
|
83
24
|
| `jun-claude-code init-context` | Context 자동 생성 설정 | PR 기반 Context 자동 생성, Codebase/Business 문서화, 별도 브랜치 PR |
|
|
84
25
|
|
|
@@ -92,6 +33,7 @@ jun-claude-code
|
|
|
92
33
|
|
|
93
34
|
| 옵션 | 설명 |
|
|
94
35
|
|------|------|
|
|
36
|
+
| `--project`, `-p` | 전역(`~/.claude/`) 대신 프로젝트(`.claude/`)에 설치 |
|
|
95
37
|
| `--dry-run`, `-d` | 실제 복사 없이 복사될 파일 목록만 확인 |
|
|
96
38
|
| `--force`, `-f` | 확인 없이 모든 파일 덮어쓰기 |
|
|
97
39
|
|
|
@@ -165,7 +107,76 @@ jun-claude-code init-context
|
|
|
165
107
|
└── INDEX.md # 비즈니스 도메인 참조 목록
|
|
166
108
|
```
|
|
167
109
|
|
|
168
|
-
|
|
110
|
+
**필수 설정:**
|
|
111
|
+
|
|
112
|
+
1. **Repository Actions 권한 활성화**
|
|
113
|
+
- Repository → Settings → Actions → General
|
|
114
|
+
- **Workflow permissions** 섹션에서:
|
|
115
|
+
- "Read and write permissions" 선택
|
|
116
|
+
- ✅ "Allow GitHub Actions to create and approve pull requests" 체크
|
|
117
|
+
|
|
118
|
+
2. **CLAUDE_CODE_OAUTH_TOKEN 추가**
|
|
119
|
+
- Settings → Secrets and variables → Actions → New repository secret
|
|
120
|
+
|
|
121
|
+
## 포함 내용
|
|
122
|
+
|
|
123
|
+
### Agents (`templates/global/agents/`)
|
|
124
|
+
|
|
125
|
+
작업별 전문 Subagent 13종. Main Agent의 Context Window를 절약하면서 각 작업을 위임합니다.
|
|
126
|
+
|
|
127
|
+
| Agent | 역할 |
|
|
128
|
+
|-------|------|
|
|
129
|
+
| `explore` | 코드베이스 탐색 |
|
|
130
|
+
| `task-planner` | 작업 계획 수립 |
|
|
131
|
+
| `code-writer` | 코드 작성 (Opus) |
|
|
132
|
+
| `simple-code-writer` | 단순 수정 (Haiku) |
|
|
133
|
+
| `code-reviewer` | 셀프 코드 리뷰 |
|
|
134
|
+
| `git-manager` | Git 커밋/PR |
|
|
135
|
+
| `impact-analyzer` | 사이드이펙트 분석 |
|
|
136
|
+
| `qa-tester` | 테스트/빌드 검증 |
|
|
137
|
+
| `architect` | 아키텍처 설계 |
|
|
138
|
+
| `designer` | UI/UX 스타일링 |
|
|
139
|
+
| `director` | 작업 총괄 디렉터 |
|
|
140
|
+
| `context-collector` | 소스 코드 기반 Context 수집 |
|
|
141
|
+
| `context-manager` | Context 문서 관리 |
|
|
142
|
+
|
|
143
|
+
### Skills (`templates/global/skills/`)
|
|
144
|
+
|
|
145
|
+
| Skill | 설명 |
|
|
146
|
+
|-------|------|
|
|
147
|
+
| `Coding` | 공통 코딩 원칙 (SRP, 응집도, 가독성) |
|
|
148
|
+
| `Git` | Git 커밋/PR 규칙, PR 리뷰, 피드백 적용 |
|
|
149
|
+
| `Backend` | 백엔드 개발 원칙 (레이어, TypeORM) |
|
|
150
|
+
| `React` | React 개발 (TanStack Router, React Hook Form, Tailwind) |
|
|
151
|
+
| `Documentation` | .claude 문서 작성 가이드 |
|
|
152
|
+
| `Director` | 디렉터 Agent 운영 스킬 |
|
|
153
|
+
|
|
154
|
+
### Hooks (`templates/global/hooks/`)
|
|
155
|
+
|
|
156
|
+
| Hook | 설명 |
|
|
157
|
+
|------|------|
|
|
158
|
+
| `workflow-enforced.sh` | 워크플로우 순서 강제 프로토콜 |
|
|
159
|
+
| `skill-forced.sh` | Skill/Agent 평가 프로토콜 |
|
|
160
|
+
|
|
161
|
+
### Project Agents (`templates/project/agents/`)
|
|
162
|
+
|
|
163
|
+
프로젝트 `.claude/`에 설치되는 프로젝트별 Agent.
|
|
164
|
+
|
|
165
|
+
| Agent | 역할 |
|
|
166
|
+
|-------|------|
|
|
167
|
+
| `project-task-manager` | GitHub Project 태스크 관리 |
|
|
168
|
+
| `context-generator` | Context 자동 생성 |
|
|
169
|
+
| `project-context-collector` | .claude/context/ 문서 기반 프로젝트 배경 수집 |
|
|
170
|
+
|
|
171
|
+
### Project Skills (`templates/project/skills/`)
|
|
172
|
+
|
|
173
|
+
| Skill | 설명 |
|
|
174
|
+
|-------|------|
|
|
175
|
+
| `ContextGeneration` | Context 자동 생성 스킬 |
|
|
176
|
+
|
|
177
|
+
### Workflow
|
|
178
|
+
|
|
179
|
+
Planning -> Validation -> Implementation -> Review 순서의 작업 워크플로우와 Context 절약 원칙(Subagent 위임 규칙)을 정의합니다.
|
|
169
180
|
|
|
170
181
|
## 프로젝트 구조
|
|
171
182
|
|
|
@@ -187,7 +198,7 @@ templates/
|
|
|
187
198
|
|
|
188
199
|
## 커스터마이징
|
|
189
200
|
|
|
190
|
-
|
|
201
|
+
`--project` 옵션을 사용하면 전역 템플릿을 프로젝트 `.claude/`에 바로 설치할 수 있습니다. 설치 후 필요에 맞게 수정하세요.
|
|
191
202
|
|
|
192
203
|
```
|
|
193
204
|
your-project/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const vitest_1 = require("vitest");
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const utils_1 = require("../utils");
|
|
41
|
+
const copy_1 = require("../copy");
|
|
42
|
+
const init_project_1 = require("../init-project");
|
|
43
|
+
function createTmpDir() {
|
|
44
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'merge-settings-test-'));
|
|
45
|
+
}
|
|
46
|
+
function cleanupDir(dir) {
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
function readJson(filePath) {
|
|
50
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
51
|
+
}
|
|
52
|
+
function writeJson(filePath, data) {
|
|
53
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
54
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
// ─── getHookKey ───
|
|
57
|
+
(0, vitest_1.describe)('getHookKey', () => {
|
|
58
|
+
(0, vitest_1.it)('should generate key for flat hook entry', () => {
|
|
59
|
+
const entry = { type: 'command', command: 'echo hello' };
|
|
60
|
+
(0, vitest_1.expect)((0, utils_1.getHookKey)(entry)).toBe('command:echo hello');
|
|
61
|
+
});
|
|
62
|
+
(0, vitest_1.it)('should generate key for nested hooks entry without matcher', () => {
|
|
63
|
+
const entry = {
|
|
64
|
+
hooks: [
|
|
65
|
+
{ type: 'command', command: 'cmd-a' },
|
|
66
|
+
{ type: 'command', command: 'cmd-b' },
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
const key = (0, utils_1.getHookKey)(entry);
|
|
70
|
+
(0, vitest_1.expect)(key).toBe('command:cmd-a\ncommand:cmd-b');
|
|
71
|
+
});
|
|
72
|
+
(0, vitest_1.it)('should sort nested hooks for consistent key', () => {
|
|
73
|
+
const entry1 = {
|
|
74
|
+
hooks: [
|
|
75
|
+
{ type: 'command', command: 'cmd-b' },
|
|
76
|
+
{ type: 'command', command: 'cmd-a' },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
const entry2 = {
|
|
80
|
+
hooks: [
|
|
81
|
+
{ type: 'command', command: 'cmd-a' },
|
|
82
|
+
{ type: 'command', command: 'cmd-b' },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
(0, vitest_1.expect)((0, utils_1.getHookKey)(entry1)).toBe((0, utils_1.getHookKey)(entry2));
|
|
86
|
+
});
|
|
87
|
+
(0, vitest_1.it)('should include matcher in key when present', () => {
|
|
88
|
+
const entry = {
|
|
89
|
+
matcher: 'Bash',
|
|
90
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
91
|
+
};
|
|
92
|
+
(0, vitest_1.expect)((0, utils_1.getHookKey)(entry)).toBe('[Bash]command:blocker.sh');
|
|
93
|
+
});
|
|
94
|
+
(0, vitest_1.it)('should generate different keys for same hooks with different matchers', () => {
|
|
95
|
+
const entryBash = {
|
|
96
|
+
matcher: 'Bash',
|
|
97
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
98
|
+
};
|
|
99
|
+
const entryWrite = {
|
|
100
|
+
matcher: 'Write',
|
|
101
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
102
|
+
};
|
|
103
|
+
(0, vitest_1.expect)((0, utils_1.getHookKey)(entryBash)).not.toBe((0, utils_1.getHookKey)(entryWrite));
|
|
104
|
+
});
|
|
105
|
+
(0, vitest_1.it)('should generate different keys for same hooks with vs without matcher', () => {
|
|
106
|
+
const withMatcher = {
|
|
107
|
+
matcher: 'Bash',
|
|
108
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
109
|
+
};
|
|
110
|
+
const withoutMatcher = {
|
|
111
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
112
|
+
};
|
|
113
|
+
(0, vitest_1.expect)((0, utils_1.getHookKey)(withMatcher)).not.toBe((0, utils_1.getHookKey)(withoutMatcher));
|
|
114
|
+
});
|
|
115
|
+
(0, vitest_1.it)('should handle empty matcher as no matcher', () => {
|
|
116
|
+
const entry = {
|
|
117
|
+
matcher: '',
|
|
118
|
+
hooks: [{ type: 'command', command: 'test.sh' }],
|
|
119
|
+
};
|
|
120
|
+
const entryNoMatcher = {
|
|
121
|
+
hooks: [{ type: 'command', command: 'test.sh' }],
|
|
122
|
+
};
|
|
123
|
+
(0, vitest_1.expect)((0, utils_1.getHookKey)(entry)).toBe((0, utils_1.getHookKey)(entryNoMatcher));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
// ─── copy.ts mergeSettingsJson (global) ───
|
|
127
|
+
(0, vitest_1.describe)('copy.ts mergeSettingsJson', () => {
|
|
128
|
+
let sourceDir;
|
|
129
|
+
let destDir;
|
|
130
|
+
(0, vitest_1.beforeEach)(() => {
|
|
131
|
+
sourceDir = createTmpDir();
|
|
132
|
+
destDir = createTmpDir();
|
|
133
|
+
});
|
|
134
|
+
(0, vitest_1.afterEach)(() => {
|
|
135
|
+
cleanupDir(sourceDir);
|
|
136
|
+
cleanupDir(destDir);
|
|
137
|
+
});
|
|
138
|
+
(0, vitest_1.it)('should create settings.json on fresh install', () => {
|
|
139
|
+
const sourceSettings = {
|
|
140
|
+
statusLine: { type: 'command', command: 'echo test' },
|
|
141
|
+
hooks: {
|
|
142
|
+
UserPromptSubmit: [
|
|
143
|
+
{
|
|
144
|
+
hooks: [{ type: 'command', command: 'skill-forced.sh' }],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
150
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
151
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
152
|
+
(0, vitest_1.expect)(result.statusLine).toEqual(sourceSettings.statusLine);
|
|
153
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit).toHaveLength(1);
|
|
154
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit[0].hooks[0].command).toBe('skill-forced.sh');
|
|
155
|
+
});
|
|
156
|
+
(0, vitest_1.it)('should skip duplicate hooks on re-run', () => {
|
|
157
|
+
const sourceSettings = {
|
|
158
|
+
hooks: {
|
|
159
|
+
UserPromptSubmit: [
|
|
160
|
+
{ hooks: [{ type: 'command', command: 'skill-forced.sh' }] },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
165
|
+
// First run
|
|
166
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
167
|
+
// Second run (re-install)
|
|
168
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
169
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
170
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit).toHaveLength(1);
|
|
171
|
+
});
|
|
172
|
+
(0, vitest_1.it)('should distinguish entries with different matchers', () => {
|
|
173
|
+
const sourceSettings = {
|
|
174
|
+
hooks: {
|
|
175
|
+
PreToolUse: [
|
|
176
|
+
{
|
|
177
|
+
matcher: 'Bash',
|
|
178
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
matcher: 'Write',
|
|
182
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
188
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
189
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
190
|
+
(0, vitest_1.expect)(result.hooks.PreToolUse).toHaveLength(2);
|
|
191
|
+
(0, vitest_1.expect)(result.hooks.PreToolUse[0].matcher).toBe('Bash');
|
|
192
|
+
(0, vitest_1.expect)(result.hooks.PreToolUse[1].matcher).toBe('Write');
|
|
193
|
+
});
|
|
194
|
+
(0, vitest_1.it)('should not add duplicate when matcher-entry already exists in dest', () => {
|
|
195
|
+
const sourceSettings = {
|
|
196
|
+
hooks: {
|
|
197
|
+
PreToolUse: [
|
|
198
|
+
{
|
|
199
|
+
matcher: 'Bash',
|
|
200
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
206
|
+
const destSettings = {
|
|
207
|
+
hooks: {
|
|
208
|
+
PreToolUse: [
|
|
209
|
+
{
|
|
210
|
+
matcher: 'Bash',
|
|
211
|
+
hooks: [{ type: 'command', command: 'blocker.sh' }],
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
writeJson(path.join(destDir, 'settings.json'), destSettings);
|
|
217
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
218
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
219
|
+
(0, vitest_1.expect)(result.hooks.PreToolUse).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
(0, vitest_1.it)('should preserve existing hooks in dest that are not in source', () => {
|
|
222
|
+
const sourceSettings = {
|
|
223
|
+
hooks: {
|
|
224
|
+
UserPromptSubmit: [
|
|
225
|
+
{ hooks: [{ type: 'command', command: 'new-hook.sh' }] },
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
230
|
+
const destSettings = {
|
|
231
|
+
hooks: {
|
|
232
|
+
StartSession: [
|
|
233
|
+
{ hooks: [{ type: 'command', command: 'existing-hook.sh' }] },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
writeJson(path.join(destDir, 'settings.json'), destSettings);
|
|
238
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
239
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
240
|
+
// Source hook merged in
|
|
241
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit).toHaveLength(1);
|
|
242
|
+
// Existing hook preserved
|
|
243
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
244
|
+
(0, vitest_1.expect)(result.hooks.StartSession[0].hooks[0].command).toBe('existing-hook.sh');
|
|
245
|
+
});
|
|
246
|
+
(0, vitest_1.it)('should preserve existing top-level keys in dest', () => {
|
|
247
|
+
const sourceSettings = {
|
|
248
|
+
statusLine: { type: 'command', command: 'source-status' },
|
|
249
|
+
hooks: {},
|
|
250
|
+
};
|
|
251
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
252
|
+
const destSettings = {
|
|
253
|
+
statusLine: { type: 'command', command: 'dest-status' },
|
|
254
|
+
customKey: 'preserved',
|
|
255
|
+
};
|
|
256
|
+
writeJson(path.join(destDir, 'settings.json'), destSettings);
|
|
257
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
258
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
259
|
+
// Dest's existing key is preserved (not overwritten)
|
|
260
|
+
(0, vitest_1.expect)(result.statusLine.command).toBe('dest-status');
|
|
261
|
+
(0, vitest_1.expect)(result.customKey).toBe('preserved');
|
|
262
|
+
});
|
|
263
|
+
(0, vitest_1.it)('should skip merge when source settings.json does not exist', () => {
|
|
264
|
+
// No settings.json in sourceDir
|
|
265
|
+
const destSettings = { hooks: { StartSession: [] } };
|
|
266
|
+
writeJson(path.join(destDir, 'settings.json'), destSettings);
|
|
267
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
268
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
269
|
+
(0, vitest_1.expect)(result).toEqual(destSettings);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
// ─── init-project.ts mergeSettingsJson (project) ───
|
|
273
|
+
(0, vitest_1.describe)('init-project.ts mergeSettingsJson', () => {
|
|
274
|
+
let tmpDir;
|
|
275
|
+
(0, vitest_1.beforeEach)(() => {
|
|
276
|
+
tmpDir = createTmpDir();
|
|
277
|
+
});
|
|
278
|
+
(0, vitest_1.afterEach)(() => {
|
|
279
|
+
cleanupDir(tmpDir);
|
|
280
|
+
});
|
|
281
|
+
(0, vitest_1.it)('should create settings.json with StartSession hook on fresh init', () => {
|
|
282
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
283
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
284
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
285
|
+
(0, vitest_1.expect)(result.hooks.StartSession[0].hooks[0].command).toBe('bash .claude/hooks/task-loader.sh');
|
|
286
|
+
});
|
|
287
|
+
(0, vitest_1.it)('should skip duplicate StartSession hook on re-run', () => {
|
|
288
|
+
// First run
|
|
289
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
290
|
+
// Second run
|
|
291
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
292
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
293
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
(0, vitest_1.it)('should preserve existing StartSession hooks while adding new one', () => {
|
|
296
|
+
const existingSettings = {
|
|
297
|
+
hooks: {
|
|
298
|
+
StartSession: [
|
|
299
|
+
{
|
|
300
|
+
hooks: [{ type: 'command', command: 'existing-session-hook.sh' }],
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
writeJson(path.join(tmpDir, 'settings.json'), existingSettings);
|
|
306
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
307
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
308
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(2);
|
|
309
|
+
(0, vitest_1.expect)(result.hooks.StartSession[0].hooks[0].command).toBe('existing-session-hook.sh');
|
|
310
|
+
(0, vitest_1.expect)(result.hooks.StartSession[1].hooks[0].command).toBe('bash .claude/hooks/task-loader.sh');
|
|
311
|
+
});
|
|
312
|
+
(0, vitest_1.it)('should preserve other hook events', () => {
|
|
313
|
+
const existingSettings = {
|
|
314
|
+
hooks: {
|
|
315
|
+
UserPromptSubmit: [
|
|
316
|
+
{ hooks: [{ type: 'command', command: 'prompt-hook.sh' }] },
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
writeJson(path.join(tmpDir, 'settings.json'), existingSettings);
|
|
321
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
322
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
323
|
+
// Existing event preserved
|
|
324
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit).toHaveLength(1);
|
|
325
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit[0].hooks[0].command).toBe('prompt-hook.sh');
|
|
326
|
+
// New hook added
|
|
327
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
328
|
+
});
|
|
329
|
+
(0, vitest_1.it)('should preserve non-hook settings', () => {
|
|
330
|
+
const existingSettings = {
|
|
331
|
+
statusLine: { type: 'command', command: 'echo status' },
|
|
332
|
+
hooks: {},
|
|
333
|
+
};
|
|
334
|
+
writeJson(path.join(tmpDir, 'settings.json'), existingSettings);
|
|
335
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
336
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
337
|
+
(0, vitest_1.expect)(result.statusLine.command).toBe('echo status');
|
|
338
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
339
|
+
});
|
|
340
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -45,15 +45,17 @@ const validate_1 = require("./validate");
|
|
|
45
45
|
const program = new commander_1.Command();
|
|
46
46
|
program
|
|
47
47
|
.name('jun-claude-code')
|
|
48
|
-
.description('Copy .claude configuration files to
|
|
48
|
+
.description('Copy .claude configuration files to ~/.claude (global) or .claude/ (project)')
|
|
49
49
|
.version('1.0.0')
|
|
50
50
|
.option('-d, --dry-run', 'Preview files to be copied without actually copying')
|
|
51
51
|
.option('-f, --force', 'Overwrite existing files without confirmation')
|
|
52
|
+
.option('-p, --project', 'Install to project .claude/ instead of global ~/.claude/')
|
|
52
53
|
.action(async (options) => {
|
|
53
54
|
try {
|
|
54
55
|
await (0, copy_1.copyClaudeFiles)({
|
|
55
56
|
dryRun: options.dryRun,
|
|
56
57
|
force: options.force,
|
|
58
|
+
project: options.project,
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
catch (error) {
|
package/dist/copy.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export interface CopyOptions {
|
|
2
2
|
dryRun?: boolean;
|
|
3
3
|
force?: boolean;
|
|
4
|
+
project?: boolean;
|
|
4
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Merge settings.json from source into destination.
|
|
8
|
+
* Hooks are merged per event key; duplicate hook entries (by deep equality) are skipped.
|
|
9
|
+
* Non-hook keys are shallow-merged (source wins for new keys, dest preserved for existing).
|
|
10
|
+
*/
|
|
11
|
+
export declare function mergeSettingsJson(sourceDir: string, destDir: string): void;
|
|
5
12
|
/**
|
|
6
13
|
* Copy .claude files to user's home directory
|
|
7
14
|
*/
|
package/dist/copy.js
CHANGED
|
@@ -36,11 +36,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.mergeSettingsJson = mergeSettingsJson;
|
|
39
40
|
exports.copyClaudeFiles = copyClaudeFiles;
|
|
40
41
|
const fs = __importStar(require("fs"));
|
|
41
42
|
const path = __importStar(require("path"));
|
|
42
43
|
const readline = __importStar(require("readline"));
|
|
44
|
+
const crypto = __importStar(require("crypto"));
|
|
43
45
|
const chalk_1 = __importDefault(require("chalk"));
|
|
46
|
+
const utils_1 = require("./utils");
|
|
44
47
|
/**
|
|
45
48
|
* Prompt user for confirmation using readline
|
|
46
49
|
*/
|
|
@@ -57,6 +60,13 @@ function askConfirmation(question) {
|
|
|
57
60
|
});
|
|
58
61
|
});
|
|
59
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Calculate SHA-256 hash of a file
|
|
65
|
+
*/
|
|
66
|
+
function getFileHash(filePath) {
|
|
67
|
+
const content = fs.readFileSync(filePath);
|
|
68
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
69
|
+
}
|
|
60
70
|
/**
|
|
61
71
|
* Get all files recursively from a directory
|
|
62
72
|
*/
|
|
@@ -160,22 +170,10 @@ function mergeSettingsJson(sourceDir, destDir) {
|
|
|
160
170
|
destHooks[event] = [];
|
|
161
171
|
}
|
|
162
172
|
const destEntries = destHooks[event];
|
|
163
|
-
// Extract a command-based key from a hook entry for duplicate detection.
|
|
164
|
-
// Type 1: { type: "command", command: "..." } → returns "type:command"
|
|
165
|
-
// Type 2: { hooks: [{ type: "command", command: "..." }, ...] } → returns sorted "type:command" joined
|
|
166
|
-
const getHookKey = (entry) => {
|
|
167
|
-
if (entry.hooks && Array.isArray(entry.hooks)) {
|
|
168
|
-
return entry.hooks
|
|
169
|
-
.map((h) => `${h.type || ''}:${h.command || ''}`)
|
|
170
|
-
.sort()
|
|
171
|
-
.join('\n');
|
|
172
|
-
}
|
|
173
|
-
return `${entry.type || ''}:${entry.command || ''}`;
|
|
174
|
-
};
|
|
175
173
|
// Build a Set of command keys from existing entries for fast duplicate detection
|
|
176
|
-
const existingKeys = new Set(destEntries.map((entry) => getHookKey(entry)));
|
|
174
|
+
const existingKeys = new Set(destEntries.map((entry) => (0, utils_1.getHookKey)(entry)));
|
|
177
175
|
for (const entry of sourceEntries) {
|
|
178
|
-
const key = getHookKey(entry);
|
|
176
|
+
const key = (0, utils_1.getHookKey)(entry);
|
|
179
177
|
if (!existingKeys.has(key)) {
|
|
180
178
|
destEntries.push(entry);
|
|
181
179
|
existingKeys.add(key);
|
|
@@ -190,11 +188,12 @@ function mergeSettingsJson(sourceDir, destDir) {
|
|
|
190
188
|
* Copy .claude files to user's home directory
|
|
191
189
|
*/
|
|
192
190
|
async function copyClaudeFiles(options = {}) {
|
|
193
|
-
const { dryRun = false, force = false } = options;
|
|
191
|
+
const { dryRun = false, force = false, project = false } = options;
|
|
194
192
|
const sourceDir = getSourceGlobalDir();
|
|
195
|
-
const destDir = getDestClaudeDir();
|
|
193
|
+
const destDir = project ? path.join(process.cwd(), '.claude') : getDestClaudeDir();
|
|
194
|
+
const targetLabel = project ? 'project' : 'global';
|
|
196
195
|
console.log(chalk_1.default.blue('Source:'), sourceDir);
|
|
197
|
-
console.log(chalk_1.default.blue('Destination:'), destDir);
|
|
196
|
+
console.log(chalk_1.default.blue('Destination:'), `${destDir} ${chalk_1.default.gray(`(${targetLabel})`)}`);
|
|
198
197
|
console.log();
|
|
199
198
|
// Check if source exists
|
|
200
199
|
if (!fs.existsSync(sourceDir)) {
|
|
@@ -221,10 +220,18 @@ async function copyClaudeFiles(options = {}) {
|
|
|
221
220
|
console.log(chalk_1.default.yellow('[DRY RUN] Files that would be copied:'));
|
|
222
221
|
console.log();
|
|
223
222
|
for (const file of files) {
|
|
223
|
+
const sourcePath = path.join(sourceDir, file);
|
|
224
224
|
const destPath = path.join(destDir, file);
|
|
225
225
|
const exists = fs.existsSync(destPath);
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
if (exists) {
|
|
227
|
+
const sourceHash = getFileHash(sourcePath);
|
|
228
|
+
const destHash = getFileHash(destPath);
|
|
229
|
+
const status = sourceHash === destHash ? chalk_1.default.gray('[unchanged]') : chalk_1.default.yellow('[overwrite]');
|
|
230
|
+
console.log(` ${status} ${file}`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
console.log(` ${chalk_1.default.green('[new]')} ${file}`);
|
|
234
|
+
}
|
|
228
235
|
}
|
|
229
236
|
// settings.json merge indicator
|
|
230
237
|
const sourceSettingsExists = fs.existsSync(path.join(sourceDir, 'settings.json'));
|
|
@@ -243,8 +250,15 @@ async function copyClaudeFiles(options = {}) {
|
|
|
243
250
|
const destPath = path.join(destDir, file);
|
|
244
251
|
const exists = fs.existsSync(destPath);
|
|
245
252
|
if (exists && !force) {
|
|
246
|
-
|
|
247
|
-
const
|
|
253
|
+
const sourceHash = getFileHash(sourcePath);
|
|
254
|
+
const destHash = getFileHash(destPath);
|
|
255
|
+
if (sourceHash === destHash) {
|
|
256
|
+
console.log(` ${chalk_1.default.gray('[unchanged]')} ${file}`);
|
|
257
|
+
skippedCount++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// Hash differs - ask for confirmation
|
|
261
|
+
const shouldOverwrite = await askConfirmation(chalk_1.default.yellow(`File changed: ${file}. Overwrite? (y/N): `));
|
|
248
262
|
if (!shouldOverwrite) {
|
|
249
263
|
console.log(chalk_1.default.gray(` Skipped: ${file}`));
|
|
250
264
|
skippedCount++;
|