create-harness-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/cli.js +204 -0
- package/dist/detect.js +128 -0
- package/dist/eslintPatch.js +58 -0
- package/dist/manifest.js +63 -0
- package/dist/ponytail.js +56 -0
- package/dist/prompts.js +85 -0
- package/dist/registry.js +334 -0
- package/dist/render.js +43 -0
- package/dist/suggest.js +114 -0
- package/dist/types.js +1 -0
- package/package.json +48 -0
- package/templates/core/AGENTS.md +55 -0
- package/templates/core/CLAUDE.md +11 -0
- package/templates/core/conventions/00-core.md +36 -0
- package/templates/core/conventions/10-architecture.md +54 -0
- package/templates/core/conventions/20-data-fetching.md +51 -0
- package/templates/core/conventions/30-design-system.md +46 -0
- package/templates/core/conventions/40-testing.md +46 -0
- package/templates/core/conventions/50-auth-http.md +45 -0
- package/templates/core/docs/architecture.md +21 -0
- package/templates/core/docs/decisions.md +16 -0
- package/templates/core/docs/product-spec.md +22 -0
- package/templates/core/docs/specs/_template.md +33 -0
- package/templates/core/docs/task-log.md +4 -0
- package/templates/core/gates/claude-settings.json +16 -0
- package/templates/core/gates/cursor-hooks.json +10 -0
- package/templates/core/gates/gate.mjs +115 -0
- package/templates/core/gates/pre-commit-gate.sh +7 -0
- package/templates/core/gates/run-checks.mjs +39 -0
- package/templates/core/workflows/ds-add.md +28 -0
- package/templates/core/workflows/ds-init.md +59 -0
- package/templates/core/workflows/impl.md +26 -0
- package/templates/core/workflows/ship.md +31 -0
- package/templates/core/workflows/spec.md +26 -0
- package/templates/core/workflows/verify.md +27 -0
- package/templates/presets/react-fe/configs/commitlint.config.js +36 -0
- package/templates/presets/react-fe/configs/eslint.harness.config.js +104 -0
- package/templates/presets/react-fe/configs/prettier.config.js +9 -0
- package/templates/presets/react-fe/design-system/_story-template.tsx +56 -0
- package/templates/presets/react-fe/design-system/stylelint.config.js +78 -0
- package/templates/presets/react-fe/design-system/tokens.css +57 -0
- package/templates/presets/react-fe/design-system/tokens.ts +40 -0
- package/templates/presets/react-fe/reference/auth-http/ProtectedRoute.tsx +62 -0
- package/templates/presets/react-fe/reference/auth-http/axiosInstance.ts +103 -0
- package/templates/presets/react-fe/reference/data-fetching/alertDialogStore.ts +30 -0
- package/templates/presets/react-fe/reference/data-fetching/api.ts +11 -0
- package/templates/presets/react-fe/reference/data-fetching/exampleApi.ts +46 -0
- package/templates/presets/react-fe/reference/data-fetching/exampleQueryKeys.ts +14 -0
- package/templates/presets/react-fe/reference/data-fetching/index.ts +25 -0
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
const MODULE_LABELS = {
|
|
3
|
+
'design-system': '토큰 스켈레톤 + stylelint(색상 원시값 차단) + 스토리 템플릿',
|
|
4
|
+
'auth-http': 'axios 인터셉터(토큰 첨부·refresh·401) + ProtectedRoute',
|
|
5
|
+
'data-fetching': 'queries 3계층 샘플 + IApiResponse + Zustand 스토어',
|
|
6
|
+
lint: '명명 규칙·import 경계 ESLint 조각 + prettier + commitlint',
|
|
7
|
+
};
|
|
8
|
+
const MODULE_ORDER = [
|
|
9
|
+
'design-system',
|
|
10
|
+
'auth-http',
|
|
11
|
+
'data-fetching',
|
|
12
|
+
'lint',
|
|
13
|
+
];
|
|
14
|
+
export const runPrompts = async (detected, defaults, suggestions) => {
|
|
15
|
+
if (defaults.yes)
|
|
16
|
+
return defaults;
|
|
17
|
+
p.intro('create-harness');
|
|
18
|
+
p.log.info([
|
|
19
|
+
`프로젝트: ${detected.projectName}`,
|
|
20
|
+
`감지: ${[
|
|
21
|
+
detected.isReact ? 'React' : null,
|
|
22
|
+
detected.isVite ? 'Vite' : null,
|
|
23
|
+
detected.isTypeScript ? 'TypeScript' : null,
|
|
24
|
+
detected.hasAxios ? 'axios' : null,
|
|
25
|
+
detected.hasTanstackQuery ? 'TanStack Query' : null,
|
|
26
|
+
detected.hasZustand ? 'Zustand' : null,
|
|
27
|
+
detected.hasTailwind ? 'Tailwind' : null,
|
|
28
|
+
detected.hasCssInJs ? 'CSS-in-JS' : null,
|
|
29
|
+
]
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(' · ')}`,
|
|
32
|
+
].join('\n'));
|
|
33
|
+
if (detected.existingAgentFiles.length > 0) {
|
|
34
|
+
p.log.warn(`이미 존재하는 에이전트 파일: ${detected.existingAgentFiles.join(', ')}\n` +
|
|
35
|
+
'내용이 다른 파일은 덮어쓰지 않고 .harness/incoming/ 아래에 둡니다.');
|
|
36
|
+
}
|
|
37
|
+
const agents = await p.multiselect({
|
|
38
|
+
message: '어떤 에이전트를 대상으로 하나요?',
|
|
39
|
+
options: [
|
|
40
|
+
{ value: 'cursor', label: 'Cursor' },
|
|
41
|
+
{ value: 'claude', label: 'Claude Code' },
|
|
42
|
+
],
|
|
43
|
+
initialValues: defaults.agents,
|
|
44
|
+
required: true,
|
|
45
|
+
});
|
|
46
|
+
if (p.isCancel(agents)) {
|
|
47
|
+
p.cancel('취소되었습니다.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const suggestionByModule = new Map(suggestions.map((suggestion) => [suggestion.module, suggestion]));
|
|
51
|
+
const modules = await p.multiselect({
|
|
52
|
+
message: '어떤 모듈을 포함하나요? (코어 규칙·워크플로·게이트는 항상 포함)',
|
|
53
|
+
options: MODULE_ORDER.map((module) => {
|
|
54
|
+
const suggestion = suggestionByModule.get(module);
|
|
55
|
+
const mark = suggestion?.isRecommended ? '' : ' (비권장)';
|
|
56
|
+
return {
|
|
57
|
+
value: module,
|
|
58
|
+
label: `${module}${mark}`,
|
|
59
|
+
hint: suggestion
|
|
60
|
+
? `${MODULE_LABELS[module]} — ${suggestion.reason}`
|
|
61
|
+
: MODULE_LABELS[module],
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
initialValues: defaults.modules,
|
|
65
|
+
required: false,
|
|
66
|
+
});
|
|
67
|
+
if (p.isCancel(modules)) {
|
|
68
|
+
p.cancel('취소되었습니다.');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
p.log.info([
|
|
72
|
+
'ponytail — 이 하네스와 무관한 서드파티 규칙(YAGNI 사다리, 최소 구현 강제).',
|
|
73
|
+
'Cursor: 최신 릴리스에서 규칙 파일을 받아 자동 설치합니다.',
|
|
74
|
+
'Claude Code: 플러그인 설치 명령 두 줄을 마지막에 안내합니다 (직접 실행 필요).',
|
|
75
|
+
].join('\n'));
|
|
76
|
+
const ponytail = await p.confirm({
|
|
77
|
+
message: 'ponytail도 함께 설정할까요?',
|
|
78
|
+
initialValue: defaults.ponytail,
|
|
79
|
+
});
|
|
80
|
+
if (p.isCancel(ponytail)) {
|
|
81
|
+
p.cancel('취소되었습니다.');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
return { ...defaults, agents, modules, ponytail };
|
|
85
|
+
};
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getTemplatesRoot, loadTemplate, parseFrontmatter, serializeFrontmatter, } from './render.js';
|
|
4
|
+
const PM_RUN = {
|
|
5
|
+
npm: 'npm run',
|
|
6
|
+
pnpm: 'pnpm run',
|
|
7
|
+
yarn: 'yarn',
|
|
8
|
+
bun: 'bun run',
|
|
9
|
+
};
|
|
10
|
+
const PM_EXEC = {
|
|
11
|
+
npm: 'npx',
|
|
12
|
+
pnpm: 'pnpm exec',
|
|
13
|
+
yarn: 'yarn',
|
|
14
|
+
bun: 'bunx',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* 기존 CSS에 색상 원시값이 남아 있으면 그 파일들만 유예 목록에 넣는다.
|
|
18
|
+
* 전부 error면 첫 커밋부터 막혀 게이트를 꺼버리게 되고,
|
|
19
|
+
* 전부 warning이면 새 코드의 드리프트를 못 막는다 — 목록으로 끊는다.
|
|
20
|
+
*/
|
|
21
|
+
export const hasStylelintBaseline = (detected, options) => options.modules.includes('design-system') &&
|
|
22
|
+
detected.cssFilesWithRawColor.length > 0;
|
|
23
|
+
export const buildVars = (detected, options) => ({
|
|
24
|
+
PROJECT_NAME: detected.projectName,
|
|
25
|
+
PM: detected.packageManager,
|
|
26
|
+
PM_RUN: PM_RUN[detected.packageManager],
|
|
27
|
+
PM_EXEC: PM_EXEC[detected.packageManager],
|
|
28
|
+
RULES_DIR: options.agents.includes('cursor')
|
|
29
|
+
? '.cursor/rules'
|
|
30
|
+
: 'docs/conventions',
|
|
31
|
+
DESIGN_SYSTEM: String(options.modules.includes('design-system')),
|
|
32
|
+
STYLELINT_BASELINE: String(hasStylelintBaseline(detected, options)),
|
|
33
|
+
CSS_RAW_COLOR_FILES: String(detected.cssFilesWithRawColor.length),
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* 대상 프로젝트의 package.json scripts를 보고 실제 존재하는 체크만 담는다.
|
|
37
|
+
* 없는 스크립트를 참조해 게이트가 즉시 깨지는 일을 막기 위함이다.
|
|
38
|
+
* 빠른 실패 순서: typecheck → lint → stylelint → test → build
|
|
39
|
+
*/
|
|
40
|
+
export const buildChecks = (detected, options) => {
|
|
41
|
+
const run = PM_RUN[detected.packageManager];
|
|
42
|
+
const exec = PM_EXEC[detected.packageManager];
|
|
43
|
+
const checks = [];
|
|
44
|
+
if (detected.scripts['typecheck']) {
|
|
45
|
+
checks.push({ id: 'typecheck', command: `${run} typecheck` });
|
|
46
|
+
}
|
|
47
|
+
else if (detected.isTypeScript) {
|
|
48
|
+
checks.push({ id: 'typecheck', command: `${exec} tsc --noEmit` });
|
|
49
|
+
}
|
|
50
|
+
if (detected.scripts['lint']) {
|
|
51
|
+
checks.push({ id: 'lint', command: `${run} lint` });
|
|
52
|
+
}
|
|
53
|
+
if (options.modules.includes('design-system')) {
|
|
54
|
+
checks.push({
|
|
55
|
+
id: 'stylelint',
|
|
56
|
+
command: `${exec} stylelint "src/**/*.css"`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const testScript = detected.scripts['test'];
|
|
60
|
+
if (testScript) {
|
|
61
|
+
const isWatchByDefault = testScript.includes('vitest') && !/\b(run|--run)\b/.test(testScript);
|
|
62
|
+
checks.push({
|
|
63
|
+
id: 'test',
|
|
64
|
+
command: isWatchByDefault ? `${run} test -- --run` : `${run} test`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (detected.scripts['build']) {
|
|
68
|
+
checks.push({ id: 'build', command: `${run} build` });
|
|
69
|
+
}
|
|
70
|
+
return checks;
|
|
71
|
+
};
|
|
72
|
+
const listTemplates = (relDir) => readdirSync(path.join(getTemplatesRoot(), relDir))
|
|
73
|
+
.filter((name) => !name.startsWith('.'))
|
|
74
|
+
.sort();
|
|
75
|
+
/** 해당 모듈을 빼면 그 모듈을 전제하는 규칙 정본도 같이 뺀다 */
|
|
76
|
+
const RULE_MODULE_REQUIREMENT = {
|
|
77
|
+
'30-design-system.md': 'design-system',
|
|
78
|
+
};
|
|
79
|
+
/** templates/core/conventions/*.md → .cursor/rules/*.mdc (또는 docs/conventions/*.md) */
|
|
80
|
+
const buildRuleActions = (options, vars) => listTemplates('core/conventions')
|
|
81
|
+
.filter((file) => {
|
|
82
|
+
const required = RULE_MODULE_REQUIREMENT[file];
|
|
83
|
+
return !required || options.modules.includes(required);
|
|
84
|
+
})
|
|
85
|
+
.map((file) => {
|
|
86
|
+
const raw = loadTemplate(`core/conventions/${file}`, vars);
|
|
87
|
+
if (options.agents.includes('cursor')) {
|
|
88
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
89
|
+
return {
|
|
90
|
+
dest: `.cursor/rules/${file.replace(/\.md$/, '.mdc')}`,
|
|
91
|
+
content: serializeFrontmatter(meta) + body,
|
|
92
|
+
module: 'core',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Cursor 미선택 시에는 규칙을 docs/conventions/ 아래 일반 문서로 둔다
|
|
96
|
+
return {
|
|
97
|
+
dest: `docs/conventions/${file}`,
|
|
98
|
+
content: raw,
|
|
99
|
+
module: 'core',
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
const BASE_WORKFLOWS = ['spec', 'impl', 'verify', 'ship'];
|
|
103
|
+
const DESIGN_SYSTEM_WORKFLOWS = ['ds-init', 'ds-add'];
|
|
104
|
+
/** templates/core/workflows → .cursor/commands + .claude/skills(SKILL.md) fan-out */
|
|
105
|
+
const buildWorkflowActions = (options, vars) => {
|
|
106
|
+
const actions = [];
|
|
107
|
+
const parsed = new Map();
|
|
108
|
+
// 디자인시스템 워크플로는 토큰·스토리 템플릿을 전제한다 — 모듈이 빠지면 함께 뺀다
|
|
109
|
+
const hasDesignSystem = options.modules.includes('design-system');
|
|
110
|
+
const workflows = hasDesignSystem
|
|
111
|
+
? [...BASE_WORKFLOWS, ...DESIGN_SYSTEM_WORKFLOWS]
|
|
112
|
+
: BASE_WORKFLOWS;
|
|
113
|
+
for (const name of workflows) {
|
|
114
|
+
parsed.set(name, parseFrontmatter(loadTemplate(`core/workflows/${name}.md`, vars)));
|
|
115
|
+
}
|
|
116
|
+
if (options.agents.includes('cursor')) {
|
|
117
|
+
for (const name of workflows) {
|
|
118
|
+
const { body } = parsed.get(name);
|
|
119
|
+
actions.push({
|
|
120
|
+
dest: `.cursor/commands/${name}.md`,
|
|
121
|
+
content: body,
|
|
122
|
+
module: 'core',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (options.agents.includes('claude')) {
|
|
127
|
+
// spec / impl / verify / ship 은 1:1 스킬
|
|
128
|
+
for (const name of BASE_WORKFLOWS) {
|
|
129
|
+
const { meta, body } = parsed.get(name);
|
|
130
|
+
actions.push({
|
|
131
|
+
dest: `.claude/skills/${name}/SKILL.md`,
|
|
132
|
+
content: serializeFrontmatter({
|
|
133
|
+
name,
|
|
134
|
+
description: meta['description'] ?? '',
|
|
135
|
+
}) + body,
|
|
136
|
+
module: 'core',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// design-system 스킬 하나가 ds-init·ds-add 두 흐름을 포함한다
|
|
140
|
+
if (hasDesignSystem) {
|
|
141
|
+
const dsInit = parsed.get('ds-init');
|
|
142
|
+
const dsAdd = parsed.get('ds-add');
|
|
143
|
+
actions.push({
|
|
144
|
+
dest: '.claude/skills/design-system/SKILL.md',
|
|
145
|
+
content: serializeFrontmatter({
|
|
146
|
+
name: 'design-system',
|
|
147
|
+
description: 'Design system workflows: one-time Storybook setup (ds-init) and adding components before layout work (ds-add).',
|
|
148
|
+
}) +
|
|
149
|
+
`# Design System\n\n## Part 1 — ds-init (최초 1회 설정)\n\n${dsInit.body}\n\n---\n\n## Part 2 — ds-add (UI 작업마다)\n\n${dsAdd.body}`,
|
|
150
|
+
module: 'core',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return actions;
|
|
155
|
+
};
|
|
156
|
+
const buildGateActions = (options, vars) => {
|
|
157
|
+
const actions = [
|
|
158
|
+
{
|
|
159
|
+
dest: '.harness/gates/pre-commit-gate.sh',
|
|
160
|
+
content: loadTemplate('core/gates/pre-commit-gate.sh', vars),
|
|
161
|
+
module: 'core',
|
|
162
|
+
executable: true,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
dest: '.harness/gates/gate.mjs',
|
|
166
|
+
content: loadTemplate('core/gates/gate.mjs', vars),
|
|
167
|
+
module: 'core',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
dest: '.harness/gates/run-checks.mjs',
|
|
171
|
+
content: loadTemplate('core/gates/run-checks.mjs', vars),
|
|
172
|
+
module: 'core',
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
if (options.agents.includes('cursor')) {
|
|
176
|
+
actions.push({
|
|
177
|
+
dest: '.cursor/hooks.json',
|
|
178
|
+
content: loadTemplate('core/gates/cursor-hooks.json', vars),
|
|
179
|
+
module: 'core',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (options.agents.includes('claude')) {
|
|
183
|
+
actions.push({
|
|
184
|
+
dest: '.claude/settings.json',
|
|
185
|
+
content: loadTemplate('core/gates/claude-settings.json', vars),
|
|
186
|
+
module: 'core',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return actions;
|
|
190
|
+
};
|
|
191
|
+
const buildDocActions = (vars) => [
|
|
192
|
+
...['architecture.md', 'decisions.md', 'product-spec.md', 'task-log.md'].map((file) => ({
|
|
193
|
+
dest: `docs/${file}`,
|
|
194
|
+
content: loadTemplate(`core/docs/${file}`, vars),
|
|
195
|
+
module: 'core',
|
|
196
|
+
})),
|
|
197
|
+
{
|
|
198
|
+
dest: 'docs/specs/_template.md',
|
|
199
|
+
content: loadTemplate('core/docs/specs/_template.md', vars),
|
|
200
|
+
module: 'core',
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
const buildModuleActions = (detected, options, vars) => {
|
|
204
|
+
const actions = [];
|
|
205
|
+
const preset = `presets/${options.preset}`;
|
|
206
|
+
if (options.modules.includes('design-system')) {
|
|
207
|
+
if (hasStylelintBaseline(detected, options)) {
|
|
208
|
+
actions.push({
|
|
209
|
+
dest: '.harness/stylelint-baseline.json',
|
|
210
|
+
content: JSON.stringify(detected.cssFilesWithRawColor, null, 4) + '\n',
|
|
211
|
+
module: 'design-system',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
actions.push({
|
|
215
|
+
dest: 'src/design-system/tokens.css',
|
|
216
|
+
content: loadTemplate(`${preset}/design-system/tokens.css`, vars),
|
|
217
|
+
module: 'design-system',
|
|
218
|
+
}, {
|
|
219
|
+
dest: 'src/design-system/tokens.ts',
|
|
220
|
+
content: loadTemplate(`${preset}/design-system/tokens.ts`, vars),
|
|
221
|
+
module: 'design-system',
|
|
222
|
+
}, {
|
|
223
|
+
// .stories. 를 파일명에 넣지 않는다 — Storybook 테스트 러너의
|
|
224
|
+
// *.stories.* glob이 참고용 템플릿을 실제로 실행하려 든다
|
|
225
|
+
dest: 'src/design-system/_story-template.tsx',
|
|
226
|
+
content: loadTemplate(`${preset}/design-system/_story-template.tsx`, vars),
|
|
227
|
+
module: 'design-system',
|
|
228
|
+
}, {
|
|
229
|
+
dest: 'stylelint.config.js',
|
|
230
|
+
content: loadTemplate(`${preset}/design-system/stylelint.config.js`, vars),
|
|
231
|
+
module: 'design-system',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (options.modules.includes('auth-http')) {
|
|
235
|
+
actions.push({
|
|
236
|
+
dest: 'src/utils/axiosInstance.ts',
|
|
237
|
+
content: loadTemplate(`${preset}/reference/auth-http/axiosInstance.ts`, vars),
|
|
238
|
+
module: 'auth-http',
|
|
239
|
+
}, {
|
|
240
|
+
dest: 'src/components/shared/ProtectedRoute.tsx',
|
|
241
|
+
content: loadTemplate(`${preset}/reference/auth-http/ProtectedRoute.tsx`, vars),
|
|
242
|
+
module: 'auth-http',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (options.modules.includes('data-fetching')) {
|
|
246
|
+
actions.push({
|
|
247
|
+
dest: 'src/types/api.ts',
|
|
248
|
+
content: loadTemplate(`${preset}/reference/data-fetching/api.ts`, vars),
|
|
249
|
+
module: 'data-fetching',
|
|
250
|
+
}, {
|
|
251
|
+
dest: 'src/queries/Example/exampleApi.ts',
|
|
252
|
+
content: loadTemplate(`${preset}/reference/data-fetching/exampleApi.ts`, vars),
|
|
253
|
+
module: 'data-fetching',
|
|
254
|
+
}, {
|
|
255
|
+
dest: 'src/queries/Example/exampleQueryKeys.ts',
|
|
256
|
+
content: loadTemplate(`${preset}/reference/data-fetching/exampleQueryKeys.ts`, vars),
|
|
257
|
+
module: 'data-fetching',
|
|
258
|
+
}, {
|
|
259
|
+
dest: 'src/queries/Example/index.ts',
|
|
260
|
+
content: loadTemplate(`${preset}/reference/data-fetching/index.ts`, vars),
|
|
261
|
+
module: 'data-fetching',
|
|
262
|
+
}, {
|
|
263
|
+
dest: 'src/stores/shared/alertDialogStore.ts',
|
|
264
|
+
content: loadTemplate(`${preset}/reference/data-fetching/alertDialogStore.ts`, vars),
|
|
265
|
+
module: 'data-fetching',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (options.modules.includes('lint')) {
|
|
269
|
+
actions.push({
|
|
270
|
+
dest: 'eslint.harness.config.js',
|
|
271
|
+
content: loadTemplate(`${preset}/configs/eslint.harness.config.js`, vars),
|
|
272
|
+
module: 'lint',
|
|
273
|
+
}, {
|
|
274
|
+
dest: 'commitlint.config.js',
|
|
275
|
+
content: loadTemplate(`${preset}/configs/commitlint.config.js`, vars),
|
|
276
|
+
module: 'lint',
|
|
277
|
+
}, {
|
|
278
|
+
dest: 'prettier.config.js',
|
|
279
|
+
content: loadTemplate(`${preset}/configs/prettier.config.js`, vars),
|
|
280
|
+
module: 'lint',
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return actions;
|
|
284
|
+
};
|
|
285
|
+
/** 스캐폴딩으로 생성할 전체 파일 목록을 만든다 (manifest.json 제외 — 마지막에 별도 기록) */
|
|
286
|
+
export const buildPlan = (detected, options) => {
|
|
287
|
+
const vars = buildVars(detected, options);
|
|
288
|
+
const config = {
|
|
289
|
+
packageManager: detected.packageManager,
|
|
290
|
+
checks: buildChecks(detected, options),
|
|
291
|
+
};
|
|
292
|
+
const actions = [
|
|
293
|
+
{
|
|
294
|
+
dest: 'AGENTS.md',
|
|
295
|
+
content: loadTemplate('core/AGENTS.md', vars),
|
|
296
|
+
module: 'core',
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
dest: '.harness/config.json',
|
|
300
|
+
content: JSON.stringify(config, null, 4) + '\n',
|
|
301
|
+
module: 'core',
|
|
302
|
+
},
|
|
303
|
+
...buildRuleActions(options, vars),
|
|
304
|
+
...buildWorkflowActions(options, vars),
|
|
305
|
+
...buildGateActions(options, vars),
|
|
306
|
+
...buildDocActions(vars),
|
|
307
|
+
...buildModuleActions(detected, options, vars),
|
|
308
|
+
];
|
|
309
|
+
if (options.agents.includes('claude')) {
|
|
310
|
+
actions.splice(1, 0, {
|
|
311
|
+
dest: 'CLAUDE.md',
|
|
312
|
+
content: loadTemplate('core/CLAUDE.md', vars),
|
|
313
|
+
module: 'core',
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return actions;
|
|
317
|
+
};
|
|
318
|
+
/** 선택 모듈이 요구하는 devDependencies (자동 설치하지 않고 안내 출력용) */
|
|
319
|
+
export const requiredDevDeps = (options) => {
|
|
320
|
+
const deps = [];
|
|
321
|
+
if (options.modules.includes('design-system')) {
|
|
322
|
+
deps.push('stylelint', 'stylelint-declaration-strict-value');
|
|
323
|
+
}
|
|
324
|
+
if (options.modules.includes('lint')) {
|
|
325
|
+
deps.push('eslint-plugin-import', '@commitlint/cli', '@commitlint/config-conventional', 'prettier');
|
|
326
|
+
}
|
|
327
|
+
if (options.modules.includes('data-fetching')) {
|
|
328
|
+
deps.push('@tanstack/react-query', 'zustand');
|
|
329
|
+
}
|
|
330
|
+
if (options.modules.includes('auth-http')) {
|
|
331
|
+
deps.push('axios', 'react-router-dom');
|
|
332
|
+
}
|
|
333
|
+
return [...new Set(deps)];
|
|
334
|
+
};
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* 렌더러의 소스 루트는 templates/ 하나로 하드코딩한다.
|
|
6
|
+
* 저장소 루트의 TODO.md·DECISIONS.md 같은 내부 기록이
|
|
7
|
+
* 대상 프로젝트로 복사될 경로가 애초에 존재하지 않게 하기 위함이다.
|
|
8
|
+
*/
|
|
9
|
+
export const getTemplatesRoot = () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
10
|
+
/**
|
|
11
|
+
* `{{#if FLAG}}...{{/if}}` 블록. 플래그가 'true' 가 아니면 블록 전체를 지운다.
|
|
12
|
+
* 모듈을 빼면 그 모듈을 참조하는 규칙 문장도 같이 빠져야 하기 때문에 필요하다
|
|
13
|
+
* (존재하지 않는 파일을 가리키는 규칙은 에이전트를 헷갈리게 한다).
|
|
14
|
+
* 블록이 줄 전체를 차지하면 남는 빈 줄까지 함께 제거한다.
|
|
15
|
+
*/
|
|
16
|
+
const applyConditionals = (content, vars) => content.replace(/[ \t]*\{\{#if ([A-Z0-9_]+)\}\}\n?([\s\S]*?)[ \t]*\{\{\/if\}\}\n?/g, (_match, key, block) => vars[key] === 'true' ? block : '');
|
|
17
|
+
export const renderString = (content, vars) => applyConditionals(content, vars).replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => (key in vars ? vars[key] : match));
|
|
18
|
+
/** templates/ 기준 상대 경로의 템플릿을 읽어 변수 치환까지 마친 문자열을 돌려준다 */
|
|
19
|
+
export const loadTemplate = (relPath, vars) => renderString(readFileSync(path.join(getTemplatesRoot(), relPath), 'utf-8'), vars);
|
|
20
|
+
/**
|
|
21
|
+
* `---` 로 감싼 단순 frontmatter 파서.
|
|
22
|
+
* `key: value` 한 줄 형식만 지원한다 (템플릿 정본에는 그 이상이 필요 없다).
|
|
23
|
+
*/
|
|
24
|
+
export const parseFrontmatter = (content) => {
|
|
25
|
+
const match = /^---\n([\s\S]*?)\n---\n?/.exec(content);
|
|
26
|
+
if (!match)
|
|
27
|
+
return { meta: {}, body: content };
|
|
28
|
+
const meta = {};
|
|
29
|
+
for (const line of match[1].split('\n')) {
|
|
30
|
+
const idx = line.indexOf(':');
|
|
31
|
+
if (idx === -1)
|
|
32
|
+
continue;
|
|
33
|
+
const key = line.slice(0, idx).trim();
|
|
34
|
+
const value = line.slice(idx + 1).trim();
|
|
35
|
+
if (key)
|
|
36
|
+
meta[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return { meta, body: content.slice(match[0].length) };
|
|
39
|
+
};
|
|
40
|
+
export const serializeFrontmatter = (meta) => {
|
|
41
|
+
const lines = Object.entries(meta).map(([key, value]) => `${key}: ${value}`);
|
|
42
|
+
return `---\n${lines.join('\n')}\n---\n`;
|
|
43
|
+
};
|
package/dist/suggest.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 감지 결과로 모듈 기본 선택값을 정한다.
|
|
3
|
+
*
|
|
4
|
+
* 기준은 하나다: **생성 직후 대상 프로젝트에서 그대로 컴파일·통과하는가.**
|
|
5
|
+
* 참조 구현은 특정 라이브러리(axios·TanStack Query 등)를 전제하므로,
|
|
6
|
+
* 그 전제가 없는 프로젝트에 기본으로 넣으면 typecheck가 즉시 깨진다.
|
|
7
|
+
* 비추천이어도 사용자가 프롬프트나 --modules 로 직접 켤 수 있다.
|
|
8
|
+
*/
|
|
9
|
+
export const suggestModules = (detected) => [
|
|
10
|
+
suggestDesignSystem(detected),
|
|
11
|
+
suggestAuthHttp(detected),
|
|
12
|
+
suggestDataFetching(detected),
|
|
13
|
+
suggestLint(detected),
|
|
14
|
+
];
|
|
15
|
+
export const recommendedModules = (detected) => suggestModules(detected)
|
|
16
|
+
.filter((suggestion) => suggestion.isRecommended)
|
|
17
|
+
.map((suggestion) => suggestion.module);
|
|
18
|
+
const suggestDesignSystem = (detected) => {
|
|
19
|
+
// stylelint 색상 강제는 CSS 선언을 검사한다. Tailwind는 유틸리티 클래스라 검사 대상이
|
|
20
|
+
// 거의 없고, CSS-in-JS는 값이 TS 안에 있어 stylelint가 아예 보지 못한다.
|
|
21
|
+
// 강제할 수 없는데 켜두면 "지켜지고 있다"는 착각만 준다.
|
|
22
|
+
if (detected.hasTailwind) {
|
|
23
|
+
return {
|
|
24
|
+
module: 'design-system',
|
|
25
|
+
isRecommended: false,
|
|
26
|
+
reason: 'Tailwind 감지 — 토큰 강제가 불가능합니다 (하네스는 CSS Modules + 토큰을 권장)',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (detected.hasCssInJs) {
|
|
30
|
+
return {
|
|
31
|
+
module: 'design-system',
|
|
32
|
+
isRecommended: false,
|
|
33
|
+
reason: 'CSS-in-JS 감지 — 색상값이 TS 안에 있어 stylelint가 검사하지 못합니다',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
module: 'design-system',
|
|
38
|
+
isRecommended: true,
|
|
39
|
+
reason: 'CSS / CSS Modules 프로젝트로 판단',
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
const suggestAuthHttp = (detected) => {
|
|
43
|
+
const missing = [];
|
|
44
|
+
if (!detected.hasAxios)
|
|
45
|
+
missing.push('axios');
|
|
46
|
+
if (!detected.hasReactRouter)
|
|
47
|
+
missing.push('react-router');
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
return {
|
|
50
|
+
module: 'auth-http',
|
|
51
|
+
isRecommended: false,
|
|
52
|
+
reason: `${missing.join('·')} 없음 — 참조 구현이 컴파일되지 않습니다`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!detected.isVite) {
|
|
56
|
+
return {
|
|
57
|
+
module: 'auth-http',
|
|
58
|
+
isRecommended: false,
|
|
59
|
+
reason: 'Vite 아님 — 참조 구현이 import.meta.env.VITE_* 를 씁니다',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
module: 'auth-http',
|
|
64
|
+
isRecommended: true,
|
|
65
|
+
reason: 'axios + react-router + Vite 감지',
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const suggestDataFetching = (detected) => {
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (!detected.hasTanstackQuery)
|
|
71
|
+
missing.push('@tanstack/react-query');
|
|
72
|
+
if (!detected.hasZustand)
|
|
73
|
+
missing.push('zustand');
|
|
74
|
+
if (missing.length > 0) {
|
|
75
|
+
return {
|
|
76
|
+
module: 'data-fetching',
|
|
77
|
+
isRecommended: false,
|
|
78
|
+
reason: `${missing.join('·')} 없음 — 참조 구현이 컴파일되지 않습니다`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (!detected.hasAxios) {
|
|
82
|
+
return {
|
|
83
|
+
module: 'data-fetching',
|
|
84
|
+
isRecommended: false,
|
|
85
|
+
reason: 'axios 없음 — queries 샘플이 axiosInstance 를 씁니다',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
module: 'data-fetching',
|
|
90
|
+
isRecommended: true,
|
|
91
|
+
reason: 'TanStack Query + Zustand 감지',
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const suggestLint = (detected) => {
|
|
95
|
+
if (!detected.hasEslintFlatConfig) {
|
|
96
|
+
return {
|
|
97
|
+
module: 'lint',
|
|
98
|
+
isRecommended: false,
|
|
99
|
+
reason: 'ESLint flat config(eslint.config.*) 없음 — 규칙 조각을 spread할 대상이 없습니다',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!detected.isTypeScript) {
|
|
103
|
+
return {
|
|
104
|
+
module: 'lint',
|
|
105
|
+
isRecommended: false,
|
|
106
|
+
reason: 'TypeScript 아님 — 명명 규칙이 타입 정보를 요구합니다',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
module: 'lint',
|
|
111
|
+
isRecommended: true,
|
|
112
|
+
reason: 'ESLint flat config + TypeScript 감지',
|
|
113
|
+
};
|
|
114
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-harness-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold AI coding agent harness (conventions, verification gates, workflows) onto an existing project — for Cursor and Claude Code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-harness-cli": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"templates"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.0.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"dev": "tsx src/cli.ts",
|
|
19
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"check": "npm run typecheck && npm run build && npm run test"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/ysh038/create-harness.git"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"ai",
|
|
29
|
+
"agent",
|
|
30
|
+
"harness",
|
|
31
|
+
"scaffold",
|
|
32
|
+
"cursor",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"agents-md",
|
|
35
|
+
"cli"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@clack/prompts": "^1.7.0",
|
|
40
|
+
"picocolors": "^1.1.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^26.1.2",
|
|
44
|
+
"tsx": "^4.23.1",
|
|
45
|
+
"typescript": "^7.0.2",
|
|
46
|
+
"vitest": "^3.2.7"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — Agent Guide
|
|
2
|
+
|
|
3
|
+
> 이 파일은 모든 AI 에이전트(Cursor, Claude Code 등)가 항상 읽는 정본이다.
|
|
4
|
+
> 짧게 유지한다. 상세 규칙은 `{{RULES_DIR}}/` 에 있고, 해당 파일을 만질 때 로드된다.
|
|
5
|
+
|
|
6
|
+
## 프로젝트 개요
|
|
7
|
+
|
|
8
|
+
<!-- TODO: 한 문단으로 채우세요. 무엇을 하는 서비스이고, 핵심 도메인 용어는 무엇인지 -->
|
|
9
|
+
|
|
10
|
+
## 자주 쓰는 명령어
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
{{PM_RUN}} dev # 개발 서버
|
|
14
|
+
node .harness/gates/run-checks.mjs # 전체 검증 (.harness/config.json 의 checks 순차 실행)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 검증 게이트
|
|
18
|
+
|
|
19
|
+
- 커밋 전 **반드시** `node .harness/gates/run-checks.mjs` 가 통과해야 한다.
|
|
20
|
+
커밋 게이트(`.harness/gates/pre-commit-gate.sh`)가 실패 시 커밋을 거부한다.
|
|
21
|
+
- 검증 목록은 `.harness/config.json` 의 `checks` 배열이다. 체크를 추가/제거하려면 이 파일을 수정한다.
|
|
22
|
+
- 테스트를 통과시키기 위해 단정문(assertion)을 약화시키지 않는다. 실패하면 코드를 고친다.
|
|
23
|
+
|
|
24
|
+
## 워크플로
|
|
25
|
+
|
|
26
|
+
| 커맨드 | 용도 |
|
|
27
|
+
|--------|------|
|
|
28
|
+
| `/spec <기능>` | 구현 전 명세 작성 (`docs/specs/`) — 수용 기준은 테스트로 번역 가능해야 함 |
|
|
29
|
+
| `/impl <slug>` | 명세 기반 구현 — 실패하는 테스트 먼저 (Red → Green → Refactor) |
|
|
30
|
+
| `/verify` | checks 순차 실행, 실패 시 수정 루프 |
|
|
31
|
+
| `/ship` | 검증 → 커밋 → `docs/task-log.md` 기록 |
|
|
32
|
+
{{#if DESIGN_SYSTEM}}| `/ds-init` | Storybook 온디맨드 설치 (최초 UI 작업 전 1회) |
|
|
33
|
+
| `/ds-add` | 레이아웃 착수 전 디자인시스템 컴포넌트 + 스토리 선행 추가 |
|
|
34
|
+
{{/if}}
|
|
35
|
+
|
|
36
|
+
## 절대 금지
|
|
37
|
+
|
|
38
|
+
| 금지 | 이유 |
|
|
39
|
+
|------|------|
|
|
40
|
+
| `any` 타입 | 타입 안전성 포기. `unknown` + 좁히기를 쓴다 |
|
|
41
|
+
| `git commit --no-verify` | 게이트 우회 금지 |
|
|
42
|
+
| `git push --force` (보호 브랜치) | 이력 파괴. 필요하면 `--force-with-lease` + 사전 협의 |
|
|
43
|
+
| `.env*` 파일 커밋 | 시크릿 유출 |
|
|
44
|
+
| 라우트(페이지) 컴포넌트에 비즈니스 로직 | hooks/queries 레이어로 내린다 (`{{RULES_DIR}}/10-architecture` 참고) |
|
|
45
|
+
{{#if DESIGN_SYSTEM}}| CSS 색상 원시값 (`#hex`, `rgb()`) | 디자인 토큰만 사용. stylelint가 error 처리 |
|
|
46
|
+
{{/if}}| 테스트 단정문 약화로 통과시키기 | 검증의 의미가 사라진다 |
|
|
47
|
+
|
|
48
|
+
## 장기 기억 문서
|
|
49
|
+
|
|
50
|
+
| 파일 | 용도 |
|
|
51
|
+
|------|------|
|
|
52
|
+
| `docs/architecture.md` | 구조가 바뀔 때 갱신 |
|
|
53
|
+
| `docs/decisions.md` | 결정과 **근거** (결론만 적지 않는다) |
|
|
54
|
+
| `docs/product-spec.md` | 기능 명세 + TODO 목록 |
|
|
55
|
+
| `docs/task-log.md` | `/ship` 시 자동 기록 |
|