byuckchon-frontend-cli 1.7.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -156,6 +156,36 @@ bc › Auto layout 이 row 였고 padding 12/16 이었어요. MemberCard 만들
156
156
  > Figma 응답은 자동으로 압축됩니다 (자식 60개, 깊이 8 까지). 너무 큰 프레임은 더 작은
157
157
  > 자식 frame URL 을 줘서 분할 정복하세요.
158
158
 
159
+ ### 팀 컨벤션 문서(.md) 자동 주입 (v1.8+)
160
+
161
+ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙 등)을 `.md` 로 적어두면
162
+ **매 chat 세션에 시스템 프롬프트로 자동 주입**됩니다. AI 는 기존 코드 패턴보다 이 문서를 우선합니다.
163
+
164
+ **파일명은 고정이 아닙니다.** 아무 경로나 `bc.config.json` 의 `docs` 에 적으면 됩니다.
165
+ 적지 않으면 관례 파일명(`bc.md`, `.bc/conventions.md`, `AGENTS.md`, `FRONTEND.md`, `docs/frontend.md`)을 자동 탐지합니다.
166
+
167
+ ```json
168
+ {
169
+ "docs": ["docs/fe-conventions.md", "docs/api-guide.md"]
170
+ }
171
+ ```
172
+
173
+ - 문서당 최대 24KB, 전체 48KB 까지 (토큰 폭발 방지). 헤더에 `docs` 줄로 로드된 파일이 표시됩니다.
174
+ - (고급) 항목을 `{ "path": "...", "when": { "framework": "next" } }` 형태로 적으면 프레임워크별 조건부 주입도 가능합니다.
175
+
176
+ #### API 코드 컨벤션 .md 자동 포함
177
+
178
+ `bc init` / `bc adopt` 를 실행하면 **API 코드 생성 가이드(`api-codegen.md`)가 프레임워크에 맞는 위치에 자동으로 깔립니다.**
179
+
180
+ | 프레임워크 | 위치 |
181
+ | --- | --- |
182
+ | React (Vite/CRA 등) | `src/api/api-codegen.md` |
183
+ | Next.js | `lib/api/api-codegen.md` |
184
+
185
+ - 이 파일은 `bc.config.json` 의 `docs` 에 자동 등록되어 **chat 시작 시 주입**됩니다.
186
+ - 하나의 .md 로 React/Next 를 모두 다루며(차이는 위치뿐), 팀 규칙에 맞게 직접 다듬어 쓰면 됩니다.
187
+ - 이미 파일이 있으면 덮어쓰지 않습니다.
188
+
159
189
  ### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
160
190
 
