byuckchon-frontend-cli 1.4.1 → 1.6.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
@@ -62,6 +62,97 @@ bc init
62
62
  프로젝트 이름, 프레임워크, **기본 AI 모델, Figma URL, OpenAPI URL** 을 묻고
63
63
  새 폴더에 코드 + `bc.config.json` 까지 만들어 줍니다.
64
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
+
94
+ ### Figma 연동 — 디자인 → 코드 (v1.6+)
95
+
96
+ 채팅 안에서 모델이 직접 Figma REST API 를 호출해서 디자인 정보를 읽고 컴포넌트/페이지를 만듭니다.
97
+
98
+ #### 사용자가 한 번만 하는 셋업
99
+
100
+ ```bash
101
+ # 1) Figma → Settings → Personal access tokens → "Generate new token"
102
+ # Read 권한만 있으면 충분 (file 읽기 / image export 둘 다 read 로 됨)
103
+
104
+ # 2) 토큰을 .env 에 박기 (gitignore 됨)
105
+ echo "FIGMA_TOKEN=figd_xxxxxxxxxxxxxxxx" >> .env
106
+
107
+ # 3) bc.config.json 의 design.figma 에 파일/노드 URL 박기 (bc adopt 시점에 입력하거나 직접 편집)
108
+ ```
109
+
110
+ `bc.config.json` 예시:
111
+
112
+ ```json
113
+ {
114
+ "design": {
115
+ "figma": "https://www.figma.com/design/ABC123/Marketd-Admin?node-id=2-105",
116
+ "figmaTokenEnv": "FIGMA_TOKEN"
117
+ }
118
+ }
119
+ ```
120
+
121
+ #### 디자이너 협업이 필요한 부분
122
+
123
+ | 디자이너 측 작업 | 왜 필요? |
124
+ | --------------------------------------- | --------------------------------------------------------- |
125
+ | 프레임/컴포넌트에 **의미 있는 이름** | `Frame 21` 이 아니라 `Card/Product/Sold-out` 처럼 의미별로 — AI 가 이름으로 컴포넌트 이름과 variant 를 추론합니다. |
126
+ | **Auto layout** 적용 | 안 쓰면 픽셀 좌표만 떨어져 `position: absolute` 코드가 나옵니다. Auto layout 이면 자동으로 `flex`/`gap` 변환. |
127
+ | **로컬 스타일** 등록 (color/text) | "Brand/Primary" 같은 스타일을 등록해두면 `fetch_figma_styles` 로 디자인 토큰을 일괄 추출해서 Tailwind 테마로 바로 박을 수 있어요. |
128
+ | **Components** 화 (♦ 마름모 아이콘) | 반복 UI 가 component 면 모델이 "이거 디자인 시스템 컴포넌트구나" 인식 → 코드에서도 재사용 컴포넌트를 만듭니다. |
129
+ | frame 별로 **"Copy link to selection"** | 일반 share link 는 파일 전체. 특정 frame URL 을 받아야 AI 가 그것만 정확히 가져옵니다. |
130
+
131
+ #### 채팅에서 쓰는 법
132
+
133
+ ```text
134
+ you › 새 멤버 카드 컴포넌트 만들어줘. 디자인은 https://www.figma.com/design/.../?node-id=12-34 이거 참고해서.
135
+
136
+ 🔧 fetch_figma("https://www.figma.com/design/.../?node-id=12-34")
137
+ 🔧 list_files("src/components/**/Card*")
138
+ 🔧 read_file("src/components/Card/ProductCard.tsx")
139
+ 🆕 생성 src/components/Card/MemberCard/MemberCard.tsx (52 lines)
140
+ 🆕 생성 src/components/Card/MemberCard/index.ts (3 lines)
141
+ bc › Auto layout 이 row 였고 padding 12/16 이었어요. MemberCard 만들었습니다.
142
+ 기존 ProductCard 와 같은 폴더 컨벤션을 따랐어요.
143
+ ```
144
+
145
+ 내장 Figma 툴:
146
+
147
+ | 툴 | 동작 |
148
+ | --------------------- | ------------------------------------------------------------- |
149
+ | `fetch_figma` | 노드 트리 (autoLayout / fills / text / size / children) 가져오기 |
150
+ | `fetch_figma_image` | 프레임을 PNG/JPG/SVG 로 export — public asset 으로 저장도 가능 |
151
+ | `fetch_figma_styles` | 파일의 컬러/타이포 토큰 목록 → 디자인 토큰 generator 만들 때 |
152
+
153
+ > Figma 응답은 자동으로 압축됩니다 (자식 60개, 깊이 8 까지). 너무 큰 프레임은 더 작은
154
+ > 자식 frame URL 을 줘서 분할 정복하세요.
155
+
65
156
  ### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
66
157
 
67
158
  `bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
@@ -179,11 +270,25 @@ Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
179
270
  3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
180
271
  - 사전에 `brew install pngpaste` 한 번 필요.
181
272
 
182
- ### 한글 입력이 보일
273
+ ### 한글 입력이 자꾸 씹힐 (v1.6+)
274
+
275
+ `ink` 의 TextInput 은 macOS 한글 IME 의 조합 단계와 충돌해 글자가 한 박자 늦게 보이거나
276
+ 빠뜨려지는 경우가 있습니다 — ink-text-input 의 알려진 한계입니다.
277
+
278
+ **가장 확실한 해결**: 입력 모드를 plain(readline) 으로 영구 전환
279
+
280
+ ```bash
281
+ bc config set-ui plain # 글로벌로 plain 모드 고정
282
+ # 한글 입력 안정, 모든 기본 기능 동작 (RAG, OpenAPI, Figma 툴 호출까지)
283
+ # 단, ink 전용 기능 일부 미지원: 슬래시 자동완성 메뉴, 인라인 이미지 첨부
284
+ ```
285
+
286
+ ink 로 다시 돌아오려면:
287
+ ```bash
288
+ bc config set-ui ink
289
+ ```
183
290
 
184
- ink 터미널 커서를 숨겨버려서 macOS 한글 IME 의 조합 미리보기가 안 보이는 이슈가 있었습니다.
185
- v1.4 부터는 ink 시작 후 커서를 강제로 다시 켜고 가짜 커서를 끄는 방식으로 수정되어 정상 동작해야 합니다.
186
- 혹시 그래도 문제가 보이면 `bc chat --plain` 으로 readline 모드를 쓸 수 있습니다 (TUI 기능은 일부 제한).
291
+ 일회성으로 plain 쓰고 싶으면 `bc chat --plain`.
187
292
 
188
293
  ### `bc config` — 설정
189
294
 
@@ -195,6 +300,8 @@ bc config set-key anthropic # 키 안전 입력 (가려짐)
195
300
  bc config set-key anthropic sk-ant-... # 직접 지정
196
301
  bc config set-gateway https://ai.example.com # 사내 게이트웨이 모드
197
302
  bc config set-gateway # 게이트웨이 해제 (BYOK 모드)
303
+ bc config set-ui plain # 한글 IME 안정 모드
304
+ bc config set-ui ink # 풀 TUI 복귀
198
305
  ```
199
306
 
200
307
  ## 설정 위치
@@ -233,6 +340,9 @@ bc config set-gateway # 게이트웨이 해제 (BYOK 모
233
340
  - [x] Phase 3b: `bc gen api-types` (OpenAPI → TS 타입), `/paste` 클립보드 이미지, 한글 IME 수정
234
341
  - [x] Phase 3c-1: chat 시작 시 인덱스 자동 빌드, OpenAPI 자동 fetch+캐시+시스템 프롬프트 주입
235
342
  - [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
343
+ - [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
344
+ - [x] v1.6.0 — Figma 툴 (fetch_figma / image / styles), 한글 IME 안정 plain 모드 (`bc config set-ui plain`)
345
+ - [ ] v1.7.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
236
346
  - [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
237
347
  - [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
238
348
 
package/bin/index.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  configSetModelCommand,
18
18
  configSetKeyCommand,
19
19
  configSetGatewayCommand,
20
+ configSetUiCommand,
20
21
  } from '../src/commands/config.js';
21
22
 
22
23
  // 프로젝트 .env 가 있으면 자동 로드 (ANTHROPIC_API_KEY, OPENAI_API_KEY 등).
@@ -27,7 +28,7 @@ const program = new Command();
27
28
  program
28
29
  .name('bc')
29
30
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
30
- .version('1.4.1');
31
+ .version('1.6.0');
31
32
 
32
33
  program
33
34
  .command('init')
@@ -128,6 +129,13 @@ cfg
128
129
  await configSetGatewayCommand(url);
129
130
  });
130
131
 
132
+ cfg
133
+ .command('set-ui <mode>')
134
+ .description('chat 입력 모드: ink (풀 TUI) | plain (readline — 한글 IME 안정)')
135
+ .action(async (mode) => {
136
+ await configSetUiCommand(mode);
137
+ });
138
+
131
139
  program.exitOverride((err) => {
132
140
  if (
133
141
  err.code === 'commander.help' ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.4.1",
3
+ "version": "1.6.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,44 @@ 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
+ '## Figma 작업 (디자인 → 코드)',
32
+ '사용자가 Figma 링크를 던지거나 "디자인대로 만들어줘" 같은 요청을 하면:',
33
+ ' 1) `fetch_figma(url)` 로 디자인 트리를 받는다. 노드의 name, autoLayout, fills, text, size 를 학습.',
34
+ ' 2) 필요하면 `fetch_figma_styles(url)` 로 컬러/타이포 토큰을 받아 Tailwind config 또는 theme 변수에 반영.',
35
+ ' 3) `list_files` 로 기존 UI 컴포넌트 폴더 구조를 보고, 같은 컨벤션 따라 `write_file` 로 생성.',
36
+ ' 4) Figma `INSTANCE` (= 디자인 시스템 컴포넌트) 가 보이면 기존 코드의 동일 컴포넌트를 ',
37
+ ' `search_code` 로 찾아 재사용한다. 없으면 컴포넌트부터 생성.',
38
+ ' 5) 픽셀 좌표(absoluteBoundingBox) 보다 **autoLayout** 우선. autoLayout 이 있으면',
39
+ ' `flex direction={row|col} gap-x` 패턴으로 짠다. 없으면 디자이너에게 ',
40
+ ' "Auto layout 으로 정리해달라" 고 요청하라고 안내.',
41
+ ' 6) 색은 가능하면 fills 의 raw rgba 대신 Tailwind 색 이름이나 디자인 토큰을 사용.',
42
+ '',
43
+ '"코드 짜줘" 라는 표현은 채팅창에 코드 블록을 출력하라는 의미가 **아니다**.',
44
+ '항상 툴을 사용해 실제 파일을 만들어라. 채팅에는 진행 상황과 결과 요약만 짧게 적는다.',
45
+ '',
46
+ '## 안전 규칙',
47
+ '- 절대 프로젝트 루트 밖을 읽거나 쓰지 않는다.',
48
+ '- 기존 파일을 덮어쓸 때는 먼저 `read_file` 로 현재 내용을 보고, 의도된 덮어쓰기인지 확인.',
49
+ '- 큰 변경은 `edit_file` 여러 번이 안전. 통째 덮어쓰기는 새 파일이거나 작은 파일에만.',
50
+ '- 코드 컨벤션이 모호하면 사용자에게 한 번 물어볼 것 (툴 호출 멈추고 메시지로).',
16
51
  ];
17
52
 
18
53
  const stack = describeStack(project);
@@ -34,7 +69,13 @@ export function buildSystemPrompt({ effective, paths, project }) {
34
69
  }\``,
35
70
  );
36
71
  }
37
- if (project?.design?.figma) meta.push(`- Figma: ${project.design.figma}`);
72
+ if (project?.design?.figma) {
73
+ meta.push(`- Figma: ${project.design.figma}`);
74
+ meta.push(
75
+ ' (Figma 작업 요청을 받으면 fetch_figma 툴로 디자인을 먼저 읽고, ' +
76
+ '필요하면 fetch_figma_styles 로 토큰을 가져와 코드를 짠다.)',
77
+ );
78
+ }
38
79
  if (project?.api?.openapi) meta.push(`- OpenAPI: ${project.api.openapi}`);
39
80
  if (project?.api?.baseUrl) meta.push(`- API base URL: ${project.api.baseUrl}`);
40
81
  if (effective?.model) meta.push(`- 사용 모델: ${effective.model}`);
@@ -0,0 +1,374 @@
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
+ import {
9
+ fetchFromUrl as fetchFigmaFromUrl,
10
+ fetchImageUrls as fetchFigmaImageUrls,
11
+ fetchStyles as fetchFigmaStyles,
12
+ } from '../figma/api.js';
13
+ import { simplifyFetchNodes } from '../figma/simplify.js';
14
+ import { parseFigmaUrl } from '../figma/url.js';
15
+
16
+ /**
17
+ * Agentic chat 용 툴 정의.
18
+ *
19
+ * 설계 원칙:
20
+ * - **모든 파일 경로는 projectRoot 하위로 강제** (탈출 시도는 에러).
21
+ * - read/list/search 는 always-allow (안전).
22
+ * - write/edit 는 `safeWrite` 가 디스크에 쓰고 onWrite 콜백으로 UI 에 알린다.
23
+ * 승인 게이트를 추후 끼우려면 onWrite 안에서 await 로 막으면 된다.
24
+ * - 모든 결과는 plain JSON 으로 돌려준다 (모델이 다시 추론하기 좋게).
25
+ *
26
+ * 사용:
27
+ * const tools = buildTools({ projectRoot, effective, onEvent });
28
+ * streamText({ tools, stopWhen: stepCountIs(12), ... });
29
+ */
30
+ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
31
+ const root = path.resolve(projectRoot);
32
+
33
+ function safePath(p) {
34
+ if (!p || typeof p !== 'string') {
35
+ throw new Error('path 가 비어있습니다');
36
+ }
37
+ const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(root, p);
38
+ const rel = path.relative(root, abs);
39
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
40
+ throw new Error(`프로젝트 루트(${root}) 밖의 경로는 접근할 수 없습니다: ${p}`);
41
+ }
42
+ return { abs, rel: rel || '.' };
43
+ }
44
+
45
+ async function readFile({ path: p }) {
46
+ const { abs, rel } = safePath(p);
47
+ let stat;
48
+ try {
49
+ stat = await fs.stat(abs);
50
+ } catch {
51
+ return { ok: false, error: `파일이 존재하지 않습니다: ${rel}` };
52
+ }
53
+ if (stat.isDirectory()) {
54
+ const entries = await fs.readdir(abs, { withFileTypes: true });
55
+ return {
56
+ ok: true,
57
+ kind: 'directory',
58
+ path: rel,
59
+ entries: entries.map((e) => (e.isDirectory() ? e.name + '/' : e.name)),
60
+ };
61
+ }
62
+ if (stat.size > 256 * 1024) {
63
+ return { ok: false, error: `파일이 너무 큽니다 (${stat.size}B). 256KB 이하만 지원.` };
64
+ }
65
+ const content = await fs.readFile(abs, 'utf8');
66
+ return { ok: true, kind: 'file', path: rel, lines: content.split('\n').length, content };
67
+ }
68
+
69
+ async function listFiles({ pattern = '**/*', limit = 100 }) {
70
+ const matches = await fg(pattern, {
71
+ cwd: root,
72
+ ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', '.bc/**', '.git/**'],
73
+ onlyFiles: false,
74
+ dot: false,
75
+ followSymbolicLinks: false,
76
+ });
77
+ const truncated = matches.length > limit;
78
+ return {
79
+ ok: true,
80
+ total: matches.length,
81
+ truncated,
82
+ matches: matches.slice(0, limit),
83
+ };
84
+ }
85
+
86
+ async function searchCode({ query, k = 8 }) {
87
+ try {
88
+ const res = await searchIndex(query, effective, { topK: Math.min(20, Math.max(1, k)), minScore: 0.15 });
89
+ if (!res.ok) {
90
+ return { ok: false, error: res.reason ?? 'index 없음 — /index 로 빌드하라고 안내할 것' };
91
+ }
92
+ return {
93
+ ok: true,
94
+ hits: res.results.map((r) => ({
95
+ file: r.chunk.file,
96
+ range: `${r.chunk.startLine}-${r.chunk.endLine}`,
97
+ score: Number(r.score.toFixed(3)),
98
+ snippet: r.chunk.text.slice(0, 1200),
99
+ })),
100
+ };
101
+ } catch (err) {
102
+ return { ok: false, error: err?.message ?? String(err) };
103
+ }
104
+ }
105
+
106
+ async function writeFile({ path: p, content }) {
107
+ const { abs, rel } = safePath(p);
108
+ let existed = false;
109
+ let prevContent = '';
110
+ try {
111
+ prevContent = await fs.readFile(abs, 'utf8');
112
+ existed = true;
113
+ } catch {
114
+ /* 새 파일 */
115
+ }
116
+ if (existed && prevContent === content) {
117
+ onEvent({ kind: 'write_skipped', path: rel, reason: '동일' });
118
+ return { ok: true, path: rel, action: 'noop', reason: '내용 동일' };
119
+ }
120
+ await fs.mkdir(path.dirname(abs), { recursive: true });
121
+ await fs.writeFile(abs, content, 'utf8');
122
+ onEvent({
123
+ kind: existed ? 'write_overwritten' : 'write_created',
124
+ path: rel,
125
+ lines: content.split('\n').length,
126
+ bytes: Buffer.byteLength(content, 'utf8'),
127
+ });
128
+ return {
129
+ ok: true,
130
+ path: rel,
131
+ action: existed ? 'overwritten' : 'created',
132
+ lines: content.split('\n').length,
133
+ };
134
+ }
135
+
136
+ async function editFile({ path: p, old_string, new_string }) {
137
+ const { abs, rel } = safePath(p);
138
+ let content;
139
+ try {
140
+ content = await fs.readFile(abs, 'utf8');
141
+ } catch {
142
+ return { ok: false, error: `편집할 파일이 없습니다: ${rel}` };
143
+ }
144
+ if (typeof old_string !== 'string' || old_string.length === 0) {
145
+ return { ok: false, error: 'old_string 이 비어있습니다' };
146
+ }
147
+ // 정확 일치 횟수 계산
148
+ let count = 0;
149
+ let idx = 0;
150
+ while ((idx = content.indexOf(old_string, idx)) !== -1) {
151
+ count++;
152
+ idx += old_string.length;
153
+ }
154
+ if (count === 0) {
155
+ return { ok: false, error: `old_string 을 ${rel} 에서 찾지 못했습니다. 주변 라인을 더 포함해서 다시 시도.` };
156
+ }
157
+ if (count > 1) {
158
+ return {
159
+ ok: false,
160
+ error: `old_string 이 ${rel} 에 ${count}번 등장합니다. 더 많은 컨텍스트로 유일해지게 만들어 주세요.`,
161
+ };
162
+ }
163
+ const updated = content.replace(old_string, new_string);
164
+ await fs.writeFile(abs, updated, 'utf8');
165
+ onEvent({
166
+ kind: 'edit',
167
+ path: rel,
168
+ removed: old_string.split('\n').length,
169
+ added: new_string.split('\n').length,
170
+ });
171
+ return { ok: true, path: rel, action: 'edited' };
172
+ }
173
+
174
+ // ─────────── Figma 툴 ───────────
175
+
176
+ async function fetchFigma({ url, depth = 4 }) {
177
+ try {
178
+ const result = await fetchFigmaFromUrl({ url, effective, depth });
179
+ if (result.kind === 'file') {
180
+ return {
181
+ ok: true,
182
+ kind: 'file_summary',
183
+ file: result.summary.name,
184
+ pages: result.summary.pages,
185
+ hint:
186
+ 'node-id 가 없는 파일 링크입니다. 디자이너에게 특정 frame 의 ' +
187
+ '"Copy link to selection" 을 받아오면 더 정확한 코드 생성 가능.',
188
+ };
189
+ }
190
+ const simple = simplifyFetchNodes(result.raw);
191
+ return {
192
+ ok: true,
193
+ kind: 'nodes',
194
+ fileKey: result.fileKey,
195
+ nodeId: result.nodeId,
196
+ documents: simple.documents,
197
+ components: simple.components,
198
+ styles: simple.styles,
199
+ };
200
+ } catch (err) {
201
+ return { ok: false, error: err?.message ?? String(err), status: err?.status };
202
+ }
203
+ }
204
+
205
+ async function fetchFigmaImage({ url, format = 'png', scale = 2, savePath }) {
206
+ try {
207
+ const parsed = parseFigmaUrl(url);
208
+ if (!parsed?.nodeId) {
209
+ return { ok: false, error: 'node-id 가 있는 frame 링크가 필요합니다.' };
210
+ }
211
+ const images = await fetchFigmaImageUrls({
212
+ fileKey: parsed.fileKey,
213
+ nodeIds: [parsed.nodeId],
214
+ format,
215
+ scale,
216
+ effective,
217
+ });
218
+ const imageUrl = images[parsed.nodeId];
219
+ if (!imageUrl) {
220
+ return { ok: false, error: 'Figma 가 이미지 URL 을 돌려주지 않음' };
221
+ }
222
+ if (savePath) {
223
+ const { abs, rel } = safePath(savePath);
224
+ const res = await fetch(imageUrl);
225
+ const buf = Buffer.from(await res.arrayBuffer());
226
+ await fs.mkdir(path.dirname(abs), { recursive: true });
227
+ await fs.writeFile(abs, buf);
228
+ onEvent({ kind: 'write_created', path: rel, bytes: buf.byteLength });
229
+ return { ok: true, savedTo: rel, bytes: buf.byteLength, format };
230
+ }
231
+ return { ok: true, url: imageUrl, format, expiresInSeconds: 60 * 60 * 24 * 14 };
232
+ } catch (err) {
233
+ return { ok: false, error: err?.message ?? String(err) };
234
+ }
235
+ }
236
+
237
+ async function fetchFigmaStylesTool({ url }) {
238
+ try {
239
+ const parsed = parseFigmaUrl(url);
240
+ if (!parsed) return { ok: false, error: 'Figma URL 형식이 아닙니다.' };
241
+ const styles = await fetchFigmaStyles({ fileKey: parsed.fileKey, effective });
242
+ // 모델이 디자인 토큰을 만들 때 쓸 수 있도록 styleType 별로 그룹.
243
+ const grouped = {};
244
+ for (const s of styles) {
245
+ const t = s.style_type ?? s.styleType ?? 'OTHER';
246
+ (grouped[t] ??= []).push({
247
+ name: s.name,
248
+ description: s.description ?? '',
249
+ key: s.key,
250
+ nodeId: s.node_id ?? s.nodeId,
251
+ });
252
+ }
253
+ return { ok: true, fileKey: parsed.fileKey, total: styles.length, grouped };
254
+ } catch (err) {
255
+ return { ok: false, error: err?.message ?? String(err) };
256
+ }
257
+ }
258
+
259
+ return {
260
+ read_file: tool({
261
+ description:
262
+ '프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
263
+ inputSchema: {
264
+ type: 'object',
265
+ properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
266
+ required: ['path'],
267
+ additionalProperties: false,
268
+ },
269
+ execute: readFile,
270
+ }),
271
+ list_files: tool({
272
+ description:
273
+ '글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
274
+ inputSchema: {
275
+ type: 'object',
276
+ properties: {
277
+ pattern: { type: 'string', default: '**/*' },
278
+ limit: { type: 'number', default: 100 },
279
+ },
280
+ additionalProperties: false,
281
+ },
282
+ execute: listFiles,
283
+ }),
284
+ search_code: tool({
285
+ description:
286
+ '코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
287
+ inputSchema: {
288
+ type: 'object',
289
+ properties: {
290
+ query: { type: 'string' },
291
+ k: { type: 'number', default: 8 },
292
+ },
293
+ required: ['query'],
294
+ additionalProperties: false,
295
+ },
296
+ execute: searchCode,
297
+ }),
298
+ write_file: tool({
299
+ description:
300
+ '새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
301
+ inputSchema: {
302
+ type: 'object',
303
+ properties: {
304
+ path: { type: 'string' },
305
+ content: { type: 'string' },
306
+ },
307
+ required: ['path', 'content'],
308
+ additionalProperties: false,
309
+ },
310
+ execute: writeFile,
311
+ }),
312
+ edit_file: tool({
313
+ description:
314
+ '기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
315
+ inputSchema: {
316
+ type: 'object',
317
+ properties: {
318
+ path: { type: 'string' },
319
+ old_string: { type: 'string' },
320
+ new_string: { type: 'string' },
321
+ },
322
+ required: ['path', 'old_string', 'new_string'],
323
+ additionalProperties: false,
324
+ },
325
+ execute: editFile,
326
+ }),
327
+ fetch_figma: tool({
328
+ description:
329
+ 'Figma 노드 트리(컴포넌트/프레임/페이지) 를 읽는다. URL 에 node-id 가 있으면 그 frame 의 ' +
330
+ '간소화된 디자인 정보(autoLayout, fills, text, size, children 등) 를 반환. ' +
331
+ '없으면 파일 페이지 목록만. 컴포넌트/페이지 생성 요청을 받으면 이 툴을 먼저 호출해서 ' +
332
+ '디자인 의도를 학습한 뒤 코드를 짠다.',
333
+ inputSchema: {
334
+ type: 'object',
335
+ properties: {
336
+ url: { type: 'string', description: 'Figma share/copy link' },
337
+ depth: { type: 'number', default: 4, description: '노드 트리 탐색 깊이 (1-8)' },
338
+ },
339
+ required: ['url'],
340
+ additionalProperties: false,
341
+ },
342
+ execute: fetchFigma,
343
+ }),
344
+ fetch_figma_image: tool({
345
+ description:
346
+ 'Figma 프레임을 PNG/JPG/SVG 이미지로 export. savePath 를 주면 프로젝트 폴더 안에 파일로 저장 ' +
347
+ '(스토리북 배경, public asset 등). 안 주면 임시 URL 만 반환.',
348
+ inputSchema: {
349
+ type: 'object',
350
+ properties: {
351
+ url: { type: 'string' },
352
+ format: { type: 'string', enum: ['png', 'jpg', 'svg', 'pdf'], default: 'png' },
353
+ scale: { type: 'number', default: 2 },
354
+ savePath: { type: 'string' },
355
+ },
356
+ required: ['url'],
357
+ additionalProperties: false,
358
+ },
359
+ execute: fetchFigmaImage,
360
+ }),
361
+ fetch_figma_styles: tool({
362
+ description:
363
+ 'Figma 파일의 로컬 스타일(컬러/타이포/이펙트 토큰) 목록을 가져온다. 디자인 토큰 추출 / ' +
364
+ 'Tailwind 테마 설정 / theme.ts 생성 시 사용.',
365
+ inputSchema: {
366
+ type: 'object',
367
+ properties: { url: { type: 'string' } },
368
+ required: ['url'],
369
+ additionalProperties: false,
370
+ },
371
+ execute: fetchFigmaStylesTool,
372
+ }),
373
+ };
374
+ }
@@ -141,6 +141,13 @@ export async function adoptCommand(opts = {}) {
141
141
  if (!process.env.ANTHROPIC_API_KEY) {
142
142
  console.log(chalk.dim(' bc config set-key anthropic # API 키 등록'));
143
143
  }
144
+ if (next.design.figma && !process.env.FIGMA_TOKEN) {
145
+ console.log(
146
+ chalk.dim(
147
+ ' .env 에 FIGMA_TOKEN=figd-... 추가 # https://www.figma.com/settings 에서 발급',
148
+ ),
149
+ );
150
+ }
144
151
  console.log(chalk.dim(' bc chat # 이 프로젝트 컨텍스트로 대화'));
145
152
  console.log();
146
153
  }
@@ -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,
@@ -130,8 +133,11 @@ export async function chatCommand(opts = {}) {
130
133
  await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
131
134
 
132
135
  // ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
136
+ // - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
137
+ // - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
133
138
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
134
- if (!isTTY || opts.plain) {
139
+ const wantPlain = opts.plain || cfg.global?.ui?.mode === 'plain';
140
+ if (!isTTY || wantPlain) {
135
141
  return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
136
142
  }
137
143
 
@@ -189,15 +195,39 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
189
195
 
190
196
  async function runOnce({ cfg, resolved, system, prompt }) {
191
197
  const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
198
+ const projectRoot = cfg.paths.projectFile
199
+ ? path.dirname(cfg.paths.projectFile)
200
+ : process.cwd();
201
+ const tools = buildTools({
202
+ projectRoot,
203
+ effective: cfg.effective,
204
+ onEvent: (ev) => {
205
+ const label =
206
+ ev.kind === 'write_created'
207
+ ? '🆕'
208
+ : ev.kind === 'write_overwritten'
209
+ ? '✏️ '
210
+ : ev.kind === 'edit'
211
+ ? '✏️ '
212
+ : '·';
213
+ console.log(chalk.dim(` ${label} ${ev.path}`));
214
+ },
215
+ });
192
216
  const result = streamText({
193
217
  model: resolved.model,
194
218
  system,
195
219
  messages: [{ role: 'user', content: prompt }],
220
+ tools,
221
+ stopWhen: stepCountIs(12),
196
222
  onError: ({ error }) => {
197
223
  console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
198
224
  },
199
225
  });
200
- for await (const delta of result.textStream) process.stdout.write(delta);
226
+ for await (const part of result.fullStream) {
227
+ if (part.type === 'text-delta') process.stdout.write(part.text);
228
+ else if (part.type === 'tool-call')
229
+ process.stdout.write(chalk.dim(`\n 🔧 ${part.toolName}\n`));
230
+ }
201
231
  process.stdout.write('\n');
202
232
  try {
203
233
  meter.add(await result.usage);
@@ -257,10 +287,30 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
257
287
  history.push({ role: 'user', content: line });
258
288
  rl.pause();
259
289
 
290
+ const projectRoot = cfg.paths.projectFile
291
+ ? path.dirname(cfg.paths.projectFile)
292
+ : process.cwd();
293
+ const tools = buildTools({
294
+ projectRoot,
295
+ effective: cfg.effective,
296
+ onEvent: (ev) => {
297
+ const label =
298
+ ev.kind === 'write_created'
299
+ ? '🆕 생성'
300
+ : ev.kind === 'write_overwritten'
301
+ ? '✏️ 덮어씀'
302
+ : ev.kind === 'edit'
303
+ ? '✏️ 편집'
304
+ : '·';
305
+ console.log(chalk.dim(`\n ${label} ${ev.path}`));
306
+ },
307
+ });
260
308
  const result = streamText({
261
309
  model: resolved.model,
262
310
  system,
263
311
  messages: history,
312
+ tools,
313
+ stopWhen: stepCountIs(12),
264
314
  onError: ({ error }) => {
265
315
  console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
266
316
  },
@@ -268,9 +318,13 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
268
318
  process.stdout.write(chalk.bold.green('\n bc › '));
269
319
  let acc = '';
270
320
  try {
271
- for await (const delta of result.textStream) {
272
- acc += delta;
273
- process.stdout.write(delta);
321
+ for await (const part of result.fullStream) {
322
+ if (part.type === 'text-delta') {
323
+ acc += part.text;
324
+ process.stdout.write(part.text);
325
+ } else if (part.type === 'tool-call') {
326
+ process.stdout.write(chalk.dim(`\n 🔧 ${part.toolName}`));
327
+ }
274
328
  }
275
329
  } catch (err) {
276
330
  console.error('\n' + chalk.red(' 스트리밍 중단: ') + (err?.message ?? err));
@@ -51,6 +51,12 @@ export async function configShowCommand() {
51
51
  console.log(
52
52
  ` ${chalk.dim('요청 확인')} ${eff.effective.limits.confirmAtTokens.toLocaleString()} tokens`,
53
53
  );
54
+ console.log();
55
+ console.log(chalk.bold(' UI'));
56
+ console.log(
57
+ ` ${chalk.dim('chat 입력 모드')} ${eff.global?.ui?.mode ?? 'ink'} ` +
58
+ chalk.dim('(plain 으로 두면 한글 IME 안정. bc config set-ui plain)'),
59
+ );
54
60
 
55
61
  if (eff.paths.projectFile) {
56
62
  console.log();
@@ -130,6 +136,24 @@ export async function configSetKeyCommand(provider, key) {
130
136
  console.log(chalk.dim(` 파일: ${CONFIG_PATHS.globalFile} (chmod 600)\n`));
131
137
  }
132
138
 
139
+ export async function configSetUiCommand(mode) {
140
+ const allowed = ['ink', 'plain'];
141
+ if (!allowed.includes(mode)) {
142
+ console.error(chalk.red(`사용법: bc config set-ui <${allowed.join('|')}>`));
143
+ console.error(
144
+ chalk.dim(
145
+ ' ink = 풀 TUI (기본). 한글 IME 가 종종 씹히는 환경에서는 plain 권장.\n' +
146
+ ' plain = readline 폴백. 한글 입력 안정, 슬래시 명령/이미지 미지원.\n',
147
+ ),
148
+ );
149
+ process.exit(1);
150
+ }
151
+ const global = await loadGlobalConfig();
152
+ global.ui = { ...(global.ui ?? {}), mode };
153
+ await saveGlobalConfig(global);
154
+ console.log(chalk.green(`\n ✓ chat 입력 모드를 '${mode}' 로 저장했습니다.\n`));
155
+ }
156
+
133
157
  export async function configSetGatewayCommand(url) {
134
158
  const global = await loadGlobalConfig();
135
159
  global.ai.gateway = url && url.trim() ? url.trim() : null;
@@ -20,6 +20,7 @@ const DEFAULT_GLOBAL = {
20
20
  apiKeys: {
21
21
  // anthropic: 'sk-ant-...',
22
22
  // openai: 'sk-...',
23
+ // figma: 'figd-...' // 통상 .env(FIGMA_TOKEN) 로 둠
23
24
  },
24
25
  /** 사내 게이트웨이를 쓰는 경우 base URL. 비우면 BYOK 모드. */
25
26
  gateway: null,
@@ -30,6 +31,14 @@ const DEFAULT_GLOBAL = {
30
31
  /** 한 요청이 이 토큰을 넘으면 사용자에게 확인. */
31
32
  confirmAtTokens: 12_000,
32
33
  },
34
+ ui: {
35
+ /**
36
+ * "ink" | "plain"
37
+ * 한글 IME 가 ink 에서 글자가 씹히면 "plain" 으로 두면 항상 readline 모드로 진입.
38
+ * --plain 플래그를 매번 안 쳐도 됨.
39
+ */
40
+ mode: 'ink',
41
+ },
33
42
  };
34
43
 
35
44
  /**
@@ -0,0 +1,117 @@
1
+ import { parseFigmaUrl } from './url.js';
2
+
3
+ /**
4
+ * Figma REST API 클라이언트.
5
+ *
6
+ * 인증: Personal Access Token 을 `X-Figma-Token` 헤더로 보냄.
7
+ * 토큰은 https://www.figma.com/settings 에서 "Personal access tokens" 로 발급.
8
+ *
9
+ * effective.figmaToken (또는 process.env[figmaTokenEnv]) 에서 키를 가져옴.
10
+ */
11
+ const BASE = 'https://api.figma.com/v1';
12
+
13
+ export class FigmaError extends Error {
14
+ constructor(message, { status, body } = {}) {
15
+ super(message);
16
+ this.status = status;
17
+ this.body = body;
18
+ }
19
+ }
20
+
21
+ function tokenFromEnv(effective) {
22
+ // 1) bc.config.json design.figmaTokenEnv 로 지정한 환경변수
23
+ const envName = effective?.design?.figmaTokenEnv ?? 'FIGMA_TOKEN';
24
+ return process.env[envName] ?? process.env.FIGMA_TOKEN ?? null;
25
+ }
26
+
27
+ async function call(pathAndQuery, { token }) {
28
+ if (!token) {
29
+ throw new FigmaError(
30
+ 'Figma 토큰이 없습니다. https://www.figma.com/settings 에서 Personal access token 을 발급받고 ' +
31
+ '`.env` 에 `FIGMA_TOKEN=figd_...` 로 등록하세요.',
32
+ );
33
+ }
34
+ const res = await fetch(BASE + pathAndQuery, {
35
+ headers: { 'X-Figma-Token': token },
36
+ });
37
+ if (!res.ok) {
38
+ let body = null;
39
+ try {
40
+ body = await res.text();
41
+ } catch {
42
+ /* noop */
43
+ }
44
+ throw new FigmaError(`Figma API ${res.status} ${res.statusText}: ${pathAndQuery}`, {
45
+ status: res.status,
46
+ body,
47
+ });
48
+ }
49
+ return res.json();
50
+ }
51
+
52
+ /** 파일의 상위 메타 (페이지 목록만 — 노드 트리는 안 가져옴). */
53
+ export async function fetchFileSummary({ fileKey, effective }) {
54
+ const token = tokenFromEnv(effective);
55
+ const data = await call(`/files/${fileKey}?depth=1`, { token });
56
+ return {
57
+ fileKey,
58
+ name: data.name,
59
+ lastModified: data.lastModified,
60
+ pages:
61
+ data.document?.children?.map((c) => ({
62
+ id: c.id,
63
+ name: c.name,
64
+ type: c.type,
65
+ })) ?? [],
66
+ };
67
+ }
68
+
69
+ /** 특정 노드들의 상세 트리를 가져옴 (가장 자주 쓰는 API). */
70
+ export async function fetchNodes({ fileKey, nodeIds, effective, depth }) {
71
+ const token = tokenFromEnv(effective);
72
+ const ids = (Array.isArray(nodeIds) ? nodeIds : [nodeIds])
73
+ .filter(Boolean)
74
+ .map(encodeURIComponent)
75
+ .join(',');
76
+ const depthQ = depth ? `&depth=${depth}` : '';
77
+ const data = await call(`/files/${fileKey}/nodes?ids=${ids}${depthQ}`, { token });
78
+ return data;
79
+ }
80
+
81
+ /** 노드를 이미지(PNG/JPG/SVG)로 export 하는 임시 URL 을 받아옴 */
82
+ export async function fetchImageUrls({ fileKey, nodeIds, format = 'png', scale = 2, effective }) {
83
+ const token = tokenFromEnv(effective);
84
+ const ids = nodeIds.map(encodeURIComponent).join(',');
85
+ const data = await call(
86
+ `/images/${fileKey}?ids=${ids}&format=${format}&scale=${scale}`,
87
+ { token },
88
+ );
89
+ // 응답: { images: { "1:2": "https://...", ... } }
90
+ return data.images ?? {};
91
+ }
92
+
93
+ /** 파일에 정의된 로컬 스타일 (color/typography/effect/grid) */
94
+ export async function fetchStyles({ fileKey, effective }) {
95
+ const token = tokenFromEnv(effective);
96
+ const data = await call(`/files/${fileKey}/styles`, { token });
97
+ return data.meta?.styles ?? data.styles ?? [];
98
+ }
99
+
100
+ /** URL 한 줄로 시작하는 헬퍼 — chat 의 툴에서 가장 흔히 쓰임. */
101
+ export async function fetchFromUrl({ url, effective, depth }) {
102
+ const parsed = parseFigmaUrl(url);
103
+ if (!parsed) {
104
+ throw new FigmaError(`Figma URL 형식이 아닙니다: ${url}`);
105
+ }
106
+ if (!parsed.nodeId) {
107
+ // node-id 없으면 파일 요약만
108
+ return { kind: 'file', summary: await fetchFileSummary({ fileKey: parsed.fileKey, effective }) };
109
+ }
110
+ const data = await fetchNodes({
111
+ fileKey: parsed.fileKey,
112
+ nodeIds: [parsed.nodeId],
113
+ effective,
114
+ depth,
115
+ });
116
+ return { kind: 'nodes', fileKey: parsed.fileKey, nodeId: parsed.nodeId, raw: data };
117
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Figma 노드 트리를 LLM 이 읽기 좋게 압축한다.
3
+ *
4
+ * Figma 원 응답은 노드 하나에 수십 KB 도 흔하다. 그대로 모델에 넣으면 토큰이 폭발하고
5
+ * 모델도 중요한 게 뭔지 못 찾는다. 다음 정보만 남긴다:
6
+ * - 이름 (= 디자이너의 의도. 컴포넌트/페이지 명명 규칙)
7
+ * - 타입 (FRAME, COMPONENT, INSTANCE, TEXT, RECTANGLE, ...)
8
+ * - 위치/크기 (필요한 경우만)
9
+ * - autoLayout (있으면 flex/gap 변환에 핵심)
10
+ * - fills (색)
11
+ * - strokes
12
+ * - effects (shadow)
13
+ * - text 의 경우 글자 + 폰트 사양
14
+ * - cornerRadius, padding 같은 자주 쓰는 박스 속성
15
+ * - 자식들 (재귀)
16
+ *
17
+ * 모델은 이 압축본을 받아서 React/Tailwind/styled JSX 를 생성한다.
18
+ */
19
+
20
+ const MAX_CHILDREN = 60;
21
+ const MAX_DEPTH = 8;
22
+
23
+ function pickColor(paint) {
24
+ if (!paint || paint.visible === false) return null;
25
+ if (paint.type === 'SOLID' && paint.color) {
26
+ const { r, g, b } = paint.color;
27
+ const a = paint.opacity ?? paint.color.a ?? 1;
28
+ return {
29
+ type: 'solid',
30
+ rgba: `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${Number(a.toFixed(3))})`,
31
+ };
32
+ }
33
+ if (paint.type?.startsWith('GRADIENT')) {
34
+ return {
35
+ type: 'gradient',
36
+ kind: paint.type,
37
+ stops:
38
+ paint.gradientStops?.map((s) => ({
39
+ position: s.position,
40
+ rgba:
41
+ s.color &&
42
+ `rgba(${Math.round(s.color.r * 255)}, ${Math.round(s.color.g * 255)}, ${Math.round(s.color.b * 255)}, ${Number((s.color.a ?? 1).toFixed(3))})`,
43
+ })) ?? [],
44
+ };
45
+ }
46
+ if (paint.type === 'IMAGE') {
47
+ return { type: 'image', scaleMode: paint.scaleMode };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function describeAutoLayout(node) {
53
+ if (!node.layoutMode || node.layoutMode === 'NONE') return null;
54
+ return {
55
+ direction: node.layoutMode === 'HORIZONTAL' ? 'row' : 'column',
56
+ gap: node.itemSpacing ?? 0,
57
+ padding: {
58
+ top: node.paddingTop ?? 0,
59
+ right: node.paddingRight ?? 0,
60
+ bottom: node.paddingBottom ?? 0,
61
+ left: node.paddingLeft ?? 0,
62
+ },
63
+ alignItems: node.counterAxisAlignItems,
64
+ justifyContent: node.primaryAxisAlignItems,
65
+ wrap: node.layoutWrap === 'WRAP',
66
+ };
67
+ }
68
+
69
+ function describeText(node) {
70
+ if (node.type !== 'TEXT') return null;
71
+ const s = node.style ?? {};
72
+ return {
73
+ characters: node.characters ?? '',
74
+ fontFamily: s.fontFamily,
75
+ fontSize: s.fontSize,
76
+ fontWeight: s.fontWeight,
77
+ lineHeight: s.lineHeightPx,
78
+ letterSpacing: s.letterSpacing,
79
+ textAlign: s.textAlignHorizontal?.toLowerCase(),
80
+ };
81
+ }
82
+
83
+ function simplifyNode(node, depth = 0) {
84
+ if (!node) return null;
85
+ const out = {
86
+ id: node.id,
87
+ name: node.name,
88
+ type: node.type,
89
+ };
90
+
91
+ if (node.absoluteBoundingBox) {
92
+ out.size = {
93
+ w: Math.round(node.absoluteBoundingBox.width),
94
+ h: Math.round(node.absoluteBoundingBox.height),
95
+ };
96
+ }
97
+
98
+ const auto = describeAutoLayout(node);
99
+ if (auto) out.autoLayout = auto;
100
+
101
+ if (node.fills?.length) {
102
+ const fills = node.fills.map(pickColor).filter(Boolean);
103
+ if (fills.length) out.fills = fills;
104
+ }
105
+ if (node.strokes?.length) {
106
+ const strokes = node.strokes.map(pickColor).filter(Boolean);
107
+ if (strokes.length) {
108
+ out.strokes = strokes;
109
+ out.strokeWeight = node.strokeWeight;
110
+ }
111
+ }
112
+ if (node.cornerRadius != null) out.cornerRadius = node.cornerRadius;
113
+ if (node.rectangleCornerRadii) out.cornerRadii = node.rectangleCornerRadii;
114
+ if (node.effects?.length) {
115
+ out.effects = node.effects.map((e) => ({
116
+ type: e.type,
117
+ radius: e.radius,
118
+ offset: e.offset,
119
+ color: e.color &&
120
+ `rgba(${Math.round(e.color.r * 255)}, ${Math.round(e.color.g * 255)}, ${Math.round(e.color.b * 255)}, ${Number((e.color.a ?? 1).toFixed(3))})`,
121
+ }));
122
+ }
123
+ if (node.opacity != null && node.opacity < 1) out.opacity = node.opacity;
124
+
125
+ const text = describeText(node);
126
+ if (text) out.text = text;
127
+
128
+ // Component / Instance — 디자인 시스템의 신호. AI 가 재사용 결정하는 단서.
129
+ if (node.type === 'INSTANCE' && node.componentId) {
130
+ out.componentRef = node.componentId;
131
+ }
132
+
133
+ if (node.children?.length && depth < MAX_DEPTH) {
134
+ const kids = node.children.slice(0, MAX_CHILDREN);
135
+ const truncated = node.children.length > MAX_CHILDREN;
136
+ out.children = kids
137
+ .map((c) => simplifyNode(c, depth + 1))
138
+ .filter(Boolean);
139
+ if (truncated) out.childrenTruncated = node.children.length - MAX_CHILDREN;
140
+ }
141
+
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Figma `fetchNodes` 응답을 받아서 LLM 친화적으로 압축.
147
+ *
148
+ * @param {object} fetchNodesResponse - Figma API `/v1/files/.../nodes` 결과
149
+ * @returns {{ documents: Array<simpleNode>, components: Record, styles: Record }}
150
+ */
151
+ export function simplifyFetchNodes(fetchNodesResponse) {
152
+ const out = { documents: [], components: {}, styles: {} };
153
+ const nodes = fetchNodesResponse?.nodes ?? {};
154
+ for (const [id, payload] of Object.entries(nodes)) {
155
+ if (!payload?.document) continue;
156
+ out.documents.push({
157
+ requestedId: id,
158
+ ...simplifyNode(payload.document, 0),
159
+ });
160
+ if (payload.components) {
161
+ Object.assign(out.components, payload.components);
162
+ }
163
+ if (payload.styles) {
164
+ Object.assign(out.styles, payload.styles);
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /** 사람 눈으로 보기 좋은 한 줄 요약 (디버깅/UI 표시용) */
171
+ export function quickSummary(simple) {
172
+ if (!simple) return '';
173
+ const parts = [simple.name + ' [' + simple.type + ']'];
174
+ if (simple.size) parts.push(`${simple.size.w}×${simple.size.h}`);
175
+ if (simple.autoLayout) parts.push('auto-' + simple.autoLayout.direction);
176
+ if (simple.children?.length) parts.push(`children=${simple.children.length}`);
177
+ return parts.join(' · ');
178
+ }
179
+
180
+ export { simplifyNode };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Figma URL/링크 파서.
3
+ *
4
+ * 지원하는 URL 형태:
5
+ * https://www.figma.com/file/{fileKey}/... (구버전 share link)
6
+ * https://www.figma.com/design/{fileKey}/... (신버전, 2023+)
7
+ * https://www.figma.com/proto/{fileKey}/... (프로토타입)
8
+ * ...?node-id=123-456 또는 ...?node-id=123%3A456 (특정 노드)
9
+ *
10
+ * Figma 내부 노드 ID 는 "123:456" 인데 URL 에서는 보통 "123-456" 또는 인코딩됨.
11
+ * API 호출 시엔 "123:456" 으로 다시 변환해야 함.
12
+ */
13
+
14
+ const URL_RE = /figma\.com\/(?:file|design|proto)\/([A-Za-z0-9]+)/;
15
+
16
+ export function parseFigmaUrl(input) {
17
+ if (!input || typeof input !== 'string') return null;
18
+ const m = input.match(URL_RE);
19
+ if (!m) return null;
20
+ const fileKey = m[1];
21
+
22
+ // node-id 추출
23
+ let nodeId = null;
24
+ try {
25
+ const u = new URL(input);
26
+ const raw = u.searchParams.get('node-id');
27
+ if (raw) {
28
+ // "123-456" → "123:456", "123%3A456" → "123:456"
29
+ nodeId = decodeURIComponent(raw).replace(/-/g, ':');
30
+ }
31
+ } catch {
32
+ /* not a valid URL — fileKey 만 있으면 그것대로 ok */
33
+ }
34
+
35
+ return { fileKey, nodeId };
36
+ }
37
+
38
+ /** 디자이너가 도면에서 "Copy link" 한 결과인지 (= node-id 있음) */
39
+ export function hasNode(parsed) {
40
+ return !!parsed?.nodeId;
41
+ }
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];