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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/cli.js +204 -0
  4. package/dist/detect.js +128 -0
  5. package/dist/eslintPatch.js +58 -0
  6. package/dist/manifest.js +63 -0
  7. package/dist/ponytail.js +56 -0
  8. package/dist/prompts.js +85 -0
  9. package/dist/registry.js +334 -0
  10. package/dist/render.js +43 -0
  11. package/dist/suggest.js +114 -0
  12. package/dist/types.js +1 -0
  13. package/package.json +48 -0
  14. package/templates/core/AGENTS.md +55 -0
  15. package/templates/core/CLAUDE.md +11 -0
  16. package/templates/core/conventions/00-core.md +36 -0
  17. package/templates/core/conventions/10-architecture.md +54 -0
  18. package/templates/core/conventions/20-data-fetching.md +51 -0
  19. package/templates/core/conventions/30-design-system.md +46 -0
  20. package/templates/core/conventions/40-testing.md +46 -0
  21. package/templates/core/conventions/50-auth-http.md +45 -0
  22. package/templates/core/docs/architecture.md +21 -0
  23. package/templates/core/docs/decisions.md +16 -0
  24. package/templates/core/docs/product-spec.md +22 -0
  25. package/templates/core/docs/specs/_template.md +33 -0
  26. package/templates/core/docs/task-log.md +4 -0
  27. package/templates/core/gates/claude-settings.json +16 -0
  28. package/templates/core/gates/cursor-hooks.json +10 -0
  29. package/templates/core/gates/gate.mjs +115 -0
  30. package/templates/core/gates/pre-commit-gate.sh +7 -0
  31. package/templates/core/gates/run-checks.mjs +39 -0
  32. package/templates/core/workflows/ds-add.md +28 -0
  33. package/templates/core/workflows/ds-init.md +59 -0
  34. package/templates/core/workflows/impl.md +26 -0
  35. package/templates/core/workflows/ship.md +31 -0
  36. package/templates/core/workflows/spec.md +26 -0
  37. package/templates/core/workflows/verify.md +27 -0
  38. package/templates/presets/react-fe/configs/commitlint.config.js +36 -0
  39. package/templates/presets/react-fe/configs/eslint.harness.config.js +104 -0
  40. package/templates/presets/react-fe/configs/prettier.config.js +9 -0
  41. package/templates/presets/react-fe/design-system/_story-template.tsx +56 -0
  42. package/templates/presets/react-fe/design-system/stylelint.config.js +78 -0
  43. package/templates/presets/react-fe/design-system/tokens.css +57 -0
  44. package/templates/presets/react-fe/design-system/tokens.ts +40 -0
  45. package/templates/presets/react-fe/reference/auth-http/ProtectedRoute.tsx +62 -0
  46. package/templates/presets/react-fe/reference/auth-http/axiosInstance.ts +103 -0
  47. package/templates/presets/react-fe/reference/data-fetching/alertDialogStore.ts +30 -0
  48. package/templates/presets/react-fe/reference/data-fetching/api.ts +11 -0
  49. package/templates/presets/react-fe/reference/data-fetching/exampleApi.ts +46 -0
  50. package/templates/presets/react-fe/reference/data-fetching/exampleQueryKeys.ts +14 -0
  51. package/templates/presets/react-fe/reference/data-fetching/index.ts +25 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yk/side-project
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # create-harness
2
+
3
+ > 기존 프로젝트에 AI 코딩 에이전트 하네스를 한 명령으로 얹는다.
4
+
5
+ `create-harness`는 앱을 새로 만드는 스캐폴더가 아니라, **이미 있는 프로젝트**에
6
+ AI 에이전트(Cursor, Claude Code)가 일관된 결과물을 내도록 하는 하네스를 설치하는 CLI다.
7
+
8
+ > npm의 `create-harness` 이름은 무관한 다른 패키지가 선점하고 있어, 배포명은
9
+ > `create-harness-cli`다. 저장소·CLI 브랜드명은 그대로 `create-harness`를 쓴다.
10
+
11
+ ```bash
12
+ npx create-harness-cli # 현재 디렉터리에
13
+ npx create-harness-cli ./my-app --yes --dry-run # 계획만 확인
14
+ ```
15
+
16
+ ## 무엇이 생기나
17
+
18
+ 두 축으로 구성된다.
19
+
20
+ **축 1 — 코딩 컨벤션** (에이전트가 무엇을 모방할지)
21
+
22
+ - `AGENTS.md` — 모든 도구가 읽는 짧은 정본. `CLAUDE.md` 는 `@AGENTS.md` 임포트 한 줄
23
+ - `.cursor/rules/*.mdc` 6종 — 레이어·명명·상태 4분류·디자인시스템·테스트·인증 규칙 (globs 조건부 로드)
24
+ - 참조 구현 — axios 인터셉터(refresh 중복 제거)·`ProtectedRoute`·queries 3계층·Zustand 스토어.
25
+ 산문 문서가 아니라 **컴파일되는 코드**라서 에이전트가 실제로 모방한다
26
+
27
+ **축 2 — 검증 게이트** (문서가 아니라 게이트로 강제)
28
+
29
+ - `.harness/config.json` — 프로젝트가 통과해야 하는 `checks` 목록 (대상 package.json 의
30
+ scripts 를 감지해 실제 존재하는 것만 담는다)
31
+ - 커밋 게이트 — 같은 스크립트 하나를 Cursor(`beforeShellExecution`)와
32
+ Claude Code(`PreToolUse`) 양쪽에 연결. checks 실패·`.env` 스테이징·force push 시 커밋 거부
33
+ - 워크플로 6종 — `/spec`(명세) → `/impl`(테스트 우선 구현) → `/verify` → `/ship`,
34
+ UI 작업은 `/ds-init`(Storybook 온디맨드 설치)·`/ds-add`(레이아웃 전 컴포넌트 선행)
35
+ - 린트 강제 — 명명 규칙(`I` 접두 등)·공개 API 경계·색상 원시값 차단(stylelint)을 error 처리
36
+
37
+ ## 옵션
38
+
39
+ ```
40
+ npx create-harness-cli [대상 디렉터리] [옵션]
41
+
42
+ --preset <name> 프리셋 (v0.1: react-fe)
43
+ --agents <csv> cursor,claude (기본: 둘 다)
44
+ --modules <csv> design-system,auth-http,data-fetching,lint (기본: 감지 결과에 따름)
45
+ --ponytail 서드파티 ponytail 규칙(YAGNI 사다리) 연동 (기본: 끔)
46
+ --dry-run 파일을 쓰지 않고 계획만 출력
47
+ -y, --yes 질문 없이 진행
48
+ ```
49
+
50
+ ## 모듈 기본값은 감지 결과가 정한다
51
+
52
+ 코어(규칙·워크플로·게이트·docs)는 항상 설치되지만, **코드를 생성하는 모듈**은
53
+ 대상 프로젝트에서 그대로 컴파일·통과하는 것만 기본 선택됩니다.
54
+ 기준은 하나입니다 — 설치 직후 검증이 깨지지 않는가.
55
+
56
+ | 모듈 | 기본 선택 조건 | 빠지는 경우 |
57
+ |---|---|---|
58
+ | `design-system` | CSS / CSS Modules | Tailwind, CSS-in-JS (stylelint가 값에 닿지 못함) |
59
+ | `auth-http` | axios + react-router + Vite | fetch만 쓰거나 Next.js/CRA |
60
+ | `data-fetching` | TanStack Query + Zustand + axios | SWR·Redux 등 다른 조합 |
61
+ | `lint` | ESLint flat config + TypeScript | 구형 `.eslintrc`, JS 전용 |
62
+
63
+ 빠진 모듈은 이유와 함께 출력되고, `--modules` 로 명시하면 강제로 포함됩니다.
64
+ 대화형 실행에서는 비권장 모듈도 `(비권장)` 표시와 근거를 달고 목록에 나오므로 직접 켤 수 있습니다.
65
+
66
+ 모듈을 빼면 **그 모듈을 전제하는 규칙·워크플로도 함께 빠집니다.** 예를 들어
67
+ `design-system` 없이 설치하면 `30-design-system` 규칙, `/ds-init`·`/ds-add` 워크플로,
68
+ `AGENTS.md` 의 해당 항목이 모두 생성되지 않습니다. 존재하지 않는 파일을 가리키는
69
+ 규칙은 에이전트를 헷갈리게 할 뿐입니다.
70
+
71
+ ## 디자인시스템에 대한 입장
72
+
73
+ `design-system` 모듈은 웬만하면 켜는 것을 권장합니다. AI 에이전트가 화면마다 다른
74
+ 색·간격을 쓰는 UI 드리프트를 막는 **결정적**(deterministic) 수단이 토큰 + stylelint
75
+ 하나뿐이기 때문입니다. 규칙 문서는 확률적으로만 지켜집니다.
76
+
77
+ 같은 이유로 이 하네스는 **Tailwind 를 권장하지 않습니다.** 값이 클래스 문자열 안에
78
+ 있어 stylelint 가 닿지 못하고, 임의값(`bg-[#3b82f6]`)을 막으려면 별도의 ESLint 규칙
79
+ 체계를 따로 유지해야 합니다. 새 프로젝트라면 CSS Modules + `tokens.css` 를 권장합니다.
80
+
81
+ ### 기존 프로젝트의 stylelint 유예
82
+
83
+ 이미 색상 원시값을 쓰는 CSS가 있는 프로젝트에 토큰 강제를 error로 얹으면 첫 커밋부터
84
+ 수백 건이 막혀 결국 게이트를 꺼버리게 됩니다. 그래서 설치 시점에 원시값을 쓰던 파일만
85
+ `.harness/stylelint-baseline.json` 에 올려 **그 파일들만 warning** 으로 낮춥니다.
86
+ 새로 만드는 CSS는 그대로 error입니다. 정리할 때마다 목록에서 경로를 지우고, 비면
87
+ `stylelint.config.js` 의 `overrides` 를 삭제하면 됩니다.
88
+
89
+ ## eslint ignores 자동 패치
90
+
91
+ `.harness/` 안의 게이트 스크립트는 Node 인프라 코드라 호스트의 브라우저용 lint 설정에
92
+ 걸립니다. 그래서 설치 시 대상의 flat config(`eslint.config.*`)에 아래를 끼워 넣습니다.
93
+
94
+ ```js
95
+ // create-harness: 하네스 생성 파일은 호스트 lint 대상이 아니다
96
+ { ignores: ['.harness/**'] },
97
+ ```
98
+
99
+ `export default [`, `export default tseslint.config(`, `export default defineConfig([`
100
+ 형태를 인식하며, 이미 적용돼 있으면 아무것도 하지 않습니다(멱등). 알아보지 못하는
101
+ 형태면 파일을 건드리지 않고 붙여넣을 조각만 출력합니다.
102
+
103
+ ## 충돌 처리
104
+
105
+ 이미 존재하는 파일(`AGENTS.md` 등)은 덮어쓰지 않는다. `.harness/incoming/` 아래
106
+ 같은 경로에 두고 diff 명령을 안내한다. 재실행 시 동일 내용이면 건너뛴다(멱등).
107
+
108
+ 모든 생성 파일은 `.harness/manifest.json` 에 sha256과 함께 기록된다.
109
+ (v0.2의 `update` 명령이 이 해시로 로컬 수정 여부를 구분해 3-way 병합할 예정)
110
+
111
+ ## Storybook은 왜 미리 설치하지 않나
112
+
113
+ UI 작업이 없는 저장소에 Playwright 바이너리까지 끌고 들어오고, `.storybook/main.ts` 는
114
+ 프레임워크마다 달라 손으로 쓴 템플릿이 빠르게 낡는다. 대신 `/ds-init` 워크플로가
115
+ 필요해진 시점에 공식 CLI로 설치하고, a11y 위반을 검증 실패(`a11y.test = 'error'`)로
116
+ 등록한다. 자세한 근거는 `DECISIONS.md`.
117
+
118
+ ## ponytail 연동 (`--ponytail`)
119
+
120
+ [ponytail](https://github.com/DietrichGebert/ponytail)은 이 하네스와 무관한 서드파티
121
+ 규칙으로, "YAGNI 사다리"를 강제해 에이전트가 과설계하지 않고 최소 구현을 하도록 만든다.
122
+ `--ponytail`(또는 대화형 확인)을 켜면:
123
+
124
+ - **Cursor**: 최신 GitHub 릴리스 태그에서 `.cursor/rules/ponytail.mdc` 를 실행 시점에
125
+ 받아와 자동 설치한다. `templates/` 에 벤더링하지 않는다 — 릴리스 태그가 아니라
126
+ `main` 브랜치를 그대로 받으면 재실행마다 결과가 달라져 이 CLI의 멱등성 원칙과
127
+ 어긋나고, 손으로 복사해 두면 ponytail 쪽 업데이트를 우리가 계속 따라가야 한다.
128
+ 네트워크·API 실패 시에는 파일을 건너뛰고 수동 설치 안내로 폴백한다(스캐폴딩 전체를
129
+ 막지 않는다).
130
+ - **Claude Code**: `/plugin` 설치는 살아있는 세션 안에서만 실행되는 명령이라 이 CLI가
131
+ 대신 실행할 수 없다. 대신 두 줄짜리 설치 명령을 "다음 단계" 맨 위에 출력한다.
132
+
133
+ ## Storybook은 왜 미리 설치하지 않나
134
+
135
+ ```bash
136
+ npm run check # typecheck → build → test
137
+ npm run dev -- <대상경로> --yes --dry-run
138
+ ```
139
+
140
+ - `templates/` 아래가 대상 프로젝트로 복사되는 산출물, 나머지는 CLI 자체 코드
141
+ - 남은 작업: `TODO.md` / 설계 근거: `DECISIONS.md`
package/dist/cli.js ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseArgs } from 'node:util';
6
+ import * as p from '@clack/prompts';
7
+ import pc from 'picocolors';
8
+ import { detect } from './detect.js';
9
+ import { writeActions, writeManifest } from './manifest.js';
10
+ import { runPrompts } from './prompts.js';
11
+ import { patchEslintIgnores } from './eslintPatch.js';
12
+ import { buildPonytailAction } from './ponytail.js';
13
+ import { buildPlan, hasStylelintBaseline, requiredDevDeps } from './registry.js';
14
+ import { recommendedModules, suggestModules } from './suggest.js';
15
+ const VALID_AGENTS = ['cursor', 'claude'];
16
+ const VALID_MODULES = [
17
+ 'design-system',
18
+ 'auth-http',
19
+ 'data-fetching',
20
+ 'lint',
21
+ ];
22
+ const getOwnVersion = () => {
23
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
24
+ return JSON.parse(readFileSync(pkgPath, 'utf-8'))
25
+ .version;
26
+ };
27
+ const parseCsv = (raw, valid, label) => {
28
+ if (raw === undefined)
29
+ return undefined;
30
+ const values = raw
31
+ .split(',')
32
+ .map((value) => value.trim())
33
+ .filter(Boolean);
34
+ for (const value of values) {
35
+ if (!valid.includes(value)) {
36
+ console.error(`알 수 없는 ${label}: "${value}" (가능한 값: ${valid.join(', ')})`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+ return values;
41
+ };
42
+ const main = async () => {
43
+ const { values, positionals } = parseArgs({
44
+ options: {
45
+ preset: { type: 'string', default: 'react-fe' },
46
+ agents: { type: 'string' },
47
+ modules: { type: 'string' },
48
+ ponytail: { type: 'boolean', default: false },
49
+ 'dry-run': { type: 'boolean', default: false },
50
+ yes: { type: 'boolean', short: 'y', default: false },
51
+ install: { type: 'boolean', default: false },
52
+ help: { type: 'boolean', short: 'h', default: false },
53
+ version: { type: 'boolean', short: 'v', default: false },
54
+ },
55
+ allowPositionals: true,
56
+ });
57
+ if (values.version) {
58
+ console.log(getOwnVersion());
59
+ return;
60
+ }
61
+ if (values.help) {
62
+ console.log(`사용법: npx create-harness-cli [대상 디렉터리] [옵션]
63
+
64
+ 옵션:
65
+ --preset <name> 프리셋 (기본: react-fe)
66
+ --agents <csv> cursor,claude (기본: 둘 다)
67
+ --modules <csv> design-system,auth-http,data-fetching,lint
68
+ --ponytail 서드파티 ponytail 규칙(YAGNI 사다리) 연동
69
+ --dry-run 파일을 쓰지 않고 계획만 출력
70
+ -y, --yes 질문 없이 기본값/옵션값으로 진행
71
+ --install 필요한 devDependency 설치 명령까지 출력 후 실행 안내
72
+ -h, --help 도움말
73
+ -v, --version 버전`);
74
+ return;
75
+ }
76
+ if (values.preset !== 'react-fe') {
77
+ console.error(`v0.1은 react-fe 프리셋만 지원합니다 (받은 값: ${values.preset})`);
78
+ process.exit(1);
79
+ }
80
+ const targetDir = path.resolve(positionals[0] ?? '.');
81
+ const detected = detect(targetDir);
82
+ if (!detected.hasPackageJson) {
83
+ console.error(`대상에 package.json이 없습니다: ${targetDir}\n` +
84
+ 'create-harness는 기존 프로젝트에 하네스를 얹는 도구입니다.');
85
+ process.exit(1);
86
+ }
87
+ const suggestions = suggestModules(detected);
88
+ const explicitModules = parseCsv(values.modules, VALID_MODULES, 'module');
89
+ const defaults = {
90
+ targetDir,
91
+ preset: 'react-fe',
92
+ agents: parseCsv(values.agents, VALID_AGENTS, 'agent') ?? [
93
+ 'cursor',
94
+ 'claude',
95
+ ],
96
+ // --modules 를 주지 않으면 감지 결과가 기본값을 정한다
97
+ modules: explicitModules ?? recommendedModules(detected),
98
+ ponytail: values.ponytail ?? false,
99
+ dryRun: values['dry-run'],
100
+ yes: values.yes,
101
+ install: values.install,
102
+ };
103
+ const options = await runPrompts(detected, defaults, suggestions);
104
+ // 감지 때문에 빠진 모듈은 이유를 남긴다 (--yes 로 프롬프트를 건너뛴 경우 특히)
105
+ const excluded = suggestions.filter((suggestion) => !suggestion.isRecommended &&
106
+ !options.modules.includes(suggestion.module));
107
+ if (explicitModules === undefined && excluded.length > 0) {
108
+ console.log(`\n${pc.dim('감지 결과로 제외된 모듈:')}`);
109
+ for (const suggestion of excluded) {
110
+ console.log(` ${pc.dim('-')} ${suggestion.module} — ${pc.dim(suggestion.reason)}`);
111
+ }
112
+ console.log(pc.dim(` 포함하려면: --modules ${VALID_MODULES.join(',')}`));
113
+ }
114
+ // 이 하네스는 디자인시스템 모듈을 기본 전제로 삼는다 — 빠진 채로 넘어가지 않게 짚는다
115
+ if (!options.modules.includes('design-system')) {
116
+ const lines = [
117
+ `\n${pc.yellow('권고')} — design-system 모듈 없이 진행합니다.`,
118
+ pc.dim(' 이 하네스가 UI 드리프트를 막는 유일한 결정적 수단이 토큰 + stylelint 입니다.\n' +
119
+ ' 규칙 문서만으로는 에이전트가 화면마다 다른 색·간격을 씁니다.'),
120
+ ];
121
+ if (detected.hasTailwind) {
122
+ lines.push(pc.dim(' Tailwind 는 값이 클래스 문자열 안에 있어 stylelint 가 닿지 못합니다.\n' +
123
+ ' 새 프로젝트라면 CSS Modules + tokens.css 조합을 권장합니다.'));
124
+ }
125
+ console.log(lines.join('\n'));
126
+ }
127
+ const plan = buildPlan(detected, options);
128
+ let ponytailTag = null;
129
+ let ponytailFetchFailed = false;
130
+ if (options.ponytail && options.agents.includes('cursor')) {
131
+ const fetched = await buildPonytailAction();
132
+ if (fetched) {
133
+ plan.push(fetched.action);
134
+ ponytailTag = fetched.tag;
135
+ }
136
+ else {
137
+ ponytailFetchFailed = true;
138
+ }
139
+ }
140
+ const results = writeActions(plan, targetDir, options.dryRun);
141
+ writeManifest(results, options, getOwnVersion(), options.dryRun);
142
+ const written = results.filter((result) => !result.placedInIncoming);
143
+ const incoming = results.filter((result) => result.placedInIncoming);
144
+ const header = options.dryRun
145
+ ? pc.yellow('[dry-run] 실제로 쓰지 않은 계획입니다')
146
+ : pc.green('생성 완료');
147
+ console.log(`\n${header} — ${detected.projectName} (${options.preset})`);
148
+ for (const result of written) {
149
+ console.log(` ${pc.green('+')} ${result.dest}`);
150
+ }
151
+ if (incoming.length > 0) {
152
+ console.log(`\n${pc.yellow('충돌')} — 아래 파일은 이미 존재해 .harness/incoming/ 에 두었습니다:`);
153
+ for (const result of incoming) {
154
+ console.log(` ${pc.yellow('~')} ${result.dest}`);
155
+ console.log(` 비교: diff ${result.dest} .harness/incoming/${result.dest}.incoming`);
156
+ }
157
+ }
158
+ const patch = patchEslintIgnores(detected, options.dryRun);
159
+ if (patch.status === 'patched') {
160
+ console.log(`\n${pc.green('패치')} — ${patch.file} 에 하네스 파일 ignores 를 추가했습니다:\n` +
161
+ pc.dim(patch.snippet));
162
+ }
163
+ else if (patch.status === 'unrecognized') {
164
+ console.log(`\n${pc.yellow('수동 필요')} — ${patch.file} 의 export 형태를 알아보지 못했습니다.\n` +
165
+ ` 설정 배열 안에 아래를 직접 넣으세요 (없으면 eslint가 .harness/ 를 검사합니다):\n` +
166
+ pc.dim(patch.snippet));
167
+ }
168
+ if (hasStylelintBaseline(detected, options)) {
169
+ console.log(`\n${pc.yellow('stylelint 유예')} — 색상 원시값을 쓰던 기존 CSS ` +
170
+ `${detected.cssFilesWithRawColor.length}개를 .harness/stylelint-baseline.json 에 올렸습니다.\n` +
171
+ pc.dim(' 이 파일들만 warning 이고 새로 만드는 CSS는 error 입니다.\n' +
172
+ ' 정리할 때마다 목록에서 경로를 지우세요. 비면 stylelint.config.js 의 overrides 를 삭제하면 됩니다.'));
173
+ }
174
+ if (ponytailTag) {
175
+ console.log(`\n${pc.green('ponytail')} — Cursor 규칙을 릴리스 ${ponytailTag}에서 받아 설치했습니다 (.cursor/rules/ponytail.mdc).`);
176
+ }
177
+ else if (ponytailFetchFailed) {
178
+ console.log(`\n${pc.yellow('ponytail')} — 규칙 파일을 받아오지 못했습니다 (네트워크를 확인하세요).\n` +
179
+ pc.dim(' 수동 설치: https://github.com/DietrichGebert/ponytail 의 .cursor/rules/ponytail.mdc 를 프로젝트에 복사하세요.'));
180
+ }
181
+ const deps = requiredDevDeps(options);
182
+ if (deps.length > 0) {
183
+ console.log(`\n선택한 모듈이 요구하는 의존성 (자동 설치하지 않습니다):\n ${pc.cyan(`${detected.packageManager} install -D ${deps.join(' ')}`)}`);
184
+ }
185
+ const steps = [
186
+ 'AGENTS.md 의 TODO와 docs/product-spec.md 를 프로젝트에 맞게 채우세요',
187
+ '.harness/config.json 의 checks 를 확인하세요 (게이트·/verify 가 이 목록을 실행합니다)',
188
+ ];
189
+ if (options.modules.includes('design-system')) {
190
+ steps.push('UI 작업 전이라면 /ds-init 워크플로로 Storybook을 설치하세요');
191
+ }
192
+ if (options.ponytail && options.agents.includes('claude')) {
193
+ steps.unshift('ponytail(Claude Code) 설치 — 아래 두 명령을 각각 별도 메시지로 보내세요:\n' +
194
+ ' /plugin marketplace add DietrichGebert/ponytail\n' +
195
+ ' /plugin install ponytail@ponytail');
196
+ }
197
+ console.log(`\n다음 단계:\n` +
198
+ steps.map((step, index) => ` ${index + 1}. ${step}`).join('\n'));
199
+ p.outro('done');
200
+ };
201
+ main().catch((error) => {
202
+ console.error(error);
203
+ process.exit(1);
204
+ });
package/dist/detect.js ADDED
@@ -0,0 +1,128 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const detectPackageManager = (targetDir) => {
4
+ if (existsSync(path.join(targetDir, 'pnpm-lock.yaml')))
5
+ return 'pnpm';
6
+ if (existsSync(path.join(targetDir, 'yarn.lock')))
7
+ return 'yarn';
8
+ if (existsSync(path.join(targetDir, 'bun.lock')))
9
+ return 'bun';
10
+ if (existsSync(path.join(targetDir, 'bun.lockb')))
11
+ return 'bun';
12
+ return 'npm';
13
+ };
14
+ const ESLINT_FLAT_CONFIG_CANDIDATES = [
15
+ 'eslint.config.js',
16
+ 'eslint.config.mjs',
17
+ 'eslint.config.cjs',
18
+ 'eslint.config.ts',
19
+ 'eslint.config.mts',
20
+ 'eslint.config.cts',
21
+ ];
22
+ const CSS_IN_JS_PACKAGES = [
23
+ 'styled-components',
24
+ '@emotion/styled',
25
+ '@emotion/react',
26
+ '@stitches/react',
27
+ '@vanilla-extract/css',
28
+ ];
29
+ /** 선언부의 색상 원시값 — 토큰 강제를 곧바로 error로 켰을 때 걸릴 것들 */
30
+ const RAW_COLOR_PATTERN = /(?:color|fill|stroke|background|border-color|outline-color)[^;{}]*:[^;{}]*(#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\()/;
31
+ /**
32
+ * src/ 안에서 색상 원시값을 쓰는 기존 CSS 파일을 찾는다.
33
+ * 기존 프로젝트에 토큰 강제를 error로 얹으면 첫 커밋부터 수백 건이 막혀
34
+ * 게이트를 꺼버리게 된다 — 이 목록이 stylelint 유예(baseline) 대상이 된다.
35
+ */
36
+ const findCssFilesWithRawColor = (targetDir) => {
37
+ const srcDir = path.join(targetDir, 'src');
38
+ if (!existsSync(srcDir))
39
+ return [];
40
+ const found = [];
41
+ const MAX_FILES_SCANNED = 2000;
42
+ let scanned = 0;
43
+ const walk = (dir) => {
44
+ if (scanned >= MAX_FILES_SCANNED)
45
+ return;
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(dir, { withFileTypes: true });
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ for (const entry of entries) {
54
+ if (scanned >= MAX_FILES_SCANNED)
55
+ return;
56
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
57
+ continue;
58
+ }
59
+ const full = path.join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ walk(full);
62
+ }
63
+ else if (entry.name.endsWith('.css')) {
64
+ scanned += 1;
65
+ try {
66
+ if (RAW_COLOR_PATTERN.test(readFileSync(full, 'utf-8'))) {
67
+ found.push(path.relative(targetDir, full).split(path.sep).join('/'));
68
+ }
69
+ }
70
+ catch {
71
+ // 읽을 수 없는 파일은 없는 것으로 취급
72
+ }
73
+ }
74
+ }
75
+ };
76
+ walk(srcDir);
77
+ return found.sort();
78
+ };
79
+ const AGENT_FILE_CANDIDATES = [
80
+ 'AGENTS.md',
81
+ 'CLAUDE.md',
82
+ '.cursor/rules',
83
+ '.cursor/hooks.json',
84
+ '.claude/settings.json',
85
+ '.claude/skills',
86
+ '.harness',
87
+ ];
88
+ export const detect = (targetDir) => {
89
+ const pkgPath = path.join(targetDir, 'package.json');
90
+ const hasPackageJson = existsSync(pkgPath);
91
+ let projectName = path.basename(path.resolve(targetDir));
92
+ let scripts = {};
93
+ let deps = {};
94
+ if (hasPackageJson) {
95
+ try {
96
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
97
+ if (pkg.name)
98
+ projectName = pkg.name;
99
+ scripts = pkg.scripts ?? {};
100
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
101
+ }
102
+ catch {
103
+ // 깨진 package.json은 없는 것으로 취급
104
+ }
105
+ }
106
+ const existingAgentFiles = AGENT_FILE_CANDIDATES.filter((candidate) => existsSync(path.join(targetDir, candidate)));
107
+ return {
108
+ targetDir,
109
+ hasPackageJson,
110
+ projectName,
111
+ packageManager: detectPackageManager(targetDir),
112
+ isReact: 'react' in deps,
113
+ isVite: 'vite' in deps,
114
+ isTypeScript: 'typescript' in deps ||
115
+ existsSync(path.join(targetDir, 'tsconfig.json')),
116
+ hasAxios: 'axios' in deps,
117
+ hasReactRouter: 'react-router-dom' in deps || 'react-router' in deps,
118
+ hasTanstackQuery: '@tanstack/react-query' in deps || 'react-query' in deps,
119
+ hasZustand: 'zustand' in deps,
120
+ hasTailwind: 'tailwindcss' in deps,
121
+ hasCssInJs: CSS_IN_JS_PACKAGES.some((pkg) => pkg in deps),
122
+ hasEslintFlatConfig: ESLINT_FLAT_CONFIG_CANDIDATES.some((candidate) => existsSync(path.join(targetDir, candidate))),
123
+ eslintConfigFile: ESLINT_FLAT_CONFIG_CANDIDATES.find((candidate) => existsSync(path.join(targetDir, candidate))),
124
+ cssFilesWithRawColor: findCssFilesWithRawColor(targetDir),
125
+ scripts,
126
+ existingAgentFiles,
127
+ };
128
+ };
@@ -0,0 +1,58 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * 하네스가 만드는 파일 중 호스트 eslint가 집어들면 안 되는 것들.
5
+ * `.harness/gates/*.mjs` 는 Node 인프라 스크립트이고,
6
+ * `.harness/incoming/**` 는 tsconfig 밖에 있어 타입 인식 린트가 파싱에 실패한다.
7
+ */
8
+ const HARNESS_IGNORES = ['.harness/**'];
9
+ const MARKER = 'create-harness';
10
+ const SNIPPET_LINES = [
11
+ ` // ${MARKER}: 하네스 생성 파일은 호스트 lint 대상이 아니다`,
12
+ ` { ignores: [${HARNESS_IGNORES.map((glob) => `'${glob}'`).join(', ')}] },`,
13
+ ];
14
+ /**
15
+ * flat config 배열의 여는 괄호를 찾는다.
16
+ * `export default [` · `export default tseslint.config(` · `export default defineConfig([`
17
+ * 세 가지가 실사용의 대부분이고, 어느 쪽이든 바로 뒤에 설정 객체를 끼워 넣을 수 있다.
18
+ */
19
+ const findInsertionPoint = (source) => {
20
+ const match = /export\s+default\s+(?:[\w.]+\s*\(\s*)?\[?/.exec(source);
21
+ if (!match)
22
+ return null;
23
+ const tail = match[0].trimEnd();
24
+ // 배열 리터럴이나 함수 호출이 열린 지점 바로 뒤여야 한다
25
+ if (!/[[(]$/.test(tail))
26
+ return null;
27
+ return match.index + match[0].length;
28
+ };
29
+ /**
30
+ * 호스트의 eslint flat config에 `.harness/**` ignores를 끼워 넣는다.
31
+ *
32
+ * 사용자 파일을 고치는 일이라 보수적으로 간다 — 알아볼 수 있는 형태에만 손대고,
33
+ * 이미 적용돼 있으면 아무것도 하지 않으며(멱등), 실패하면 조각만 돌려주고 물러난다.
34
+ */
35
+ export const patchEslintIgnores = (detected, dryRun) => {
36
+ const snippet = SNIPPET_LINES.join('\n');
37
+ const file = detected.eslintConfigFile;
38
+ if (!file)
39
+ return { status: 'no-config', snippet };
40
+ const absPath = path.join(detected.targetDir, file);
41
+ let source;
42
+ try {
43
+ source = readFileSync(absPath, 'utf-8');
44
+ }
45
+ catch {
46
+ return { status: 'no-config', snippet };
47
+ }
48
+ if (source.includes(MARKER) || /['"`]\.harness\/\*\*['"`]/.test(source)) {
49
+ return { status: 'already-present', file, snippet };
50
+ }
51
+ const insertAt = findInsertionPoint(source);
52
+ if (insertAt === null)
53
+ return { status: 'unrecognized', file, snippet };
54
+ const patched = `${source.slice(0, insertAt)}\n${snippet}${source.slice(insertAt)}`;
55
+ if (!dryRun)
56
+ writeFileSync(absPath, patched, 'utf-8');
57
+ return { status: 'patched', file, snippet };
58
+ };
@@ -0,0 +1,63 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ export const sha256 = (content) => createHash('sha256').update(content, 'utf-8').digest('hex');
5
+ /**
6
+ * 파일을 쓴다. 대상 경로에 내용이 다른 파일이 이미 있으면 덮어쓰지 않고
7
+ * .harness/incoming/ 아래 같은 상대 경로에 두어 사용자가 비교할 수 있게 한다.
8
+ */
9
+ export const writeActions = (actions, targetDir, dryRun) => actions.map((action) => {
10
+ const absDest = path.join(targetDir, action.dest);
11
+ let placedInIncoming = false;
12
+ if (existsSync(absDest)) {
13
+ const existing = readFileSync(absDest, 'utf-8');
14
+ if (existing === action.content) {
15
+ // 동일 내용이면 그대로 둔다 (재실행 멱등성)
16
+ return {
17
+ dest: action.dest,
18
+ placedInIncoming: false,
19
+ sha256: sha256(action.content),
20
+ module: action.module,
21
+ };
22
+ }
23
+ placedInIncoming = true;
24
+ }
25
+ // .incoming 접미사: 대상 프로젝트의 tsc·eslint가 이 파일을 집어들지 않게 한다
26
+ const finalPath = placedInIncoming
27
+ ? path.join(targetDir, '.harness/incoming', `${action.dest}.incoming`)
28
+ : absDest;
29
+ if (!dryRun) {
30
+ mkdirSync(path.dirname(finalPath), { recursive: true });
31
+ writeFileSync(finalPath, action.content, 'utf-8');
32
+ if (action.executable)
33
+ chmodSync(finalPath, 0o755);
34
+ }
35
+ return {
36
+ dest: action.dest,
37
+ placedInIncoming,
38
+ sha256: sha256(action.content),
39
+ module: action.module,
40
+ };
41
+ });
42
+ export const writeManifest = (results, options, version, dryRun) => {
43
+ const manifest = {
44
+ version,
45
+ createdAt: new Date().toISOString(),
46
+ preset: options.preset,
47
+ agents: options.agents,
48
+ modules: options.modules,
49
+ files: results
50
+ .filter((result) => !result.placedInIncoming)
51
+ .map((result) => ({
52
+ path: result.dest,
53
+ sha256: result.sha256,
54
+ module: result.module,
55
+ })),
56
+ };
57
+ if (!dryRun) {
58
+ const manifestPath = path.join(options.targetDir, '.harness/manifest.json');
59
+ mkdirSync(path.dirname(manifestPath), { recursive: true });
60
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + '\n', 'utf-8');
61
+ }
62
+ return manifest;
63
+ };
@@ -0,0 +1,56 @@
1
+ const REPO = 'DietrichGebert/ponytail';
2
+ const RULE_PATH = '.cursor/rules/ponytail.mdc';
3
+ const FETCH_TIMEOUT_MS = 5000;
4
+ const withTimeout = async (fetchImpl, url, init) => {
5
+ const controller = new AbortController();
6
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
7
+ try {
8
+ return await fetchImpl(url, { ...init, signal: controller.signal });
9
+ }
10
+ finally {
11
+ clearTimeout(timer);
12
+ }
13
+ };
14
+ /**
15
+ * GitHub Releases API로 최신 릴리스 태그를 찾는다.
16
+ * main 브랜치를 그대로 받으면 재실행마다 결과가 달라져 이 CLI가 지키는
17
+ * 멱등성 원칙과 어긋난다 — 태그에 고정해 같은 버전을 반복 재현한다.
18
+ */
19
+ export const fetchLatestPonytailTag = async (fetchImpl = fetch) => {
20
+ const res = await withTimeout(fetchImpl, `https://api.github.com/repos/${REPO}/releases/latest`, { headers: { Accept: 'application/vnd.github+json' } });
21
+ if (!res.ok)
22
+ throw new Error(`GitHub API ${res.status}`);
23
+ const body = (await res.json());
24
+ if (!body.tag_name)
25
+ throw new Error('tag_name 없음');
26
+ return body.tag_name;
27
+ };
28
+ /** 고정된 릴리스 태그에서 Cursor 규칙 파일 원문을 받는다 (벤더링이 아니라 실행 시점 fetch) */
29
+ export const fetchPonytailCursorRule = async (tag, fetchImpl = fetch) => {
30
+ const res = await withTimeout(fetchImpl, `https://raw.githubusercontent.com/${REPO}/${tag}/${RULE_PATH}`);
31
+ if (!res.ok)
32
+ throw new Error(`raw.githubusercontent.com ${res.status}`);
33
+ return res.text();
34
+ };
35
+ /**
36
+ * ponytail의 Cursor 규칙 파일을 최신 릴리스에서 받아 IFileAction으로 돌려준다.
37
+ * 네트워크·API 실패는 여기서 흡수하고 null을 돌려준다 — 호출부는 실패해도
38
+ * 스캐폴딩 전체를 막지 않고 수동 설치 안내로 폴백해야 하기 때문이다.
39
+ */
40
+ export const buildPonytailAction = async (fetchImpl = fetch) => {
41
+ try {
42
+ const tag = await fetchLatestPonytailTag(fetchImpl);
43
+ const content = await fetchPonytailCursorRule(tag, fetchImpl);
44
+ return {
45
+ tag,
46
+ action: {
47
+ dest: RULE_PATH,
48
+ content,
49
+ module: 'ponytail',
50
+ },
51
+ };
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ };