byuckchon-frontend-cli 1.4.0 → 1.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/README.md CHANGED
@@ -10,11 +10,47 @@ React(Vite) / Next.js(App Router) TypeScript 프로젝트를 만들고, `bc chat
10
10
 
11
11
  ## 설치
12
12
 
13
+ `bc` 는 **CLI 툴** 이라서 프로젝트 의존성으로 설치하면 안 되고, **글로벌**(또는 `npx`)로 써야 합니다.
14
+
13
15
  ```bash
16
+ # 글로벌 설치 (제일 흔한 방식)
14
17
  npm install -g byuckchon-frontend-cli
15
- # 짧은 alias 도 동시에 설치됨: `bc`
18
+ pnpm add -g byuckchon-frontend-cli
19
+ yarn global add byuckchon-frontend-cli
20
+
21
+ # 또는 설치 없이 일회성
22
+ npx byuckchon-frontend-cli adopt
23
+ pnpm dlx byuckchon-frontend-cli adopt
24
+ ```
25
+
26
+ 설치되면 `bc` / `byuckchon-frontend-cli` 두 개 다 PATH 에 깔립니다.
27
+
28
+ ### 모노레포에서 쓰기
29
+
30
+ `bc` 자체는 **루트에 한 번만** 글로벌 설치하면 충분합니다. 다만 `bc.config.json` 은
31
+ **각 패키지(앱) 디렉터리마다 따로** 두는 걸 권장 — Tailwind 버전, 라우팅, 스타일링이
32
+ 앱마다 다르면 `detected.*` 가 달라야 RAG 컨텍스트도 정확해집니다.
33
+
34
+ ```bash
35
+ # 루트에서 한 번만
36
+ npm i -g byuckchon-frontend-cli
37
+
38
+ # 각 앱마다 따로 셋업
39
+ cd apps/web && bc adopt # apps/web/bc.config.json 생성
40
+ cd apps/mobile && bc adopt # apps/mobile/bc.config.json 생성
41
+
42
+ # 일할 땐 그 앱 디렉토리에서 실행
43
+ cd apps/web && bc # apps/web/bc.config.json 을 자동으로 읽어감
16
44
  ```
17
45
 
46
+ `bc` 는 실행 디렉토리에서 위로 거슬러 올라가며 가장 가까운 `bc.config.json` 을 찾습니다.
47
+ 즉 모노레포 루트에 `bc.config.json` 이 없고 `apps/web/bc.config.json` 만 있으면,
48
+ `apps/web/somewhere/deeper/...` 에서 `bc` 를 쳐도 `apps/web/bc.config.json` 이 잡힙니다.
49
+
50
+ > 만약 `pnpm add byuckchon-frontend-cli` (글로벌 플래그 없이) 한 상태에서 `bc` 가
51
+ > 안 먹는다면, 이건 패키지 의존성으로 박혀서 그래요. `pnpm remove byuckchon-frontend-cli`
52
+ > 후 위처럼 `pnpm add -g` 로 다시 설치해주세요.
53
+
18
54
  ## 명령
19
55
 
20
56
  ### `bc init` — 새 프로젝트 만들기
@@ -26,6 +62,35 @@ bc init
26
62
  프로젝트 이름, 프레임워크, **기본 AI 모델, Figma URL, OpenAPI URL** 을 묻고
27
63
  새 폴더에 코드 + `bc.config.json` 까지 만들어 줍니다.
28
64
 
65
+ ### 에이전트 모드 — AI 가 실제 파일을 만들고 고친다 (v1.5+)
66
+
67
+ `bc chat` 은 더 이상 채팅창에 코드 블록을 출력만 하지 않습니다. **모델이 직접 툴을 호출해서
68
+ 파일을 만들고/고칩니다** (Codex CLI / Cursor agent 와 같은 컨셉).
69
+
70
+ 내장된 툴:
71
+
72
+ | 툴 | 동작 |
73
+ | ------------- | ------------------------------------------------------ |
74
+ | `read_file` | 프로젝트 내 파일/디렉터리 내용 읽기 |
75
+ | `list_files` | 글롭 패턴으로 파일 나열 |
76
+ | `search_code` | RAG 인덱스 의미 기반 검색 (인덱스 있어야 함) |
77
+ | `write_file` | 새 파일 생성 또는 통째 덮어쓰기 |
78
+ | `edit_file` | 유일한 `old_string → new_string` 으로 부분 수정 (안전) |
79
+
80
+ 모델은 한 턴 안에서 **최대 12 step** 까지 툴을 자유롭게 호출합니다. 일반적인 흐름:
81
+ 1. `list_files` 로 `src/api/` 구조 파악
82
+ 2. `read_file` 로 기존 모듈 2~3개 읽고 컨벤션 학습
83
+ 3. `search_code` 로 fetch 래퍼 / hook 패턴 검색
84
+ 4. `write_file` 로 `api/`, `service/`, `hook/`, `schema/`, `types/` 파일들을 한꺼번에 생성
85
+ 5. 마지막에 만든 파일 목록과 import 가이드를 짧게 요약
86
+
87
+ 모든 파일 경로는 `bc.config.json` 이 있는 디렉터리(=프로젝트 루트) 하위로만 강제됩니다.
88
+ `../` 이나 절대경로 탈출은 에러로 거부.
89
+
90
+ > **승인 게이트 (Phase 4 예정):** 지금은 모델이 write/edit 을 호출하면 즉시 디스크에 반영됩니다.
91
+ > 안전망은 git diff. 매 작업 후 `git status` / `git diff` 로 확인하고, 마음에 안 들면 `git checkout .` 으로 되돌리세요.
92
+ > 다음 버전에서 per-file 승인(`y/n/v`) 옵션 추가 예정.
93
+
29
94
  ### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
30
95
 
31
96
  `bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
