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 +36 -0
- package/bin/index.js +1 -1
- package/package.json +2 -1
- package/src/commands/adopt.js +24 -1
- package/src/commands/chat.js +121 -27
- package/src/config/index.js +6 -0
- package/src/context/conventions.js +114 -0
- package/src/generators/apiConventionDoc.js +49 -0
- package/src/generators/createBcConfig.js +4 -0
- package/src/generators/createProject.js +3 -0
- package/src/history/management.js +30 -0
- package/src/history/store.js +33 -5
- package/src/ui/ChatApp.js +186 -7
- package/templates/conventions/api-codegen.md +400 -0
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "byuckchon-frontend-cli",
|
|
3
|
-
"version": "1.
|
|
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": [
|
package/src/commands/adopt.js
CHANGED
|
@@ -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}
|
|
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 키 등록'));
|
package/src/commands/chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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();
|
package/src/config/index.js
CHANGED
|
@@ -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
|
+
}
|