@warnyin/agents 0.13.0 → 0.15.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 (81) hide show
  1. package/CHANGELOG.md +139 -125
  2. package/README.md +160 -148
  3. package/package.json +38 -38
  4. package/src/.claude/agents/warnyin-infra.md +13 -13
  5. package/src/.claude/agents/warnyin-qa.md +13 -13
  6. package/src/.claude/agents/warnyin-sa.md +13 -13
  7. package/src/.claude/agents/warnyin-security.md +13 -13
  8. package/src/.claude/agents/warnyin-tech-lead.md +13 -13
  9. package/src/.claude/commands/warnyin/build.md +31 -31
  10. package/src/.claude/commands/warnyin/design.md +27 -27
  11. package/src/.claude/commands/warnyin/discovery.md +22 -17
  12. package/src/.claude/commands/warnyin/explore.md +14 -14
  13. package/src/.claude/commands/warnyin/init.md +12 -12
  14. package/src/.claude/commands/warnyin/install-skill.md +14 -14
  15. package/src/.claude/commands/warnyin/next.md +17 -17
  16. package/src/.claude/commands/warnyin/ship.md +28 -28
  17. package/src/.claude/commands/warnyin/triage.md +14 -14
  18. package/src/.claude/commands/warnyin/update-codemaps.md +12 -12
  19. package/src/.claude/commands/warnyin/verify.md +20 -20
  20. package/src/.claude/skills/explore/SKILL.md +8 -8
  21. package/src/.claude/skills/next/SKILL.md +8 -8
  22. package/src/.claude/skills/update-codemaps/SKILL.md +8 -8
  23. package/src/.warnyin/installer/templates/CLAUDE.global.md +5 -5
  24. package/src/.warnyin/installer/templates/CLAUDE.md +34 -34
  25. package/src/.warnyin/template/docs/codemap/index.md +18 -18
  26. package/src/.warnyin/template/docs/features/[feature-name]/business.md +5 -5
  27. package/src/.warnyin/template/docs/features/[feature-name]/feature.md +5 -5
  28. package/src/.warnyin/template/docs/features/[feature-name]/spec.md +16 -16
  29. package/src/.warnyin/template/docs/infra.md +16 -16
  30. package/src/.warnyin/template/docs/project.md +18 -18
  31. package/src/.warnyin/template/docs/rule.md +7 -7
  32. package/src/.warnyin/template/docs/techstack/[component]/about.md +6 -6
  33. package/src/.warnyin/template/docs/techstack/[component]/rule.md +6 -6
  34. package/src/.warnyin/template/docs/techstack/[component]/standard.md +6 -6
  35. package/src/.warnyin/template/docs/techstack/[component]/structure.md +7 -7
  36. package/src/.warnyin/template/docs/techstack/[component]/test.md +7 -7
  37. package/src/.warnyin/template/docs/troubleshooting.md +32 -32
  38. package/src/.warnyin/template/stages/[topic]/build.md +58 -58
  39. package/src/.warnyin/template/stages/[topic]/business.md +21 -21
  40. package/src/.warnyin/template/stages/[topic]/design.md +63 -63
  41. package/src/.warnyin/template/stages/[topic]/discovery.md +69 -69
  42. package/src/.warnyin/template/stages/[topic]/proposal.md +43 -43
  43. package/src/.warnyin/template/stages/[topic]/research.md +49 -49
  44. package/src/.warnyin/template/stages/[topic]/ship.md +32 -32
  45. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/issue.md +19 -19
  46. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/rule.md +13 -13
  47. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/spec.md +36 -36
  48. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/standard.md +21 -21
  49. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/task.md +40 -40
  50. package/src/.warnyin/template/stages/[topic]/test.md +46 -46
  51. package/src/.warnyin/template/stages/[topic]/troubleshooting.md +34 -34
  52. package/src/.warnyin/template/stages/[topic]/verify.md +44 -44
  53. package/src/.warnyin/workflow/README.md +105 -102
  54. package/src/.warnyin/workflow/api-doc.md +93 -93
  55. package/src/.warnyin/workflow/codemap.md +91 -91
  56. package/src/.warnyin/workflow/contexts/README.md +51 -51
  57. package/src/.warnyin/workflow/contexts/build.md +25 -25
  58. package/src/.warnyin/workflow/contexts/research.md +25 -25
  59. package/src/.warnyin/workflow/contexts/review.md +25 -25
  60. package/src/.warnyin/workflow/explore.md +32 -32
  61. package/src/.warnyin/workflow/init.md +136 -136
  62. package/src/.warnyin/workflow/next.md +48 -48
  63. package/src/.warnyin/workflow/roles/README.md +47 -47
  64. package/src/.warnyin/workflow/roles/ba.md +25 -25
  65. package/src/.warnyin/workflow/roles/developer.md +31 -31
  66. package/src/.warnyin/workflow/roles/infra.md +24 -24
  67. package/src/.warnyin/workflow/roles/po.md +28 -28
  68. package/src/.warnyin/workflow/roles/qa.md +35 -35
  69. package/src/.warnyin/workflow/roles/sa.md +28 -28
  70. package/src/.warnyin/workflow/roles/security.md +39 -39
  71. package/src/.warnyin/workflow/roles/tech-lead.md +28 -28
  72. package/src/.warnyin/workflow/scripts/build-wave.mjs +145 -143
  73. package/src/.warnyin/workflow/scripts/validate-topic.mjs +378 -378
  74. package/src/.warnyin/workflow/stages/build.md +98 -98
  75. package/src/.warnyin/workflow/stages/design.md +154 -131
  76. package/src/.warnyin/workflow/stages/discovery.md +256 -78
  77. package/src/.warnyin/workflow/stages/ship.md +94 -94
  78. package/src/.warnyin/workflow/stages/verify.md +82 -82
  79. package/src/.warnyin/workflow/triage.md +74 -74
  80. package/src/AGENTS.md +54 -54
  81. package/src/bin/cli.mjs +310 -310
