okstra 0.4.0 → 0.5.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/docs/kr/cli.md ADDED
@@ -0,0 +1,400 @@
1
+ # okstra — CLI 인자 / 옵션 매뉴얼 (한국어)
2
+
3
+ > 이 문서는 [README.kr.md](../../README.kr.md) 의 보충 문서입니다. 내부 아키텍처와 storage / workflow 계약은 [architecture.md](architecture.md) 를 참고하세요.
4
+
5
+ ---
6
+
7
+ ## Command forms
8
+
9
+ 기본 명령(첫 진입 / full args):
10
+
11
+ ```bash
12
+ scripts/okstra.sh [--render-only] [--yes] [--refresh-assets] --task-type <task-type> [--workers worker1,worker2] [--lead-model <model>] [--claude-model <model>] [--codex-model <model>] [--gemini-model <model>] [--report-writer-model <model>] [--related-tasks taskA,taskB] [--clarification-response <previous-final-report>] --project-id <project-id> --task-group <task-group> --task-id <task-id> --task-brief <brief-path> [--directive <directive>]
13
+ ```
14
+
15
+ 후속 phase 단축 형식(기존 task-manifest.json이 존재할 때):
16
+
17
+ ```bash
18
+ scripts/okstra.sh --task-key <project-id>:<task-group>:<task-id> [--task-type <task-type>]
19
+ ```
20
+
21
+ Clarification 답변 즉시 재실행:
22
+
23
+ ```bash
24
+ scripts/okstra.sh --resume-clarification --task-key <project-id>:<task-group>:<task-id>
25
+ ```
26
+
27
+ interactive terminal에서 실행하면 다음 규칙이 추가로 적용됩니다.
28
+
29
+ - 필수 인자가 비어 있으면 stdin으로 직접 입력을 받습니다.
30
+ - 입력이 모두 준비되면 실행 전 요약을 보여줍니다.
31
+ - `y` 또는 `yes`를 입력했을 때만 실제 생성 또는 실행 단계로 진행합니다.
32
+ - interactive가 아닌 실행에서는 누락 인자를 prompt하지 않고 에러로 종료합니다.
33
+ - `--yes`를 사용하면 prompt와 confirmation을 모두 건너뛰지만, 이때는 모든 필수 인자가 반드시 이미 채워져 있어야 합니다.
34
+ - 우선순위는 `명시적 CLI 인자 > interactive prompt 또는 non-interactive 에러`입니다.
35
+
36
+ ## Required arguments
37
+
38
+ ### `--project-id`
39
+
40
+ 전역에서 고유해야 하는 프로젝트 ID입니다. 첫 실행 시 `<PROJECT_ROOT>/.project-docs/okstra/project.json` 에 자기 등록(self-registration)되며, 동일 PROJECT_ROOT 에서 재실행할 때 인자값과 저장된 `projectId` 가 다르면 즉시 에러로 종료합니다.
41
+
42
+ 예:
43
+
44
+ - `fontradar-v2-api`
45
+ - `jobs`
46
+
47
+ ### `--task-group`
48
+
49
+ 관련 task를 묶는 논리 그룹입니다.
50
+
51
+ 예:
52
+
53
+ - `feature-8858`
54
+ - `bugfix-auth`
55
+ - `tasks`
56
+
57
+ ### `--task-id`
58
+
59
+ 같은 그룹 안에서 안정적으로 재사용할 task 식별자입니다.
60
+
61
+ 예:
62
+
63
+ - `email-recipient-loss`
64
+ - `8852`
65
+
66
+ ### `--task-type`
67
+
68
+ 이번 run의 목적과 profile 선택, run directory 세그먼트, lifecycle phase 라우팅을 결정하는 단일 입력값입니다.
69
+ 표준 값과 phase별 책임은 위 [Task type](#task-type) 섹션을 참고합니다.
70
+
71
+ ### `--task-brief`
72
+
73
+ 분석의 기준이 되는 task brief 문서 경로입니다.
74
+ 상대 경로는 대상 프로젝트 루트를 기준으로 해석됩니다.
75
+
76
+ 예:
77
+
78
+ - `.project-docs/linear/feature/8858/okstra-task-brief.md`
79
+ - `.project-docs/tasks/8852/BUG_REPORT.md`
80
+
81
+ ### `--yes`
82
+
83
+ interactive prompt와 confirmation을 생략합니다.
84
+
85
+ 대신 아래 조건이 강제됩니다.
86
+
87
+ - `project-id`
88
+ - `task-group`
89
+ - `task-id`
90
+ - `task-type`
91
+ - `brief-path`
92
+
93
+ 즉, 필수 인자가 하나라도 비어 있으면 prompt하지 않고 즉시 종료합니다.
94
+
95
+ 예:
96
+
97
+ ```bash
98
+ scripts/okstra.sh --yes --render-only --task-type error-analysis --project-id jobs --task-group tasks --task-id 8852 --task-brief .project-docs/tasks/8852/BUG_REPORT.md
99
+ ```
100
+
101
+ ## Optional arguments and options
102
+
103
+ ### `--task-key`
104
+
105
+ 기존 task에 대한 후속 phase를 시작할 때 `--project-id`/`--task-group`/`--task-id`를 한 번에 전달하는 단축 형식입니다.
106
+
107
+ - 입력 형식: `<project-id>:<task-group>:<task-id>`
108
+ - 동작:
109
+ - 세 ID로 split하여 `PROJECT_ID`/`TASK_GROUP`/`TASK_ID`에 채웁니다.
110
+ - 명시적으로 같은 값들이 함께 들어오면 일치할 때만 허용하고, 충돌하면 즉시 에러로 종료합니다.
111
+ - 해당 task의 `task-manifest.json`이 존재하면 누락된 `--task-brief`를 manifest의 `taskBriefPath` 로, 누락된 `--task-type`을 `workflow.nextRecommendedPhase` 로 자동 채웁니다.
112
+ - manifest가 없으면(첫 진입) 기존 누락-인자 검증이 그대로 동작합니다(=full args 강제).
113
+ - 명시적으로 전달된 인자가 항상 manifest 값보다 우선합니다.
114
+ - `nextRecommendedPhase`가 `pending-routing-decision` 또는 `done-or-follow-up`이면 자동 채움하지 않고, 사용자가 명시적으로 `--task-type`을 주거나 brief을 보강한 뒤 다시 실행해야 합니다.
115
+
116
+ 예:
117
+
118
+ ```bash
119
+ scripts/okstra.sh --task-key jobs:tasks:8852
120
+ ```
121
+
122
+ ```bash
123
+ scripts/okstra.sh --task-key jobs:tasks:8852 --task-type final-verification
124
+ ```
125
+
126
+ ### `--clarification-response`
127
+
128
+ 이전 run의 final report(`Section 5. Clarification Requests for the Next Run`에 사용자가 답변을 inline으로 채운 파일)를 다음 run으로 carry-in합니다.
129
+
130
+ - 입력: `<previous-run>/reports/final-report-<task-type>-<seq>.md` 경로
131
+ - 동작: 파일을 현재 run의 `instruction-set/clarification-response.md`로 복사하고, lead가 Section 0에서 prior `Q*` 행의 `Status`(`resolved` / `obsolete`)를 갱신한 뒤 진행합니다.
132
+ - 사용 시점: `requirements-discovery`, `error-analysis`처럼 미해결 질문을 남기는 phase 이후, 사용자가 답변을 채워 다음 phase로 넘길 때.
133
+
134
+ 예:
135
+
136
+ ```bash
137
+ scripts/okstra.sh \
138
+ --task-type implementation-planning \
139
+ --project-id jobs --task-group tasks --task-id 8852 \
140
+ --task-brief .project-docs/tasks/8852/BUG_REPORT.md \
141
+ --clarification-response .project-docs/okstra/tasks/tasks/8852/runs/2026-04-29/error-analysis/reports/final-report-2026-04-29_10-15-30.md
142
+ ```
143
+
144
+ ### `--resume-clarification`
145
+
146
+ 직전 `requirements-discovery` 또는 `error-analysis` run의 final report를 즉시 편집하고, 같은 phase를 자동으로 재실행합니다. `--clarification-response` 경로를 사용자가 직접 들고 다닐 필요가 없습니다.
147
+
148
+ - 입력: task identity (`--project-id` + `--task-group` + `--task-id`, 또는 `--task-key <p>:<g>:<i>`)
149
+ - 동작 순서:
150
+ 1. task의 `runs/{requirements-discovery,error-analysis}/reports/final-report-*.md` 중 가장 최신 timestamp 파일을 자동 선택합니다.
151
+ 2. `$EDITOR`(미설정 시 `vi`)로 해당 파일을 엽니다. 사용자는 Section 5의 `Q*` 행에 답변을 inline으로 채운 뒤 저장하고 종료합니다.
152
+ 3. okstra가 같은 task-type으로 자기 자신을 `exec` 재호출하면서 편집된 파일을 `--clarification-response`로 carry-in 합니다.
153
+ 4. 이후 confirmation, render, Claude handoff 흐름은 일반 실행과 동일합니다.
154
+ - 명시적 `--task-type`을 함께 주면 그 task type의 reports/ 만 검색합니다. 생략 시 `error-analysis` → `requirements-discovery` 순으로 검색하고 timestamp가 더 큰 보고서를 사용합니다.
155
+ - 다음 옵션과 함께 쓸 수 없습니다: `--render-only`, `--clarification-response`, `--approved-plan`.
156
+ - `EDITOR`를 비대화형 도구(예: `true`)로 설정하면 답변 편집을 건너뛴 채 곧바로 재실행됩니다. 디버깅용으로만 권장합니다.
157
+
158
+ 예:
159
+
160
+ ```bash
161
+ scripts/okstra.sh --resume-clarification --task-key jobs:tasks:8852
162
+ ```
163
+
164
+ ```bash
165
+ EDITOR=code\ -w scripts/okstra.sh --resume-clarification --project-id jobs --task-group tasks --task-id 8852
166
+ ```
167
+
168
+ 내부적으로는 다음과 동등합니다.
169
+
170
+ ```bash
171
+ $EDITOR <project-root>/.project-docs/okstra/tasks/<group>/<id>/runs/<task-type>/reports/final-report-<latest>.md
172
+ scripts/okstra.sh --task-type <same-type> \
173
+ --project-id <p> --task-group <g> --task-id <i> \
174
+ --task-brief <from manifest> \
175
+ --clarification-response <edited final-report-<latest>.md>
176
+ ```
177
+
178
+ ### `--project-root`
179
+
180
+ 대상 프로젝트의 절대 경로입니다. 명시하지 않으면 `okstra.sh` 가 다음 우선순위로 자동 해석합니다.
181
+
182
+ 1. cwd 또는 그 조상 디렉토리 중 `.project-docs/okstra/project.json` 보유 위치를 찾아 PROJECT_ROOT 로 채택합니다.
183
+ 2. 위가 실패하면 `git rev-parse --show-toplevel` 결과를 사용합니다.
184
+ 3. 모두 실패하면 즉시 에러로 종료합니다.
185
+
186
+ 해석된 PROJECT_ROOT 에서 첫 실행이라면 `<PROJECT_ROOT>/.project-docs/okstra/project.json` 이 다음 스키마로 자동 생성됩니다.
187
+
188
+ ```json
189
+ {
190
+ "projectId": "<--project-id 인자 값>",
191
+ "projectRoot": "<resolved absolute path>",
192
+ "createdAt": "<ISO8601 UTC>",
193
+ "updatedAt": "<ISO8601 UTC>"
194
+ }
195
+ ```
196
+
197
+ 이미 존재하면 `projectId` 일치 검사 후 `projectRoot`/`updatedAt` 만 갱신합니다. `projectId` 가 다르면 즉시 에러로 종료해 동일 디렉토리에서 두 개의 ID 가 혼용되는 것을 막습니다.
198
+
199
+ 예:
200
+
201
+ ```bash
202
+ scripts/okstra.sh --task-type error-analysis --project-id jobs --project-root /Volumes/Workspaces/workspace/projects/jobs ...
203
+ ```
204
+
205
+ ### `--directive`
206
+
207
+ 사용자 의도를 자유 텍스트로 lead·worker·downstream skill에 전달하는 채널입니다. 값은 `instruction-set/analysis-material.md` 끝에 `## Directive` 섹션으로 임베딩되고, 동시에 `instruction-set/directive.txt`로 백업됩니다.
208
+
209
+ 용도:
210
+
211
+ - 기본 heuristic을 뒤집고 싶을 때 — 예: "단일 XL task여도 Gantt 그려줘 (Phase 2개, 총 ~10d)", "Gantt 생략 사유를 명시해줘" (참고: `## Cumulative Timeline`은 schedule contract에서 제거되었으므로 더 이상 toggle 대상이 아닙니다)
212
+ - 분석/계획의 강조점 지시 — 예: "rollout 리스크 우선 분석", "test 커버리지 부재를 별도 finding으로"
213
+ - Phase/Step 분할이나 day 추정을 미리 제시 — 예: "Part 1: 4d, measure: 0.5d, Part 2: 4d, ops: 1d"
214
+
215
+ 해석 규칙:
216
+
217
+ - lead·worker는 `analysis-material.md`를 이미 end-to-end로 읽으므로 별도 작업 없이 자동 전파됩니다.
218
+ - skill 측은 자체 contract보다 user prompt를 **우선** 적용해야 하며 (예: `okstra-schedule` 스킬의 "Directive override (highest priority)" 절), heuristic을 뒤집은 경우 결과 문서에 그 사실을 한 줄로 표시합니다.
219
+
220
+ 예:
221
+
222
+ ```bash
223
+ scripts/okstra.sh --task-type implementation-planning ... \
224
+ --directive "Phase 2개(Part 1 ~4d, Part 2 ~5d, 순차)로 총 ~10d. Gantt 꼭 그려줘 (임계 경로 표시)."
225
+ ```
226
+
227
+ ### `--workers`
228
+
229
+ 이번 run에서 사용할 worker 목록을 직접 지정합니다.
230
+ 생략하면 기본값 `claude,codex,gemini,report-writer`를 사용합니다.
231
+ 지정하면 전달한 worker subset만 dedupe 후 기록됩니다.
232
+
233
+ 예:
234
+
235
+ ```bash
236
+ scripts/okstra.sh --task-type implementation-planning --workers claude,codex --project-id jobs --task-group tasks --task-id 8852 --task-brief .project-docs/tasks/8852/BUG_REPORT.md
237
+ ```
238
+
239
+ ### `--claude-model`
240
+
241
+ `Claude worker`에 사용할 모델을 지정합니다.
242
+ 지정하지 않으면 중앙 기본값 `OKSTRA_DEFAULT_CLAUDE_MODEL` 또는 fallback `sonnet`을 사용합니다.
243
+
244
+ ### `--lead-model`
245
+
246
+ `Claude lead`에 사용할 모델을 지정합니다.
247
+ 지정하지 않으면 중앙 기본값 `OKSTRA_DEFAULT_LEAD_MODEL` 또는 fallback `opus`를 사용합니다.
248
+
249
+ ### `--codex-model`
250
+
251
+ `Codex worker`에 사용할 모델을 지정합니다.
252
+ 지정하지 않으면 중앙 기본값 `OKSTRA_DEFAULT_CODEX_MODEL` 또는 fallback `gpt-5.5`를 사용합니다.
253
+
254
+ ### `--gemini-model`
255
+
256
+ `Gemini worker`에 사용할 모델을 지정합니다.
257
+ 지정하지 않으면 중앙 기본값 `OKSTRA_DEFAULT_GEMINI_MODEL` 또는 fallback `auto`를 사용합니다.
258
+
259
+ ### `--report-writer-model`
260
+
261
+ `Report writer worker`에 사용할 모델을 지정합니다.
262
+ 지정하지 않으면 중앙 기본값 `OKSTRA_DEFAULT_REPORT_WRITER_MODEL` 또는 lead 기본값을 사용합니다.
263
+
264
+ 중앙 기본값 환경 변수는 아래와 같습니다.
265
+
266
+ - `OKSTRA_DEFAULT_LEAD_MODEL`
267
+ - `OKSTRA_DEFAULT_CLAUDE_MODEL`
268
+ - `OKSTRA_DEFAULT_CODEX_MODEL`
269
+ - `OKSTRA_DEFAULT_GEMINI_MODEL`
270
+ - `OKSTRA_DEFAULT_REPORT_WRITER_MODEL`
271
+
272
+ fallback 기본값은 아래와 같습니다.
273
+
274
+ - `Claude lead`: `opus`
275
+ - `Report writer worker`: `opus`
276
+ - `Claude worker`: `sonnet`
277
+ - `Codex worker`: `gpt-5.5`
278
+ - `Gemini worker`: `auto`
279
+
280
+ ### `--related-tasks`
281
+
282
+ 간단한 연관 task 식별자 목록을 쉼표로 전달합니다.
283
+
284
+ 예:
285
+
286
+ ```bash
287
+ scripts/okstra.sh --task-type error-analysis --related-tasks scanner-regression,reply-ui --project-id jobs --task-group tasks --task-id 8852 --task-brief .project-docs/tasks/8852/BUG_REPORT.md
288
+ ```
289
+
290
+ 연관 task는 참고용 메타데이터이며 현재 task key를 대체하지 않습니다.
291
+
292
+ ### `--render-only`
293
+
294
+ 실제 Claude 실행 없이 프롬프트와 task bundle만 생성합니다.
295
+
296
+ 이 모드에서는 아래를 수행하지 않습니다.
297
+
298
+ - Claude 실행
299
+ - `final-report-<task-type>-<seq>.md` 생성
300
+ - `final-<task-type>-<seq>.status` 생성
301
+
302
+ 하지만 아래는 수행합니다.
303
+
304
+ - stable task root 생성 또는 갱신
305
+ - `task-manifest.json` 생성 또는 갱신
306
+ - `task-index.md` 생성 또는 갱신
307
+ - `instruction-set/` 생성 또는 갱신
308
+ - `instruction-set/reference-expectations.md` 생성 또는 갱신
309
+ - `instruction-set/final-report-template.md` 생성 또는 갱신
310
+ - current `manifests/run-manifest-<task-type>-<seq>.json` 기록
311
+ - `history/timeline.json` 갱신
312
+ - project-level discovery pointer 생성 또는 갱신
313
+
314
+ ### `--refresh-assets`
315
+
316
+ project-local `.claude/skills/`와 `.claude/agents/` 아래의 okstra mapped asset을 workspace source 기준으로 다시 생성합니다.
317
+ 기본 rerun에서는 기존 project-local asset을 유지합니다.
318
+
319
+
320
+ ## Interactive input flow
321
+
322
+ interactive terminal에서 필수 인자 일부를 생략하면 `okstra`가 아래 항목을 순서대로 물을 수 있습니다.
323
+
324
+ - `Project ID`
325
+ - `Task Group`
326
+ - `Task ID`
327
+ - `Task Type`
328
+ - `Task Brief Path`
329
+
330
+ 예를 들어 아래처럼 실행할 수 있습니다.
331
+
332
+ ```bash
333
+ scripts/okstra.sh --render-only --task-type error-analysis
334
+ ```
335
+
336
+ 그러면 누락된 필수값을 stdin으로 입력받은 뒤, 최종 요약과 confirmation을 거쳐 진행합니다.
337
+
338
+ ## Confirmation flow
339
+
340
+ interactive 실행에서는 실제 파일 생성 전에 아래 성격의 요약을 보여줍니다.
341
+
342
+ - render-only 여부
343
+ - task type
344
+ - project id
345
+ - task group
346
+ - task id
347
+ - task brief path
348
+ - analysis target
349
+ - related tasks
350
+ - selected workers
351
+ - lead model
352
+ - configured worker models
353
+ - task key
354
+ - task root
355
+ - current run dir
356
+
357
+ 이후 `Continue? [y/yes]:` 프롬프트가 표시되며, `y` 또는 `yes`가 아니면 실행하지 않고 종료합니다.
358
+
359
+ `--yes`를 사용하면 이 confirmation flow 자체를 건너뜁니다.
360
+
361
+
362
+ ---
363
+
364
+ ## okstra Control Center — 설치 / 자주 쓰는 명령
365
+
366
+ ## okstra Control Center
367
+
368
+ `okstra-ctl` 은 사용자 홈의 `~/.okstra/` 인덱스를 사용해 모든 타깃 프로젝트의 okstra run 을 가로질러 조회·모니터링·재실행할 수 있는 CLI 다.
369
+
370
+ ### 설치 (전역 wrapper)
371
+
372
+ `okstra` 와 동일하게 `~/.local/bin/` 아래에 wrapper 를 두면 어디서든 호출 가능하다.
373
+
374
+ ```bash
375
+ cat > ~/.local/bin/okstra-ctl <<'WRAPPER'
376
+ #!/usr/bin/env bash
377
+ exec /Volumes/Workspaces/workspace/projects/Okstra/scripts/okstra-ctl.sh "$@"
378
+ WRAPPER
379
+ chmod +x ~/.local/bin/okstra-ctl
380
+ ```
381
+
382
+ (`~/.local/bin` 이 `PATH` 에 포함되어 있어야 한다.)
383
+
384
+ ### 자주 쓰는 명령
385
+
386
+ | 목적 | 명령 |
387
+ |---|---|
388
+ | 작업한 프로젝트 목록 | `okstra-ctl projects` |
389
+ | 최근 run 검색 | `okstra-ctl list --since 7d` |
390
+ | 특정 프로젝트만 | `okstra-ctl list --project fontradar` |
391
+ | 진행 중 run 보기 | `okstra-ctl tail active` |
392
+ | 단일 run 결과 메타 | `okstra-ctl show <runId-or-prefix>` |
393
+ | 결과 보고서 경로 | `okstra-ctl open <runId-or-prefix>` |
394
+ | 단일 재실행 | `okstra-ctl rerun <runId-or-prefix> --yes` |
395
+ | 다중 재실행 | `okstra-ctl rerun --filter --project X --status failed --yes` |
396
+ | 가장 최근 재실행 | `okstra-ctl rerun last --project X --task-group Y --yes` |
397
+ | 백필/재스캔 | `okstra-ctl reindex` |
398
+ | 활성 run 재조정 | `okstra-ctl reconcile [--project <id|all>]` |
399
+ | 배치 진행 | `okstra-ctl batch status <batch-id>` |
400
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -16,7 +16,9 @@
16
16
  "bin/",
17
17
  "src/",
18
18
  "runtime/",
19
- "README.md"
19
+ "docs/",
20
+ "README.md",
21
+ "README.kr.md"
20
22
  ],