161
191
  `bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
@@ -254,6 +284,8 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
254
284
  | ------------------- | ------------------------------------------ |
255
285
  | `/help` | 도움말 |
256
286
  | `/clear` | 대화 컨텍스트 초기화 |
287
+ | `/history` | 이전 대화 선택 후 해당 컨텍스트 이어가기 |
288
+ | `/retry` | 마지막 사용자 요청 다시 실행 |
257
289
  | `/model [id]` | 세션 모델 변경 (인자 없으면 목록) |
258
290
  | `/cost` | 누적 토큰/비용 |
259
291
  | `/image <path>` | 다음 메시지에 이미지 첨부 (Vision 모델 권장) |
@@ -264,6 +296,10 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
264
296
  | `/rag on\|off` | RAG 컨텍스트 주입 즉석 토글 |
265
297
  | `/exit` | 종료 (`Ctrl+C` 도 가능) |
266
298
 
299
+ Ink 모드에서 `/history`를 실행하면 `↑↓`로 세션을 선택하고 `Enter`로 불러올 수 있습니다.
300
+ 선택한 세션에서 `d`를 누른 뒤 `y`로 확인하면 해당 기록을 삭제합니다. 메시지를 한 번도
301
+ 보내지 않고 종료한 빈 세션은 저장되거나 목록에 표시되지 않습니다.
302
+
267
303
  이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
268
304
  Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
269
305
 
package/bin/index.js CHANGED
@@ -28,7 +28,7 @@ const program = new Command();
28
28
  program
29
29
  .name('bc')
30
30
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
31
- .version('1.7.0');
31
+ .version('1.9.0');
32
32
 
33
33
  program
34
34
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.1",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,6 +34,7 @@
34
34
  "files": [
35
35
  "bin",
36
36
  "src",
37
+ "templates",
37
38
  "README.md"
38
39
  ],
39
40
  "keywords": [
@@ -7,6 +7,10 @@ import inquirer from 'inquirer';
7
7
  import { detectProjectContext, summarizeContext } from '../context/detect.js';
8
8
  import { CONFIG_PATHS } from '../config/index.js';
9
9
  import { modelChoices, DEFAULT_MODEL_ID } from '../ai/models.js';
10
+ import {
11
+ apiRootForFramework,
12
+ scaffoldApiConventionDoc,
13
+ } from '../generators/apiConventionDoc.js';
10
14
 
11
15
  /**
12
16
  * `bc adopt`
@@ -119,6 +123,9 @@ export async function adoptCommand(opts = {}) {
119
123
  exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**', '.next/**'],
120
124
  maxFiles: 20,
121
125
  },
126
+ docs: existing?.docs ?? [
127
+ path.posix.join(apiRootForFramework(ctx.framework), 'api-codegen.md'),
128
+ ],
122
129
  framework: ctx.framework,
123
130
  detected: {
124
131
  language: ctx.language,
@@ -136,7 +143,23 @@ export async function adoptCommand(opts = {}) {
136
143
  await fs.writeFile(targetFile, JSON.stringify(next, null, 2) + '\n', 'utf8');
137
144
 
138
145
  console.log(chalk.green(`\n ✓ ${CONFIG_PATHS.projectFileName} 작성 완료.`));
139
- console.log(chalk.dim(` ${targetFile}\n`));
146
+ console.log(chalk.dim(` ${targetFile}`));
147
+
148
+ // API 코드 컨벤션 .md 를 API 루트(src/api | lib/api)에 깐다 (이미 있으면 유지).
149
+ try {
150
+ const { relPath, written } = await scaffoldApiConventionDoc({
151
+ projectRoot: cwd,
152
+ framework: ctx.framework,
153
+ });
154
+ if (written) {
155
+ console.log(chalk.green(` ✓ API 코드 컨벤션 문서 생성: ${relPath}`));
156
+ } else {
157
+ console.log(chalk.dim(` API 코드 컨벤션 문서 유지: ${relPath} (이미 존재)`));
158
+ }
159
+ } catch {
160
+ /* 문서 스캐폴드 실패는 치명적이지 않음 */
161
+ }
162
+ console.log();
140
163
  console.log(chalk.dim(' 다음:'));
141
164
  if (!process.env.ANTHROPIC_API_KEY) {
142
165
  console.log(chalk.dim(' bc config set-key anthropic # API 키 등록'));
@@ -17,10 +17,12 @@ import { printInlineThumbnail } from '../ai/imagePreview.js';
17
17
  import {
18
18
  createSession,
19
19
  saveSession,
20
+ deleteSession,
20
21
  listSessions,
21
22
  loadSession,
22
23
  loadLatestSession,
23
24
  } from '../history/store.js';
25
+ import { findLastRetryableUser, formatSessionList } from '../history/management.js';
24
26
  import { getCachedOpenApi } from '../openapi/cache.js';
25
27
  import { summarizeOpenApi } from '../openapi/summary.js';
26
28
 
@@ -63,12 +65,37 @@ export async function chatCommand(opts = {}) {
63
65
  process.exit(1);
64
66
  }
65
67
 
66
- const baseSystem = buildSystemPrompt({
68
+ let baseSystem = buildSystemPrompt({
67
69
  effective: cfg.effective,
68
70
  paths: cfg.paths,
69
71
  project: cfg.project,
70
72
  });
71
73
 
74
+ // 컨벤션 문서(.md) 자동 주입 — FE 전반 규칙, 스웨거→코드 변환 규칙 등.
75
+ // bc.config.json 의 docs:[...] 또는 bc.md/AGENTS.md 등 관례 파일을 읽는다.
76
+ let conventionFiles = [];
77
+ if (cfg.paths.projectFile) {
78
+ try {
79
+ const { loadConventionDocs } = await import('../context/conventions.js');
80
+ const projectRoot = path.dirname(cfg.paths.projectFile);
81
+ const conv = await loadConventionDocs({
82
+ projectRoot,
83
+ docs: cfg.effective.docs,
84
+ framework: cfg.project?.framework ?? cfg.project?.detected?.framework,
85
+ });
86
+ if (conv.text) {
87
+ conventionFiles = conv.files;
88
+ baseSystem +=
89
+ '\n\n---\n## 팀 컨벤션 문서 (반드시 우선 준수)\n' +
90
+ '아래는 이 프로젝트/팀의 프론트엔드 컨벤션이다. 코드 생성·수정 시 여기 규칙을 ' +
91
+ '기존 코드 패턴보다 우선 적용한다. 충돌하면 이 문서를 따른다.\n\n' +
92
+ conv.text;
93
+ }
94
+ } catch {
95
+ /* 문서 로딩 실패는 무시 */
96
+ }
97
+ }
98
+
72
99
  // OpenAPI 자동 주입 — bc.config.json 의 api.openapi 가 있으면 fetch 후 요약을
73
100
  // 시스템 프롬프트에 박는다. 1시간 캐시. 실패해도 chat 은 그대로 동작.
74
101
  let openapiInfo = null;
@@ -137,18 +164,16 @@ export async function chatCommand(opts = {}) {
137
164
  // 모델은 사용자가 명시했거나 글로벌 설정으로 갱신 가능 — 세션의 model 은 표시용.
138
165
  session.model = resolved.meta.id;
139
166
  }
140
- await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
141
-
142
167
  // ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
143
168
  // - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
144
169
  // - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
145
170
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
146
171
  const wantPlain = opts.plain || cfg.global?.ui?.mode === 'plain';
147
172
  if (!isTTY || wantPlain) {
148
- return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
173
+ return runReadlineFallback({ cfg, resolved, system, session, openapiInfo, conventionFiles });
149
174
  }
150
175
 
151
- return runInkApp({ cfg, resolved, system, session, openapiInfo });
176
+ return runInkApp({ cfg, resolved, system, session, openapiInfo, conventionFiles });
152
177
  }
153
178
 
154
179
  async function printHistoryList() {
@@ -172,18 +197,45 @@ async function printHistoryList() {
172
197
 
173
198
  /* ───────────────────────── ink 모드 ───────────────────────── */
174
199
 
175
- async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
200
+ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventionFiles = [] }) {
176
201
  // ink/React 는 무겁고 비-TTY 환경에서 import 만으로도 종종 문제 일으키므로
177
202
  // 여기서 늦게 import 한다 (--once / pipe 모드에 영향 없도록).
178
203
  const { render } = await import('ink');
179
204
  const { ChatApp } = await import('../ui/ChatApp.js');
180
205
  const React = (await import('react')).default;
181
206
 
182
- const initialConfig = { ...cfg, system, openapiInfo };
207
+ const initialConfig = { ...cfg, system, openapiInfo, conventionFiles };
208
+
209
+ let activeSession = session;
183
210
 
184
211
  const onSessionUpdate = async (messages) => {
185
- session.messages = messages;
186
- await saveSession(session);
212
+ const target = activeSession;
213
+ const hasConversation = messages.some(
214
+ (message) =>
215
+ (message.role === 'user' || message.role === 'assistant') &&
216
+ typeof message.text === 'string' &&
217
+ message.text.trim(),
218
+ );
219
+ if (!hasConversation && !target._persisted) return;
220
+ target.messages = messages;
221
+ await saveSession(target);
222
+ };
223
+
224
+ const onSessionSwitch = async (id) => {
225
+ const loaded = await loadSession(id);
226
+ loaded.model = resolved.meta.id;
227
+ activeSession = loaded;
228
+ return loaded;
229
+ };
230
+
231
+ const onSessionDelete = async (id) => {
232
+ const deletingActive = activeSession.id === id;
233
+ const result = await deleteSession(id);
234
+ if (!deletingActive) return { ...result, replacementSession: null };
235
+
236
+ const replacementSession = await createSession({ model: resolved.meta.id });
237
+ activeSession = replacementSession;
238
+ return { ...result, replacementSession };
187
239
  };
188
240
 
189
241
  const { waitUntilExit } = render(
@@ -192,6 +244,8 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
192
244
  initialResolved: resolved,
193
245
  session,
194
246
  onSessionUpdate,
247
+ onSessionSwitch,
248
+ onSessionDelete,
195
249
  }),
196
250
  { exitOnCtrlC: false },
197
251
  );
@@ -247,7 +301,7 @@ async function runOnce({ cfg, resolved, system, prompt }) {
247
301
 
248
302
  /* ──────────────────── 비-TTY / --plain 폴백 ──────────────────── */
249
303
 
250
- async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo }) {
304
+ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo, conventionFiles = [] }) {
251
305
  const readline = await import('node:readline');
252
306
  const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
253
307
 
@@ -259,11 +313,16 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
259
313
  chalk.dim(' openapi: ' + openapiInfo.source + (openapiInfo.cached ? ' (cached)' : ' (live)')),
260
314
  );
261
315
  }
316
+ if (conventionFiles.length) {
317
+ console.log(chalk.dim(' 컨벤션 문서: ' + conventionFiles.join(', ')));
318
+ }
262
319
  if (session?.messages?.length) {
263
320
  console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
264
321
  }
265
322
  console.log(
266
- chalk.dim(' /image <경로> · /paste(클립보드 이미지) · /clear-attach · /exit\n'),
323
+ chalk.dim(
324
+ ' /history · /retry · /image <경로> · /paste · /clear-attach · /exit\n',
325
+ ),
267
326
  );
268
327
 
269
328
  // 다음 메시지에 함께 보낼 이미지 첨부 목록.
@@ -313,6 +372,21 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
313
372
  const history = (session?.messages ?? [])
314
373
  .filter((m) => m.role === 'user' || m.role === 'assistant')
315
374
  .map((m) => ({ role: m.role, content: m.text ?? '' }));
375
+ const persistHistory = async () => {
376
+ if (!session) return;
377
+ session.messages = history.map((message) => {
378
+ // 이미지 바이트는 세션 파일에 저장하지 않고 사용자 텍스트만 보존한다.
379
+ if (Array.isArray(message.content)) {
380
+ const textPart = message.content.find((part) => part.type === 'text');
381
+ return {
382
+ role: message.role,
383
+ text: textPart?.text ?? '[이미지 첨부]',
384
+ };
385
+ }
386
+ return { role: message.role, text: message.content };
387
+ });
388
+ await saveSession(session);
389
+ };
316
390
  const ask = () => rl.prompt();
317
391
 
318
392
  rl.on('close', () => {
@@ -331,6 +405,29 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
331
405
  rl.close();
332
406
  return;
333
407
  }
408
+ if (line === '/history') {
409
+ try {
410
+ const sessions = await listSessions();
411
+ console.log(chalk.dim('\n' + formatSessionList(sessions)));
412
+ console.log(chalk.dim('\n 이어가기: bc chat --resume <id>'));
413
+ } catch (err) {
414
+ console.log(chalk.red(' 대화 목록을 불러올 수 없습니다: ' + err.message));
415
+ }
416
+ ask();
417
+ continue;
418
+ }
419
+ let retryContent = null;
420
+ if (line === '/retry') {
421
+ const retry = findLastRetryableUser(history);
422
+ if (!retry) {
423
+ console.log(chalk.red(' 다시 실행할 사용자 요청이 없습니다.'));
424
+ ask();
425
+ continue;
426
+ }
427
+ retryContent = retry.message.content;
428
+ history.splice(retry.index);
429
+ console.log(chalk.dim(' 마지막 요청을 다시 실행합니다.'));
430
+ }
334
431
  // 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
335
432
  if (line.startsWith('/image')) {
336
433
  await addImage(line.slice('/image'.length).trim());
@@ -360,7 +457,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
360
457
  }
361
458
 
362
459
  // 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
363
- if (pendingAttachments.length) {
460
+ if (retryContent != null) {
461
+ history.push({ role: 'user', content: retryContent });
462
+ } else if (pendingAttachments.length) {
364
463
  const parts = [{ type: 'text', text: line }];
365
464
  try {
366
465
  for (const att of pendingAttachments) {
@@ -375,6 +474,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
375
474
  } else {
376
475
  history.push({ role: 'user', content: line });
377
476
  }
477
+ try {
478
+ await persistHistory();
479
+ } catch {
480
+ /* 저장 실패가 AI 요청을 막지는 않게 한다. */
481
+ }
378
482
  rl.pause();
379
483
 
380
484
  const projectRoot = cfg.paths.projectFile
@@ -429,21 +533,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
429
533
  }
430
534
  if (acc) history.push({ role: 'assistant', content: acc });
431
535
 
432
- // 세션 자동 저장 readline 모드에서도 끊김 대비.
433
- if (session) {
434
- session.messages = history.map((h) => {
435
- // 멀티모달(content 가 배열) 인 경우 text 파트만 추출해 저장 (이미지 바이트는 세션에 안 남김).
436
- if (Array.isArray(h.content)) {
437
- const textPart = h.content.find((p) => p.type === 'text');
438
- return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
439
- }
440
- return { role: h.role, text: h.content };
441
- });
442
- try {
443
- await saveSession(session);
444
- } catch {
445
- /* noop */
446
- }
536
+ // 응답까지 포함한 최신 상태로 다시 저장한다.
537
+ try {
538
+ await persistHistory();
539
+ } catch {
540
+ /* noop */
447
541
  }
448
542
 
449
543
  rl.resume();
@@ -65,6 +65,11 @@ const DEFAULT_PROJECT = {
65
65
  exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
66
66
  maxFiles: 20,
67
67
  },
68
+ /**
69
+ * FE 전반 컨벤션을 적은 .md 경로들. 매 chat 세션에 시스템 프롬프트로 주입된다.
70
+ * 비우면 bc.md / .bc/conventions.md / AGENTS.md 등을 자동 탐지.
71
+ */
72
+ docs: [],
68
73
  /** bc adopt 가 채워준다. systemPrompt 가 읽어 모델에 알린다. */
69
74
  framework: null,
70
75
  detected: null,
@@ -181,6 +186,7 @@ export async function loadEffectiveConfig(startDir = process.cwd()) {
181
186
  design: project.design,
182
187
  api: project.api,
183
188
  context: project.context,
189
+ docs: project.docs ?? [],
184
190
  },
185
191
  };
186
192
  }
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * 프로젝트의 "컨벤션 문서(.md)" 를 찾아 읽어서 시스템 프롬프트에 주입할 텍스트로 만든다.
6
+ *
7
+ * 우선순위:
8
+ * 1) bc.config.json 의 `docs: [...]` 에 명시된 항목 (순서대로)
9
+ * - 항목은 문자열(경로) 이거나, 조건부 객체일 수 있다:
10
+ * "docs/common.md"
11
+ * { "path": "docs/api-codegen.md", "when": { "frameworkNot": "next" } }
12
+ * { "path": "docs/api-next.md", "when": { "framework": "next" } }
13
+ * - when 의 framework / frameworkNot 는 문자열 또는 문자열 배열.
14
+ * 2) 명시가 없으면 프로젝트 루트의 관례적 파일명 자동 탐지
15
+ * - bc.md, .bc/conventions.md, AGENTS.md, FRONTEND.md, docs/frontend.md
16
+ *
17
+ * 토큰 폭발 방지: 문서당 최대 bytes, 전체 합계 최대 bytes 로 컷.
18
+ */
19
+ const AUTO_NAMES = [
20
+ 'bc.md',
21
+ '.bc/conventions.md',
22
+ 'AGENTS.md',
23
+ 'FRONTEND.md',
24
+ 'docs/frontend.md',
25
+ 'docs/FRONTEND.md',
26
+ ];
27
+
28
+ const PER_DOC_MAX = 24 * 1024;
29
+ const TOTAL_MAX = 48 * 1024;
30
+
31
+ async function readIfExists(abs) {
32
+ try {
33
+ const stat = await fs.stat(abs);
34
+ if (!stat.isFile()) return null;
35
+ let text = await fs.readFile(abs, 'utf8');
36
+ if (Buffer.byteLength(text, 'utf8') > PER_DOC_MAX) {
37
+ text = text.slice(0, PER_DOC_MAX) + '\n... (이하 생략 — 문서가 너무 깁니다)';
38
+ }
39
+ return text;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function asArray(v) {
46
+ if (v == null) return [];
47
+ return Array.isArray(v) ? v : [v];
48
+ }
49
+
50
+ /** docs 항목의 when 조건이 현재 framework 에 맞는지. */
51
+ function matchesFramework(when, framework) {
52
+ if (!when || typeof when !== 'object') return true;
53
+ const fw = framework || 'unknown';
54
+
55
+ const only = asArray(when.framework);
56
+ if (only.length && !only.includes(fw)) return false;
57
+
58
+ const not = asArray(when.frameworkNot);
59
+ if (not.length && not.includes(fw)) return false;
60
+
61
+ return true;
62
+ }
63
+
64
+ /** docs 항목(문자열 | 객체)을 { path, when } 로 정규화. */
65
+ function normalizeEntry(entry) {
66
+ if (typeof entry === 'string') return { path: entry, when: null };
67
+ if (entry && typeof entry === 'object' && typeof entry.path === 'string') {
68
+ return { path: entry.path, when: entry.when ?? null };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * @param {object} args
75
+ * @param {string} args.projectRoot bc.config.json 이 있는 디렉터리
76
+ * @param {Array<string|object>} [args.docs] config 의 docs (문자열 또는 조건부 객체)
77
+ * @param {string} [args.framework] detect.js 의 framework (조건부 docs 판별용)
78
+ * @returns {Promise<{ text: string|null, files: string[] }>}
79
+ */
80
+ export async function loadConventionDocs({ projectRoot, docs, framework }) {
81
+ if (!projectRoot) return { text: null, files: [] };
82
+
83
+ const explicit = Array.isArray(docs) && docs.length > 0;
84
+ const rawEntries = explicit ? docs : AUTO_NAMES;
85
+
86
+ const entries = rawEntries
87
+ .map(normalizeEntry)
88
+ .filter(Boolean)
89
+ .filter((e) => matchesFramework(e.when, framework));
90
+
91
+ const collected = [];
92
+ const files = [];
93
+ let total = 0;
94
+
95
+ for (const { path: rel } of entries) {
96
+ const abs = path.resolve(projectRoot, rel);
97
+ // 루트 밖 경로는 무시 (안전)
98
+ const within = !path.relative(projectRoot, abs).startsWith('..');
99
+ if (!within) continue;
100
+
101
+ const content = await readIfExists(abs);
102
+ if (!content) continue;
103
+
104
+ const bytes = Buffer.byteLength(content, 'utf8');
105
+ if (total + bytes > TOTAL_MAX) break;
106
+ total += bytes;
107
+
108
+ files.push(rel);
109
+ collected.push(`### 문서: ${rel}\n${content}`);
110
+ }
111
+
112
+ if (collected.length === 0) return { text: null, files: [] };
113
+ return { text: collected.join('\n\n'), files };
114
+ }
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const TEMPLATE_PATH = path.resolve(
6
+ path.dirname(fileURLToPath(import.meta.url)),
7
+ '../../templates/conventions/api-codegen.md',
8
+ );
9
+
10
+ /**
11
+ * 프레임워크에 맞는 API 루트 폴더.
12
+ * - Next.js → lib/api
13
+ * - 그 외 React 계열 → src/api
14
+ */
15
+ export function apiRootForFramework(framework) {
16
+ return framework === 'next' ? 'lib/api' : 'src/api';
17
+ }
18
+
19
+ /**
20
+ * API 코드 컨벤션 .md 를 프로젝트의 API 루트(`src/api` 또는 `lib/api`)에 깐다.
21
+ *
22
+ * @param {object} args
23
+ * @param {string} args.projectRoot
24
+ * @param {string} args.framework 'react' | 'next' | detect.js 의 framework 값
25
+ * @param {boolean} [args.force] 이미 있으면 덮어쓸지
26
+ * @returns {Promise<{ relPath: string, written: boolean }>}
27
+ */
28
+ export async function scaffoldApiConventionDoc({ projectRoot, framework, force = false }) {
29
+ const apiRoot = apiRootForFramework(framework);
30
+ const relPath = path.join(apiRoot, 'api-codegen.md');
31
+ const absPath = path.join(projectRoot, relPath);
32
+
33
+ let exists = false;
34
+ try {
35
+ await fs.access(absPath);
36
+ exists = true;
37
+ } catch {
38
+ /* not there */
39
+ }
40
+
41
+ if (exists && !force) {
42
+ return { relPath, written: false };
43
+ }
44
+
45
+ const template = await fs.readFile(TEMPLATE_PATH, 'utf8');
46
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
47
+ await fs.writeFile(absPath, template, 'utf8');
48
+ return { relPath, written: true };
49
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import { apiRootForFramework } from './apiConventionDoc.js';
5
+
4
6
  /**
5
7
  * 새 프로젝트 루트에 `bc.config.json` 을 만든다.
6
8
  *
@@ -28,6 +30,8 @@ export async function createBcConfig(rootDir, config) {
28
30
  exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
29
31
  maxFiles: 20,
30
32
  },
33
+ // chat 시작 시 자동 주입되는 팀 컨벤션 문서. API 코드 가이드가 기본 포함.
34
+ docs: [path.posix.join(apiRootForFramework(config.framework), 'api-codegen.md')],
31
35
  framework: config.framework,
32
36
  // init 단계에선 사용자가 React/Next 중 골랐고 TS/Tailwind 가 항상 들어가니
33
37
  // 감지 결과를 미리 채워둔다 (bc adopt 의 detected 와 같은 모양).
@@ -8,6 +8,7 @@ import { createBcConfig } from './createBcConfig.js';
8
8
  import { createFolders } from './createFolders.js';
9
9
  import { createPackageJson } from './createPackageJson.js';
10
10
  import { createReadme } from './createReadme.js';
11
+ import { scaffoldApiConventionDoc } from './apiConventionDoc.js';
11
12
 
12
13
  const exec = promisify(execCallback);
13
14
  const BYUCKCHON_PACKAGES = [
@@ -25,6 +26,8 @@ export async function createProject(config) {
25
26
  await createPackageJson(rootDir, config);
26
27
  await createBaseFiles(rootDir, config);
27
28
  await createReadme(rootDir, config);
29
+ // API 코드 컨벤션 .md 를 프레임워크에 맞는 API 루트(src/api | lib/api)에 깐다.
30
+ await scaffoldApiConventionDoc({ projectRoot: rootDir, framework: config.framework });
28
31
  await createBcConfig(rootDir, config);
29
32
 
30
33
  // 최신 버전(latest 포함) 의존성을 실제로 설치해 lockfile까지 생성
@@ -0,0 +1,30 @@
1
+ function messageText(message) {
2
+ if (typeof message?.text === 'string') return message.text;
3
+ if (typeof message?.content === 'string') return message.content;
4
+ if (Array.isArray(message?.content)) {
5
+ return message.content.find((part) => part.type === 'text')?.text ?? '[이미지 첨부]';
6
+ }
7
+ return '';
8
+ }
9
+
10
+ export function findLastRetryableUser(messages) {
11
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
12
+ const message = messages[index];
13
+ if (message.role === 'user' && messageText(message).trim()) {
14
+ return { index, message, text: messageText(message) };
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export function formatSessionList(sessions, { limit = 10 } = {}) {
21
+ if (!sessions.length) return '저장된 이전 세션이 없습니다.';
22
+ return sessions
23
+ .slice(0, limit)
24
+ .map((session) => {
25
+ const when = session.updatedAt?.replace('T', ' ').slice(0, 19) ?? '';
26
+ const preview = session.preview || '(빈 세션)';
27
+ return `${session.id} ${when} ${session.turns} turns\n ${preview}`;
28
+ })
29
+ .join('\n');
30
+ }