package/src/bin/cli.mjs CHANGED
@@ -1,310 +1,310 @@
1
- #!/usr/bin/env node
2
- /**
3
- * warnyin-agents installer
4
- * ติดตั้ง Warnyin Standard Workflow ลงโปรเจกต์ปัจจุบัน (cwd) หรือแบบ global (~/)
5
- *
6
- * npx @warnyin/agents ติดตั้งลงโปรเจกต์ (ข้ามไฟล์ที่มีอยู่แล้ว)
7
- * npx @warnyin/agents --global ติดตั้งแบบ global (~/) ใช้ได้ทุกโปรเจกต์
8
- * npx @warnyin/agents --project ติดตั้งลงโปรเจกต์ (บังคับ — ไม่ถาม)
9
- * npx @warnyin/agents --update อัปเดต playbook กลาง (เขียนทับเฉพาะไฟล์ core)
10
- * npx @warnyin/agents --dry-run แสดงว่าจะทำอะไร โดยไม่เขียนไฟล์จริง
11
- * (ทางสำรองไม่ผ่าน npm: npx github:warnyin/warnyin-agents)
12
- */
13
- import fs from 'node:fs'
14
- import os from 'node:os'
15
- import path from 'node:path'
16
- import { fileURLToPath } from 'node:url'
17
- import { createInterface } from 'node:readline/promises'
18
-
19
- const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
20
- const cwd = process.cwd()
21
- const args = new Set(process.argv.slice(2))
22
- const UPDATE = args.has('--update')
23
- const DRY = args.has('--dry-run')
24
-
25
- /**
26
- * ★ pure function — เลือก mode จาก flag/TTY/answer (ไม่มี side-effect, export ให้ unit test)
27
- * @param {{globalFlag?:boolean, projectFlag?:boolean, isTTY?:boolean, answer?:string}} o
28
- * @returns {'project'|'global'}
29
- */
30
- export function resolveMode({ globalFlag, projectFlag, isTTY, answer } = {}) {
31
- if (globalFlag && projectFlag) {
32
- throw new Error('--global กับ --project ใช้พร้อมกันไม่ได้ (เลือกอย่างใดอย่างหนึ่ง)')
33
- }
34
- if (globalFlag) return 'global'
35
- if (projectFlag) return 'project'
36
- if (!isTTY) return 'project' // CI-safe default — npx/pipe ไม่ค้างรอ input
37
- const a = (answer ?? '').trim().toLowerCase()
38
- return a === '2' || a === 'global' ? 'global' : 'project'
39
- }
40
-
41
- if (args.has('--help') || args.has('-h')) {
42
- console.log(`warnyin-agents — ติดตั้ง Warnyin Standard Workflow ลงโปรเจกต์ปัจจุบัน หรือแบบ global
43
-
44
- ใช้งาน:
45
- npx @warnyin/agents ติดตั้งลงโปรเจกต์ (ถ้า TTY จะถามก่อน; ข้ามไฟล์ที่มีอยู่แล้ว)
46
- npx @warnyin/agents --global ติดตั้งแบบ global ลง ~/ (~/.warnyin + ~/.claude) ใช้ได้ทุกโปรเจกต์
47
- npx @warnyin/agents --project ติดตั้งลงโปรเจกต์ (บังคับ ไม่ถาม)
48
- npx @warnyin/agents --update อัปเดต playbook กลางเป็นเวอร์ชันล่าสุด
49
- (เขียนทับเฉพาะ .warnyin/workflow/, .claude/commands/warnyin/,
50
- template .warnyin/template/stages/[topic] — ไม่แตะ docs/ และงานจริง)
51
- npx @warnyin/agents --dry-run แสดงรายการไฟล์ที่จะสร้าง/อัปเดต โดยไม่เขียนจริง
52
-
53
- หลังติดตั้ง: เปิด Claude Code ในโปรเจกต์ แล้วรัน /warnyin:init ให้ agent วิเคราะห์โปรเจกต์ + เติม docs/`)
54
- process.exit(0)
55
- }
56
-
57
- // guard กัน self-install — เก็บไว้แบบ defensive (zero-cost)
58
- // หลังย้าย source เข้า src/ → pkgRoot = src/ (sibling ของ bin/) จึงแทบไม่มีทาง === cwd (repo root / temp sandbox)
59
- // → guard นี้เป็น no-op โดยตั้งใจในเคสปกติ/sandbox; ยังคงดักได้เฉพาะ edge case ที่ install ลงโฟลเดอร์ที่เป็น src/ เอง
60
- if (path.resolve(pkgRoot) === path.resolve(cwd)) {
61
- console.error('✖ กำลังรันอยู่ใน repo ของ warnyin-agents เอง — ให้ cd ไปที่โปรเจกต์ปลายทางก่อน')
62
- process.exit(1)
63
- }
64
-
65
- // โครงเก่า (≤0.2.x): workflow/ + warnyin-stages/ ที่ root — เตือนให้ย้ายเอง ไม่แตะงานจริงของ user
66
- const legacyV2 = ['workflow', 'warnyin-stages'].filter((d) => fs.existsSync(path.join(cwd, d)))
67
- if (legacyV2.length) {
68
- console.warn(`⚠ พบโครงเลย์เอาต์เก่า (≤0.2.x): ${legacyV2.join(', ')}
69
- เวอร์ชันนี้ย้าย core ไปใต้ .warnyin/ และงานจริงไป docs/stages/ — แนะนำย้ายด้วยตัวเองก่อน:
70
- 1. mkdir -p docs/stages && git mv warnyin-stages/* docs/stages/ # งานจริงของคุณ (ปลอดภัย ไม่ถูกแตะโดย installer)
71
- 2. rm -rf workflow warnyin-stages # core เก่า + โฟลเดอร์ที่ย้ายของออกแล้ว
72
- แล้วรันคำสั่งนี้อีกครั้ง\n`)
73
- }
74
-
75
- // โครงเก่า (0.3–0.5.x): ทุกอย่างอยู่ใต้ warnyin/ ที่ root — เวอร์ชันนี้แยกเป็น .warnyin/ (core) + docs/stages (งานจริง)
76
- const legacyV5 = ['workflow', 'template', 'installer', 'stages'].filter((d) =>
77
- fs.existsSync(path.join(cwd, 'warnyin', d)),
78
- )
79
- if (legacyV5.length) {
80
- console.warn(`⚠ พบโครงเลย์เอาต์เก่า (0.3–0.5.x): warnyin/{${legacyV5.join(', ')}}
81
- เวอร์ชันนี้ย้าย core ไป .warnyin/ และงานจริงไป docs/stages/ — แนะนำย้ายด้วยตัวเองก่อน:
82
- 1. mkdir -p docs/stages && git mv warnyin/stages/* docs/stages/ # งานจริงของคุณ (active + achieved) — ปลอดภัย ไม่ถูกแตะ
83
- 2. rm -rf warnyin # core เก่าทั้งหมด (เวอร์ชันใหม่ installer จะวางที่ .warnyin/)
84
- แล้วรันคำสั่งนี้อีกครั้ง — installer จะวาง .warnyin/ ชุดใหม่ให้\n`)
85
- }
86
-
87
- // core = playbook กลาง + command + agent + template — เขียนทับได้เมื่อ --update
88
- const CORE = [
89
- path.join('.warnyin', 'workflow'),
90
- path.join('.warnyin', 'template'),
91
- path.join('.claude', 'commands', 'warnyin'),
92
- path.join('.claude', 'agents'),
93
- path.join('.claude', 'skills'),
94
- ]
95
- // scaffold = พื้นที่ทำงานเปล่าของโปรเจกต์ — installer "สร้างเอง" ไม่ copy tree จาก package
96
- // (สำคัญ: ถ้า copy docs/stages จาก pkgRoot งานจริงของ repo ต้นทางจะรั่วไป target ทุกครั้ง — ดู verify installer-test-ci)
97
- const SCAFFOLD_FILES = [
98
- path.join('docs', 'stages', 'context.md'), // บริบทงานที่จดไว้ (next/discovery/explore อ่าน "ถ้ามี")
99
- path.join('docs', 'stages', 'achieved', '.gitkeep'), // ให้ git track โฟลเดอร์ archive เปล่า
100
- ]
101
-
102
- const stats = { created: 0, updated: 0, skipped: 0 }
103
-
104
- // target = ปลายทางที่จะเขียนไฟล์ — ตั้งหลัง resolve mode (project=cwd | global=homedir)
105
- let target = cwd
106
-
107
- function copyTree(relDir, { overwrite }) {
108
- const srcDir = path.join(pkgRoot, relDir)
109
- if (!fs.existsSync(srcDir)) return
110
- for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
111
- const rel = path.join(relDir, entry.name)
112
- if (entry.isDirectory()) {
113
- copyTree(rel, { overwrite })
114
- continue
115
- }
116
- const src = path.join(pkgRoot, rel)
117
- const dest = path.join(target, rel)
118
- const exists = fs.existsSync(dest)
119
- if (exists && !overwrite) {
120
- stats.skipped++
121
- continue
122
- }
123
- if (exists && overwrite && fs.readFileSync(src).equals(fs.readFileSync(dest))) {
124
- stats.skipped++
125
- continue
126
- }
127
- if (!DRY) {
128
- fs.mkdirSync(path.dirname(dest), { recursive: true })
129
- fs.copyFileSync(src, dest)
130
- }
131
- stats[exists ? 'updated' : 'created']++
132
- console.log(` ${exists ? '↻' : '+'} ${rel}`)
133
- }
134
- }
135
-
136
- /** สร้างโครง scaffold เปล่าของ docs/stages เอง (ไม่ copy จาก package — กันงานจริงของ repo ต้นทางรั่วไป target) — ไม่ทับไฟล์ที่มีอยู่ */
137
- function ensureScaffold() {
138
- for (const rel of SCAFFOLD_FILES) {
139
- const dest = path.join(target, rel)
140
- if (fs.existsSync(dest)) {
141
- stats.skipped++
142
- continue
143
- }
144
- if (!DRY) {
145
- fs.mkdirSync(path.dirname(dest), { recursive: true })
146
- fs.writeFileSync(dest, '')
147
- }
148
- stats.created++
149
- console.log(` + ${rel}`)
150
- }
151
- }
152
-
153
- /** seed docs/ ของโปรเจกต์จาก .warnyin/template/docs — ข้ามโฟลเดอร์ template `[...]` (ไว้ให้ /warnyin:init copy เป็นชื่อจริง) และไม่ทับไฟล์ที่มีอยู่ */
154
- const TEMPLATE_DOCS = path.join('.warnyin', 'template', 'docs')
155
- function seedDocs(relDir = TEMPLATE_DOCS) {
156
- const srcDir = path.join(pkgRoot, relDir)
157
- if (!fs.existsSync(srcDir)) return
158
- for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
159
- if (entry.name.startsWith('[')) continue
160
- const rel = path.join(relDir, entry.name)
161
- if (entry.isDirectory()) {
162
- seedDocs(rel)
163
- continue
164
- }
165
- const destRel = path.join('docs', path.relative(TEMPLATE_DOCS, rel))
166
- const dest = path.join(target, destRel)
167
- if (fs.existsSync(dest)) {
168
- stats.skipped++
169
- continue
170
- }
171
- if (!DRY) {
172
- fs.mkdirSync(path.dirname(dest), { recursive: true })
173
- fs.copyFileSync(path.join(pkgRoot, rel), dest)
174
- }
175
- stats.created++
176
- console.log(` + ${destRel}`)
177
- }
178
- }
179
-
180
- /** CLAUDE.md / AGENTS.md: ไม่มี → สร้างจาก template; มีอยู่แล้วแต่ยังไม่มี workflow → ต่อท้ายเป็น section */
181
- function installRootDoc(name, srcPath) {
182
- const dest = path.join(target, name)
183
- const content = fs.readFileSync(srcPath, 'utf8')
184
- if (!fs.existsSync(dest)) {
185
- if (!DRY) fs.writeFileSync(dest, content)
186
- stats.created++
187
- console.log(` + ${name}`)
188
- return
189
- }
190
- const existing = fs.readFileSync(dest, 'utf8')
191
- if (existing.includes('warnyin/workflow/stages/')) {
192
- stats.skipped++
193
- return
194
- }
195
- const section = '\n\n' + content.replace(/^#\s[^\n]*\n/, '## Warnyin Standard Workflow\n')
196
- if (!DRY) fs.appendFileSync(dest, section)
197
- stats.updated++
198
- console.log(` ± ${name} (ต่อท้าย section Warnyin Standard Workflow)`)
199
- }
200
-
201
- const GLOBAL_NOTE_MARKER = '<!-- warnyin:global-note -->'
202
-
203
- /**
204
- * เขียน resolution note ลง ~/.claude/CLAUDE.md แบบ note-only append-with-marker (global mode)
205
- * — ห้ามเขียนทับทั้งไฟล์ (personal global memory ของ user); append เฉพาะถ้ายังไม่มี marker (idempotent)
206
- * — defensive skip ถ้า template ไม่มี (worktree T1 เดี่ยวก่อน merge T2) — pattern เดียวกับ copyTree/seedDocs
207
- */
208
- function installGlobalNote() {
209
- const src = path.join(pkgRoot, '.warnyin', 'installer', 'templates', 'CLAUDE.global.md')
210
- const destRel = path.join('.claude', 'CLAUDE.md')
211
- const dest = path.join(target, destRel)
212
- if (!fs.existsSync(src)) {
213
- console.log(` · ข้าม ${destRel} (ยังไม่มี template CLAUDE.global.md)`)
214
- return
215
- }
216
- const note = fs.readFileSync(src, 'utf8')
217
- if (!fs.existsSync(dest)) {
218
- if (!DRY) {
219
- fs.mkdirSync(path.dirname(dest), { recursive: true })
220
- fs.writeFileSync(dest, note)
221
- }
222
- stats.created++
223
- console.log(` + ${destRel}`)
224
- return
225
- }
226
- const existing = fs.readFileSync(dest, 'utf8')
227
- if (existing.includes(GLOBAL_NOTE_MARKER)) {
228
- stats.skipped++
229
- return
230
- }
231
- const section = (existing.endsWith('\n') ? '\n' : '\n\n') + note
232
- if (!DRY) fs.appendFileSync(dest, section)
233
- stats.updated++
234
- console.log(` ± ${destRel} (ต่อท้าย warnyin global note)`)
235
- }
236
-
237
- /** เลือก mode + ติดตั้งตาม mode (target=cwd|homedir). ห่อ async เฉพาะ path TTY (readline) */
238
- async function main() {
239
- const globalFlag = args.has('--global')
240
- const projectFlag = args.has('--project')
241
- const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY)
242
-
243
- let mode
244
- try {
245
- if (!globalFlag && !projectFlag && isTTY) {
246
- // ถาม เฉพาะ path TTY — non-TTY/flag ไม่แตะ readline (ไม่ค้าง/ไม่ช้าลง)
247
- const rl = createInterface({ input: process.stdin, output: process.stdout })
248
- let answer
249
- try {
250
- answer = await rl.question('ติดตั้งแบบไหน? [1] โปรเจกต์นี้ (default) [2] global (~/) : ')
251
- } finally {
252
- rl.close()
253
- }
254
- mode = resolveMode({ globalFlag, projectFlag, isTTY, answer })
255
- } else {
256
- mode = resolveMode({ globalFlag, projectFlag, isTTY })
257
- }
258
- } catch (e) {
259
- console.error(`✖ ${e.message}`)
260
- process.exit(1)
261
- }
262
-
263
- if (mode === 'global') {
264
- const home = os.homedir()
265
- // homedir guard — falsy หรือ filesystem root (CI/container ไม่มี passwd) → error แทนเขียนลง root
266
- if (!home || path.resolve(home) === path.parse(path.resolve(home)).root) {
267
- console.error('✖ หา home directory ไม่ได้ (หรือเป็น filesystem root) — ใช้ --project แทน')
268
- process.exit(1)
269
- }
270
- target = home
271
- console.log(`Warnyin Standard Workflow → global ${target}${DRY ? ' (dry-run)' : ''}`)
272
- console.log(` จะเขียนนอกโปรเจกต์ที่: ${path.join(target, '.warnyin')}, ${path.join(target, '.claude', 'commands', 'warnyin')}, ${path.join(target, '.claude', 'CLAUDE.md')}\n`)
273
- // first-install overwrite:false (skip ของเดิมใน ~/.claude/{agents,skills}) — ทับเฉพาะ --global --update
274
- for (const dir of CORE) copyTree(dir, { overwrite: UPDATE })
275
- // ข้าม scaffold/seedDocs (ยกให้ /warnyin:init) + ข้าม AGENTS.md global (DQ3 limitation)
276
- installGlobalNote()
277
- } else {
278
- target = cwd
279
- console.log(`Warnyin Standard Workflow → ${target}${DRY ? ' (dry-run)' : ''}\n`)
280
- for (const dir of CORE) copyTree(dir, { overwrite: UPDATE })
281
- ensureScaffold()
282
- seedDocs()
283
- installRootDoc('CLAUDE.md', path.join(pkgRoot, '.warnyin', 'installer', 'templates', 'CLAUDE.md'))
284
- installRootDoc('AGENTS.md', path.join(pkgRoot, 'AGENTS.md'))
285
- }
286
-
287
- console.log(`\nสรุป: สร้างใหม่ ${stats.created} · อัปเดต ${stats.updated} · ข้าม (มีอยู่แล้ว) ${stats.skipped}`)
288
-
289
- if (!UPDATE) {
290
- if (mode === 'global') {
291
- console.log(`
292
- ติดตั้ง global แล้ว — /warnyin:* ใช้ได้ทุกโปรเจกต์ (จาก ~/.claude/commands/)
293
- เปิดโปรเจกต์ใด ๆ ใน Claude Code → รัน /warnyin:init เพื่อสร้าง workspace (docs/) ของโปรเจกต์นั้น
294
-
295
- อัปเดต playbook ภายหลัง: npx @warnyin/agents --global --update`)
296
- } else {
297
- console.log(`
298
- ขั้นถัดไป:
299
- 1. เปิด Claude Code ในโปรเจกต์นี้ แล้วรัน /warnyin:init — ให้ agent วิเคราะห์โปรเจกต์ + เติม docs/
300
- 2. เริ่มงานแรก: /warnyin:discovery <topic> หรือ /warnyin:design <slug> <change>
301
-
302
- อัปเดต playbook ภายหลัง: npx @warnyin/agents --update`)
303
- }
304
- }
305
- }
306
-
307
- // main-guard: รันเฉพาะตอน execute ตรง ๆ (ไม่ trigger ตอน import เพื่อ unit-test resolveMode)
308
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
309
- await main()
310
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * warnyin-agents installer
4
+ * ติดตั้ง Warnyin Standard Workflow ลงโปรเจกต์ปัจจุบัน (cwd) หรือแบบ global (~/)
5
+ *
6
+ * npx @warnyin/agents ติดตั้งลงโปรเจกต์ (ข้ามไฟล์ที่มีอยู่แล้ว)
7
+ * npx @warnyin/agents --global ติดตั้งแบบ global (~/) ใช้ได้ทุกโปรเจกต์
8
+ * npx @warnyin/agents --project ติดตั้งลงโปรเจกต์ (บังคับ — ไม่ถาม)
9
+ * npx @warnyin/agents --update อัปเดต playbook กลาง (เขียนทับเฉพาะไฟล์ core)
10
+ * npx @warnyin/agents --dry-run แสดงว่าจะทำอะไร โดยไม่เขียนไฟล์จริง
11
+ * (ทางสำรองไม่ผ่าน npm: npx github:warnyin/warnyin-agents)
12
+ */
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { createInterface } from 'node:readline/promises'
18
+
19
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
20
+ const cwd = process.cwd()
21
+ const args = new Set(process.argv.slice(2))
22
+ const UPDATE = args.has('--update')
23
+ const DRY = args.has('--dry-run')
24
+
25
+ /**
26
+ * ★ pure function — เลือก mode จาก flag/TTY/answer (ไม่มี side-effect, export ให้ unit test)
27
+ * @param {{globalFlag?:boolean, projectFlag?:boolean, isTTY?:boolean, answer?:string}} o
28
+ * @returns {'project'|'global'}
29
+ */
30
+ export function resolveMode({ globalFlag, projectFlag, isTTY, answer } = {}) {
31
+ if (globalFlag && projectFlag) {
32
+ throw new Error('--global กับ --project ใช้พร้อมกันไม่ได้ (เลือกอย่างใดอย่างหนึ่ง)')
33
+ }
34
+ if (globalFlag) return 'global'
35
+ if (projectFlag) return 'project'
36
+ if (!isTTY) return 'project' // CI-safe default — npx/pipe ไม่ค้างรอ input
37
+ const a = (answer ?? '').trim().toLowerCase()
38
+ return a === '2' || a === 'global' ? 'global' : 'project'
39
+ }
40
+
41
+ if (args.has('--help') || args.has('-h')) {
42
+ console.log(`warnyin-agents — ติดตั้ง Warnyin Standard Workflow ลงโปรเจกต์ปัจจุบัน หรือแบบ global
43
+
44
+ ใช้งาน:
45
+ npx @warnyin/agents ติดตั้งลงโปรเจกต์ (ถ้า TTY จะถามก่อน; ข้ามไฟล์ที่มีอยู่แล้ว)
46
+ npx @warnyin/agents --global ติดตั้งแบบ global ลง ~/ (~/.warnyin + ~/.claude) ใช้ได้ทุกโปรเจกต์
47
+ npx @warnyin/agents --project ติดตั้งลงโปรเจกต์ (บังคับ ไม่ถาม)
48
+ npx @warnyin/agents --update อัปเดต playbook กลางเป็นเวอร์ชันล่าสุด
49
+ (เขียนทับเฉพาะ .warnyin/workflow/, .claude/commands/warnyin/,
50
+ template .warnyin/template/stages/[topic] — ไม่แตะ docs/ และงานจริง)
51
+ npx @warnyin/agents --dry-run แสดงรายการไฟล์ที่จะสร้าง/อัปเดต โดยไม่เขียนจริง
52
+
53
+ หลังติดตั้ง: เปิด Claude Code ในโปรเจกต์ แล้วรัน /warnyin:init ให้ agent วิเคราะห์โปรเจกต์ + เติม docs/`)
54
+ process.exit(0)
55
+ }
56
+
57
+ // guard กัน self-install — เก็บไว้แบบ defensive (zero-cost)
58
+ // หลังย้าย source เข้า src/ → pkgRoot = src/ (sibling ของ bin/) จึงแทบไม่มีทาง === cwd (repo root / temp sandbox)
59
+ // → guard นี้เป็น no-op โดยตั้งใจในเคสปกติ/sandbox; ยังคงดักได้เฉพาะ edge case ที่ install ลงโฟลเดอร์ที่เป็น src/ เอง
60
+ if (path.resolve(pkgRoot) === path.resolve(cwd)) {
61
+ console.error('✖ กำลังรันอยู่ใน repo ของ warnyin-agents เอง — ให้ cd ไปที่โปรเจกต์ปลายทางก่อน')
62
+ process.exit(1)
63
+ }
64
+
65
+ // โครงเก่า (≤0.2.x): workflow/ + warnyin-stages/ ที่ root — เตือนให้ย้ายเอง ไม่แตะงานจริงของ user
66
+ const legacyV2 = ['workflow', 'warnyin-stages'].filter((d) => fs.existsSync(path.join(cwd, d)))
67
+ if (legacyV2.length) {
68
+ console.warn(`⚠ พบโครงเลย์เอาต์เก่า (≤0.2.x): ${legacyV2.join(', ')}
69
+ เวอร์ชันนี้ย้าย core ไปใต้ .warnyin/ และงานจริงไป docs/stages/ — แนะนำย้ายด้วยตัวเองก่อน:
70
+ 1. mkdir -p docs/stages && git mv warnyin-stages/* docs/stages/ # งานจริงของคุณ (ปลอดภัย ไม่ถูกแตะโดย installer)
71
+ 2. rm -rf workflow warnyin-stages # core เก่า + โฟลเดอร์ที่ย้ายของออกแล้ว
72
+ แล้วรันคำสั่งนี้อีกครั้ง\n`)
73
+ }
74
+
75
+ // โครงเก่า (0.3–0.5.x): ทุกอย่างอยู่ใต้ warnyin/ ที่ root — เวอร์ชันนี้แยกเป็น .warnyin/ (core) + docs/stages (งานจริง)
76
+ const legacyV5 = ['workflow', 'template', 'installer', 'stages'].filter((d) =>
77
+ fs.existsSync(path.join(cwd, 'warnyin', d)),
78
+ )
79
+ if (legacyV5.length) {
80
+ console.warn(`⚠ พบโครงเลย์เอาต์เก่า (0.3–0.5.x): warnyin/{${legacyV5.join(', ')}}
81
+ เวอร์ชันนี้ย้าย core ไป .warnyin/ และงานจริงไป docs/stages/ — แนะนำย้ายด้วยตัวเองก่อน:
82
+ 1. mkdir -p docs/stages && git mv warnyin/stages/* docs/stages/ # งานจริงของคุณ (active + achieved) — ปลอดภัย ไม่ถูกแตะ
83
+ 2. rm -rf warnyin # core เก่าทั้งหมด (เวอร์ชันใหม่ installer จะวางที่ .warnyin/)
84
+ แล้วรันคำสั่งนี้อีกครั้ง — installer จะวาง .warnyin/ ชุดใหม่ให้\n`)
85
+ }
86
+
87
+ // core = playbook กลาง + command + agent + template — เขียนทับได้เมื่อ --update
88
+ const CORE = [
89
+ path.join('.warnyin', 'workflow'),
90
+ path.join('.warnyin', 'template'),
91
+ path.join('.claude', 'commands', 'warnyin'),
92
+ path.join('.claude', 'agents'),
93
+ path.join('.claude', 'skills'),
94
+ ]
95
+ // scaffold = พื้นที่ทำงานเปล่าของโปรเจกต์ — installer "สร้างเอง" ไม่ copy tree จาก package
96
+ // (สำคัญ: ถ้า copy docs/stages จาก pkgRoot งานจริงของ repo ต้นทางจะรั่วไป target ทุกครั้ง — ดู verify installer-test-ci)
97
+ const SCAFFOLD_FILES = [
98
+ path.join('docs', 'stages', 'context.md'), // บริบทงานที่จดไว้ (next/discovery/explore อ่าน "ถ้ามี")
99
+ path.join('docs', 'stages', 'achieved', '.gitkeep'), // ให้ git track โฟลเดอร์ archive เปล่า
100
+ ]
101
+
102
+ const stats = { created: 0, updated: 0, skipped: 0 }
103
+
104
+ // target = ปลายทางที่จะเขียนไฟล์ — ตั้งหลัง resolve mode (project=cwd | global=homedir)
105
+ let target = cwd
106
+
107
+ function copyTree(relDir, { overwrite }) {
108
+ const srcDir = path.join(pkgRoot, relDir)
109
+ if (!fs.existsSync(srcDir)) return
110
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
111
+ const rel = path.join(relDir, entry.name)
112
+ if (entry.isDirectory()) {
113
+ copyTree(rel, { overwrite })
114
+ continue
115
+ }
116
+ const src = path.join(pkgRoot, rel)
117
+ const dest = path.join(target, rel)
118
+ const exists = fs.existsSync(dest)
119
+ if (exists && !overwrite) {
120
+ stats.skipped++
121
+ continue
122
+ }
123
+ if (exists && overwrite && fs.readFileSync(src).equals(fs.readFileSync(dest))) {
124
+ stats.skipped++
125
+ continue
126
+ }
127
+ if (!DRY) {
128
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
129
+ fs.copyFileSync(src, dest)
130
+ }
131
+ stats[exists ? 'updated' : 'created']++
132
+ console.log(` ${exists ? '↻' : '+'} ${rel}`)
133
+ }
134
+ }
135
+
136
+ /** สร้างโครง scaffold เปล่าของ docs/stages เอง (ไม่ copy จาก package — กันงานจริงของ repo ต้นทางรั่วไป target) — ไม่ทับไฟล์ที่มีอยู่ */
137
+ function ensureScaffold() {
138
+ for (const rel of SCAFFOLD_FILES) {
139
+ const dest = path.join(target, rel)
140
+ if (fs.existsSync(dest)) {
141
+ stats.skipped++
142
+ continue
143
+ }
144
+ if (!DRY) {
145
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
146
+ fs.writeFileSync(dest, '')
147
+ }
148
+ stats.created++
149
+ console.log(` + ${rel}`)
150
+ }
151
+ }
152
+
153
+ /** seed docs/ ของโปรเจกต์จาก .warnyin/template/docs — ข้ามโฟลเดอร์ template `[...]` (ไว้ให้ /warnyin:init copy เป็นชื่อจริง) และไม่ทับไฟล์ที่มีอยู่ */
154
+ const TEMPLATE_DOCS = path.join('.warnyin', 'template', 'docs')
155
+ function seedDocs(relDir = TEMPLATE_DOCS) {
156
+ const srcDir = path.join(pkgRoot, relDir)
157
+ if (!fs.existsSync(srcDir)) return
158
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
159
+ if (entry.name.startsWith('[')) continue
160
+ const rel = path.join(relDir, entry.name)
161
+ if (entry.isDirectory()) {
162
+ seedDocs(rel)
163
+ continue
164
+ }
165
+ const destRel = path.join('docs', path.relative(TEMPLATE_DOCS, rel))
166
+ const dest = path.join(target, destRel)
167
+ if (fs.existsSync(dest)) {
168
+ stats.skipped++
169
+ continue
170
+ }
171
+ if (!DRY) {
172
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
173
+ fs.copyFileSync(path.join(pkgRoot, rel), dest)
174
+ }
175
+ stats.created++
176
+ console.log(` + ${destRel}`)
177
+ }
178
+ }
179
+
180
+ /** CLAUDE.md / AGENTS.md: ไม่มี → สร้างจาก template; มีอยู่แล้วแต่ยังไม่มี workflow → ต่อท้ายเป็น section */
181
+ function installRootDoc(name, srcPath) {
182
+ const dest = path.join(target, name)
183
+ const content = fs.readFileSync(srcPath, 'utf8')
184
+ if (!fs.existsSync(dest)) {
185
+ if (!DRY) fs.writeFileSync(dest, content)
186
+ stats.created++
187
+ console.log(` + ${name}`)
188
+ return
189
+ }
190
+ const existing = fs.readFileSync(dest, 'utf8')
191
+ if (existing.includes('warnyin/workflow/stages/')) {
192
+ stats.skipped++
193
+ return
194
+ }
195
+ const section = '\n\n' + content.replace(/^#\s[^\n]*\n/, '## Warnyin Standard Workflow\n')
196
+ if (!DRY) fs.appendFileSync(dest, section)
197
+ stats.updated++
198
+ console.log(` ± ${name} (ต่อท้าย section Warnyin Standard Workflow)`)
199
+ }
200
+
201
+ const GLOBAL_NOTE_MARKER = '<!-- warnyin:global-note -->'
202
+
203
+ /**
204
+ * เขียน resolution note ลง ~/.claude/CLAUDE.md แบบ note-only append-with-marker (global mode)
205
+ * — ห้ามเขียนทับทั้งไฟล์ (personal global memory ของ user); append เฉพาะถ้ายังไม่มี marker (idempotent)
206
+ * — defensive skip ถ้า template ไม่มี (worktree T1 เดี่ยวก่อน merge T2) — pattern เดียวกับ copyTree/seedDocs
207
+ */
208
+ function installGlobalNote() {
209
+ const src = path.join(pkgRoot, '.warnyin', 'installer', 'templates', 'CLAUDE.global.md')
210
+ const destRel = path.join('.claude', 'CLAUDE.md')
211
+ const dest = path.join(target, destRel)
212
+ if (!fs.existsSync(src)) {
213
+ console.log(` · ข้าม ${destRel} (ยังไม่มี template CLAUDE.global.md)`)
214
+ return
215
+ }
216
+ const note = fs.readFileSync(src, 'utf8')
217
+ if (!fs.existsSync(dest)) {
218
+ if (!DRY) {
219
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
220
+ fs.writeFileSync(dest, note)
221
+ }
222
+ stats.created++
223
+ console.log(` + ${destRel}`)
224
+ return
225
+ }
226
+ const existing = fs.readFileSync(dest, 'utf8')
227
+ if (existing.includes(GLOBAL_NOTE_MARKER)) {
228
+ stats.skipped++
229
+ return
230
+ }
231
+ const section = (existing.endsWith('\n') ? '\n' : '\n\n') + note
232
+ if (!DRY) fs.appendFileSync(dest, section)
233
+ stats.updated++
234
+ console.log(` ± ${destRel} (ต่อท้าย warnyin global note)`)
235
+ }
236
+
237
+ /** เลือก mode + ติดตั้งตาม mode (target=cwd|homedir). ห่อ async เฉพาะ path TTY (readline) */
238
+ async function main() {
239
+ const globalFlag = args.has('--global')
240
+ const projectFlag = args.has('--project')
241
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY)
242
+
243
+ let mode
244
+ try {
245
+ if (!globalFlag && !projectFlag && isTTY) {
246
+ // ถาม เฉพาะ path TTY — non-TTY/flag ไม่แตะ readline (ไม่ค้าง/ไม่ช้าลง)
247
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
248
+ let answer
249
+ try {
250
+ answer = await rl.question('ติดตั้งแบบไหน? [1] โปรเจกต์นี้ (default) [2] global (~/) : ')
251
+ } finally {
252
+ rl.close()
253
+ }
254
+ mode = resolveMode({ globalFlag, projectFlag, isTTY, answer })
255
+ } else {
256
+ mode = resolveMode({ globalFlag, projectFlag, isTTY })
257
+ }
258
+ } catch (e) {
259
+ console.error(`✖ ${e.message}`)
260
+ process.exit(1)
261
+ }
262
+
263
+ if (mode === 'global') {
264
+ const home = os.homedir()
265
+ // homedir guard — falsy หรือ filesystem root (CI/container ไม่มี passwd) → error แทนเขียนลง root
266
+ if (!home || path.resolve(home) === path.parse(path.resolve(home)).root) {
267
+ console.error('✖ หา home directory ไม่ได้ (หรือเป็น filesystem root) — ใช้ --project แทน')
268
+ process.exit(1)
269
+ }
270
+ target = home
271
+ console.log(`Warnyin Standard Workflow → global ${target}${DRY ? ' (dry-run)' : ''}`)
272
+ console.log(` จะเขียนนอกโปรเจกต์ที่: ${path.join(target, '.warnyin')}, ${path.join(target, '.claude', 'commands', 'warnyin')}, ${path.join(target, '.claude', 'CLAUDE.md')}\n`)
273
+ // first-install overwrite:false (skip ของเดิมใน ~/.claude/{agents,skills}) — ทับเฉพาะ --global --update
274
+ for (const dir of CORE) copyTree(dir, { overwrite: UPDATE })
275
+ // ข้าม scaffold/seedDocs (ยกให้ /warnyin:init) + ข้าม AGENTS.md global (DQ3 limitation)
276
+ installGlobalNote()
277
+ } else {
278
+ target = cwd
279
+ console.log(`Warnyin Standard Workflow → ${target}${DRY ? ' (dry-run)' : ''}\n`)
280
+ for (const dir of CORE) copyTree(dir, { overwrite: UPDATE })
281
+ ensureScaffold()
282
+ seedDocs()
283
+ installRootDoc('CLAUDE.md', path.join(pkgRoot, '.warnyin', 'installer', 'templates', 'CLAUDE.md'))
284
+ installRootDoc('AGENTS.md', path.join(pkgRoot, 'AGENTS.md'))
285
+ }
286
+
287
+ console.log(`\nสรุป: สร้างใหม่ ${stats.created} · อัปเดต ${stats.updated} · ข้าม (มีอยู่แล้ว) ${stats.skipped}`)
288
+
289
+ if (!UPDATE) {
290
+ if (mode === 'global') {
291
+ console.log(`
292
+ ติดตั้ง global แล้ว — /warnyin:* ใช้ได้ทุกโปรเจกต์ (จาก ~/.claude/commands/)
293
+ เปิดโปรเจกต์ใด ๆ ใน Claude Code → รัน /warnyin:init เพื่อสร้าง workspace (docs/) ของโปรเจกต์นั้น
294
+
295
+ อัปเดต playbook ภายหลัง: npx @warnyin/agents --global --update`)
296
+ } else {
297
+ console.log(`
298
+ ขั้นถัดไป:
299
+ 1. เปิด Claude Code ในโปรเจกต์นี้ แล้วรัน /warnyin:init — ให้ agent วิเคราะห์โปรเจกต์ + เติม docs/
300
+ 2. เริ่มงานแรก: /warnyin:discovery <topic> หรือ /warnyin:design <slug> <change>
301
+
302
+ อัปเดต playbook ภายหลัง: npx @warnyin/agents --update`)
303
+ }
304
+ }
305
+ }
306
+
307
+ // main-guard: รันเฉพาะตอน execute ตรง ๆ (ไม่ trigger ตอน import เพื่อ unit-test resolveMode)
308
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
309
+ await main()
310
+ }