21
23
  "engines": {
22
24
  "node": ">=18"
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.4.0",
3
- "builtAt": "2026-05-12T03:09:54.795Z",
2
+ "package": "0.5.0",
3
+ "builtAt": "2026-05-12T04:37:48.519Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
package/src/install.mjs CHANGED
@@ -226,6 +226,13 @@ async function installLinkMode(repoPath, paths, opts) {
226
226
  process.stdout.write(` dev-link stamp: ${repoAbs}\n`);
227
227
  process.stdout.write(` version stamp: ${paths.package}\n`);
228
228
  process.stdout.write("done.\n");
229
+ process.stdout.write(
230
+ "\nNext step: register the current project.\n" +
231
+ " cd <your project> && npx -y okstra@latest setup --project-id <id>\n" +
232
+ " (or run /okstra-setup inside a Claude Code session)\n" +
233
+ "\nTip: to use a bare 'okstra' command instead of npx, run:\n" +
234
+ " npm i -g okstra\n",
235
+ );
229
236
  }
230
237
  return 0;
231
238
  }
@@ -401,6 +408,13 @@ export async function runInstall(args) {
401
408
  if (!opts.quiet) {
402
409
  process.stdout.write(` version stamp: ${paths.package}\n`);
403
410
  process.stdout.write("done.\n");
411
+ process.stdout.write(
412
+ "\nNext step: register the current project.\n" +
413
+ " cd <your project> && npx -y okstra@latest setup --project-id <id>\n" +
414
+ " (or run /okstra-setup inside a Claude Code session)\n" +
415
+ "\nTip: to use a bare 'okstra' command instead of npx, run:\n" +
416
+ " npm i -g okstra\n",
417
+ );
404
418
  }
405
419
  return 0;
406
420
  }
