jun-claude-code 0.0.17 → 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/README.md +63 -61
- package/dist/__tests__/merge-settings.test.d.ts +1 -0
- package/dist/__tests__/merge-settings.test.js +407 -0
- package/dist/cli.js +3 -1
- package/dist/copy.d.ts +11 -0
- package/dist/copy.js +45 -21
- 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/agents/git-manager.md +41 -3
- package/templates/global/settings.json +2 -2
- package/templates/global/skills/Backend/SKILL.md +12 -2
- package/templates/global/skills/Backend/bdd-testing.md +211 -0
- package/templates/global/statusline-command.sh +84 -0
- package/templates/project/workflows/context-gen.yml +8 -4
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
|
|
|
@@ -176,6 +118,66 @@ jun-claude-code init-context
|
|
|
176
118
|
2. **CLAUDE_CODE_OAUTH_TOKEN 추가**
|
|
177
119
|
- Settings → Secrets and variables → Actions → New repository secret
|
|
178
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 위임 규칙)을 정의합니다.
|
|
180
|
+
|
|
179
181
|
## 프로젝트 구조
|
|
180
182
|
|
|
181
183
|
```
|
|
@@ -196,7 +198,7 @@ templates/
|
|
|
196
198
|
|
|
197
199
|
## 커스터마이징
|
|
198
200
|
|
|
199
|
-
|
|
201
|
+
`--project` 옵션을 사용하면 전역 템플릿을 프로젝트 `.claude/`에 바로 설치할 수 있습니다. 설치 후 필요에 맞게 수정하세요.
|
|
200
202
|
|
|
201
203
|
```
|
|
202
204
|
your-project/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,407 @@
|
|
|
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).toBeUndefined();
|
|
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
|
+
(0, vitest_1.it)('should exclude statusLine from source even on fresh install', () => {
|
|
272
|
+
const sourceSettings = {
|
|
273
|
+
statusLine: { type: 'command', command: '~/.claude/statusline-command.sh' },
|
|
274
|
+
hooks: {},
|
|
275
|
+
};
|
|
276
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
277
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
278
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
279
|
+
(0, vitest_1.expect)(result.statusLine).toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
(0, vitest_1.it)('should preserve existing statusLine in dest when source also has statusLine', () => {
|
|
282
|
+
const sourceSettings = {
|
|
283
|
+
statusLine: { type: 'command', command: 'source-status' },
|
|
284
|
+
hooks: {},
|
|
285
|
+
};
|
|
286
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
287
|
+
const destSettings = {
|
|
288
|
+
statusLine: { type: 'command', command: 'dest-status' },
|
|
289
|
+
};
|
|
290
|
+
writeJson(path.join(destDir, 'settings.json'), destSettings);
|
|
291
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
292
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
293
|
+
(0, vitest_1.expect)(result.statusLine.command).toBe('dest-status');
|
|
294
|
+
});
|
|
295
|
+
(0, vitest_1.it)('should convert ~/.claude/ to ./.claude/ in command fields when project=true', () => {
|
|
296
|
+
const sourceSettings = {
|
|
297
|
+
hooks: {
|
|
298
|
+
UserPromptSubmit: [
|
|
299
|
+
{
|
|
300
|
+
hooks: [
|
|
301
|
+
{ type: 'command', command: '~/.claude/hooks/skill-forced.sh' },
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
PreToolUse: [
|
|
306
|
+
{
|
|
307
|
+
matcher: 'Bash',
|
|
308
|
+
hooks: [
|
|
309
|
+
{ type: 'command', command: '~/.claude/hooks/blocker.sh' },
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
316
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir, { project: true });
|
|
317
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
318
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit[0].hooks[0].command).toBe('./.claude/hooks/skill-forced.sh');
|
|
319
|
+
(0, vitest_1.expect)(result.hooks.PreToolUse[0].hooks[0].command).toBe('./.claude/hooks/blocker.sh');
|
|
320
|
+
});
|
|
321
|
+
(0, vitest_1.it)('should keep ~/.claude/ paths unchanged when project option is not set', () => {
|
|
322
|
+
const sourceSettings = {
|
|
323
|
+
hooks: {
|
|
324
|
+
UserPromptSubmit: [
|
|
325
|
+
{
|
|
326
|
+
hooks: [
|
|
327
|
+
{ type: 'command', command: '~/.claude/hooks/skill-forced.sh' },
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
writeJson(path.join(sourceDir, 'settings.json'), sourceSettings);
|
|
334
|
+
(0, copy_1.mergeSettingsJson)(sourceDir, destDir);
|
|
335
|
+
const result = readJson(path.join(destDir, 'settings.json'));
|
|
336
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit[0].hooks[0].command).toBe('~/.claude/hooks/skill-forced.sh');
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
// ─── init-project.ts mergeSettingsJson (project) ───
|
|
340
|
+
(0, vitest_1.describe)('init-project.ts mergeSettingsJson', () => {
|
|
341
|
+
let tmpDir;
|
|
342
|
+
(0, vitest_1.beforeEach)(() => {
|
|
343
|
+
tmpDir = createTmpDir();
|
|
344
|
+
});
|
|
345
|
+
(0, vitest_1.afterEach)(() => {
|
|
346
|
+
cleanupDir(tmpDir);
|
|
347
|
+
});
|
|
348
|
+
(0, vitest_1.it)('should create settings.json with StartSession hook on fresh init', () => {
|
|
349
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
350
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
351
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
352
|
+
(0, vitest_1.expect)(result.hooks.StartSession[0].hooks[0].command).toBe('bash .claude/hooks/task-loader.sh');
|
|
353
|
+
});
|
|
354
|
+
(0, vitest_1.it)('should skip duplicate StartSession hook on re-run', () => {
|
|
355
|
+
// First run
|
|
356
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
357
|
+
// Second run
|
|
358
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
359
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
360
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
361
|
+
});
|
|
362
|
+
(0, vitest_1.it)('should preserve existing StartSession hooks while adding new one', () => {
|
|
363
|
+
const existingSettings = {
|
|
364
|
+
hooks: {
|
|
365
|
+
StartSession: [
|
|
366
|
+
{
|
|
367
|
+
hooks: [{ type: 'command', command: 'existing-session-hook.sh' }],
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
writeJson(path.join(tmpDir, 'settings.json'), existingSettings);
|
|
373
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
374
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
375
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(2);
|
|
376
|
+
(0, vitest_1.expect)(result.hooks.StartSession[0].hooks[0].command).toBe('existing-session-hook.sh');
|
|
377
|
+
(0, vitest_1.expect)(result.hooks.StartSession[1].hooks[0].command).toBe('bash .claude/hooks/task-loader.sh');
|
|
378
|
+
});
|
|
379
|
+
(0, vitest_1.it)('should preserve other hook events', () => {
|
|
380
|
+
const existingSettings = {
|
|
381
|
+
hooks: {
|
|
382
|
+
UserPromptSubmit: [
|
|
383
|
+
{ hooks: [{ type: 'command', command: 'prompt-hook.sh' }] },
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
writeJson(path.join(tmpDir, 'settings.json'), existingSettings);
|
|
388
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
389
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
390
|
+
// Existing event preserved
|
|
391
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit).toHaveLength(1);
|
|
392
|
+
(0, vitest_1.expect)(result.hooks.UserPromptSubmit[0].hooks[0].command).toBe('prompt-hook.sh');
|
|
393
|
+
// New hook added
|
|
394
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
395
|
+
});
|
|
396
|
+
(0, vitest_1.it)('should preserve non-hook settings', () => {
|
|
397
|
+
const existingSettings = {
|
|
398
|
+
statusLine: { type: 'command', command: 'echo status' },
|
|
399
|
+
hooks: {},
|
|
400
|
+
};
|
|
401
|
+
writeJson(path.join(tmpDir, 'settings.json'), existingSettings);
|
|
402
|
+
(0, init_project_1.mergeProjectSettingsJson)(tmpDir);
|
|
403
|
+
const result = readJson(path.join(tmpDir, 'settings.json'));
|
|
404
|
+
(0, vitest_1.expect)(result.statusLine.command).toBe('echo status');
|
|
405
|
+
(0, vitest_1.expect)(result.hooks.StartSession).toHaveLength(1);
|
|
406
|
+
});
|
|
407
|
+
});
|
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,18 @@
|
|
|
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
|
+
* statusLine is always excluded from merge (personal environment setting).
|
|
11
|
+
* When project=true, ~/.claude/ paths in command fields are converted to ./.claude/.
|
|
12
|
+
*/
|
|
13
|
+
export declare function mergeSettingsJson(sourceDir: string, destDir: string, options?: {
|
|
14
|
+
project?: boolean;
|
|
15
|
+
}): void;
|
|
5
16
|
/**
|
|
6
17
|
* Copy .claude files to user's home directory
|
|
7
18
|
*/
|
package/dist/copy.js
CHANGED
|
@@ -36,12 +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"));
|
|
43
44
|
const crypto = __importStar(require("crypto"));
|
|
44
45
|
const chalk_1 = __importDefault(require("chalk"));
|
|
46
|
+
const utils_1 = require("./utils");
|
|
45
47
|
/**
|
|
46
48
|
* Prompt user for confirmation using readline
|
|
47
49
|
*/
|
|
@@ -123,7 +125,35 @@ function getDestClaudeDir() {
|
|
|
123
125
|
* Hooks are merged per event key; duplicate hook entries (by deep equality) are skipped.
|
|
124
126
|
* Non-hook keys are shallow-merged (source wins for new keys, dest preserved for existing).
|
|
125
127
|
*/
|
|
126
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Replace ~/.claude/ paths with ./.claude/ in all command fields (deep traverse).
|
|
130
|
+
*/
|
|
131
|
+
function replaceClaudePaths(obj) {
|
|
132
|
+
const result = {};
|
|
133
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
134
|
+
if (key === 'command' && typeof value === 'string') {
|
|
135
|
+
result[key] = value.replace(/~\/\.claude\//g, './.claude/');
|
|
136
|
+
}
|
|
137
|
+
else if (Array.isArray(value)) {
|
|
138
|
+
result[key] = value.map((item) => typeof item === 'object' && item !== null ? replaceClaudePaths(item) : item);
|
|
139
|
+
}
|
|
140
|
+
else if (typeof value === 'object' && value !== null) {
|
|
141
|
+
result[key] = replaceClaudePaths(value);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
result[key] = value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Merge settings.json from source into destination.
|
|
151
|
+
* Hooks are merged per event key; duplicate hook entries (by deep equality) are skipped.
|
|
152
|
+
* Non-hook keys are shallow-merged (source wins for new keys, dest preserved for existing).
|
|
153
|
+
* statusLine is always excluded from merge (personal environment setting).
|
|
154
|
+
* When project=true, ~/.claude/ paths in command fields are converted to ./.claude/.
|
|
155
|
+
*/
|
|
156
|
+
function mergeSettingsJson(sourceDir, destDir, options) {
|
|
127
157
|
const sourcePath = path.join(sourceDir, 'settings.json');
|
|
128
158
|
const destPath = path.join(destDir, 'settings.json');
|
|
129
159
|
if (!fs.existsSync(sourcePath)) {
|
|
@@ -149,8 +179,8 @@ function mergeSettingsJson(sourceDir, destDir) {
|
|
|
149
179
|
}
|
|
150
180
|
// Merge top-level keys (source fills in missing keys, dest's existing keys preserved)
|
|
151
181
|
for (const key of Object.keys(sourceSettings)) {
|
|
152
|
-
if (key === 'hooks') {
|
|
153
|
-
continue; // hooks are merged separately below
|
|
182
|
+
if (key === 'hooks' || key === 'statusLine') {
|
|
183
|
+
continue; // hooks are merged separately below; statusLine is excluded
|
|
154
184
|
}
|
|
155
185
|
if (!(key in destSettings)) {
|
|
156
186
|
destSettings[key] = sourceSettings[key];
|
|
@@ -168,28 +198,20 @@ function mergeSettingsJson(sourceDir, destDir) {
|
|
|
168
198
|
destHooks[event] = [];
|
|
169
199
|
}
|
|
170
200
|
const destEntries = destHooks[event];
|
|
171
|
-
// Extract a command-based key from a hook entry for duplicate detection.
|
|
172
|
-
// Type 1: { type: "command", command: "..." } → returns "type:command"
|
|
173
|
-
// Type 2: { hooks: [{ type: "command", command: "..." }, ...] } → returns sorted "type:command" joined
|
|
174
|
-
const getHookKey = (entry) => {
|
|
175
|
-
if (entry.hooks && Array.isArray(entry.hooks)) {
|
|
176
|
-
return entry.hooks
|
|
177
|
-
.map((h) => `${h.type || ''}:${h.command || ''}`)
|
|
178
|
-
.sort()
|
|
179
|
-
.join('\n');
|
|
180
|
-
}
|
|
181
|
-
return `${entry.type || ''}:${entry.command || ''}`;
|
|
182
|
-
};
|
|
183
201
|
// Build a Set of command keys from existing entries for fast duplicate detection
|
|
184
|
-
const existingKeys = new Set(destEntries.map((entry) => getHookKey(entry)));
|
|
202
|
+
const existingKeys = new Set(destEntries.map((entry) => (0, utils_1.getHookKey)(entry)));
|
|
185
203
|
for (const entry of sourceEntries) {
|
|
186
|
-
const key = getHookKey(entry);
|
|
204
|
+
const key = (0, utils_1.getHookKey)(entry);
|
|
187
205
|
if (!existingKeys.has(key)) {
|
|
188
206
|
destEntries.push(entry);
|
|
189
207
|
existingKeys.add(key);
|
|
190
208
|
}
|
|
191
209
|
}
|
|
192
210
|
}
|
|
211
|
+
// Convert ~/.claude/ → ./.claude/ paths for project mode
|
|
212
|
+
if (options?.project) {
|
|
213
|
+
destSettings = replaceClaudePaths(destSettings);
|
|
214
|
+
}
|
|
193
215
|
ensureDir(destDir);
|
|
194
216
|
fs.writeFileSync(destPath, JSON.stringify(destSettings, null, 2) + '\n', 'utf-8');
|
|
195
217
|
console.log(` ${chalk_1.default.blue('[merged]')} settings.json`);
|
|
@@ -198,11 +220,12 @@ function mergeSettingsJson(sourceDir, destDir) {
|
|
|
198
220
|
* Copy .claude files to user's home directory
|
|
199
221
|
*/
|
|
200
222
|
async function copyClaudeFiles(options = {}) {
|
|
201
|
-
const { dryRun = false, force = false } = options;
|
|
223
|
+
const { dryRun = false, force = false, project = false } = options;
|
|
202
224
|
const sourceDir = getSourceGlobalDir();
|
|
203
|
-
const destDir = getDestClaudeDir();
|
|
225
|
+
const destDir = project ? path.join(process.cwd(), '.claude') : getDestClaudeDir();
|
|
226
|
+
const targetLabel = project ? 'project' : 'global';
|
|
204
227
|
console.log(chalk_1.default.blue('Source:'), sourceDir);
|
|
205
|
-
console.log(chalk_1.default.blue('Destination:'), destDir);
|
|
228
|
+
console.log(chalk_1.default.blue('Destination:'), `${destDir} ${chalk_1.default.gray(`(${targetLabel})`)}`);
|
|
206
229
|
console.log();
|
|
207
230
|
// Check if source exists
|
|
208
231
|
if (!fs.existsSync(sourceDir)) {
|
|
@@ -212,6 +235,7 @@ async function copyClaudeFiles(options = {}) {
|
|
|
212
235
|
// Files to exclude from global copy (merge-handled separately)
|
|
213
236
|
const EXCLUDE_FROM_GLOBAL = [
|
|
214
237
|
'settings.json',
|
|
238
|
+
'statusline-command.sh',
|
|
215
239
|
];
|
|
216
240
|
// Get all files to copy
|
|
217
241
|
const allFiles = getAllFiles(sourceDir);
|
|
@@ -280,7 +304,7 @@ async function copyClaudeFiles(options = {}) {
|
|
|
280
304
|
copiedCount++;
|
|
281
305
|
}
|
|
282
306
|
// Merge settings.json (hooks are merged, not overwritten)
|
|
283
|
-
mergeSettingsJson(sourceDir, destDir);
|
|
307
|
+
mergeSettingsJson(sourceDir, destDir, { project });
|
|
284
308
|
console.log();
|
|
285
309
|
console.log(chalk_1.default.green(`Done! Copied ${copiedCount} files, skipped ${skippedCount} files.`));
|
|
286
310
|
}
|
package/dist/init-project.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge StartSession hook into settings.json.
|
|
3
|
+
* Appends the task-loader hook if not already present (duplicate detection via getHookKey).
|
|
4
|
+
*/
|
|
5
|
+
export declare function mergeProjectSettingsJson(destDir: string): void;
|
|
1
6
|
/**
|
|
2
7
|
* Initialize GitHub Project integration in current directory
|
|
3
8
|
*/
|
package/dist/init-project.js
CHANGED
|
@@ -36,11 +36,13 @@ 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.mergeProjectSettingsJson = mergeProjectSettingsJson;
|
|
39
40
|
exports.initProject = initProject;
|
|
40
41
|
const fs = __importStar(require("fs"));
|
|
41
42
|
const path = __importStar(require("path"));
|
|
42
43
|
const readline = __importStar(require("readline"));
|
|
43
44
|
const chalk_1 = __importDefault(require("chalk"));
|
|
45
|
+
const utils_1 = require("./utils");
|
|
44
46
|
/**
|
|
45
47
|
* Prompt user for input using readline
|
|
46
48
|
*/
|
|
@@ -112,9 +114,10 @@ function copyProjectFile(srcRelative, destDir) {
|
|
|
112
114
|
console.log(chalk_1.default.green(` ✓ ${path.relative(process.cwd(), destPath)}`));
|
|
113
115
|
}
|
|
114
116
|
/**
|
|
115
|
-
* Merge StartSession hook into settings.json
|
|
117
|
+
* Merge StartSession hook into settings.json.
|
|
118
|
+
* Appends the task-loader hook if not already present (duplicate detection via getHookKey).
|
|
116
119
|
*/
|
|
117
|
-
function
|
|
120
|
+
function mergeProjectSettingsJson(destDir) {
|
|
118
121
|
const settingsPath = path.join(destDir, 'settings.json');
|
|
119
122
|
let settings = {};
|
|
120
123
|
if (fs.existsSync(settingsPath)) {
|
|
@@ -129,12 +132,21 @@ function mergeSettingsJson(destDir) {
|
|
|
129
132
|
if (!settings.hooks) {
|
|
130
133
|
settings.hooks = {};
|
|
131
134
|
}
|
|
132
|
-
settings.hooks.StartSession
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
if (!settings.hooks.StartSession) {
|
|
136
|
+
settings.hooks.StartSession = [];
|
|
137
|
+
}
|
|
138
|
+
const newEntry = {
|
|
139
|
+
hooks: [
|
|
140
|
+
{
|
|
141
|
+
type: 'command',
|
|
142
|
+
command: 'bash .claude/hooks/task-loader.sh',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
const existingKeys = new Set(settings.hooks.StartSession.map((e) => (0, utils_1.getHookKey)(e)));
|
|
147
|
+
if (!existingKeys.has((0, utils_1.getHookKey)(newEntry))) {
|
|
148
|
+
settings.hooks.StartSession.push(newEntry);
|
|
149
|
+
}
|
|
138
150
|
fs.mkdirSync(destDir, { recursive: true });
|
|
139
151
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
140
152
|
console.log(chalk_1.default.green(` ✓ ${path.relative(process.cwd(), settingsPath)} (StartSession hook 추가)`));
|
|
@@ -152,7 +164,7 @@ async function initProject() {
|
|
|
152
164
|
copyProjectFile('hooks/task-loader.sh', destDir);
|
|
153
165
|
copyProjectFile('agents/project-task-manager.md', destDir);
|
|
154
166
|
// 3. settings.json merge
|
|
155
|
-
|
|
167
|
+
mergeProjectSettingsJson(destDir);
|
|
156
168
|
console.log(chalk_1.default.cyan('\n✅ GitHub Project 설정 완료!'));
|
|
157
169
|
console.log(chalk_1.default.gray(` Owner: ${config.owner}`));
|
|
158
170
|
console.log(chalk_1.default.gray(` Project: #${config.projectNumber}`));
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a command-based key from a hook entry for duplicate detection.
|
|
3
|
+
* Includes the matcher field so that entries with the same command but different matchers
|
|
4
|
+
* are treated as distinct items.
|
|
5
|
+
*
|
|
6
|
+
* Type 1 (nested): { matcher?: "...", hooks: [{ type: "command", command: "..." }, ...] }
|
|
7
|
+
* -> returns "[matcher]type:command\ntype:command" (sorted) or "type:command\ntype:command" if no matcher
|
|
8
|
+
*
|
|
9
|
+
* Type 2 (flat): { type: "command", command: "..." }
|
|
10
|
+
* -> returns "type:command"
|
|
11
|
+
*/
|
|
12
|
+
export declare const getHookKey: (entry: any) => string;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getHookKey = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Extract a command-based key from a hook entry for duplicate detection.
|
|
6
|
+
* Includes the matcher field so that entries with the same command but different matchers
|
|
7
|
+
* are treated as distinct items.
|
|
8
|
+
*
|
|
9
|
+
* Type 1 (nested): { matcher?: "...", hooks: [{ type: "command", command: "..." }, ...] }
|
|
10
|
+
* -> returns "[matcher]type:command\ntype:command" (sorted) or "type:command\ntype:command" if no matcher
|
|
11
|
+
*
|
|
12
|
+
* Type 2 (flat): { type: "command", command: "..." }
|
|
13
|
+
* -> returns "type:command"
|
|
14
|
+
*/
|
|
15
|
+
const getHookKey = (entry) => {
|
|
16
|
+
const matcher = entry.matcher || '';
|
|
17
|
+
if (entry.hooks && Array.isArray(entry.hooks)) {
|
|
18
|
+
const hooksKey = entry.hooks
|
|
19
|
+
.map((h) => `${h.type || ''}:${h.command || ''}`)
|
|
20
|
+
.sort()
|
|
21
|
+
.join('\n');
|
|
22
|
+
return matcher ? `[${matcher}]${hooksKey}` : hooksKey;
|
|
23
|
+
}
|
|
24
|
+
return `${entry.type || ''}:${entry.command || ''}`;
|
|
25
|
+
};
|
|
26
|
+
exports.getHookKey = getHookKey;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jun-claude-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Claude Code configuration template - copy .claude settings to your project",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": "dist/cli.js",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
],
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
|
+
"test": "vitest run",
|
|
13
14
|
"prepublishOnly": "yarn build"
|
|
14
15
|
},
|
|
15
16
|
"packageManager": "yarn@3.8.7",
|
|
@@ -19,7 +20,8 @@
|
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@types/node": "^20.10.0",
|
|
22
|
-
"typescript": "^5.5.3"
|
|
23
|
+
"typescript": "^5.5.3",
|
|
24
|
+
"vitest": "^4.0.18"
|
|
23
25
|
},
|
|
24
26
|
"keywords": [
|
|
25
27
|
"claude",
|
|
@@ -100,25 +100,63 @@ git log --oneline -10
|
|
|
100
100
|
git diff main...HEAD
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
### Step 2:
|
|
103
|
+
### Step 2: PR 템플릿 확인
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# 프로젝트에 PR 템플릿이 있는지 확인
|
|
107
|
+
# 확인 경로 (우선순위 순):
|
|
108
|
+
# 1. .github/pull_request_template.md
|
|
109
|
+
# 2. .github/PULL_REQUEST_TEMPLATE.md
|
|
110
|
+
# 3. docs/pull_request_template.md
|
|
111
|
+
# 4. pull_request_template.md
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- **템플릿이 있으면**: 해당 템플릿의 섹션 구조를 그대로 따라 PR 본문 작성
|
|
115
|
+
- **템플릿이 없으면**: 아래 Step 4의 기본 포맷 사용
|
|
116
|
+
|
|
117
|
+
### Step 3: 변경사항 분석
|
|
104
118
|
|
|
105
119
|
모든 커밋을 분석하여 PR 내용 구성:
|
|
106
120
|
- 변경된 파일 목록
|
|
107
121
|
- 각 파일의 변경 내용
|
|
108
122
|
- 전체 변경의 목적
|
|
109
123
|
|
|
110
|
-
###
|
|
124
|
+
### PR 본문 필수 포함 사항
|
|
125
|
+
|
|
126
|
+
**템플릿 사용 여부와 관계없이**, PR 본문에는 반드시 다음 3가지를 포함:
|
|
127
|
+
|
|
128
|
+
| 항목 | 설명 | 예시 |
|
|
129
|
+
|------|------|------|
|
|
130
|
+
| **의도 (Why)** | 이 변경을 하는 이유/배경 | "사용자 로그인 실패 시 에러 메시지가 표시되지 않는 문제" |
|
|
131
|
+
| **문제 (What)** | 해결하려는 구체적 문제 | "catch 블록에서 에러를 무시하고 있었음" |
|
|
132
|
+
| **해결 방법 (How)** | 어떻게 해결했는지 | "에러 핸들러를 추가하고 toast 알림으로 사용자에게 표시" |
|
|
133
|
+
|
|
134
|
+
### Step 4: PR 생성
|
|
111
135
|
|
|
112
136
|
```bash
|
|
113
137
|
# 원격에 push (필요시)
|
|
114
138
|
git push -u origin <branch-name>
|
|
115
139
|
|
|
116
|
-
# PR
|
|
140
|
+
# PR 템플릿이 있는 경우: 템플릿 구조를 따르되 Why/What/How 포함
|
|
141
|
+
# PR 템플릿이 없는 경우: 아래 기본 포맷 사용
|
|
142
|
+
|
|
117
143
|
gh pr create --title "<간결한 제목>" --body "$(cat <<'EOF'
|
|
118
144
|
## 📋 Summary
|
|
119
145
|
|
|
120
146
|
> 이 PR이 해결하는 문제와 접근 방식을 1-2문장으로 설명
|
|
121
147
|
|
|
148
|
+
## 🎯 Why (의도)
|
|
149
|
+
|
|
150
|
+
> 왜 이 변경이 필요한가? 배경과 동기를 설명
|
|
151
|
+
|
|
152
|
+
## 🐛 What (문제)
|
|
153
|
+
|
|
154
|
+
> 어떤 문제가 있었는가? 구체적인 증상이나 요구사항
|
|
155
|
+
|
|
156
|
+
## 🔧 How (해결 방법)
|
|
157
|
+
|
|
158
|
+
> 어떻게 해결했는가? 접근 방식과 핵심 변경 내용
|
|
159
|
+
|
|
122
160
|
## 🔄 주요 변경사항
|
|
123
161
|
|
|
124
162
|
### 변경 1: [제목]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: Backend
|
|
3
|
-
description: NestJS/TypeORM 백엔드 개발 시 사용.
|
|
4
|
-
keywords: [Backend, 백엔드, 레이어, DTO, Entity, Service, Controller, TypeORM, find, queryBuilder]
|
|
3
|
+
description: NestJS/TypeORM 백엔드 개발 시 사용. 레이어 객체 변환, find vs queryBuilder 선택 기준, BDD 테스트 작성 규칙 제공.
|
|
4
|
+
keywords: [Backend, 백엔드, 레이어, DTO, Entity, Service, Controller, TypeORM, find, queryBuilder, test, BDD, 테스트, Jest]
|
|
5
5
|
estimated_tokens: ~400
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -174,3 +174,13 @@ const user = await this.userRepository.findOneBy({ id });
|
|
|
174
174
|
- [ ] QueryBuilder는 groupBy, getRawMany 등 필요한 경우에만 사용하는가?
|
|
175
175
|
|
|
176
176
|
</checklist>
|
|
177
|
+
|
|
178
|
+
<reference>
|
|
179
|
+
|
|
180
|
+
## 관련 문서
|
|
181
|
+
|
|
182
|
+
| 주제 | 위치 | 설명 |
|
|
183
|
+
|-----|------|------|
|
|
184
|
+
| BDD 테스트 | `bdd-testing.md` | NestJS + Jest BDD 스타일 테스트 작성 규칙 |
|
|
185
|
+
|
|
186
|
+
</reference>
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bdd-testing
|
|
3
|
+
description: NestJS + Jest BDD 스타일 테스트 작성 시 사용. describe 네스팅 구조, Given/When/Then 패턴, Testing Module Mock 설정 규칙 제공.
|
|
4
|
+
keywords: [BDD, test, 테스트, Jest, NestJS, describe, it, Given, When, Then, mock, TestingModule]
|
|
5
|
+
estimated_tokens: ~500
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# BDD 테스트 작성 규칙
|
|
9
|
+
|
|
10
|
+
<rules>
|
|
11
|
+
|
|
12
|
+
## describe 네스팅 구조
|
|
13
|
+
|
|
14
|
+
> **Service 클래스명 > 메서드명 > 시나리오** 구조로 describe를 네스팅한다.
|
|
15
|
+
|
|
16
|
+
| 레벨 | 대상 | 예시 |
|
|
17
|
+
|------|------|------|
|
|
18
|
+
| 1 | Service 클래스 | `describe('UserService', ...)` |
|
|
19
|
+
| 2 | 메서드명 | `describe('create', ...)` |
|
|
20
|
+
| 3 (선택) | 조건 그룹 | `describe('when role is admin', ...)` |
|
|
21
|
+
|
|
22
|
+
- 3레벨은 동일 메서드에서 조건 분기가 많을 때만 사용한다
|
|
23
|
+
- 3레벨까지만 사용한다
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
describe('UserService', () => {
|
|
27
|
+
// 공통 setup (TestingModule, mock 등)
|
|
28
|
+
|
|
29
|
+
describe('create', () => {
|
|
30
|
+
it('should create user when valid dto given', () => {});
|
|
31
|
+
it('should throw ConflictException when email duplicated', () => {});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('findById', () => {
|
|
35
|
+
it('should return user when id exists', () => {});
|
|
36
|
+
it('should throw NotFoundException when id not found', () => {});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## it 문구 및 Given/When/Then
|
|
42
|
+
|
|
43
|
+
### it 문구 형식
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
"should [결과] when [조건]"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| 요소 | 설명 | 예시 |
|
|
50
|
+
|------|------|------|
|
|
51
|
+
| 결과 | 기대하는 동작 | `create user`, `throw NotFoundException` |
|
|
52
|
+
| 조건 | 입력/상태 | `valid dto given`, `id not found` |
|
|
53
|
+
|
|
54
|
+
### 블록 내부 구조
|
|
55
|
+
|
|
56
|
+
각 `it` 블록 내부에 `// Given` / `// When` / `// Then` 주석으로 구분한다.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
it('should create user when valid dto given', async () => {
|
|
60
|
+
// Given
|
|
61
|
+
const dto: CreateUserDto = { name: 'John', email: 'john@test.com' };
|
|
62
|
+
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
|
63
|
+
jest.spyOn(userRepository, 'save').mockResolvedValue({ id: 1, ...dto } as User);
|
|
64
|
+
|
|
65
|
+
// When
|
|
66
|
+
const result = await service.create(dto);
|
|
67
|
+
|
|
68
|
+
// Then
|
|
69
|
+
expect(result.name).toBe('John');
|
|
70
|
+
expect(userRepository.save).toHaveBeenCalledWith(expect.objectContaining({ name: 'John' }));
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## NestJS Testing Module 설정
|
|
75
|
+
|
|
76
|
+
### 기본 패턴
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
80
|
+
|
|
81
|
+
describe('UserService', () => {
|
|
82
|
+
let service: UserService;
|
|
83
|
+
let userRepository: Repository<User>;
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
87
|
+
providers: [
|
|
88
|
+
UserService,
|
|
89
|
+
{
|
|
90
|
+
provide: getRepositoryToken(User),
|
|
91
|
+
useValue: {
|
|
92
|
+
find: jest.fn(),
|
|
93
|
+
findOneBy: jest.fn(),
|
|
94
|
+
save: jest.fn(),
|
|
95
|
+
delete: jest.fn(),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
}).compile();
|
|
100
|
+
|
|
101
|
+
service = module.get<UserService>(UserService);
|
|
102
|
+
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Mock 규칙
|
|
108
|
+
|
|
109
|
+
| 패턴 | 용도 | 예시 |
|
|
110
|
+
|------|------|------|
|
|
111
|
+
| `useValue` + `jest.fn()` | Repository mock | `{ save: jest.fn() }` |
|
|
112
|
+
| `jest.spyOn` | 특정 호출에 반환값 지정 | `jest.spyOn(repo, 'findOneBy').mockResolvedValue(...)` |
|
|
113
|
+
| `jest.mocked` | 이미 mock된 함수 타입 캐스팅 | `jest.mocked(repo.save).mockResolvedValue(...)` |
|
|
114
|
+
|
|
115
|
+
- `useValue`로 mock 객체를 주입하고, 각 테스트에서 `jest.spyOn` 또는 `jest.mocked`로 반환값을 지정한다
|
|
116
|
+
- `jest.mock()` 모듈 레벨 mock은 외부 라이브러리에만 사용한다
|
|
117
|
+
|
|
118
|
+
### 외부 의존성 Mock
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// Service가 다른 Service에 의존할 때
|
|
122
|
+
{
|
|
123
|
+
provide: EmailService,
|
|
124
|
+
useValue: {
|
|
125
|
+
sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
</rules>
|
|
131
|
+
|
|
132
|
+
<examples>
|
|
133
|
+
|
|
134
|
+
### describe 구조
|
|
135
|
+
|
|
136
|
+
<example type="good">
|
|
137
|
+
```typescript
|
|
138
|
+
// Service > Method > Scenario 명확
|
|
139
|
+
describe('OrderService', () => {
|
|
140
|
+
describe('cancel', () => {
|
|
141
|
+
it('should cancel order when status is pending', async () => {});
|
|
142
|
+
it('should throw BadRequestException when status is shipped', async () => {});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
</example>
|
|
147
|
+
<example type="bad">
|
|
148
|
+
```typescript
|
|
149
|
+
// 메서드별 그룹 없이 플랫하게 나열
|
|
150
|
+
describe('OrderService', () => {
|
|
151
|
+
it('cancel pending order', async () => {});
|
|
152
|
+
it('cancel shipped order throws', async () => {});
|
|
153
|
+
it('create order', async () => {});
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
</example>
|
|
157
|
+
|
|
158
|
+
### Assertion
|
|
159
|
+
|
|
160
|
+
<example type="good">
|
|
161
|
+
```typescript
|
|
162
|
+
// 구체적 assertion
|
|
163
|
+
expect(result.status).toBe('cancelled');
|
|
164
|
+
expect(orderRepository.save).toHaveBeenCalledWith(
|
|
165
|
+
expect.objectContaining({ status: 'cancelled' }),
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
</example>
|
|
169
|
+
<example type="bad">
|
|
170
|
+
```typescript
|
|
171
|
+
// 의미 없는 assertion
|
|
172
|
+
expect(result).toBeDefined();
|
|
173
|
+
expect(result).toBeTruthy();
|
|
174
|
+
```
|
|
175
|
+
</example>
|
|
176
|
+
|
|
177
|
+
### Exception 테스트
|
|
178
|
+
|
|
179
|
+
<example type="good">
|
|
180
|
+
```typescript
|
|
181
|
+
// 예외 타입 검증
|
|
182
|
+
await expect(service.cancel(orderId)).rejects.toThrow(BadRequestException);
|
|
183
|
+
```
|
|
184
|
+
</example>
|
|
185
|
+
<example type="bad">
|
|
186
|
+
```typescript
|
|
187
|
+
// try-catch로 예외 테스트
|
|
188
|
+
try {
|
|
189
|
+
await service.cancel(orderId);
|
|
190
|
+
fail('should have thrown');
|
|
191
|
+
} catch (e) {
|
|
192
|
+
expect(e).toBeDefined();
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
</example>
|
|
196
|
+
|
|
197
|
+
</examples>
|
|
198
|
+
|
|
199
|
+
<checklist>
|
|
200
|
+
|
|
201
|
+
## 체크리스트
|
|
202
|
+
|
|
203
|
+
- [ ] describe 네스팅이 Service > Method > Scenario 구조인가?
|
|
204
|
+
- [ ] it 문구가 `"should [결과] when [조건]"` 형식인가?
|
|
205
|
+
- [ ] 각 it 블록에 `// Given` / `// When` / `// Then` 주석이 있는가?
|
|
206
|
+
- [ ] `Test.createTestingModule`로 테스트 모듈을 구성했는가?
|
|
207
|
+
- [ ] Repository mock은 `useValue` + `jest.fn()`으로 주입했는가?
|
|
208
|
+
- [ ] assertion이 구체적인 값을 검증하는가?
|
|
209
|
+
- [ ] 예외 테스트에 `rejects.toThrow`를 사용했는가?
|
|
210
|
+
|
|
211
|
+
</checklist>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Read JSON input from stdin
|
|
4
|
+
input=$(cat)
|
|
5
|
+
|
|
6
|
+
# Extract data from JSON
|
|
7
|
+
cwd=$(echo "$input" | jq -r '.workspace.current_dir')
|
|
8
|
+
model=$(echo "$input" | jq -r '.model.display_name')
|
|
9
|
+
session_name=$(echo "$input" | jq -r '.session_name // empty')
|
|
10
|
+
remaining=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty')
|
|
11
|
+
used_tokens=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty')
|
|
12
|
+
total_tokens=$(echo "$input" | jq -r '.context_window.context_window_size // empty')
|
|
13
|
+
|
|
14
|
+
# Get short directory name (basename)
|
|
15
|
+
dir_name=$(basename "$cwd")
|
|
16
|
+
|
|
17
|
+
# Git information
|
|
18
|
+
git_info=""
|
|
19
|
+
if git -C "$cwd" rev-parse --git-dir > /dev/null 2>&1; then
|
|
20
|
+
branch=$(git -C "$cwd" -c core.useBuiltinFSMonitor=false -c core.fsmonitor=false symbolic-ref --short HEAD 2>/dev/null || echo "detached")
|
|
21
|
+
|
|
22
|
+
# Check for uncommitted changes
|
|
23
|
+
if ! git -C "$cwd" -c core.useBuiltinFSMonitor=false -c core.fsmonitor=false diff --quiet 2>/dev/null || \
|
|
24
|
+
! git -C "$cwd" -c core.useBuiltinFSMonitor=false -c core.fsmonitor=false diff --cached --quiet 2>/dev/null; then
|
|
25
|
+
status="✗"
|
|
26
|
+
else
|
|
27
|
+
status=""
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
git_info=" git:($(printf '\033[31m')${branch}$(printf '\033[0m'))${status}"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Format token information
|
|
34
|
+
format_token_count() {
|
|
35
|
+
local value=$1
|
|
36
|
+
if (( value >= 1000 )); then
|
|
37
|
+
printf "%.1fk" "$(bc <<< "scale=1; $value / 1000")"
|
|
38
|
+
else
|
|
39
|
+
echo "$value"
|
|
40
|
+
fi
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Get color based on usage percentage
|
|
44
|
+
get_color_for_percentage() {
|
|
45
|
+
local percentage=$1
|
|
46
|
+
if (( percentage >= 90 )); then
|
|
47
|
+
printf '\033[31m' # red
|
|
48
|
+
elif (( percentage >= 70 )); then
|
|
49
|
+
printf '\033[33m' # yellow
|
|
50
|
+
else
|
|
51
|
+
printf '\033[0m' # reset (no color)
|
|
52
|
+
fi
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Build status line in robbyrussell style
|
|
56
|
+
status_line="$(printf '\033[36m')${dir_name}$(printf '\033[0m')${git_info}"
|
|
57
|
+
|
|
58
|
+
# Add context percentage if available
|
|
59
|
+
if [ -n "$remaining" ]; then
|
|
60
|
+
# Calculate usage percentage (100 - remaining)
|
|
61
|
+
usage=$((100 - remaining))
|
|
62
|
+
color=$(get_color_for_percentage "$usage")
|
|
63
|
+
status_line="${status_line} | ctx:${color}${usage}%$(printf '\033[0m')"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# Add token information if available
|
|
67
|
+
if [ -n "$used_tokens" ] && [ -n "$total_tokens" ]; then
|
|
68
|
+
used_formatted=$(format_token_count "$used_tokens")
|
|
69
|
+
total_formatted=$(format_token_count "$total_tokens")
|
|
70
|
+
# Calculate token usage percentage
|
|
71
|
+
token_usage=$(echo "scale=0; $used_tokens * 100 / $total_tokens" | bc)
|
|
72
|
+
color=$(get_color_for_percentage "$token_usage")
|
|
73
|
+
status_line="${status_line} | token:${color}${used_formatted}/${total_formatted}$(printf '\033[0m')"
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# Add model info
|
|
77
|
+
status_line="${status_line} | ${model}"
|
|
78
|
+
|
|
79
|
+
# Add session name if set
|
|
80
|
+
if [ -n "$session_name" ]; then
|
|
81
|
+
status_line="${status_line} [${session_name}]"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
printf "%s" "$status_line"
|
|
@@ -75,8 +75,12 @@ jobs:
|
|
|
75
75
|
exit 0
|
|
76
76
|
fi
|
|
77
77
|
|
|
78
|
+
SOURCE_PR_TITLE="${{ github.event.pull_request.title }}"
|
|
79
|
+
CONTEXT_PR_TITLE="${SOURCE_PR_TITLE} - Docs"
|
|
80
|
+
PR_NUMBER="${{ github.event.pull_request.number }}"
|
|
81
|
+
|
|
78
82
|
CONTEXT_BRANCH="${{ github.head_ref }}-generated-context"
|
|
79
|
-
git commit -m "
|
|
83
|
+
git commit -m "${CONTEXT_PR_TITLE}"
|
|
80
84
|
git push --force origin HEAD:refs/heads/${CONTEXT_BRANCH}
|
|
81
85
|
|
|
82
86
|
# 기존 PR이 있으면 스킵, 없으면 생성
|
|
@@ -85,11 +89,11 @@ jobs:
|
|
|
85
89
|
gh pr create \
|
|
86
90
|
--head "${CONTEXT_BRANCH}" \
|
|
87
91
|
--base "${{ github.head_ref }}" \
|
|
88
|
-
--title "
|
|
89
|
-
--body "$(cat <<
|
|
92
|
+
--title "${CONTEXT_PR_TITLE}" \
|
|
93
|
+
--body "$(cat <<PREOF
|
|
90
94
|
## Auto-generated Context
|
|
91
95
|
|
|
92
|
-
|
|
96
|
+
#${PR_NUMBER} 의 코드 변경을 분석하여 자동 생성된 context 문서입니다.
|
|
93
97
|
선택적으로 머지하여 반영할 수 있습니다.
|
|
94
98
|
|
|
95
99
|
> 이 PR은 re-sync 시 force push로 업데이트됩니다.
|