@@ -196,6 +261,9 @@ bc config set-gateway # 게이트웨이 해제 (BYOK 모
196
261
  - [x] Phase 3a: 코드베이스 RAG (`bc index`, `/rag` 토글, 자동 컨텍스트 주입)
197
262
  - [x] Phase 3b: `bc gen api-types` (OpenAPI → TS 타입), `/paste` 클립보드 이미지, 한글 IME 수정
198
263
  - [x] Phase 3c-1: chat 시작 시 인덱스 자동 빌드, OpenAPI 자동 fetch+캐시+시스템 프롬프트 주입
264
+ - [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
265
+ - [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
266
+ - [ ] v1.6.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
199
267
  - [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
200
268
  - [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
201
269
 
package/bin/index.js CHANGED
@@ -27,7 +27,7 @@ const program = new Command();
27
27
  program
28
28
  .name('bc')
29
29
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
30
- .version('1.4.0');
30
+ .version('1.5.0');
31
31
 
32
32
  program
33
33
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
@@ -10,9 +10,32 @@ import { CONFIG_PATHS } from '../config/index.js';
10
10
  */
11
11
  export function buildSystemPrompt({ effective, paths, project }) {
12
12
  const lines = [
13
- '너는 Byuckchon 프론트엔드 팀의 페어 프로그래밍 AI 다.',
14
- '한국어로 친근하고 간결하게 답한다. 코드 답변은 마크다운 코드블록(언어 태그 포함)으로 준다.',
15
- '추측 대신 모르면 모른다고 말한다. 파일 경로를 언급할 때는 백틱으로 감싼다.',
13
+ '너는 Byuckchon 프론트엔드 팀의 페어 프로그래밍 AI 이자 **에이전트** 다.',
14
+ '한국어로 친근하고 간결하게 답한다. 파일 경로는 백틱으로 감싼다.',
15
+ '추측 대신 모르면 모른다고 말한다.',
16
+ '',
17
+ '## 작업 방식 (중요)',
18
+ '너는 채팅에 코드를 출력하는 게 아니라, **툴을 호출해서 실제로 파일을 만들고 고친다.**',
19
+ '코드를 작성/수정해달라는 요청을 받으면 다음 순서를 지킨다:',
20
+ ' 1) `list_files` / `read_file` / `search_code` 로 **기존 컨벤션을 먼저 학습**한다.',
21
+ ' - 비슷한 도메인의 폴더 구조, 파일 이름, import 순서, barrel(`index.ts`) 패턴, ',
22
+ ' 에러 처리 방식, 상태관리/쿼리 패턴 등을 그대로 따라간다.',
23
+ ' - "기존 api 폴더 참고해서" 같은 요청을 받으면 그 폴더를 list_files 로 훑고',
24
+ ' 대표 파일 2~3개를 read_file 로 반드시 읽는다.',
25
+ ' 2) 필요한 파일을 `write_file` (신규/덮어쓰기) 또는 `edit_file` (부분 수정) 로 **직접 만든다**.',
26
+ ' - 한 번의 요청에 여러 파일(예: api / service / hook / type / zod schema) 이 필요하면',
27
+ ' 모두 차례로 생성한다. 사용자가 명시하지 않아도 같이 만들 때가 적절하면 만든다.',
28
+ ' - 자동 생성된 `*.gen.ts` 가 있다면 거기서 타입을 import 해서 재정의를 피한다.',
29
+ ' 3) 마지막으로 **만든 파일 목록과 다음 액션(어디서 import 하면 되는지 등)** 을 한국어로 짧게 요약.',
30
+ '',
31
+ '"코드 짜줘" 라는 표현은 채팅창에 코드 블록을 출력하라는 의미가 **아니다**.',
32
+ '항상 툴을 사용해 실제 파일을 만들어라. 채팅에는 진행 상황과 결과 요약만 짧게 적는다.',
33
+ '',
34
+ '## 안전 규칙',
35
+ '- 절대 프로젝트 루트 밖을 읽거나 쓰지 않는다.',
36
+ '- 기존 파일을 덮어쓸 때는 먼저 `read_file` 로 현재 내용을 보고, 의도된 덮어쓰기인지 확인.',
37
+ '- 큰 변경은 `edit_file` 여러 번이 안전. 통째 덮어쓰기는 새 파일이거나 작은 파일에만.',
38
+ '- 코드 컨벤션이 모호하면 사용자에게 한 번 물어볼 것 (툴 호출 멈추고 메시지로).',
16
39
  ];
17
40
 
18
41
  const stack = describeStack(project);
@@ -0,0 +1,236 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import fg from 'fast-glob';
5
+ import { tool } from 'ai';
6
+
7
+ import { searchIndex } from '../indexer/search.js';
8
+
9
+ /**
10
+ * Agentic chat 용 툴 정의.
11
+ *
12
+ * 설계 원칙:
13
+ * - **모든 파일 경로는 projectRoot 하위로 강제** (탈출 시도는 에러).
14
+ * - read/list/search 는 always-allow (안전).
15
+ * - write/edit 는 `safeWrite` 가 디스크에 쓰고 onWrite 콜백으로 UI 에 알린다.
16
+ * 승인 게이트를 추후 끼우려면 onWrite 안에서 await 로 막으면 된다.
17
+ * - 모든 결과는 plain JSON 으로 돌려준다 (모델이 다시 추론하기 좋게).
18
+ *
19
+ * 사용:
20
+ * const tools = buildTools({ projectRoot, effective, onEvent });
21
+ * streamText({ tools, stopWhen: stepCountIs(12), ... });
22
+ */
23
+ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
24
+ const root = path.resolve(projectRoot);
25
+
26
+ function safePath(p) {
27
+ if (!p || typeof p !== 'string') {
28
+ throw new Error('path 가 비어있습니다');
29
+ }
30
+ const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(root, p);
31
+ const rel = path.relative(root, abs);
32
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
33
+ throw new Error(`프로젝트 루트(${root}) 밖의 경로는 접근할 수 없습니다: ${p}`);
34
+ }
35
+ return { abs, rel: rel || '.' };
36
+ }
37
+
38
+ async function readFile({ path: p }) {
39
+ const { abs, rel } = safePath(p);
40
+ let stat;
41
+ try {
42
+ stat = await fs.stat(abs);
43
+ } catch {
44
+ return { ok: false, error: `파일이 존재하지 않습니다: ${rel}` };
45
+ }
46
+ if (stat.isDirectory()) {
47
+ const entries = await fs.readdir(abs, { withFileTypes: true });
48
+ return {
49
+ ok: true,
50
+ kind: 'directory',
51
+ path: rel,
52
+ entries: entries.map((e) => (e.isDirectory() ? e.name + '/' : e.name)),
53
+ };
54
+ }
55
+ if (stat.size > 256 * 1024) {
56
+ return { ok: false, error: `파일이 너무 큽니다 (${stat.size}B). 256KB 이하만 지원.` };
57
+ }
58
+ const content = await fs.readFile(abs, 'utf8');
59
+ return { ok: true, kind: 'file', path: rel, lines: content.split('\n').length, content };
60
+ }
61
+
62
+ async function listFiles({ pattern = '**/*', limit = 100 }) {
63
+ const matches = await fg(pattern, {
64
+ cwd: root,
65
+ ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', '.bc/**', '.git/**'],
66
+ onlyFiles: false,
67
+ dot: false,
68
+ followSymbolicLinks: false,
69
+ });
70
+ const truncated = matches.length > limit;
71
+ return {
72
+ ok: true,
73
+ total: matches.length,
74
+ truncated,
75
+ matches: matches.slice(0, limit),
76
+ };
77
+ }
78
+
79
+ async function searchCode({ query, k = 8 }) {
80
+ try {
81
+ const res = await searchIndex(query, effective, { topK: Math.min(20, Math.max(1, k)), minScore: 0.15 });
82
+ if (!res.ok) {
83
+ return { ok: false, error: res.reason ?? 'index 없음 — /index 로 빌드하라고 안내할 것' };
84
+ }
85
+ return {
86
+ ok: true,
87
+ hits: res.results.map((r) => ({
88
+ file: r.chunk.file,
89
+ range: `${r.chunk.startLine}-${r.chunk.endLine}`,
90
+ score: Number(r.score.toFixed(3)),
91
+ snippet: r.chunk.text.slice(0, 1200),
92
+ })),
93
+ };
94
+ } catch (err) {
95
+ return { ok: false, error: err?.message ?? String(err) };
96
+ }
97
+ }
98
+
99
+ async function writeFile({ path: p, content }) {
100
+ const { abs, rel } = safePath(p);
101
+ let existed = false;
102
+ let prevContent = '';
103
+ try {
104
+ prevContent = await fs.readFile(abs, 'utf8');
105
+ existed = true;
106
+ } catch {
107
+ /* 새 파일 */
108
+ }
109
+ if (existed && prevContent === content) {
110
+ onEvent({ kind: 'write_skipped', path: rel, reason: '동일' });
111
+ return { ok: true, path: rel, action: 'noop', reason: '내용 동일' };
112
+ }
113
+ await fs.mkdir(path.dirname(abs), { recursive: true });
114
+ await fs.writeFile(abs, content, 'utf8');
115
+ onEvent({
116
+ kind: existed ? 'write_overwritten' : 'write_created',
117
+ path: rel,
118
+ lines: content.split('\n').length,
119
+ bytes: Buffer.byteLength(content, 'utf8'),
120
+ });
121
+ return {
122
+ ok: true,
123
+ path: rel,
124
+ action: existed ? 'overwritten' : 'created',
125
+ lines: content.split('\n').length,
126
+ };
127
+ }
128
+
129
+ async function editFile({ path: p, old_string, new_string }) {
130
+ const { abs, rel } = safePath(p);
131
+ let content;
132
+ try {
133
+ content = await fs.readFile(abs, 'utf8');
134
+ } catch {
135
+ return { ok: false, error: `편집할 파일이 없습니다: ${rel}` };
136
+ }
137
+ if (typeof old_string !== 'string' || old_string.length === 0) {
138
+ return { ok: false, error: 'old_string 이 비어있습니다' };
139
+ }
140
+ // 정확 일치 횟수 계산
141
+ let count = 0;
142
+ let idx = 0;
143
+ while ((idx = content.indexOf(old_string, idx)) !== -1) {
144
+ count++;
145
+ idx += old_string.length;
146
+ }
147
+ if (count === 0) {
148
+ return { ok: false, error: `old_string 을 ${rel} 에서 찾지 못했습니다. 주변 라인을 더 포함해서 다시 시도.` };
149
+ }
150
+ if (count > 1) {
151
+ return {
152
+ ok: false,
153
+ error: `old_string 이 ${rel} 에 ${count}번 등장합니다. 더 많은 컨텍스트로 유일해지게 만들어 주세요.`,
154
+ };
155
+ }
156
+ const updated = content.replace(old_string, new_string);
157
+ await fs.writeFile(abs, updated, 'utf8');
158
+ onEvent({
159
+ kind: 'edit',
160
+ path: rel,
161
+ removed: old_string.split('\n').length,
162
+ added: new_string.split('\n').length,
163
+ });
164
+ return { ok: true, path: rel, action: 'edited' };
165
+ }
166
+
167
+ return {
168
+ read_file: tool({
169
+ description:
170
+ '프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
174
+ required: ['path'],
175
+ additionalProperties: false,
176
+ },
177
+ execute: readFile,
178
+ }),
179
+ list_files: tool({
180
+ description:
181
+ '글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ pattern: { type: 'string', default: '**/*' },
186
+ limit: { type: 'number', default: 100 },
187
+ },
188
+ additionalProperties: false,
189
+ },
190
+ execute: listFiles,
191
+ }),
192
+ search_code: tool({
193
+ description:
194
+ '코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ query: { type: 'string' },
199
+ k: { type: 'number', default: 8 },
200
+ },
201
+ required: ['query'],
202
+ additionalProperties: false,
203
+ },
204
+ execute: searchCode,
205
+ }),
206
+ write_file: tool({
207
+ description:
208
+ '새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
209
+ inputSchema: {
210
+ type: 'object',
211
+ properties: {
212
+ path: { type: 'string' },
213
+ content: { type: 'string' },
214
+ },
215
+ required: ['path', 'content'],
216
+ additionalProperties: false,
217
+ },
218
+ execute: writeFile,
219
+ }),
220
+ edit_file: tool({
221
+ description:
222
+ '기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
223
+ inputSchema: {
224
+ type: 'object',
225
+ properties: {
226
+ path: { type: 'string' },
227
+ old_string: { type: 'string' },
228
+ new_string: { type: 'string' },
229
+ },
230
+ required: ['path', 'old_string', 'new_string'],
231
+ additionalProperties: false,
232
+ },
233
+ execute: editFile,
234
+ }),
235
+ };
236
+ }
@@ -1,13 +1,16 @@
1
1
  import process from 'node:process';
2
2
 
3
3
  import chalk from 'chalk';
4
- import { streamText } from 'ai';
4
+ import { streamText, stepCountIs } from 'ai';
5
+
6
+ import path from 'node:path';
5
7
 
6
8
  import { loadEffectiveConfig } from '../config/index.js';
7
9
  import { resolveModel } from '../ai/provider.js';
8
10
  import { TokenMeter } from '../ai/tokenMeter.js';
9
11
  import { buildSystemPrompt } from '../ai/systemPrompt.js';
10
12
  import { findModel } from '../ai/models.js';
13
+ import { buildTools } from '../ai/tools.js';
11
14
  import {
12
15
  createSession,
13
16
  saveSession,
@@ -189,15 +192,39 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
189
192
 
190
193
  async function runOnce({ cfg, resolved, system, prompt }) {
191
194
  const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
195
+ const projectRoot = cfg.paths.projectFile
196
+ ? path.dirname(cfg.paths.projectFile)
197
+ : process.cwd();
198
+ const tools = buildTools({
199
+ projectRoot,
200
+ effective: cfg.effective,
201
+ onEvent: (ev) => {
202
+ const label =
203
+ ev.kind === 'write_created'
204
+ ? '🆕'
205
+ : ev.kind === 'write_overwritten'
206
+ ? '✏️ '
207
+ : ev.kind === 'edit'
208
+ ? '✏️ '
209
+ : '·';
210
+ console.log(chalk.dim(` ${label} ${ev.path}`));
211
+ },
212
+ });
192
213
  const result = streamText({
193
214
  model: resolved.model,
194
215
  system,
195
216
  messages: [{ role: 'user', content: prompt }],
217
+ tools,
218
+ stopWhen: stepCountIs(12),
196
219
  onError: ({ error }) => {
197
220
  console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
198
221
  },
199
222
  });
200
- for await (const delta of result.textStream) process.stdout.write(delta);
223
+ for await (const part of result.fullStream) {
224
+ if (part.type === 'text-delta') process.stdout.write(part.text);
225
+ else if (part.type === 'tool-call')
226
+ process.stdout.write(chalk.dim(`\n 🔧 ${part.toolName}\n`));
227
+ }
201
228
  process.stdout.write('\n');
202
229
  try {
203
230
  meter.add(await result.usage);
@@ -257,10 +284,30 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
257
284
  history.push({ role: 'user', content: line });
258
285
  rl.pause();
259
286
 
287
+ const projectRoot = cfg.paths.projectFile
288
+ ? path.dirname(cfg.paths.projectFile)
289
+ : process.cwd();
290
+ const tools = buildTools({
291
+ projectRoot,
292
+ effective: cfg.effective,
293
+ onEvent: (ev) => {
294
+ const label =
295
+ ev.kind === 'write_created'
296
+ ? '🆕 생성'
297
+ : ev.kind === 'write_overwritten'
298
+ ? '✏️ 덮어씀'
299
+ : ev.kind === 'edit'
300
+ ? '✏️ 편집'
301
+ : '·';
302
+ console.log(chalk.dim(`\n ${label} ${ev.path}`));
303
+ },
304
+ });
260
305
  const result = streamText({
261
306
  model: resolved.model,
262
307
  system,
263
308
  messages: history,
309
+ tools,
310
+ stopWhen: stepCountIs(12),
264
311
  onError: ({ error }) => {
265
312
  console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
266
313
  },
@@ -268,9 +315,13 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
268
315
  process.stdout.write(chalk.bold.green('\n bc › '));
269
316
  let acc = '';
270
317
  try {
271
- for await (const delta of result.textStream) {
272
- acc += delta;
273
- process.stdout.write(delta);
318
+ for await (const part of result.fullStream) {
319
+ if (part.type === 'text-delta') {
320
+ acc += part.text;
321
+ process.stdout.write(part.text);
322
+ } else if (part.type === 'tool-call') {
323
+ process.stdout.write(chalk.dim(`\n 🔧 ${part.toolName}`));
324
+ }
274
325
  }
275
326
  } catch (err) {
276
327
  console.error('\n' + chalk.red(' 스트리밍 중단: ') + (err?.message ?? err));
@@ -78,6 +78,9 @@ async function writeJson(file, data) {
78
78
 
79
79
  function deepMerge(base, patch) {
80
80
  if (patch == null) return base;
81
+ // 한쪽이 null/원시값이거나, 한쪽만 배열이면 patch 가 우선.
82
+ // 특히 base 가 null 인데 patch 가 객체일 때 `k in base` 가 TypeError 던지는 걸 방지.
83
+ if (base == null) return patch;
81
84
  if (typeof base !== 'object' || typeof patch !== 'object') return patch;
82
85
  if (Array.isArray(base) || Array.isArray(patch)) return patch;
83
86
  const out = { ...base };
package/src/ui/ChatApp.js CHANGED
@@ -5,17 +5,45 @@ import React, { useEffect, useState, useRef, useCallback } from 'react';
5
5
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
6
6
  import TextInput from 'ink-text-input';
7
7
  import Spinner from 'ink-spinner';
8
- import { streamText } from 'ai';
8
+ import { streamText, stepCountIs } from 'ai';
9
9
 
10
10
  import { resolveModel } from '../ai/provider.js';
11
11
  import { TokenMeter } from '../ai/tokenMeter.js';
12
12
  import { findModel, MODEL_CATALOG } from '../ai/models.js';
13
13
  import { toSdkMessages, isImagePath } from '../ai/messageContent.js';
14
+ import { buildTools } from '../ai/tools.js';
14
15
  import { searchIndex } from '../indexer/search.js';
15
16
  import { loadIndex, buildIndex } from '../indexer/store.js';
16
17
 
17
18
  const h = React.createElement;
18
19
 
20
+ /** 툴 호출의 input 을 채팅 한 줄에 보여줄 수 있게 압축. content 같은 대형 필드는 길이만 표시. */
21
+ function summarizeToolInput(name, input) {
22
+ if (!input || typeof input !== 'object') return '';
23
+ switch (name) {
24
+ case 'read_file':
25
+ case 'write_file':
26
+ case 'edit_file':
27
+ return JSON.stringify(input.path ?? '');
28
+ case 'list_files':
29
+ return JSON.stringify(input.pattern ?? '**/*');
30
+ case 'search_code':
31
+ return JSON.stringify(input.query ?? '');
32
+ default:
33
+ // 일반 케이스 — 너무 긴 필드는 잘라낸다.
34
+ try {
35
+ const small = {};
36
+ for (const [k, v] of Object.entries(input)) {
37
+ if (typeof v === 'string' && v.length > 60) small[k] = v.slice(0, 60) + '…';
38
+ else small[k] = v;
39
+ }
40
+ return JSON.stringify(small);
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+ }
46
+
19
47
  /**
20
48
  * macOS 클립보드의 이미지(예: 스크린샷)를 임시 파일로 떨궈 절대경로를 돌려준다.
21
49
  *
@@ -673,10 +701,45 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
673
701
 
674
702
  try {
675
703
  const sdkMessages = await toSdkMessages(newMessages);
704
+
705
+ // 프로젝트 루트 결정: bc.config.json 이 있는 디렉터리, 없으면 cwd.
706
+ const projectRoot = cfg.paths.projectFile
707
+ ? path.dirname(cfg.paths.projectFile)
708
+ : process.cwd();
709
+
710
+ // 툴 실행 이벤트는 채팅에 시스템 메시지로 표시 (사용자가 무엇이 일어났는지 보게).
711
+ const onToolEvent = (ev) => {
712
+ const labels = {
713
+ write_created: '🆕 생성',
714
+ write_overwritten: '✏️ 덮어씀',
715
+ write_skipped: '⏭ 스킵',
716
+ edit: '✏️ 편집',
717
+ };
718
+ const label = labels[ev.kind] ?? ev.kind;
719
+ const detail =
720
+ ev.lines != null
721
+ ? `(${ev.lines} lines)`
722
+ : ev.added != null
723
+ ? `(+${ev.added} / -${ev.removed} lines)`
724
+ : '';
725
+ setMessages((m) => [
726
+ ...m,
727
+ { role: 'system-info', text: `${label} ${ev.path} ${detail}`.trim() },
728
+ ]);
729
+ };
730
+
731
+ const tools = buildTools({
732
+ projectRoot,
733
+ effective: cfg.effective,
734
+ onEvent: onToolEvent,
735
+ });
736
+
676
737
  const result = streamText({
677
738
  model: resolved.model,
678
739
  system: systemWithContext,
679
740
  messages: sdkMessages,
741
+ tools,
742
+ stopWhen: stepCountIs(12),
680
743
  onError: ({ error }) => {
681
744
  replaceWithError('AI 호출 에러: ' + (error?.message ?? String(error)));
682
745
  },
@@ -685,13 +748,36 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
685
748
  setState('streaming');
686
749
  let acc = '';
687
750
  try {
688
- for await (const delta of result.textStream) {
689
- acc += delta;
690
- setMessages((m) => {
691
- const next = [...m];
692
- next[assistantIdx] = { role: 'assistant', text: acc, streaming: true };
693
- return next;
694
- });
751
+ // fullStream 으로 tool-call / tool-result / text-delta 다 다룸.
752
+ for await (const part of result.fullStream) {
753
+ if (part.type === 'text-delta') {
754
+ acc += part.text;
755
+ setMessages((m) => {
756
+ const next = [...m];
757
+ next[assistantIdx] = { role: 'assistant', text: acc, streaming: true };
758
+ return next;
759
+ });
760
+ } else if (part.type === 'tool-call') {
761
+ // 모델이 툴을 호출하는 순간 — 한 줄로 표시.
762
+ const argSummary = summarizeToolInput(part.toolName, part.input);
763
+ setMessages((m) => [
764
+ ...m,
765
+ {
766
+ role: 'system-info',
767
+ text: `🔧 ${part.toolName}(${argSummary})`,
768
+ },
769
+ ]);
770
+ } else if (part.type === 'tool-error') {
771
+ setMessages((m) => [
772
+ ...m,
773
+ {
774
+ role: 'system-error',
775
+ text: `툴 에러 (${part.toolName ?? '?'}): ${part.error?.message ?? part.error}`,
776
+ },
777
+ ]);
778
+ } else if (part.type === 'error') {
779
+ replaceWithError('스트림 에러: ' + (part.error?.message ?? String(part.error)));
780
+ }
695
781
  }
696
782
  } catch (streamErr) {
697
783
  replaceWithError('스트리밍 중단: ' + (streamErr?.message ?? String(streamErr)));
@@ -700,14 +786,26 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
700
786
  // 에러가 안 났을 때만 final assistant 로 마무리.
701
787
  if (!errorText) {
702
788
  if (acc.length === 0) {
703
- // 응답이 아예 비었지만 onError 떴다 finishReason 으로 추적.
789
+ // 텍스트가 없어도 툴만 호출하고 끝났을 있음 그건 정상.
790
+ // finishReason 으로 진짜 비정상인지 분기.
704
791
  let reason = 'unknown';
705
792
  try {
706
793
  reason = await result.finishReason;
707
794
  } catch {
708
795
  /* noop */
709
796
  }
710
- replaceWithError(`빈 응답 (finishReason=${reason}). API 키/크레딧/모델을 확인하세요.`);
797
+ if (reason === 'tool-calls' || reason === 'stop') {
798
+ // 툴만 호출하고 자연스럽게 멈춤 → placeholder 제거.
799
+ setMessages((m) => {
800
+ const next = [...m];
801
+ if (next[assistantIdx]?.role === 'assistant' && !next[assistantIdx].text) {
802
+ next.splice(assistantIdx, 1);
803
+ }
804
+ return next;
805
+ });
806
+ } else {
807
+ replaceWithError(`빈 응답 (finishReason=${reason}). API 키/크레딧/모델을 확인하세요.`);
808
+ }
711
809
  } else {
712
810
  setMessages((m) => {
713
811
  const next = [...m];