package/src/setup.mjs ADDED
@@ -0,0 +1,243 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { resolvePaths } from "./paths.mjs";
5
+
6
+ const USAGE = `okstra setup — register the current project with okstra
7
+
8
+ Writes <PROJECT_ROOT>/.project-docs/okstra/project.json. This is the
9
+ project-level companion to 'okstra install' (which is machine-level).
10
+ Inside a Claude Code session this is also exposed as the /okstra-setup
11
+ slash command.
12
+
13
+ Usage:
14
+ okstra setup --project-id <id> Register with explicit projectId
15
+ okstra setup Use existing project.json or
16
+ prompt for projectId (TTY only)
17
+ okstra setup --project-root <path> Override PROJECT_ROOT resolution
18
+ okstra setup --yes Skip prompts; require all inputs
19
+ on the command line
20
+
21
+ Behavior:
22
+ - If project.json already exists, the projectId must match (okstra refuses
23
+ to silently rename a project). Delete the file manually if you really
24
+ want to change projectId.
25
+ - If --project-id is omitted and stdin is a TTY, you are prompted.
26
+ - If --project-id is omitted and stdin is not a TTY, the command exits
27
+ with an error (use --project-id for CI / scripts).
28
+
29
+ Exit codes:
30
+ 0 project.json present and valid after the run
31
+ 1 I/O / python failure or projectId mismatch
32
+ 2 PROJECT_ROOT could not be resolved
33
+ `;
34
+
35
+ function runProcess(cmd, args, env) {
36
+ return new Promise((resolve) => {
37
+ const child = spawn(cmd, args, {
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ env: { ...process.env, ...env },
40
+ });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ child.stdout.on("data", (b) => (stdout += b.toString()));
44
+ child.stderr.on("data", (b) => (stderr += b.toString()));
45
+ child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
46
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
47
+ });
48
+ }
49
+
50
+ function parseArgs(args) {
51
+ const opts = {
52
+ projectId: null,
53
+ projectRoot: null,
54
+ yes: false,
55
+ };
56
+ for (let i = 0; i < args.length; i++) {
57
+ const a = args[i];
58
+ if (a === "--yes" || a === "-y") opts.yes = true;
59
+ else if (a === "--project-id") {
60
+ const next = args[i + 1];
61
+ if (!next || next.startsWith("--")) throw new Error("--project-id requires a value");
62
+ opts.projectId = next;
63
+ i++;
64
+ } else if (a === "--project-root") {
65
+ const next = args[i + 1];
66
+ if (!next || next.startsWith("--")) throw new Error("--project-root requires a path");
67
+ opts.projectRoot = next;
68
+ i++;
69
+ } else {
70
+ throw new Error(`unknown argument '${a}'`);
71
+ }
72
+ }
73
+ return opts;
74
+ }
75
+
76
+ async function fileExists(p) {
77
+ try {
78
+ await fs.access(p);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function prompt(question) {
86
+ return new Promise((resolve) => {
87
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
88
+ rl.question(question, (answer) => {
89
+ rl.close();
90
+ resolve(answer.trim());
91
+ });
92
+ });
93
+ }
94
+
95
+ function validateProjectId(id) {
96
+ if (!id) return "project-id is empty";
97
+ if (!/[A-Za-z0-9]/.test(id)) return "project-id must contain at least one alphanumeric character";
98
+ return null;
99
+ }
100
+
101
+ async function resolveProjectRoot(paths, explicit) {
102
+ const probe = await runProcess(
103
+ "python3",
104
+ [
105
+ "-c",
106
+ [
107
+ "import sys",
108
+ "from okstra_project import resolve_project_root, project_json_path, ResolverError",
109
+ "try:",
110
+ " pr = resolve_project_root(explicit_root=sys.argv[1], cwd=sys.argv[2])",
111
+ " print('PROJECT_ROOT', pr)",
112
+ " print('PROJECT_JSON', project_json_path(pr))",
113
+ "except ResolverError as e:",
114
+ " print('RESOLVER_ERROR', e)",
115
+ ].join("\n"),
116
+ explicit || "",
117
+ process.cwd(),
118
+ ],
119
+ { PYTHONPATH: paths.pythonpath },
120
+ );
121
+ if (probe.code !== 0) {
122
+ throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
123
+ }
124
+ const lines = probe.stdout.trim().split("\n");
125
+ const tagOf = (key) =>
126
+ lines
127
+ .find((l) => l.startsWith(key + " "))
128
+ ?.slice(key.length + 1)
129
+ .trim() ?? null;
130
+ const resolverError = tagOf("RESOLVER_ERROR");
131
+ if (resolverError) {
132
+ const err = new Error(resolverError);
133
+ err.code = "RESOLVER";
134
+ throw err;
135
+ }
136
+ return {
137
+ projectRoot: tagOf("PROJECT_ROOT"),
138
+ projectJsonPath: tagOf("PROJECT_JSON"),
139
+ };
140
+ }
141
+
142
+ async function upsert(paths, projectRoot, projectId) {
143
+ const probe = await runProcess(
144
+ "python3",
145
+ [
146
+ "-c",
147
+ [
148
+ "import json, sys",
149
+ "from pathlib import Path",
150
+ "from okstra_project import upsert_project_json, ResolverError",
151
+ "try:",
152
+ " result = upsert_project_json(Path(sys.argv[1]), sys.argv[2])",
153
+ " print('OK', json.dumps(result))",
154
+ "except ResolverError as e:",
155
+ " print('ERROR', e)",
156
+ ].join("\n"),
157
+ projectRoot,
158
+ projectId,
159
+ ],
160
+ { PYTHONPATH: paths.pythonpath },
161
+ );
162
+ if (probe.code !== 0) {
163
+ throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
164
+ }
165
+ const out = probe.stdout.trim();
166
+ if (out.startsWith("OK ")) {
167
+ return JSON.parse(out.slice(3));
168
+ }
169
+ if (out.startsWith("ERROR ")) {
170
+ throw new Error(out.slice(6));
171
+ }
172
+ throw new Error(`unexpected upsert output: ${out}`);
173
+ }
174
+
175
+ export async function run(args) {
176
+ if (args.includes("--help") || args.includes("-h")) {
177
+ process.stdout.write(USAGE);
178
+ return 0;
179
+ }
180
+
181
+ let opts;
182
+ try {
183
+ opts = parseArgs(args);
184
+ } catch (err) {
185
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
186
+ return 1;
187
+ }
188
+
189
+ const paths = await resolvePaths();
190
+
191
+ let resolved;
192
+ try {
193
+ resolved = await resolveProjectRoot(paths, opts.projectRoot);
194
+ } catch (err) {
195
+ process.stderr.write(`error: could not resolve PROJECT_ROOT: ${err.message}\n`);
196
+ return err.code === "RESOLVER" ? 2 : 1;
197
+ }
198
+ const { projectRoot, projectJsonPath } = resolved;
199
+
200
+ let existing = null;
201
+ if (await fileExists(projectJsonPath)) {
202
+ try {
203
+ existing = JSON.parse(await fs.readFile(projectJsonPath, "utf8"));
204
+ } catch (err) {
205
+ process.stderr.write(`error: failed to parse ${projectJsonPath}: ${err.message}\n`);
206
+ return 1;
207
+ }
208
+ }
209
+
210
+ let projectId = opts.projectId;
211
+ if (!projectId && existing?.projectId) {
212
+ projectId = existing.projectId;
213
+ }
214
+
215
+ if (!projectId) {
216
+ if (opts.yes || !process.stdin.isTTY) {
217
+ process.stderr.write(
218
+ `error: --project-id is required (no existing project.json, not a TTY)\n`,
219
+ );
220
+ return 1;
221
+ }
222
+ process.stderr.write(`PROJECT_ROOT: ${projectRoot}\n`);
223
+ const answer = await prompt("project-id (e.g. INV-1234, fontsninja): ");
224
+ projectId = answer;
225
+ }
226
+
227
+ const invalid = validateProjectId(projectId);
228
+ if (invalid) {
229
+ process.stderr.write(`error: ${invalid}\n`);
230
+ return 1;
231
+ }
232
+
233
+ let result;
234
+ try {
235
+ result = await upsert(paths, projectRoot, projectId);
236
+ } catch (err) {
237
+ process.stderr.write(`error: ${err.message}\n`);
238
+ return 1;
239
+ }
240
+
241
+ process.stdout.write(JSON.stringify({ ok: true, ...result, projectJsonPath }, null, 2) + "\n");
242
+ return 0;
243
+ }